Skip to main content

3 posts tagged with "工程化"

View All Tags

· 4 min read

以前其实做过类似的项目构建上的优化工作,但比较散乱,希望能做一个系统的整理,至少划分出几个基本的优化方向,以作为后续项目优化的参考

这里的构建优化,暂时针对的是 webpack 构建的单页面应用项目

现有问题修复​

当然要优先解决掉项目现有的问题

代码规范配置​

这个是为了提高协作效率,主要是新增以下配置

  • .vscode
  • eslint
  • prettier
  • commitlint
  • husky
  • editorConfig

配置详情可以参考我之前整理的文章 https://blog.zhouweibin.top/FEED/create-react-app/

热更新异常​

触发热更新时,控制台会报错 React Uncaught ReferenceError: process is not defined

原因是 react-error-overlay 最新版本已支持 webpack5 ,如果要适配 webpack4,插件需要回退到 6.0.9。问题参考 https://stackoverflow.com/questions/70368760/react-uncaught-referenceerror-process-is-not-defined

后面升级到 webpack5,其实也就自动 fix 这个问题了

支持本地单独打包​

之前单独打包后,没法用 serve 调试,排查发现是 package.json 的 homepage 改了资源路径,去除后单独构建就正常了

开发体验​

  • 增加构建进度条
  • 增加项目版本输出信息
  • 增强 axios 接口封装函数
  • react 组件局部热更。好像暂不支持 webpack5,留意后续的更新吧

构建速度​

分析工具:

create-react-app@5(CRA5)默认做了蛮多构建优化的工作了,后面打算研究下源码看看

升级 webpack5​

增加了开箱即用的持久化缓存,是 webpack4 的 hardSourcesPlugin/cache-loader/loader cache的有效替代品,rebuild 速度提升明显。当然还有其他的特性和优化,可以参考 webpack5 升级踩坑

升级前后构建耗时参考:

  • 升级前,首次构建 ≈50s,二次构建 ≈18s;build≈110s
  • 升级后,首次构建 ≈42s,二次构建 ≈3.6s;build≈70s

esbuild-loader​

替换 babel-loader,但 CRA5 高度配置的情况下不好替换,估计还得 eject 配置,待定

包体积优化​

分析工具:

优化方法​

  • 移除不用的依赖
  • 大体积包按需引入
    • echarts 按需引入(210k-->69k)
    • antv/G2 按需加载暂不支持,现在是项目使用的最大的包,后续有计划统一换成 echarts
    • loadsh 属于其他模块引入,暂不处理
    • ant-design 增加按需引用 babel-plugin-import 插件,发现实际并没效果,应该是 antd 本身已支持 tree-shaking,参考这个 issue。antd 的样式应该还是可以通过 babel-plugin-import 进行按需加载,不过 antd5 已经用 css-in-js 重构了样式代码

分包​

分包(code-spliting)的目的是防止多个文件(比如路由懒加载产生的多个文件)重复打包公共的依赖,比如 react、vue、antd 等,同时也能更好地利用浏览器并行下载资源来加速首屏

怎么合理地分包是个值得探究的问题 ~

其他​

最后​

项目构建优化这方面感觉还是有很多东西待探索的,而且不局限于单页面应用,持续更新 ing...

· 6 min read

最近把组内的一个比较大的微前端项目做了下重构。之前是基于 single-spa 结合软链接子应用代码来统一打包,存在以下问题:

  • 基座有额外的维护成本。基座需要适配所有子应用的打包配置,比如 less-loader、ts-loader,这样造成的情况是新增应用的成本较大,有未知的修改基座的成本在,因为可能会修改构建配置
  • 软链接子应用代码的形式,造成打包逻辑较为复杂,中途构建出错需要删除干净所有的软链
  • 不支持子项目使用移动端适配插件,比如 px2rem
  • 不支持子项目定义全局样式,会造成 css 污染
  • css 隔离和 js 沙箱配置难度大

综上,重构目标有几点

  • 去除基座的维护成本。将统一打包改为子应用分别打包,但是会相应地增加一些重复的公共依赖
  • 更靠谱的前端编译。优化前端打包流程,去除软链接,减少前端构建出错的情况
  • 更独立的子项目,涉及样式定义、移动端适配等
  • 更容易接入新的子应用,支持 css 隔离和 js 沙箱

重构步骤​

应用部署方式​

共用一个端口,通过二级目录划分子应用,主应用通过一级目录路由访问对应的子应用

部署方式参考 https://qiankun.umijs.org/zh/cookbook#%E5%A6%82%E4%BD%95%E9%83%A8%E7%BD%B2

基座引入 qiankun​

基座不限技术栈,可以基于 CRA5 新建

yarn add qiankun

修改入口文件 index.ts

import { registerMicroApps, start, setDefaultMountApp } from "qiankun";

registerMicroApps([
{
name: "reactApp",
// 需要确保斜线结尾,防止资源加载异常
entry: "/project/app1/",
container: "#app1-root",
activeRule: "/app1",
},
{
name: "vueApp",
entry: "/project/app2/",
container: "#app2-root",
activeRule: "/app2",
},
]);

// 启动 qiankun
start({
// 去除baidu脚本的跨域限制
// 注意:这种方式只支持异步引入baidu sdk,不支持在script标签引入
excludeAssetFilter: (url) => {
return url.indexOf("api.map.baidu.com") !== -1;
},
});
// 设置默认启动应用
setDefaultMountApp("/app1");

调整 index.html

// ...
<div id="root"></div>
<div id="app1-root"></div>
<div id="app2-root"></div>

异步引入 baidu jssdk​

去除 script 标签引入的代码

<!--引入百度地图api-->
<script
type="text/javascript"
src="https://api.map.baidu.com/api?v=3.0&ak=你的密钥"
></script>

改成以下方式:

function loadBaiduJssdk(url) {
return new Promise((resolve, reject) => {
const scriptEl = document.createElement("script");
scriptEl.type = "text/javascript";
scriptEl.src = url;
document.body.appendChild(scriptEl);
scriptEl.onload = resolve;
scriptEl.onerror = reject;
});
}
// 异步加载
async function initMap() {
await loadBaiduJssdk("https://api.map.baidu.com/api?v=3.0&ak=你的密钥");
}

子应用配置​

CRA5 新增配置​

// config-overrides.js
const setOutputForQiankun = () => (config) => {
config.output.library = `${name}`;
config.output.libraryTarget = "umd";
config.output.globalObject = "window";
return config;
};
// ...
module.exports = {
webpack: override(
setOutputForQiankun(),
// ...
)
devServer: overrideDevServer((config) => {
config.historyApiFallback = true;
config.open = false;
config.headers = {
"Access-Control-Allow-Origin": "*",
};
return config;
}),
}

在 src 下新增 pubilc-path.js(ts 也行)

if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

调整入口文件(src/index.ts),暴露钩子函数。注意根节点要与 container 保持一致(也需要调整 public/index.html,默认根节点 id 为 root)

import "public-path.js";
// ...
function getSubRootContainer(container) {
return container
? container.querySelector("#app1-root")
: document.querySelector("#app1-root");
}

function render(props) {
const { container } = props;
ReactDOM.render(<App />, getSubRootContainer(container));
}

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}

export async function bootstrap() {
console.log("react app bootstraped");
}

export async function mount(props) {
console.log("props from main framework", props);
render(props);
}

export async function unmount(props) {
const { container } = props;
ReactDOM.unmountComponentAtNode(getSubRootContainer(container));
}

以上代码基于 react16

react18 可以参考 https://github.com/ice-lab/icestark/issues/581

路由配置​

basename 和 qianiun 的配置保持一致,如果是 qiankun 访问,需要追加主应用配置的路由前缀,参考如下

// app.js
// ...
return (
<Provider store={STORE}>
<Router
basename={
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.__POWERED_BY_QIANKUN__ ? "/app1" : process.env.REACT_APP_BASEURL
// 在环境变量中增加路由前缀 REACT_APP_BASEURL
// 和 activeRule 对齐,也就是 /app1
}
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<MainView />}
{/* ...其他路由 */}
{/* 路由重定向 */}
<Route path="*" element={<Navigate to="/" />}
</Routes>
</Suspense>
</Provider>
)

构建加速​

因为项目的特殊性,每一次构建都要构建所有子项目,因此可以使用缓存,缓存各项目在主分支上最近可用的 build 包,主要是根据 commitId 判断缓存的有效性

最终达到的效果是只会构建有修改的子项目,其余子项目沿用缓存,无需重新安装依赖和构建

优化 TODO​

  • 理论上还能做 node_modules 的缓存
  • qiankun 提取公共依赖,减少子项目重复安装

参考​

· 24 min read

最近有新的项目需求,突然想整理下新建项目的这个过程,供以后借鉴,或者给他人提供一些思路 ~

完整项目模板参考 Github 地址

技术调研和选型​

技术调研和选型都应该从业务出发,用技术赋能业务。以我们的项目为例,这是一个 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 # 状态管理
└─ ...

规范配置​

目的是统一代码编写格式和规范,助力团队协作,提高代码健壮性和可读性。按照后面的方法增加完配置文件后的目录结构如下:

├─ src
├─ .vscode # vscode 编辑器配置
├─ .husky # husky 配置
├─ .commitlintrc.js
# 代码检查
├─ .stylelintrc.js
├─ .eslintrc.js
# 代码格式化
├─ .prettierrc
├─ .editorConfig
├─ .gitignore
├─ .npmrc
├─ tsconfig.json

.vscode​

现在 vscode 是前端最热门的编辑器,可以约定团队成员统一使用 vscode 编码,然后预设必要的编辑器配置,比如保存自动格式化代码、默认格式化工具采用 prettier 等。在 .vscode 下新建 settings.json

{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}

eslint​

注意只是让 eslint 检查语法和发现错误而不是纠正格式,代码格式由 prettier 统一。可以让同事多注重下代码质量,而不是依赖格式化修正代码错误

yarn add eslint -D
yarn eslint --init
# 通过命令行交互生成 .eslintrc.js

.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",
customSyntax: "postcss-less",
};

增加 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"
],
},
  1. 初始化 husky
yarn prepare
  1. 安装 commitlint。这个插件可以校验提交的 commit 信息是否符合规范,不符合则不可以提交
yarn add @commitlint/cli @commitlint/config-conventional -D
  1. 在根目录下创建 .commitlintrc.js:
module.exports = {
extends: ["@commitlint/config-conventional"],
};
  1. 然后执行以下命令,会自动在生成的 .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 中文配置,自行搜索和配置吧!

扩展 webpack​

基于 react-app-rewired 扩展,这样后续也能享受到 react-scripts 的更新。当然如果是需要高度定制 webpack 配置的话,可以把配置文件导出来(eject)

alias​

比较常规的路径映射有 @ --> src 如下:

import { utils } from "@/helper/utils"
  1. 借助 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 = {
webpack: override(
// 配置alias
addWebpackAlias({
["@"]: path.resolve(__dirname, "src"),
["components"]: path.resolve(__dirname, "src/components"),
["mock"]: path.resolve(__dirname, "src/mock"),
})
),
};
  1. 需要先修改 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 = {
webpack: 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 = {
webpack: 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",
},
});
}
})
),
};

环境配置​

1.默认使用 dotenv 和 env 文件来配置环境变量。CRA 默认有开发(.env.development)和线上环境(.env.production),如果只有这两个环境需要配置,只需要在 src 目录下新建这两个文件,然后配置变量就行了,构建过程会自动加载环境变量,参考官方文档 https://create-react-app.dev/docs/adding-custom-environment-variables/#what-other-env-files-can-be-used

如果不只是这两个环境,可以在 src 下新建 env 文件夹,存放对应的环境配置文件,然后参考以下方式修改 package.json(不同环境对应不同的构建命令)

"scripts": {
"buid:dev": "dotenv -e env/.env.dev react-app-rewired build",
"build:prod": "dotenv -e env/.env.prod react-app-rewired build",
// ...
}

记得安装 dotenv 插件

yarn add dotenv-cli -D

注意变量需要以 REACT_APP_ 开头,其余自定义的变量会被忽略。配置文件参考:

// .env.dev
# 自定义环境变量
REACT_APP_ENV=dev
# 路由前缀
REACT_APP_BASEURL="/"
# 静态资源路径前缀
PUBLIC_URL="/"

2.可以通过 cross-env 和不同的执行命令注入环境变量,调整 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];

这个是相比 dotenv 要多出的一个前置步骤

增加编译进度条​

yarn add chalk@^4 progress-bar-webpack-plugin -D
// config-overrides.js
const chalk = require('chalk');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
// ...
module.exports = {
webpack: override(
// ...
addWebpackPlugin(
new ProgressBarPlugin({
format: ` :msg [:bar] ${chalk.green.bold(':percent')} (:elapsed s)`,
}),
),
}

增加项目信息输出​

// config-overrides.js
const path = require("path");
const exec = require("child_process").execSync;

process.env.REACT_APP_NAME = require("./package.json").name;
process.env.REACT_APP_VERSION = require("./package.json").version;
process.env.REACT_APP_BUILD_TIME = new Date().toLocaleString("zh-CN", {
hour12: false,
});
process.env.REACT_APP_GIT_COMMIT = exec('git rev-parse --short=8 HEAD')
.toString()
.trim();

增加基础函数

// helper/utils/index.ts
/**
* 显示项目配置信息,比如项目名、版本等
*/
export function showProjectInfo() {
log.capsule(
`${process.env.REACT_APP_NAME}`,
`v${process.env.REACT_APP_VERSION}`
);
log.primary(`Build Time: ${process.env.REACT_APP_BUILD_TIME}`);
log.primary(`Last Commit: ${process.env.REACT_APP_GIT_COMMIT}`);
}

然后修改入口文件

// src/index.tsx
import { showProjectInfo } from "./utils";
// ...
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root"),
() => {
// show project info in console
showProjectInfo();
}
);

最终的 config-overrides.js 文件参考 https://github.com/GitHubJackson/react-spa-template/blob/main/config-overrides.js

包体积分析​

借助 webpack-bundle-analyzer 插件

yarn add webpack-bundle-analyzer -D

扩展 webpack 配置

// config-overrides.js
const BundleAnalyzerPlugin =
require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
// ...
addWebpackPlugin(
// ...
new BundleAnalyzerPlugin(),
),

修改 package.json,增加相关的命令行

"scripts": {
// ...
"build:analyzer": "react-app-rewired build --stats && webpack-bundle-analyzer build/bundle-stats.json -m static -r build/bundle-stats.html -O",
}

执行命令后,浏览器打开 build/bundle-stats.html 文件就可以看到项目依赖的体积总览

项目实践​

路由配置​

基于 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

mobx-react 有用到装饰符,CRA 目前还没有内置的装饰器支持,需要增加相应的 babel 插件。如果是只使用 React Hook 和mobx-react-lite 开发,则不需要配置

const {
override,
addDecoratorsLegacy,
disableEsLint,
//...
} = require("customize-cra");

module.exports = {
webpack: 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

接口使用规范​

这个主要看个人编码喜好吧,我自己定义的接口使用规范涉及三层:

  1. 基于 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);
}
);
  1. 接口函数,封装具体接口的处理逻辑、异常处理、...
// 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[];
}
  1. 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 然后刷新页面

添加 ErrorBoundary​

如果您使用的是 React 16 或更高版本,则可以使用 Error Boundary 组件将组件树内部的 Javascript 错误自动发送到 Sentry,并设置回退 UI

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 ~

持续更新,更多玩法探索中......