react 单页面项目
技术调研和选型
技术调研和选型都应该从业务出发,用技术赋能业务。以我们的项目为例,这是一个 pc 项目,主要是对地图进行编辑(绘制图形等),对页面性能要求较高,供内部人员使用,无 seo 需求。最终确定使用的技术栈如下:
- react17、Hooks
- react-router v6
- typescript
- webpack 5
- axios
- mobx。数据管理,因为涉及较多的数据交互,使用 mobx 在性能上更胜一筹,模板代码也少一些。其他的选择有 redux、xstate
- less、css module
- ahooks。内置了很多工具 hooks,墙裂推荐 ~
- 其他的一些必要依赖
包管理器
这个看个人喜好吧,选 yarn 或 npm 都挺好的。个人比较喜欢用 yarn
,初始安装速度比 npm 快,具体对比可以参考 https://pnpm.io/zh/benchmarks
搭建完项目后就可以配置.npmrc
或.yarnrc
文件,设置 npm 源为淘宝源,这样安装依赖的时候就不用手动指定了
# .yarnrc
registry https://registry.npmmirror.com/
# 或者建议换成公司内部的 npm 源
搭建项目
基于 create-react-app
脚手架快速搭建一个 使用 typescript 的 react 项目
yarn create react-app my-app --template typescript
# 或者
npx create-react-app my-app --template typescript
安装好后,需要先把所有必要的依赖都先安装上,注意区分开发环境的依赖和生产环境的依赖
接下来在项目模板的基础上完善项目结构,主要是调整 src
的目录结构
src
├─ assets
│ ├─ css # 全局样式
│ ├─ icon
│ └─ image
├─ containers # 页面组件
├─ components # 公共组件
├─ helper # 存放工具函数或者业务抽象函数
│ ├─ utils # 工具函数
│ ├─ hooks # 公共 hooks
│ ├─ service.ts # 请求工具函数
├─ type # 全局类型
├─ store # 状态管理
└─ ...
规范配置
目的是统一代码编写格式和规范,助力团队协作,提高代码健壮性和可读性
eslint
yarn add eslint -D
yarn eslint --init
# 通过命令行交互生成 .eslintrc.js
注意只是让 eslint
检查语法和发现错误而不是纠正格式,代码格式由 prettier
统一即可
.eslintrc.js
参考配置如下:
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
jest: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["react", "@typescript-eslint"],
rules: {
"no-var-requires": 0,
},
};
package.json
增加 script
命令,用于全局检查项目代码
"eslint": "eslint --fix src/**/*.{js,ts,jsx,tsx}",
需要配合 vscode 的 eslint 插件来使用
详细配置参考 https://eslint.org/docs/user-guide/configuring/
stylelint
类似于 eslint
,stylelint
可以帮助检查和修复 css/less/scss
代码
yarn add stylelint stylelint-config-standard -D
项目根目录下新增 .stylelintrc.js
module.exports = {
extends: "stylelint-config-standard",
rules: {}, // 自定义规则
};
增加 script
命令,便于全局检查样式文件:
"stylelint": "stylelint --fix src/**/*.{css,less}",
然后记得在 vscode 中安装 stylelint 插件
prettier
yarn add prettier -D
.prettierrc
{
"tabWidth": 2,
"singleQuote": true,
"semi": true,
"trailingComma": "all"
}
增加 script 命令,便于格式化项目代码:
"prettier": "prettier --write src/**/*.{js,ts,jsx,tsx,less,css}"
需要安装 vscode 的 prettier 插件,然后可以选择开启保存时自动格式化代码的功能
详细配置参考 https://prettier.io/docs/en/configuration.html
EditorConfig
使用不同编辑器打开同一份文件,如果编辑器配置不统一,显示效果和输入内容很有可能不一致。EditorConfig
就主要用于统一代码编辑器编码风格
.editorConfig
配置文件参考
# https://editorconfig.org
# 已经是顶层配置文件,不必继续向上搜索
root = true
[*]
# 编码字符集
charset = utf-8
# 缩进风格是空格
indent_style = space
# 一个缩进占用两个空格,因没有设置tab_with,一个Tab占用2列
indent_size = 2
# 换行符 lf
end_of_line = lf
# 文件以一个空白行结尾
insert_final_newline = true
# 去除行首的任意空白字符
trim_trailing_whitespace = true
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false
详细配置参考 https://editorconfig.org/
Git 扩展
husky
。操作 git 钩子的工具lint-staged
。本地暂存代码检查工具,可以让husky
只检验 git 工作区的文件
yarn add husky lint-staged -D
在 package.json
追加以下配置
"scripts": {
"prepare": "husky install",
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --cache --fix",
"git add"
],
"src/**/*.{css,less}": [
"stylelint --fix",
"git add"
],
},
- 初始化
husky
yarn prepare
- 安装
commitlint
。这个插件可以校验提交的 commit 信息是否符合规范,不符合则不可以提交
yarn add @commitlint/cli @commitlint/config-conventional -D
- 在根目录下创建
commitlint.config.js
:
module.exports = {
extends: ["@commitlint/config-conventional"],
};
- 然后执行以下命令,会自动在生成的
.husky
文件夹下新建两个钩子文件commit-msg
和pre-commit
npx husky add .husky/commit-msg 'yarn commitlint --edit "$1"'
npx husky add .husky/pre-commit 'yarn lint-staged'
文件内容如下(确保自动生成的文件内容跟以下的保持一致):
commit-msg
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
echo "========= 校验 commit-msg ======="
yarn commitlint --edit $1
pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
echo "========= 执行 lint-staged ======="
yarn lint-staged
在 git commit 之前会进入工作区文件的扫描,执行 prettier 脚本,修改 eslint 问题,校验 commit msg,通过后再提交到工作区
注意:以上脚本执行的路径要确保与 .git 同级,如果出现以下情况
- .git
- front
- src
- package.json
先调整 package.json
"scripts": {
//...
"prepare": "cd .. && husky install front/.husky",
},
然后执行 yarn prepare
,重新生成 hook 脚本
npx husky add .husky/commit-msg 'yarn commitlint --edit "$1"'
npx husky add .husky/pre-commit 'yarn lint-staged'
修改文件内容如下
commit-msg
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
echo "========= 校验 commit-msg ======="
cd front
yarn commitlint --edit $1
pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
echo "========= 执行 lint-staged ======="
cd front
yarn lint-staged
- commitizen【可选】。命令行交互辅助输入 commit msg
npm i -g commitizen
commitizen init cz-conventional-changelog --save --save-exact
之后在提交 commit 时运行 git cz
cz-customizable。英文看着不爽的话,可以用这个插件自定义 commitizen 中文配置,自行搜索和配置吧!
其他插件的话,还有 stylelint
,可用于检查 css、less、sass 等样式文件的语法..
扩展 webpack
基于 react-app-rewired
扩展,这样后续也能享受到 react-scripts
的更新。当然如果是需要高度定制 webpack 配置的话,可以把配置文件导出来(eject)
alias
比较常规的路径映射有 @ --> src
如下:
import { utils } from "@/helper/utils"
- 借助
react-app-rewired
和customize-cra
扩展 webpack 配置
yarn add react-app-rewired customize-cra
修改 package.json
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
},
项目根目录下新增 config-overrides.js
文件
const { override, addWebpackAlias } = require("customize-cra");
const path = require("path");
module.exports = override(
// 配置alias
addWebpackAlias({
["@"]: path.resolve(__dirname, "src"),
["components"]: path.resolve(__dirname, "src/components"),
["mock"]: path.resolve(__dirname, "src/mock"),
})
);
- 需要先修改
tsconfig
,确保在编译期间 ts 能正确映射到某个路径下的文件。正常情况下是直接配置tsconfig
的paths
就可以了,但是CRA
脚手架执行项目(start)时会将其覆盖。可以通过在tsconfig
引入paths.json
,避开这个问题
// tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"extends": "./paths.json",
"include": ["src", "config"]
}
根目录新增 paths.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
然后尝试在组件中使用 @ 引入模块吧!
less + css module
css 预处理语言,其实 sass 和 less 都可以吧,也是看个人喜好,然后结合 css module
设置样式作用域。在 config-overrides.js
中追加配置如下:
yarn add less less-loader -D
// config-overrides.js
const { override } = require("customize-cra");
// 需要先安装 yarn add customize-cra-less-loader -D
const addLessLoader = require("customize-cra-less-loader");
module.exports = override(
// ...
// 添加less解析器
addLessLoader({
lessOptions: {
javascriptEnabled: true,
sourceMap: false,
// modifyVars: { '@primary-color': '#1DA57A' },
},
})
// ...
);
之后在项目中使用,不过可能会报下面的错
Cannot find module './index.module.less' or its corresponding type declarations
需要在 react-app-env.d.ts
中追加 less 文件的声明:
declare module '*.less' {
const content: { [className: string]: string };
export default content;
}
设置全局 less 变量,新增 src/styles/global.less
// base variables
@font-size-base: 16px;
@font-size-lg: @font-size-base + 2px;
@font-size-sm: 12px;
// color
@color-green: #00b050;
@color-yellow: yellow;
@color-orange: orange;
@color-red: red;
// animation
@animation-duration-slow: 0.3s;
@animation-duration-base: 0.2s;
@animation-duration-fast: 0.1s;
// z-index list
更新 webpack 的 less 文件处理规则,需要安装 style-resources-loader
const { override, adjustStyleLoaders } = require("customize-cra");
module.exports = override(
//...
adjustStyleLoaders((rule) => {
if (rule.test.toString().includes("less")) {
rule.use.push({
loader: "style-resources-loader",
options: {
patterns: path.resolve(__dirname, "src/styles/global.less"),
injector: "append",
},
});
}
})
);
环境配置
通过 cross-env
和不同的 script
命令注入环境变量,调整 package.json
,示例如下:
"scripts": {
"start": "cross-env REACT_APP_NODE_ENV=dev react-app-rewired start",
"build:test": "cross-env REACT_APP_NODE_ENV=test react-app-rewired build",
"build": "cross-env REACT_APP_NODE_ENV=prod react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
},
之后可以在代码中通过区分 REACT_APP_NODE_ENV
来加载对应的环境配置文件,比如如下的配置文件:
const configMap: Record<Env, IConfig> = {
[Env.Dev]: { ...commonConfig, ...devConfig },
[Env.Test]: { ...commonConfig, ...testConfig },
[Env.Prod]: { ...commonConfig, ...prodConfig },
};
const currentEnv = process.env.REACT_APP_NODE_ENV ?? Env.Dev;
export const envConfig = configMap[currentEnv as Env];
路由配置
基于 react-router v6
,借助 React.lazy
和动态 import 实现路由懒加载
yarn add react-router-dom
根目录新增 routes/index.ts
,路由配置如下:
import React from "react";
const LazyLogin = React.lazy(() => import("@/components/login"));
const LazyHome = React.lazy(() => import("@/components/home"));
export const routes = [
{
/**
* 登录页面
*/
path: "/login",
component: LazyLogin,
},
{
/**
* 主页
*/
path: "/home",
component: LazyHome,
},
];
App.tsx
中新增路由代码
import React, { Suspense } from "react";
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
} from "react-router-dom";
import { routes } from "./routes";
import "antd/dist/antd.min.css";
import "./App.css";
function App() {
const FallbackComponent = null;
return (
<Router>
<Suspense fallback={FallbackComponent}>
<Routes>
{routes.length > 0 &&
routes.map((router) => {
return (
<Route
path={router.path}
element={<router.component />}
key={router.path}
/>
);
})}
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
</Suspense>
</Router>
);
}
export default App;
在布局组件中,可以通过以下的方式插入子页面
import { Outlet } from "react-router";
// ...
<Content className={styles["content"]}>
<Outlet />
</Content>;
状态管理
mobx
可以给 react 增加响应式更新的能力,减少多余组件渲染的开销。参考代码如下:
import { createContext } from "react";
import { makeAutoObservable } from "mobx";
// NOTE 如果要限制,得先确保所有代码只通过 action 修改 store 属性
// 否则渲染过程有打印 warning log,会造成一定的性能损耗
// configure({ enforceActions: "always" });
// Model the application state.
class CommonStore {
count = 0;
constructor() {
makeAutoObservable(this);
}
add = () => {
this.count++;
};
reduce = () => {
this.count--;
};
get getCount() {
return "count is " + this.count;
}
}
export const commonStore = new CommonStore();
export const rootStore = {
commonStore,
};
export const storeContext = createContext(rootStore);
export const StoreProvider = storeContext.Provider;
在 index.tsx
中引入 store
//...
import { rootStore, StoreProvider } from "./store";
ReactDOM.render(
<React.StrictMode>
<StoreProvider value={rootStore}>
<App />
</StoreProvider>
</React.StrictMode>,
document.getElementById("root")
);
还可以写个公共 hooks,供函数组件调用
// useStore.ts
import { useContext } from "react";
import { storeContext } from ".";
export const useStore = () => useContext(storeContext);
在组件中使用
import React from "react";
import { useLocalStore, useObserver } from "mobx-react-lite";
import { useStore } from "@/helper/hooks/useStore";
import styles from "./index.module.less";
function Check() {
const { commonStore } = useStore();
const { add, reduce } = commonStore;
// 定义局部 store,仅限当前组件使用
const todo = useLocalStore(() => ({
// state
title: "Click to toggle",
done: false,
// action
toggle() {
todo.done = !todo.done;
},
// getter
get emoji() {
return todo.done ? "😜" : "🏃";
},
}));
return useObserver(() => (
<div className={styles["container"]}>
<button onClick={add}>add</button> {commonStore.count}
<button onClick={reduce}>reduce</button>
</div>
));
}
export default Check;
webpack 扩展配置
mobx-react
有用到装饰符,CRA 目前还没有内置的装饰器支持,需要增加相应的 babel 插件。如果是只使用 React Hook 和mobx-react-lite
开发,则不需要配置
const {
override,
addDecoratorsLegacy,
disableEsLint,
//...
} = require("customize-cra");
module.exports = override(
addDecoratorsLegacy(),
disableEsLint()
//...
);
disableEsLint
是用于防止报错 Parsing error: Using the export keyword between a decorator and a class is not allowed. Please use 'export @dec class' instead
接口使用规范
这个主要看个人编码喜好吧,我自己定义的接口使用规范涉及三层:
- 基于
axios
创建service
工具函数,在这里设置一些基本配置,比如 api url 前缀、拦截器逻辑、通用的状态码处理、...
// helper/utils/service.ts
import axios from "axios";
import { envConfig } from "@/config";
export const service = axios.create({
baseURL: envConfig.baseUrl,
timeout: 100000,
});
// Add a request interceptor
service.interceptors.request.use(
function (config) {
// Do something before request is sent
return config;
},
function (error) {
// Do something with request error
return Promise.reject(error);
}
);
// Add a response interceptor
service.interceptors.response.use(
function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
return response;
},
function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
return Promise.reject(error);
}
);
- 接口函数,封装具体接口的处理逻辑、异常处理、...
// services/index.ts
import { service } from "@/helper/utils/service.ts";
export async function getUserList(): Promise<IUserData[]> {
const res = await service.get("xxx");
if (res.code !== 200) {
message.error(`message: ${res.msg}, error: ${res.errorMsg}`, 2.5);
return;
}
// NOTE obj2CamelCase 用于转换下划线为驼峰命名,因为前端统一使用驼峰命名
return obj2CamelCase(res.data) as IUserData[];
}
- useRequest(ahooks),业务层消费接口
import { getUserList } from "./services/index.ts";
// 获取用户列表
const { data } = useRequest(getUserList, {
manual: false,
onSuccess: (data) => {
//...
},
});
新建页面
示例代码如下:
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
const : React.FC<RouteComponentProps> = props => {
return (
<div>
</div>
);
}
export default ;
可以结合 vscode 定制页面模板代码(preferences > user snippets),快速导入。比如新增上述模板代码,先选择 typescript react
进入 json 文件,新增以下配置:
{
//...
"quickly hook": {
"prefix": "reacthook",
"body": [
"import React from 'react';",
"import { RouteComponentProps } from 'react-router-dom';",
"",
"const $1: React.FC<RouteComponentProps> = props => {",
"",
"\treturn (",
"\t\t<div>",
"\t\t\t",
"\t\t</div>",
"\t);",
"}",
"",
"export default $1;"
],
"description": "quick reacthook"
}
//...
}
之后在 tsx 文件中,输入 reacthook
就可以通过提示导入模板代码
单测
前端可以通过 Jest 做一些逻辑和组件的单元测试,场景包括 UI 组件库、utils、公共 hooks 等
CRA 其实已经集成了这个能力,我们可以通过编写满足条件的测试用例文件,来快速使用这个能力
Jest 将使用以下任何流行的命名约定来查找测试文件:
__tests__
文件夹中带有.[js|ts]
后缀的文件- 带有
.test.[js|ts]
后缀的文件 - 带有
.spec.[js|ts]
后缀的文件
这些文件可以放在 src 下任意文件夹中,建议统一放置于 src/__tests__
中。编写完之后可以通过 yarn test
运行测试,Jest 将以 watch
(观察) 模式启动。 每次保存文件时,它都会重新运行测试
更多内容可以参考 https://www.html.cn/create-react-app/docs/running-tests/
sentry
sentry 可以帮助我们监控和收集页面的异常
yarn add @sentry/react @sentry/tracing
然后在 src/index.tsx
中引入即可
import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
Sentry.init({
dsn: "xxx", // TODO 这个需要在 sentry 中注册后获取,create project 时注意选择 react 应用
integrations: [new Integrations.BrowserTracing()],
// 我们建议在生产中调整此值,或使用 tracesSampler 进行更精细的控制
tracesSampleRate: 1.0,
});
之后所有未处理的异常都会被 Sentry 自动捕获,为了验证配置的有效,可以尝试在 App.tsx
中加入这个组件
<button onClick={methodDoesNotExist}>Break the world</button>
然后启动应用,之后可以在 project > 问题
中看到对应的错误信息
sentry 默认是纯英文,可以进入 User settings
修改 language,改为 Simplified Chinese
然后刷新页面
添加 Error Boundary
如果您使用的是 React 16 或更高版本,则可以使用 Error Boundary
组件将组件树内部的 Javascript 错误自动发送到 Sentry,并设置回退 UI
todo
- 区分不同环境
- 定义警报规则
mock
前端经常需要先 mock 数据开发,我们可以通过搭建一个 mock server 来帮助我们快速高效地 mock 接口数据
yarn add koa koa-router koa-bodyparser mockjs nodemon -D
在 src 下新建一个 mock 文件夹,然后在 mock 文件夹下创建 server.js
文件
const Koa = require("koa");
const router = require("koa-router")();
const bodyParser = require("koa-bodyparser");
const testData = require("./test.js");
const app = new Koa();
app.use(bodyParser());
app.use(router.routes());
router.get("/test", async (ctx, next) => {
ctx.body = testData;
await next();
});
// error-handling
app.on("error", (err, ctx) => {
console.error("server error", err, ctx);
});
app.listen(3001);
新增 test.js
,编写测试接口。接口定义建议先和后端对齐
const Mock = require("mockjs");
const data = Mock.mock({
"list|1-10": [
{
"id|+1": 1,
},
],
});
module.exports = data;
然后修改 package.json
"mock": "nodemon src/mock/server.js",
在实际开发的时候,就可以多开一个终端运行 mock server,然后在前端页面直接访问 mock 的接口就可以了
最后
我建立了一个仓库 react-spa-template,集成了以上的能力,欢迎 comment ~
持续更新,更多玩法探索中......