一、 vscode 配置
搭建好基本的框架后,需要统一vscode的插件配置 。具体内容可以参照vscode 官网的Workspace recommended extensions部分。在项目路径下面添加一个.vscode 文件夹,再新建一个extensions.json 文件,把统一要用的插件列进去
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"steoates.autoimport",
"formulahendry.auto-close-tag",
"formulahendry.auto-rename-tag",
"clinyong.vscode-css-modules",
"quicktype.quicktype"
]
}
新建一个settings.json 文件,配置下面的内容。这里配置等效于在vscode 里面的Settings editor 。setting里面有什么配置这些配置项的功能是什么,你可以去官网查看 https://code.visualstudio.com/docs/getstarted/settings。
{
{
// #每次保存的时候将代码按eslint格式进行修复
"editor.codeActionsOnSave": {
"source.fixAll": true,
"source.organizeImports": true
},
// 点击保存时,根据 eslint 规则自定修复,同时集成 prettier 到 eslint 中
"prettier.eslintIntegration": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
// 为了避免和 eslint 冲突,将编辑器默认的代码检查规则关闭(如果开启了)
"editor.formatOnSave": true,
// stylelint 扩展自身的校验就够了
"css.validate": false,
"less.validate": false,
"scss.validate": false,
// 使用本地安装的 TypeScript 替代 VSCode 内置的来提供智能提示
"typescript.tsdk": "./node_modules/typescript/lib",
// 指定哪些文件不参与搜索
"search.exclude": {
"**/node_modules": true,
"dist": true,
"yarn.lock": true
},
// 指定哪些文件不被 VSCode 监听,预防启动 VSCode 时扫描的文件太多,导致 CPU 占用过高
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/*/**": true,
"**/dist/**": true
},
"eslint.options": {
"configFile": "./.eslintrc.js"
}
}
vscode 代码片段功能非常方便程序员初始化一个组件代码,熟悉这个功能,和省掉去 ctr+c一个完整的组件 然后再猛的删掉冗余代码的操作。在.vscode 里面新建一个xx.code-snippets 的文件。把整个项目里面经常重复的代码配置成代码片段。这样只要敲关键字就能自动生成已经写好的代码。
{
// Place your global snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
// Placeholders with the same ids are connected.
// Example:
// "Print to console": {
// "scope": "javascript,typescript",
// "prefix": "log",
// "body": [
// "console.log('$1');",
// "$2"
// ],
// "description": "Log output to console"
// }
"ts react function component": {
"scope": "typescriptreact", // 语言范围ts
"prefix": "tsrfc", //匹配的关键字
"body": [
"import React from 'react';",
"import styles from './${1:${TM_FILENAME_BASE}}.module.scss';",
"",
"interface ${1:${TM_FILENAME_BASE}}Props {",
" [key: string]: any;",
"}",
"",
"const ${1:${TM_FILENAME_BASE}}: React.FC<${1:${TM_FILENAME_BASE}}Props> = () => {",
" return (",
" <div className={styles.wrap}>",
" ${0}",
" </div>",
" );",
"};",
"",
"export default ${1:${TM_FILENAME_BASE}};"
]
}
}
在vscode的tsx 文件里面 敲tsrfc
最后自动生成代码片段如下:
二、项目文件结构调整
creat-react-app facebook 提供的手脚架提供的文件结构如下:
my-app
├── README.md
├── node_modules
├── package.json
├── yarn.lock
├── .npmrc
├── .gitignore
├── public
│ ├── favicon.ico
│ ├── index.html
│ └── manifest.json
└── src
├── App.css
├── App.js
├── App.test.js
├── index.css
├── index.js
├── logo.svg
└── reportWebVitals.js
└── setupTests.js
在这基础上根目录上我会添加上typescript 的全局类型定义文件架 @types,src 里面添加assets、components、constants、layouts、utils、style、pages、hooks等。其他的文件夹比如 后续框架用到的easy-p-easy store action state 等文件所需要存储的文件,可以另外新建文件夹存储(这个后面用到的时候再讲)。
调整后的文件目录结构为:
my-app
├── .vscode # vscode 配置目录,包含常用的代码片段、设置等
│ ├── extensions.json #项目推荐使用安装的插件
│ ├── settings.json #项目里面使用的vscode 的配置内容,比如自动保存代码格式化等
│ ├── tsrc.code-snippets #代码片段配置
├── .@types # 全局类型声明
├── docs # 存放项目的文档,比如接口文档等
├── node_modules #模块
├── public # 公共文件
│ ├── favicon.ico
│ ├── index.html #入口html
│ └── manifest.json
└── src # 源码目录
│ ├── assets # 静态资源目录
│ ├── components # 公共业务组件目录
│ ├── constants # constant 目录,存储api 等公共的类型常量类的变动不大的文件
│ │ │── api # 定义对接后台api 接口的文件
│ ├── layouts # 布局目录
│ ├── models # 存放easy-peasy 的state和actions模块
│ ├── pages # 页面组件目录
│ ├── routers # 页面路由相关的文件
│ ├── services # model 对应的api 调用函数
│ ├── style # 全局样式
│ ├── utils # util 目录
│ ├── App.css
│ ├── App.js
│ ├── App.test.tsx
│ ├── index.css
│ ├── index.tsx
│ ├── logo.svg
│ └── reportWebVitals.ts
│ └── setupTests.ts
├── .eslintignore # eslint忽略文件的配置
├── .eslintrc #eslint 的配置文件
├── .gitignore #git 的忽略文件的配置
├── .npmrc #淘宝镜像的使用配置
├── .prettierrc #prettier 插件的配置
├── .stylelintrc.json #stylelint插件的配置
├── package.json #项目的配置文件
├── README.md #项目的说明文件
├── tsconfig.json #typescript 的配置
├── yarn.lock #yarn lock 文件
三、常用模块的引入和代码调整
3.1 安装antd 推荐的craco 模块
yarn add antd @craco/craco
修改package.json 里面的启动指令,在项目跟目录里面添加craco.config.js
/* package.json */
"scripts": {
- "start": "react-scripts start",
- "build": "react-scripts build",
- "test": "react-scripts test",
+ "start": "craco start",
+ "build": "craco build",
+ "test": "craco test",
}
craco.config.js,主要是在create-react-app 不用eject暴露webpack 配置的情况下,方便在外部修改webpack 的配置
/* craco.config.js */
module.exports = {
// ...
};
3.2 添加antd 和ant 国际化
安装antd
yarn add antd
添加国际语言包
import zhCN from 'antd/lib/locale/zh_CN';
// index.tsx 里面添加语言包支撑
ReactDOM.render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</React.StrictMode>,
document.getElementById('root'),
);
3.3 添加easy-peasy
项目搭建的初期选择easy-peasy 而不是选择redux 的原因有两个:1. easy-peasy 能很好的支持react 的hooks,2.easy-peasy 的代码看起来比较直观些,state 和action 直接放在一起 。
yarn add easy-peasy
easy-peasy 里面提供的示例:
const store = createStore({
todos: {
items: ['Create store', 'Wrap application', 'Use store'],
add: action((state, payload) => {
state.items.push(payload);
}),
},
});
function App() {
return (
<StoreProvider store={store}>
<TodoList />
</StoreProvider>
);
}
function TodoList() {
const todos = useStoreState((state) => state.todos.items);
const add = useStoreActions((actions) => actions.todos.add);
return (
<div>
{todos.map((todo, idx) => (
<div key={idx}>{todo}</div>
))}
<AddTodo onAdd={add} />
</div>
);
}
easy-Peasy 和redux 类似,createStore函数创建一个store 然后挂在在项目的入口标签。store 里面定义了对应的state 和actions (同步异步actions)。根据这个特性,项目的文件结构对应的调整下。新增modals services 两个文件夹。services 存放接口调用的函数、modals 存放state和actions。
具体实现如下:
在constants 下面新建一个store.ts
import { createStore } from 'easy-peasy';
import { storeModel } from 'src/models/index';
const store = createStore(storeModel);
export default store;
在根目录下新建一个models 文件夹用来存放state 和actions.
models 下面新建一个index.ts
export interface StoreModel {}
export const storeModel:StoreModel = {};
修改根目录的入口代码index.tsx
import { ConfigProvider } from 'antd';
import zhCN from 'antd/lib/locale/zh_CN';
import { StoreProvider } from 'easy-peasy';
import React from 'react';
import ReactDOM from 'react-dom';
import store from 'src/constants/store';
import App from './App';
import './index.css';
import reportWebVitals from './reportWebVitals';
ReactDOM.render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<StoreProvider store={store}>
<App />
</StoreProvider>
</ConfigProvider>
</React.StrictMode>,
document.getElementById('root'),
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
在hooks 下面创建一个easyPeasyApi.ts文件,存储easyPeasy Api 提供的userStoreActions 、useStoreState 等工具函数方便后面组件的使用,
import { createTypedHooks } from 'easy-peasy';
import { StoreModel } from 'src/models';
// Provide our model to the helper 👇
const typedHooks = createTypedHooks<StoreModel>();
// 👇 export the typed hooks
export const useStoreActions = typedHooks.useStoreActions;
export const useStoreDispatch = typedHooks.useStoreDispatch;
export const useStoreState = typedHooks.useStoreState;
export const useStore = typedHooks.useStore;
这样easy-peasy就被简单的引入进来了。和redux 一样如果要支撑异步的action 是需要一个中间件支撑的, easy-peasy 支持Redux middleware 和Redux Dev Tools。这样直接引入redux 、redux-logger 等模块。
redux:yarn add redux redux-logger ,yarn add @types/redux-logger
store.ts 文件的代码:
import { createStore } from 'easy-peasy';
import { Middleware } from 'redux';
import logger from 'redux-logger';
import { storeModel } from 'src/models';
const middlewares:Middleware[] = [];
if (process.env.NODE_ENV === 'development') {
middlewares.push(logger);
}
const store = createStore(storeModel, { middleware: middlewares });
export default store;
在根目录上新一个services文件夹,存放每一个model对应的service (model 对应的接口处理函数)文件。每个model 和service 命名规则如下:
文件:模块名称.(model/service).ts, 这样pages model service 一一对应,方便后期调试查找。
modal 文件定义的格式:
export interface GlobalModel {
[key: string]: any;
}
export const globalModel: GlobalModel = {
}
这样整个easy-peasy 相关的文件都搭建好了。
但是在后期的开发过程中,你会发现models 里面定义了太多的ts 变量类型定义,会导致代码看起来有点乱,影响代码的阅读性。所以我推荐是在models 里面再建一个types 的单独类型的文件夹,对应model 的ts 类型定义可以单独存放到里面,命名为:模块名称.types.ts。
3.4 router 的定义和改造
react 是单页面运用,路由的作用是监控url 变化来对应的控制页面显示哪个模块。不同的页面可以会有相同的布局。所以这里的路由分两层,第一层控制切换不同的页面layout,第二层控制每个layout 显示哪个子页面。
安装react-router-dom
yarn add react-router-dom
yarn add @types/react-router-dom
参照UmiJS 的动态路由设计,routers 文件里面分别创建一个 Router.ts RouterLayout.tsx PrivateRoute.tsx 以及RouterTypes.ts 等文件。
Router.ts 定义所有页面的路由;
RouterLayout.tsx 解析Router.ts并转化为router-router-dom 的router 格式的代码;
PrivateRoute.tsx 鉴权和重定向到登录页面;
RouterTypes 单独存放Router 定义需要的ts 类型。
代码的具体实现:
RouterTypes:
export interface RouteNode {
path: string;
name?: string;
// icon?: React.ElementType | string;
iconType?: string;
/** 重定向不能与布局组件同时使用,同时使用时会忽略重定向(可以通过布局组件内部处理解决) */
redirect?: string;
/** 菜单布局组件会使用 */
hideInMenu?: boolean;
/** 是否是布局组件(默认 `false`) */
layout?: boolean;
/** 当 `layout` 为 `true` 时,该组件会作为布局组件,接收 `router` (routes 别名) 及其它属性值,*/
component?: React.ComponentType<any>;
/** 用到的布局相关的子模块 */
child?: React.ComponentType<any>;
routes?: RouteNode[];
/** 授权 */
authority?: any[];
/** 拥有子菜单 */
hasSubMenu?: boolean;
/** 预留自定义属性 */
[otherProp: string]: any;
}
Router.ts :
import BasicLayout from 'src/layouts/BasicLayout';
import { RouteNode } from 'src/layouts/LayoutsTypes';
import { Roles } from 'src/models/modelTypes/global.types';
import Home from 'src/pages/Home';
import Login from 'src/pages/Login';
const router: RouteNode[] = [
{ path: '/login', name: '登录', component: Login },
{
path: '/home',
name: '主页Layout',
authority: [Roles.Admin, Roles.Guest],
component: BasicLayout,
layout: true,
routes: [
{
path: '/home',
name: '主页',
authority: [Roles.Guest],
component: Home,
},
],
},
{
path: '/',
name: '首页',
redirect: '/home',
},
];
export default router;
RouterLayout.tsx
import _ from 'lodash';
import React from 'react';
import { Redirect, Switch } from 'react-router-dom';
import { RouteNode } from 'src/layouts/LayoutsTypes';
import PrivateRoute from 'src/routers/PrivateRoute';
interface RouteLayoutProps {
router: RouteNode[];
}
const RouteLayout: React.FC<RouteLayoutProps> = ({ router }) => {
return (
<Switch>
{router.map(({ path, layout, routes, component, redirect, ...otherProps }) => {
return (
<PrivateRoute
key={path + 'layout'}
path={path}
render={(props: any) => {
// 如果只是layout 没有子路由直接生成layout组件
if (layout && !_.isEmpty(routes) && component) {
return React.createElement(component, {
router: routes, // 传入layout下面的routes 数据
...otherProps,
...props,
});
}
// 如果不是layout 页面,也不是重定向的配置,直接渲染当前页面,比如login登录页面
return redirect ? (
<Redirect to={redirect} />
) : (
component && React.createElement(component, props)
);
}}
{...otherProps}
/>
);
})}
</Switch>
);
};
export default RouteLayout;
PrivateRoute.tsx
import _ from 'lodash';
import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import { useStoreState } from 'src/hooks/easyPeasyApi';
interface PrivateRouteProps {
key: string;
[key: string]: any;
}
/**
* 路由权限的判断
* @returns 没有权限就返回登录页面,有权限就返回相应的组件
*/
const PrivateRoute: React.FC<PrivateRouteProps> = ({ key, authority = [], ...otherProps }) => {
const { userInfo } = useStoreState((state) => state.globalModel);
// 没定义权限 或者 当前用户用户这个模块的权限 和 路由是 login 组件时, 直接渲染组件
return _.isEmpty(authority) ||
authority.includes(userInfo.role) ||
otherProps.path == '/login' ? (
<Route {...otherProps} key={key} />
) : (
<Redirect to="/login" />
);
};
export default PrivateRoute;
项目的入口文件index.tsx 修改为
import { ConfigProvider } from 'antd';
import zhCN from 'antd/lib/locale/zh_CN';
import { StoreProvider } from 'easy-peasy';
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import store from 'src/constants/store';
import router from 'src/routers/Router';
import RouteLayout from 'src/routers/RouterLayout';
import './index.css';
import reportWebVitals from './reportWebVitals';
ReactDOM.render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<StoreProvider store={store}>
<Router>
<RouteLayout router={router || []} />
</Router>
</StoreProvider>
</ConfigProvider>
</React.StrictMode>,
document.getElementById('root'),
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
处理完第一层layout 的路由切换,接下来就需要处理layout 内部子模块的路由切换。如下图点击菜单栏触发绿色框里面的内容切换。
Layout 的具体实现:
import { CaretDownFilled } from '@ant-design/icons';
import { Layout, Menu } from 'antd';
import _ from 'lodash';
import React from 'react';
import { Link, Redirect, Switch, useHistory, useLocation } from 'react-router-dom';
import { useStoreState } from 'src/hooks/easyPeasyApi';
import PrivateRoute from 'src/routers/PrivateRoute';
import { RouteNode } from 'src/routers/RouterTypes';
import styles from './BasicLayout.module.scss';
interface BasicLayoutProps {
router: RouteNode[]; // 读取router 里面有layout 里面的routes 路由
}
const { Header, Content } = Layout;
const { SubMenu } = Menu;
const MenuItem = Menu.Item;
const BasicLayout: React.FC<BasicLayoutProps> = ({ router }) => {
const { userInfo } = useStoreState((state) => state.globalModel);
const history = useHistory();
const location = useLocation();
const { pathname } = location;
const openKey = '/' + pathname.split('/')[1];
return (
<Layout className={styles.wrap}>
<Header className={styles.header}>
<div className={styles.left}>
<Link className={styles.logo} to="/" />
<span>{'系统名字'}</span>
</div>
<Menu
className={styles.mid}
mode="horizontal"
selectedKeys={[openKey]}
onSelect={(param) => {
// if (param.key && param.key === 'invalid_route') return;
const item = router.find((m) => m.path === param.key && m.redirect);
if (item) {
history.push(item.redirect!);
} else {
history.push(param.key + '');
}
}}
>
{router
.filter((m) => !m.hideInMenu)
.map((m) => {
const condition =
(m.routes &&
m.routes?.filter((v) => m.authList && m.authList?.includes(userInfo.role))
.length) ||
0;
if (m.hasSubMenu && !_.isEmpty(m.routes) && m.routes && condition >= 2) {
// 只有一个菜单时,不显示下拉菜单选项
return (
<SubMenu
key={m.path}
title={
<span>
{/* {m.icon && React.createElement(m.icon)} */}
<span>{m.name}</span>
<CaretDownFilled style={{ fontSize: 14, marginLeft: 7 }} />
</span>
}
>
{m.routes?.map((sm) =>
sm.authList && sm.authList?.includes(userInfo.role) ? (
<MenuItem key={sm.path}>{sm.name}</MenuItem>
) : (
''
),
)}
</SubMenu>
);
}
return <MenuItem key={m.path}>{m.name}</MenuItem>;
})}
</Menu>
</Header>
<Layout className={styles.content}>
<Content>
<Switch>
{router.map((m) => {
const {
name,
hideInMenu,
path,
routes,
layout,
redirect,
component,
...otherProps
} = m;
// handle layout
if (layout && component && !_.isEmpty(routes)) {
return (
<PrivateRoute
key={path}
path={path}
render={(props: any) =>
React.createElement(component, { router: routes, ...props })
}
{...otherProps}
/>
);
}
if (routes && !_.isEmpty(routes)) {
return routes.map((n) => <PrivateRoute key={n.path} {...n} />);
}
return (
<PrivateRoute
key={path}
path={path}
render={(props: any) =>
redirect ? (
<Redirect to={redirect} />
) : (
component && React.createElement(component, props)
)
}
{...otherProps}
/>
);
})}
</Switch>
</Content>
</Layout>
</Layout>
);
};
export default BasicLayout;
这样路由相关的代码据基本完成。
3.5 request 请求模块的封装
request 封装自定义封装可以统一代码格式,这里我尝试用Axios模块进行二次封装。代码还未验证,如果有问题我后面再修改。
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import _ from 'lodash';
export interface ReqInit extends AxiosRequestConfig {
headers?: Record<string, string>;
/** eg. ?a=1 */
params?: { [key: string]: any };
/** eg. /:id/.. */
router?: { [key: string]: any };
/** 超时时间,默认3000 */
timeout?: number;
}
const MyAxiosInstance = axios.create({});
MyAxiosInstance.defaults = {
...axios.defaults,
method: 'GET',
headers: {
Accept: '*/*',
},
mode: 'cors',
timeout: 3000,
// credentials: 'include' // send cookies
} as ReqInit;
async function request<T = unknown>(path: string, init: ReqInit = {}): Promise<T> {
const mergeInit = {
...MyAxiosInstance.defaults,
...init,
headers: { ...MyAxiosInstance.defaults.headers, ...init.headers },
};
const { params, router, data, headers } = mergeInit;
const hasType = Reflect.has(headers, 'Content-Type') || Reflect.has(headers, 'content-type');
let url = path;
if (router) {
url = path.replace(/:([A-Za-z]+)/g, (substring, p1: string) => router[p1]);
}
if (params) {
url += _(_.reduce(params, (prev, val, key) => `${prev}${key}=${val}&`, '?')).trimEnd('&');
}
if (!hasType && typeof data === 'string') {
Reflect.set(headers, 'Content-Type', 'application/json');
}
try {
const response = MyAxiosInstance(url, mergeInit);
return Promise.resolve((await response).data);
} catch (err) {
return Promise.reject(new Error(JSON.stringify({ err })));
}
}
// 返回自定义的axios 实例
export function fetchInstance(): AxiosInstance {
return MyAxiosInstance;
}
export default request;
参考:
1.https://code.visualstudio.com/docs/editor/extension-gallery#_workspace-recommended-extensions
2.https://code.visualstudio.com/docs/editor/userdefinedsnippets
3.https://ant.design/docs/react/use-with-create-react-app-cn
4.https://www.npmjs.com/package/easy-peasy
5.https://github.com/ctrlplusb/easy-peasy/blob/master/website/docs/docs/api/store-provider.md
6.https://blog.csdn.net/weixin_40599109/article/details/107848176
7.https://blog.csdn.net/ilovethesunshine/article/details/109679326
8.https://umijs.org/zh-CN/docs/convention-routing