React最佳实践

作者:renzp94
时间:2021-08-14 11:40:13

尝试着使用React技术栈做一个后台管理系统,主要是为了探索React的最佳实践。用到的技术栈如下:

  • vitejs:下一代前端开发与构建工具,用于替代create-react-app脚手架
  • React:全部使用hooks开发
  • react-router-dom:路由
  • react-router-config:路由辅助库,用于集中配置路由
  • recoilFacebook官方出品的状态管理,不是React团队出的,是Facebook官方
  • antd:阿里出的组件库,一般React技术栈做pc网站开发首选都是这个组件库

创建项目

yarn create @vitejs/app react-admin --template react-ts

配置ESlintPrettierStylelint

配置ESlintPrettier

安装依赖

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

需要安装插件ESlintPrettier并在设置中设置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
  • stylelintstylelint基础依赖
  • stylelint-config-standardstylelint标准配置
  • 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文件可指定忽略的文件

配置huskylint-staged

husky是一个增强githook的工具,可以添加commit钩子函数,帮助在提交时做一些验证,lint-staged过滤出暂存区文件

安装依赖

yarn add -D husky lint-staged

注意:husky@4.xhusky@7.x配置有区别

配置husky

package.jsonscripts中添加"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:布局相关的组件
  • mocksmock数据
  • models:与数据模型相关的TS的类型定义
  • pages:页面相关的组件
  • router:路由相关
  • store:状态管理相关
  • utils:通用方法相关

设置目录别名

需要修改tsconfig.jsonvite.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.tsxpages/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注释