React最佳实践
尝试着使用React
技术栈做一个后台管理系统,主要是为了探索React
的最佳实践。用到的技术栈如下:
vitejs
:下一代前端开发与构建工具,用于替代create-react-app
脚手架React
:全部使用hooks
开发react-router-dom
:路由react-router-config
:路由辅助库,用于集中配置路由recoil
:Facebook
官方出品的状态管理,不是React
团队出的,是Facebook
官方antd
:阿里出的组件库,一般React
技术栈做pc
网站开发首选都是这个组件库
创建项目
yarn create @vitejs/app react-admin --template react-ts
配置ESlint
、Prettier
、Stylelint
配置ESlint
、Prettier
安装依赖
yarn add -D eslint prettier eslint-config-prettier eslint-plugin-prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser
配置ESlint
在项目根目录下创建.eslintrc.js
文件,内容如下:
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: true
}
},
env: {
node: true,
es6: true,
browser: true
},
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'eslint:recommended'
]
};
配置Prettier
在项目根目录下创建.prettierrc
文件,内容如下:
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "auto"
}
可根据自己的喜好自行配置规则
配置VSCODE
需要安装插件ESlint
和Prettier
并在设置中设置Prettier
或在用户设置文件settings.json
添加以下代码:
{
"prettier.jsxSingleQuote": true,
"prettier.requireConfig": true,
"prettier.semi": false,
"prettier.singleQuote": true,
"prettier.arrowParens": "avoid",
"prettier.endOfLine": "auto",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
其中,editor.formatOnSave
表示是否在保存时执行格式化操作,editor.codeActionsOnSave
表示在保存时执行的操作,其中"source.fixAll.eslint": true
表示自动修复,即:保存时自动修复错误格式
配置Stylelint
安装依赖
yarn add -D stylelint stylelint-config-standard stylelint-order stylelint-config-property-sort-order-smacss
stylelint
:stylelint
基础依赖stylelint-config-standard
:stylelint
标准配置stylelint-order
:属性排序配置stylelint-config-property-sort-order-smacss
:基于SMACSS方法的属性排序的Stylelint
配置。
配置Stylelint
在项目根目录下创建.stylelintrc
文件
{
"extends": ["stylelint-config-standard", "stylelint-config-property-sort-order-smacss"],
"plugins": ["stylelint-order"]
}
配置VSCODE
安装插件stylelint
常用忽略注释
/* stylelint-disable */
:全文件关闭规则校验/* stylelint-disable-line */
:当前行关闭规则校验/* stylelint-disable-next-line */
:下一行关闭规则校验
空格加规则可只关闭某个规则,规则列表用,
隔开,如:/* stylelint-disable selector-no-id, declaration-no-important */
。.stylelintignore
文件可指定忽略的文件
配置husky
和lint-staged
husky
是一个增强githook
的工具,可以添加commit
钩子函数,帮助在提交时做一些验证,lint-staged
过滤出暂存区文件
安装依赖
yarn add -D husky lint-staged
注意:
husky@4.x
和husky@7.x
配置有区别
配置husky
在package.json
的scripts
中添加"prepare": "husky install"
,然后执行npm run prepare
,会在根目录下生成.husky
目录,然后执行npx husky add .husky/pre-commit "npx lint-staged"
,则在.husky
目录下会生成pre-commit
。当执行git commit
操作时会触发npx lint-staged
命令
配置lint-staged
在项目根目录下创建.lintstagedrc.js
文件,文件内容如下:
module.exports = {
'src/**/*.{js,jsx,ts,tsx,json,html}': ['eslint', 'prettier --write', 'git add'],
'src/**/*.{css,less}': ['stylelint --fix', 'git add']
};
目录设计
@types
:与数据模型无关的TS
的类型定义antd
:组件库Antd
相关api
:后端接口的封装assets
:静态资源(样式、图片、字体)components
:通用的组件layouts
:布局相关的组件mocks
:mock
数据models
:与数据模型相关的TS
的类型定义pages
:页面相关的组件router
:路由相关store
:状态管理相关utils
:通用方法相关
设置目录别名
需要修改tsconfig.json
和vite.config.ts
tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
+ "baseUrl": ".",
+ "paths": {
+ "@/*":["./src/*"]
+ }
},
- "include": ["./src"]
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx"]
}
vite.config.ts
import { defineConfig } from 'vite'
import reactRefresh from '@vitejs/plugin-react-refresh'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [reactRefresh()],
+ resolve: {
+ alias: [{ find: '@', replacement: '/src' }],
+ },
})
路由
安装
yarn add react-router-dom react-router-config
yarn add -D @types/react-router-dom @types/react-router-config
目录介绍
router/index.ts
:所有的路由相关内容导出文件
router/routes.ts
:所有路由配置文件
创建router/index.ts
文件
export { BrowserRouter } from 'react-router-dom';
export { default as routes } from './routes';
创建router/routes.ts
文件并添加路由配置
import { lazy } from 'react';
import type { RouteConfig } from 'react-router-config';
const routes: Array<RouteConfig> = [
{
path: '/',
exact: true,
component: lazy(() => import('@/pages/home'))
},
{
path: '/login',
component: lazy(() => import('@/pages/user/login'))
}
];
export default routes;
创建页面路由pages/user/login.tsx
和pages/home/index.tsx
pages/user/login.tsx
import React from 'react';
import type { ReactElement } from 'react';
const Login = (): ReactElement => {
return <div>login</div>;
};
export default Login;
pages/home/index.tsx
import React from 'react';
import type { ReactElement } from 'react';
const Home = (): ReactElement => {
return <h1>Home</h1>;
};
export default Home;
使用
在main.tsx
文件中添加BrowserRouter
组件
import React from 'react';
import ReactDOM from 'react-dom';
import App from '@/App';
import { BrowserRouter } from '@/router';
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
document.getElementById('root')
);
在App
中渲染所有路由,因为用到懒加载,所以使用了Suspense
import React, { Suspense } from 'react';
import type { ReactElement } from 'react';
import { renderRoutes } from 'react-router-config';
import { routes } from '@/router';
const App = (): ReactElement => {
return <Suspense fallback={<div>loading...</div>}>{renderRoutes(routes)}</Suspense>;
};
export default App;
运行yarn dev
看看是否运行正常
路由鉴权
添加router/AuthRoute.tsx
用于路由鉴权
import React from 'react';
import { withRouter } from 'react-router';
import { Redirect } from 'react-router-dom';
import storage, { TOKEN } from '@/utils/storage';
const AuthRoute = withRouter((props: any) => {
const { children, history } = props;
const token = storage.get(TOKEN);
if (history.location.pathname !== '/login' && !token) {
return <Redirect to="/login" />;
}
return children;
});
export default AuthRoute;
其中使用了storage
封装,所以添加一下utils/storage.ts
import { isUndef } from './tools';
// token
export const TOKEN = 'ADMIN_TOKEN';
export default {
get: (key: string): any => {
const data: string | null = window.localStorage.getItem(key);
try {
return data === null ? data : JSON.parse(data);
} catch {
return data;
}
},
set: (key: string, data: unknown): void => {
if (!isUndef(key) && !isUndef(data)) {
let payload = <string>data;
if (typeof data !== 'string') {
payload = JSON.stringify(data);
}
window.localStorage.setItem(key, payload);
}
},
remove: (key: string): void => window.localStorage.removeItem(key)
};
其中使用了utils/tools.ts
,所有创建utils/tools.ts
文件
// 判断是否未定义
export const isUndef = (v: unknown): boolean => v === undefined || v === null;
然后将AuthRoute
组件通过router/index.ts
导出
export { BrowserRouter } from 'react-router-dom';
export { default as routes } from './routes';
export { default as AuthRoute } from './AuthRoute';
然后在App.tsx
中使用
import React, { Suspense } from 'react';
import type { ReactElement } from 'react';
import { renderRoutes } from 'react-router-config';
import { routes, AuthRoute } from '@/router';
const App = (): ReactElement => {
return (
<Suspense fallback={<div>loading...</div>}>
<AuthRoute>{renderRoutes(routes)}</AuthRoute>
</Suspense>
);
};
export default App;
组件库 Antd
安装
yarn add antd
在定制Antd
主题时准备使用less
变量定制,所以还需要安装less
yarn add -D less
使用
创建antd/index.ts
文件,用于引入项目所需的组件,其实可以在需要的地方引入,但是统一地方引入为了方便管理
export { Spin } from 'antd';
创建antd/theme.less
文件,用于主题定制
@import '~antd/lib/style/themes/default.less';
@import '~antd/dist/antd.less';
// 通过less变量定制主题色
// @primary-color: #1890ff; // 全局主色
为了使用~
引入Antd
样式需要添加别名,以及解决引入Antd
样式报错,还需要添加less
配置
import { defineConfig } from 'vite';
import reactRefresh from '@vitejs/plugin-react-refresh';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [reactRefresh()],
resolve: {
alias: [
// 处理通过"~"引入Antd问题
{ find: /^~/, replacement: '' },
{ find: '@', replacement: '/src' }
]
},
// 处理引入Antd样式报错问题
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true
}
}
}
});
然后创建assets/styles/app.less
文件,用于引入全局的样式
@import '../../antd/theme';
.page-loading {
display: flex;
z-index: 9999;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
background-color: rgba(255, 255, 255, 0.8);
}
在App.tsx
引入样式
import React, { Suspense } from 'react';
import type { ReactElement } from 'react';
import { renderRoutes } from 'react-router-config';
import { routes, AuthRoute } from '@/router';
import '@/assets/styles/app.less';
import { Spin } from '@/antd';
const App = (): ReactElement => {
const Loading = () => {
return (
<div className="page-loading">
<Spin />
</div>
);
};
return (
<Suspense fallback={<Loading />}>
<AuthRoute>{renderRoutes(routes)}</AuthRoute>
</Suspense>
);
};
export default App;
创建文件src/antd/index.ts
国际化默认是英文,在main.tsx
中引入中文并配置
import React from 'react';
import ReactDOM from 'react-dom';
import App from '@/App';
import { BrowserRouter } from '@/router';
import zhCN from 'antd/lib/locale/zh_CN';
import { ConfigProvider } from '@/antd';
ReactDOM.render(
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<App />
</BrowserRouter>
</ConfigProvider>,
document.getElementById('root')
);
添加 Axios
安装
yarn add axios
请求数据时需要加上进度条,所以需要安装nprogress
yarn add nprogress
yarn add -D @types/nprogress
封装
api/axios.ts
import axios from 'axios';
import type { AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
import storage, { TOKEN, USER_INFO } from '@/utils/storage';
import { message } from '@/antd';
import { isUndef } from '@/utils/tools';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
NProgress.configure({ showSpinner: false });
const instance: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL as string
});
instance.interceptors.request.use(
(config: AxiosRequestConfig) => {
NProgress.start();
const token = storage.get(TOKEN);
!isUndef(token) && (config.headers['token'] = token);
return config;
},
(error) => {
return Promise.reject(error);
}
);
instance.interceptors.response.use(
(response: AxiosResponse) => {
if (response?.data?.code === 0) {
message.error(response.data.msg);
return Promise.reject(response.data.msg);
}
if (response?.data?.code === 401 || response?.data?.code === '401') {
storage.remove(TOKEN);
storage.remove(USER_INFO);
return Promise.reject(response.data);
}
NProgress.done();
return response.data;
},
async (error) => {
NProgress.done();
if (error.response) {
if (error.response.status === 401 || error?.data?.code === '401') {
storage.remove(TOKEN);
storage.remove(USER_INFO);
}
if (error.response.status === 404) {
message.error(`未找到接口:${error.response.config.url}`);
}
if (error.response.status === 500) {
message.error(`接口:${error.response.config.url}在服务端发生未知错误`);
}
return Promise.reject(error.response.data);
} else {
return Promise.reject(error);
}
}
);
export default instance;
添加Mock
安装
yarn add -D mockjs @types/mockjs
创建文件src/mocks/index.ts
import Mock from 'mockjs';
import user from './modules/user';
const mockStart = (): void => {
Mock.setup({
timeout: '200-3000'
});
user();
};
export default mockStart;
在mocks
目录下添加modules
目录作为每个模块的请求
mocks/modules/user.ts
import Mock from 'mockjs';
export default (): void => {
Mock.mock('/api/login', 'post', ({ body }: any) => {
const { username, password } = JSON.parse(body);
if (username === 'admin' && password === '123456') {
return {
code: 1,
data: {
id: Mock.Random.id(),
name: 'admin',
avatar: Mock.Random.image('600x600', '#50B347', 'A'),
token: Mock.Random.guid()
}
};
} else {
return {
code: 0,
msg: '账号或密码错误'
};
}
});
};
在main.tsx
下添加代码,仅当开发环境才会启动mock
import React from 'react';
import ReactDOM from 'react-dom';
import App from '@/App';
import { BrowserRouter } from '@/router';
import mockStart from '@/mocks';
import zhCN from 'antd/lib/locale/zh_CN';
import { ConfigProvider } from '@/antd';
// 开发环境才启动mock
import.meta.env.DEV && mockStart();
ReactDOM.render(
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<App />
</BrowserRouter>
</ConfigProvider>,
document.getElementById('root')
);
状态管理
安装
yarn add recoil
在main.tsx
引入RecoilRoot
import React from 'react';
import ReactDOM from 'react-dom';
import App from '@/App';
import { BrowserRouter } from '@/router';
import mockStart from '@/mocks';
import { RecoilRoot } from 'recoil';
import zhCN from 'antd/lib/locale/zh_CN';
import { ConfigProvider } from '@/antd';
// 开发环境才启动mock
import.meta.env.DEV && mockStart();
ReactDOM.render(
<ConfigProvider locale={zhCN}>
<RecoilRoot>
<BrowserRouter>
<App />
</BrowserRouter>
</RecoilRoot>
</ConfigProvider>,
document.getElementById('root')
);
创建文件src/store/app.ts
,用于放置应用全局状态,如果需要划分模块,在src/store/modules
下创建相应模块既可
import { atom, atomFamily } from 'recoil';
// 菜单是否折叠
export const collapsedAtom = atom({
key: 'collapsedAtom',
default: false
});
// 当前路径
export const activePathAtom = atomFamily({
key: 'activePathAtom',
default: (path: string) => path
});
// 是否为深色模式
export const isDarkModeAtom = atom({
key: 'isDarkModeAtom',
default: false
});
其他
- 所有目录及文件使用短横线命名法
- 所有的
CSS
样式集class
使用BEM命名法
- 私有自定义组件,应在当前目录下的
_components
下创建,超过 3 个以上用到的应提取到通用组件目录下components
- 所有的
js
导出函数应使用JSDoc
注释