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

类似于 eslintstylelint 可以帮助检查和修复 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-msgpre-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-rewiredcustomize-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 能正确映射到某个路径下的文件。正常情况下是直接配置 tsconfigpaths 就可以了,但是 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 ~

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