Fizzy Zhang

本文目录

  1. 概述
  2. 1. 创建项目
    1. 1.1 包管理 pnpm
    2. 1.2 国内镜像
  3. 2. webpack 打包配置
  4. 3. 使用 CSS
    1. 3.1 CSS Module
    2. 3.2 PostCSS
    3. 3.3 分离打包CSS
  5. 4. 使用 React 和 TS
    1. 增加 TS 支持
  6. 5. 规范编码 Lint
    1. 5.1 ESLint
    2. 5.2 StyleLint
  7. 6. 使用 Prettier 格式化项目
  8. 7. 客户端路由
  9. 8. 开发生产分离
    1. 8.1 开发环境配置
      1. 8.1.1 使用 source-map
      2. 8.1.2 本地代理服务器
      3. 8.1.3 “热更新”
    2. 8.2 生产环境配置
      1. 8.2.1 压缩产物
      2. 8.2.2 移除调试输出语句
    3. 8.3 其他优化配置
      1. 8.3.1 配置合并
      2. 8.3.2 外部 CDN 加载
      3. 8.3.3 多线程打包
      4. 8.3.4 treeShaking
  10. 9. 扩展
    1. 9.1 antd
    2. 9.2 redux

react 项目搭建

概述

示例项目地址:https://github.com/zzyxka/fizzy-react

  1. 创建项目:包管理、镜像源
  2. webpack 打包配置:基础打包配置文件、使用 plugin 处理 html
  3. 在项目中使用 CSS:CSS Module, postCSS, 分离打包CSS
  4. 在项目中使用 React 和 TS - Babel
  5. 规范编码 Lint - ESLint, StyleLint
  6. 使用 Prettier 格式化项目
  7. 客户端路由 ReactRouter(Lazy Loading 方案)
  8. 开发生产分离:devServer、HMR;treeShaking、css 压缩;多线程打包、外部 CDN 等等
  9. 扩展
    1. UI 库:ant-design
    2. 状态管理 redux
    3. 其他流行方案:CSS IN JS 的 styled-component、Tailwind CSS 等不赘述

1. 创建项目

1
2
3
mkdir fizzy-react
cd fizzy-react
npm init -y

1.1 包管理 pnpm

package.json 限定只允许使用 pnpm 安装项目依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "fizzy-react",
"version": "1.0.0",
"description": "",
"main": "index.js", // 移除
"private": "true", // 增加
"scripts": {
"preinstall": "npx only-allow pnpm" // 增加
},
"keywords": [],
"author": "",
"license": "ISC"
}

注意:

  1. 当前还无法限制单个包安装,比如 npm i antd 将不会被拦截,而 npm i/install 会被拦截。参见:https://github.com/pnpm/only-allow/issues/1
  2. 至于拦截的原理,参考官方说明的内容,即执行 npm install 会执行的 life cycle script,参见:https://docs.npmjs.com/cli/v9/using-npm/scripts#npm-install
  3. 为什么要用 pnpm 而不是 npm ?- 待补充
  4. 设置 private 和移除 main 的原因?可参考:https://docs.npmjs.com/cli/v9/configuring-npm/package-json

1.2 国内镜像

创建 .npmrc 便于从国内镜像站安装依赖:

1
registry=https://registry.npmmirror.com

2. webpack 打包配置

  1. 创建 index.ejs(html), src/entry.js 开始准备基础打包配置
  2. 安装 webpack,创建 webpack.config.js,配置打包入口、出口
  3. 配置 clean 属性,打包时清除历史产物
  4. 安装并配置 HtmlWebpackPlugin 插件,使 html 模板自动引入产物(及其他操作 html 模板的动作)
  5. package.json 增加打包 scripts "build": "webpack --mode production"
1
2
// src/entry.js
document.body.innerHTML = '<h1>Hello Fizzy React !</h1>';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 2. pnpm install webpack webpack-cli --save-dev
// 4. pnpm install --save-dev html-webpack-plugin

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin'); // 4

module.exports = {
entry: './src/entry.js',
plugins: [
new HtmlWebpackPlugin({ // 4
template: 'index.ejs'
}),
],
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
clean: true, // 2
},
};

注意:

  1. pnpm install 安装时,–save-dev 的作用是什么?待补充
  2. entry 入口配置,可以配置为多入口 { a: ‘./a’, b: ‘./b’ }
  3. output 输出配置,output.filename 中的 [name] 占位符,实际上是 entry 配置为多入口(对象)打包时,对象的 key 值,对应打包产物 /dist 下的文件名plugins 配置,各种插件可以在打包过程中完成各种文件操作以实现某些所需功能。
  4. 关于 HtmlWebpackPlugin 还能做哪些事,详见 https://github.com/jantimon/html-webpack-plugin
  5. webpack --mode production 参数放在配置文件中亦可,下文分离打包环境,使用 mode: “development” 作为开发环境的 mode 参数。不同的 mode 设置会根据当前打包场景不同(生产/本地开发)来设置相关的“功能”(参数或插件等),比如生产打包代码压缩等。详见 https://webpack.js.org/configuration/mode/#mode-development

3. 使用 CSS

1
pnpm install --save-dev style-loader css-loader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
entry: './src/entry.js',
// 配置 loader
module: {
rules: [
{
test: /\\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: 'index.ejs'
}),
],
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};

如上述步骤,安装相关 loader 并配置 webpack,使项目支持在 js 文件里通过 import './style.css'; 引入 CSS 样式文件。

1
2
3
4
/* style.css */
h1 {
color: red;
}
1
2
3
4
// entry.js
import './style.css';

document.body.innerHTML = '<h1>Hello Fizzy React !</h1>';

注意:

  1. loader 的作用:webpack 本身只能识别 js 文件,如果需要打包/解析其他文件(如 .css .png 等),就需要相关的 loader 将其转换为 webpack 能够识别的文件。上述代码编写后,运行 npm run build 观察打包结果,可以发现 index.html 中 head 标签内,被插入了 style 标签,其内容就是 style.css 内容,这就是 css-loader 和 style-loader 共同作用的结果,一个用于解析 CSS ,另一个用于插入 style 标签。
  2. Module loaders can be chained. Each loader in the chain applies transformations to the processed resource. A chain is executed in reverse order. The first loader passes its result (resource with applied transformations) to the next one, and so forth. Finally, webpack expects JavaScript to be returned by the last loader in the chain.loader 链式加载,以相反(从右至左)的顺序分别使用对应的 loader 处理文件,并传递给下一个 loader,最终转换文件为能被 webpack 识别的 js
  3. The above order of loaders should be maintained: ‘style-loader’ comes first and followed by ‘css-loader’. If this convention is not followed, webpack is likely to throw errors.注意处理 css 文件时,两个 loader 的顺序问题。详见:https://webpack.js.org/guides/asset-management/#loading-css

3.1 CSS Module

通过上述步骤,可以发现 CSS 中的文件,最终会放到一起,那就产生了“命名冲突导致样式覆盖”的问题,为了避免这一问题,可以配置 css-loader 的 modules 属性,使其支持 css module ,在不同的模块,增加不同的 hash 后缀。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// webpack.config.js
// ...

module.exports = {
entry: './src/entry.js',
module: {
rules: [
{
test: /\\.css$/i,
use: [
'style-loader',
// 配置 css-loader
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[path][name]__[local]--[hash:base64:5]",
localIdentContext: path.resolve(__dirname, "src"),
localIdentHashSalt: "react-fizzy",
},
},
}
],
},
],
},
// ...
};

注:css-loader modules 配置详见:https://webpack.js.org/loaders/css-loader/#modules

3.2 PostCSS

CSS 也有很多预处理的解决方案,比如:less, sass, postCSS。less/sass 之类,需要创建相应的 .less .scss 文件,开发中也并不会用到该语言的全部特性,所以,这里选择用 postCSS 增加一些原生 CSS 扩展(草案)功能(语法),如:自动增加浏览器前缀(-webkit- 等)、支持嵌套语法。

关于 PostCSS 介绍参考:

  • https://postcss.org/
  • https://github.com/postcss/postcss#usage
  • PostCSS: A tool for transforming CSS with JavaScript
    • Increase code readability 提高代码可读性,自动浏览器前缀 Autoprefixer
    • Use tomorrow’s CSS today 现在就使用未来的 CSS 特性
    • The end of global CSS 前文已讲述 css-modules
    • Avoid errors in your CSS 后续讲述,使用 stylelint 在开发阶段避免 CSS 语法错误等
    • … …
  • PostCSS 的理念:https://postcss.org/docs/postcss-architecture
  • You can start using PostCSS in just two steps:
    1. Find and add PostCSS extensions for your build tool.
    2. Select plugins and add them to your PostCSS process.

按照上述指示,安装 postcss-loader 、配置 webpack 并创建 postcss.config.js 文件来使用相关的 postCSS 插件功能,如:增加浏览器兼容前缀 autoprefixer

1
2
pnpm add -D postcss-loader postcss
pnpm add -D autoprefixer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// webpack.config.js
module.exports = {
entry: './src/entry.js',
module: {
rules: [
{
test: /\\.css$/i,
use: [
// ...
'postcss-loader' // 在原有基础上,增加 postcss 处理 css 文件
],
},
],
},
};
1
2
3
4
5
6
7
8
// postcss.config.js
// 单独创建文件便于维护,也可以配置在 webpack.config.js 中对应位置

module.exports = {
plugins: [
'autoprefixer',
],
};

在 style.css 中,增加 user-select: none; 并执行打包,打开浏览器调试可以发现属性内核前缀。

正如官方描述所说,当前很多 CSS3 属性已经标准化,很大一部分已经不用声明浏览器内核前缀了,当前还会生成前缀的属性,详见:https://github.com/postcss/autoprefixer#debug

同样的方式,安装并配置 postcss-preset-env,使 css 支持草案语法。

1
2
3
4
5
6
7
8
9
10
11
/* style.css */

.h1 {
color: red;
user-select: none;

/* 使用嵌套语法 */
span {
color: blue;
}
}

使用 vscode 注意:安装 PostCSS Intellisense and Highlighting 插件,防止 stylelint 误报,如嵌套等草案语法错误。(注意:如果选择安装 PostCSS Language Support 这个插件会导致 vscode CSS 属性/值补全失效)

3.3 分离打包CSS

到现在为止,CSS 内容都是被打包到 js 中,然后插入到页面的 head 标签中,并没有分离出独立的 css 文件,接下来,使用 MiniCssExtractPlugin 来分离 CSS 文件以及做进一步优化。

参考:https://webpack.js.org/plugins/mini-css-extract-plugin/#minimizing-for-production

This plugin extracts CSS into separate files. It creates a CSS file per JS file which contains CSS. It supports On-Demand-Loading of CSS and SourceMaps.

此插件可将 CSS 提取到单独的文件中。它会为每个包含 CSS 的 JS 文件创建一个 CSS 文件。它支持 CSS 和源码图的 “按需加载”。

1
pnpm add -D mini-css-extract-plugin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const path = require('path');
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); // 引入插件
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
entry: './src/entry.js',
module: {
rules: [
{
test: /\\.css$/i,
use: [
// 不再需要 style-loader
MiniCssExtractPlugin.loader, // 添加插件提供的 loader
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[path][name]__[local]--[hash:base64:5]",
localIdentContext: path.resolve(__dirname, "src"),
localIdentHashSalt: "react-fizzy",
},
},
},
'postcss-loader'
],
},
],
},
plugins: [
new MiniCssExtractPlugin(), // 使用插件
new HtmlWebpackPlugin({
template: 'index.ejs'
}),
],
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};

执行 npm run build 观察产物,可以发现已经独立引入 css 文件 <link href="main.css" rel="stylesheet">

4. 使用 React 和 TS

像是 PostCSS 生态一样,Babel 也有生态,处理各种各样的 js 扩展语法。

关于 babel 的介绍:

  • https://babeljs.io/
  • Babel is a JavaScript compiler. Use next generation JavaScript, today.
  • Babel is a toolchain that is mainly used to convert ECMAScript 2015+ code into a backwards compatible version of JavaScript in current and older browsers or environments. Here are the main things Babel can do for you:
    • Transform syntax
    • Polyfill features that are missing in your target environment (through a third-party polyfill such as core-js)
    • Source code transformations (codemods)
    • And more! (check out these videos for inspiration)

Babel 有自己的命令行,安装 Babel 相关依赖,并配置 babel.config.json 相关内容,在命令行运行即可将项目中的高级语法转换为兼容浏览器运行的 js 文件。但我们不会每次运行项目,先运行 babel-cli 再运行 webpack-cli,所以需要借助 babel-loader 来将其配置再 webpack 中。

就如 webpack 的 entry/output/loader/plugin 一样,Babel 也有几个 核心概念:Plugins & Presets、 Polyfill

Plugins & Presets

使用 Plugins(插件) 来做转换,而 Presets(预设) 像是一组 Plugins 的合集。比如:可以组合使用 箭头函数 等插件完成 ES6+ 的语法转换,也可以直接使用 @babel/preset-env 来转换所有 ES6+ 语法。同理,使用 @babel/preset-react 转换所有 react/jsx 语法。同理还有 @babel/preset-typescript。

Polyfill

使用 Plugins & Presets 只是转换了语法,而一些 ES6+ 出现的新对象,就需要 Polyfill 出马,来在旧版本中”模拟“创建这个对象。

需要酌情考虑,毕竟引入一个完整的 polyfill js 文件会占一定体积,影响首屏加载速度。如果必须要,考虑用 CDN 加载或者只导入部分需要用的内容。

As of Babel 7.4.0, this package has been deprecated in favor of directly including core-js/stable (to polyfill ECMAScript features)

1
import "core-js/stable";

The @babel/polyfill module includes core-js and a custom regenerator runtime to emulate a full ES2015+ environment.

This means you can use new built-ins like Promise or WeakMap, static methods like Array.from or Object.assign, instance methods like Array.prototype.includes, and generator functions (when used alongside the regenerator plugin). The polyfill adds to the global scope as well as native prototypes like String in order to do this.

Importing "core-js" loads polyfills for every possible ECMAScript feature: what if you know that you only need some of them? When using core-js@3, @babel/preset-env is able to optimize every single core-js entrypoint and their combinations. For example, you might want to only polyfill array methods and new Math proposals:

1
2
import "core-js/es/array";
import "core-js/proposals/math-extensions";

You can read core-js‘s documentation for more information about the different entry points.

为项目支持 ES6+ 和 React,安装相关依赖,并完成配置:

1
2
3
4
pnpm install --save-dev babel-loader @babel/core
pnpm install --save-dev @babel/preset-env @babel/preset-react
pnpm add core-js@3 # polyfill
pnpm add react react-dom

babel.config.json

1
2
3
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
1
2
3
4
5
6
// webpack.config.js 省略其他内容

{
test: /\\.js|jsx$/i,
use: 'babel-loader',
}
1
2
3
4
5
6
7
8
9
10
// entry.js
import "core-js";
import React from 'react';
import { createRoot } from 'react-dom/client';
import style from './style.css';

document.body.innerHTML = '<div id="app"></div>'

const root = createRoot(document.getElementById('app'));
root.render(<h1 className={style.h1}>Hello <span>Fizzy</span> React !</h1>);

增加 TS 支持

1
2
3
pnpm add typescript -D
pnpm add --save-dev @babel/preset-typescript
pnpm install --save-dev @types/react @types/react-dom

babel.config.js

1
2
3
4
5
6
7
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript"
]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// entry.tsx
// import "core-js";
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

document.body.innerHTML = '<div id="app"></div>';
const rootElement = document.getElementById('app') ?? document.body;
const root = createRoot(rootElement);
root.render(<App />);

// App.tsx
import React from 'react';
import style from './style.css';

export default function App (): React.ReactElement<any, any> {
return (
<div><h1 className={style.h1}>Hello <span>Fizzy</span> React !</h1></div>
)
}

在 webpack 中配置引入文件忽略 .ts .tsx 后缀

1
2
3
resolve: {
extensions: ['.ts', '.tsx'],
},

关于 tsx 中引入 style.css 在 vscode 内报错:找不到模块“./style.css”或其相应的类型声明。ts(2307)

参考:https://github.com/Microsoft/TypeScript/issues/30055

解决方案:

  1. 在项目下创建 /src/typings/global.d.ts 内容为
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
declare module '*.css' {
const classes: Record<string, string>;
export default classes;
}
declare module '*.less' {
const classes: Record<string, string>;
export default classes;
}
declare module 'process' {
const classes: Record<string, string>;
export default classes;
}

declare module '*.svg';

declare interface Window {
selfIncrementingReactListKey?: number;
}
  1. 在项目根目录创建 tsconfig.json 来 include src 下的 ts 模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
"compilerOptions": {
"outDir": "build",
"baseUrl": ".",
"allowJs": true,
"strict": false,
"module": "esnext",
"target": "esnext",
"sourceMap": true,
"jsx": "react",
"skipLibCheck": true,
"noUnusedLocals": false,
"esModuleInterop": true,
"noImplicitReturns": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"lib": [
"esnext",
"dom",
"WebWorker"
],
"forceConsistentCasingInFileNames": true,
"paths": {}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.d.ts"
],
"exclude": [
"node_modules",
"build"
]
}

5. 规范编码 Lint

5.1 ESLint

参考:https://eslint.org/docs/latest/use/getting-started

1
npm init @eslint/config

执行上述命令,按步骤选择即可,注意最后选择 pnpm 安装方式。

安装后,.eslintrc.js 需要增加 parserOptions.project = ["./tsconfig.json"],否则 ESlint 报错无法运行。报错如下:

1
Error: Error while loading rule '@typescript-eslint/dot-notation': You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.

关于 .tsx 文件顶部注释下述报错,可通过 Add "strictNullChecks": true to the "compilerOptions" object in tsconfig.json did the trick 解决,可能需要重启 vscode。

参考:https://github.com/standard/eslint-config-standard-with-typescript/issues/481

1
Error: "This rule requires the strictNullChecks compiler option to be turned on to function correctly @typescript-eslint/strict-boolean-expressions" #481

最后,可以在 ESlint 规则文件中,自定义相关规则,满足团队编码习惯。

5.2 StyleLint

1
pnpm install --save-dev stylelint stylelint-config-standard

创建 .stylelintrc.json 配置文件,可以在 rules 中增加一些规则,如 max-nesting-depth 设置嵌套层级不超过2层:

1
2
3
4
5
6
{
"extends": "stylelint-config-standard",
"rules": {
"max-nesting-depth": 2
}
}

更多规则详见:https://stylelint.io/user-guide/rules

StyleLint 也支持安装一些 plugins 来满足 CSS lint /格式化的一些需求,如使用 stylelint-order 规范 CSS 的属性排序。

1
pnpm install stylelint-order --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"extends": "stylelint-config-standard",
"plugins": [
"stylelint-order"
],
"rules": {
"max-nesting-depth": 3,
"order/properties-order": [
"position",
"top",
"right",
"bottom",
"left",
"display",
"margin",
"padding",
"width",
"height",
"border",
"background",
"font-size",
"font-weight",
"color"
]
}
}

stylelint-order 的其他功能详见:https://www.npmjs.com/package/stylelint-order

vscode/settings.json 设置保存时,自动使用 stylelint 格式化,css 属性顺序就会按照配置文件纠正。

1
2
3
"editor.codeActionsOnSave": {
"source.fixAll.stylelint": true
},

6. 使用 Prettier 格式化项目

https://prettier.io/docs/en/

1
2
3
pnpm add --save-dev --save-exact prettier
echo {}> .prettierrc.json # 创建配置文件
echo > .prettierignore # 创建排除配置文件

If you use ESLint, install eslint-config-prettier to make ESLint and Prettier play nice with each other. It turns off all ESLint rules that are unnecessary or might conflict with Prettier. There’s a similar config for Stylelint: stylelint-config-prettier

1
2
pnpm install --save-dev eslint-config-prettier
pnpm install --save-dev stylelint-config-prettier
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// .eslintrc.js
"extends": [
"standard-with-typescript",
"plugin:react/recommended",
"prettier"
],

// .stylelintrc.json
{
"extends": [
"stylelint-config-standard",
"stylelint-config-prettier"
]
}

vscode 安装 Prettier - Code formatter 插件,大功告成,几乎所有类型文件,都可以用它来格式化了。

7. 客户端路由

单页应用的客户端路由方案:ReactRouter,这里基于 v6.15.0 版本。

跟做一遍官网的指南,可以领略到 ReactRouter 的设计理念,它并非是“客户端页面地址管理器,把单页应用变多页应用”这么简单,结合各类 Hook 及开发中容易遇到的业务场景,它有一套完整的设计理念类比“传统”的表单提交和接口请求策略。

而大多数项目中,往往只是把它当做“客户端页面地址管理器”来使用了。先不探讨其各类强大的功能,这里我们先利用它的“基础功能”,将当前项目变成能够切换路由的“多页面项目”。

  1. 安装 react-router-dom 依赖 pnpm add react-router-dom
  2. 创建 src/pages 目录,用于存放独立的页面。对于绝大部分系统,个人感觉没必要多层级创建页面目录,避免嵌套关系引发的各类问题,对于有关联的页面,可以从命名上整合,故此处约定 pages 目录下,第一层级的目录视为页面。创建 /src/pages/home 作为首页,创建 /src/pages/demo-list 作为示例列表页,创建 /src/pages/demo-detail 作为示例详情页
  3. 创建 src/layout 目录,用于存放系统基础框架。在 layout/Index.tsx 下,展示菜单 demo
  4. 创建 src/router.tsx ,改造 src/App.tsx ,渲染 router 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// App.tsx
import React from 'react';
import { RouterProvider } from 'react-router-dom';
import router from './router';

export default function App (): React.ReactElement<any, any> {
return (
<RouterProvider router={router} />
)
}

// router.tsx
import React from 'react';
import { createHashRouter } from 'react-router-dom';
import Layout from './layout/Index';
import Home from './pages/home/Index';
import List from './pages/demo-list/Index';
import Detail from './pages/demo-detail/Index';

const routeConfig = [
{ path: 'demo-list', element: <List /> },
{ path: 'demo-detail', element: <Detail /> }
];

export default createHashRouter([
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <Home /> },
...routeConfig
]
}
]);

// src/layout/Index.tsx
import React from 'react';
import { Outlet } from 'react-router-dom';

function Nav() {
return (
<div>
<h1>Nav</h1>
<div>
<a href="/">Home</a>
<a href="#/demo-list">List</a>
<a href="#/demo-detail">Detail</a>
</div>
</div>
);
}

export default function Index() {
return (
<div>
<Nav /> {/* 框架:当前是菜单/导航,也可以是其他任何内容渲染 */}
<Outlet /> {/* 渲染路由匹配的实际页面内容 */}
</div>
)
}

// src/pages/home/Index.tsx
import React from 'react'

export default function Home() {
return (
<div>Home</div>
)
}

思考:BrowserRouter 和 HashRouter 区别是什么?如何选择?

做完上述4步,已经能够实现基础的导航-页面切换功能,接下来做一些项目中常见/必备的能力:

  1. 实现匹配菜单项高亮功能(使用 NavLink

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // src/layout/Index.tsx
    import React from 'react';
    import { Outlet, NavLink } from 'react-router-dom';
    import style from './layout.css';

    function Nav () {
    const isActiveClassName = ({ isActive }) => isActive ? `${style['nav-item']} ${style.active}` : style['nav-item'];
    return (
    <div className={style['layout-nav']}>
    <h1>Nav</h1>
    <div>
    <NavLink to="/" className={isActiveClassName}>Home</NavLink>
    <NavLink to="demo-list" className={isActiveClassName}>List</NavLink>
    <NavLink to="demo-detail" className={isActiveClassName}>Detail</NavLink>
    </div>
    </div>
    );
  2. 创建 src/components 目录,用于存放公共组件,创建 src/components/PageLoading.tsx,实现页面切换 loading 功能(这里的 loading 是整个项目级别的,需要考虑实际情况是否使用,切换页面时,若目标页面进入时 router.loader 阻塞过久,对应匹配的 NavLink 也是等待匹配状态,会有交互歧义)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    import { useNavigation } from 'react-router-dom';
    // 这里用到了别名,需要在 webpack.config.js 和 tsconfig.json 中设置
    import PageLoading from '@/components/PageLoading';

    export default function Index () {
    const navigation = useNavigation();
    const isLoading = navigation.state === 'loading';
    return (
    <div>
    <Nav />
    {isLoading && <PageLoading />}
    <Outlet />
    </div>
    )
    }

    // webpack.config.js
    resolve: {
    extensions: ['.ts', '.tsx'],
    alias: {
    '@': path.resolve(__dirname, 'src') // 别名设置
    },
    },

    // tsconfig.json
    "paths": {
    "@/*": ["./src/*"],
    }
  3. 按需加载页面资源(访问对应页面时再加载组件 js/css),此处的 Suspense.fallback 中渲染 loading 会更实用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // router.tsx
    import React, { lazy, Suspense } from 'react';
    import { createHashRouter } from 'react-router-dom';
    import Layout from './layout/Index';
    import Home from './pages/home/Index';

    const List = lazy(async () => await import('./pages/demo-list/Index'));
    const Detail = lazy(async () => await import('./pages/demo-detail/Index'));

    const routeConfig = [
    { path: 'demo-list', element: <Suspense fallback={'loading'}><List /></Suspense> },
    { path: 'demo-detail', element: <Suspense fallback={'loading'}><Detail /></Suspense> }
    ];

    export default createHashRouter([
    {
    path: '/',
    element: <Layout />,
    children: [
    { index: true, element: <Home /> },
    ...routeConfig
    ]
    }
    ]);
  4. 解耦独立页面路由配置文件,使项目具备页面自动生成路由。

    为什么这么做?从上一步可以看出,每新增一个页面,我们就要维护 router.jsx 增加路由配置,这样 router.tsx 就会越来越大,且不便于检索对应页面的路由配置,所以我们需要通过某些手段解耦,将对应页面的路由配置,放到对应页面目录下。

    明确下我们的目标,即下述代码注释部分,需要在打包时遍历 pages/* 自动生成页面路由配置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // router.tsx
    import React /*, { lazy, Suspense } */ from 'react';
    import { createHashRouter } from 'react-router-dom';
    import Layout from './layout/Index';
    import Home from './pages/home/Index';

    // const List = lazy(async () => await import('./pages/demo-list/Index'));
    // const Detail = lazy(async () => await import('./pages/demo-detail/Index'));

    // const routeConfig = [
    // { path: 'demo-list', element: <Suspense fallback={'loading'}><List /></Suspense> },
    // { path: 'demo-detail', element: <Suspense fallback={'loading'}><Detail /></Suspense> }
    // ];

    export default createHashRouter([
    {
    path: '/',
    element: <Layout />,
    children: [
    { index: true, element: <Home /> },
    ...routeConfig
    ]
    }
    ]);

​ 详见:实现一个 webpack plugin (TODO: router-plugin)

8. 开发生产分离

8.1 开发环境配置

复制当前配置文件,创建 webpack.dev.js 用于开发环境的打包配置。

在 package.json 中,增加 dev scripts 用于开发环境打包。

1
2
3
4
5
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "webpack serve --config webpack.dev.js --progress --color",
"build": "webpack --config webpack.config.js --progress --color"
},

思考一下开发环境需要的一些能力:

  • 前文提到的 mode - development
  • 易于调试 source-map
  • 本地代理服务器 devServer(包括解决跨域,代理转发转发后端API的能力)
  • 热更新 HMR

8.1.1 使用 source-map

https://webpack.js.org/guides/development/#using-source-maps

1
devtool: 'inline-source-map',

8.1.2 本地代理服务器

https://webpack.js.org/guides/development/#using-webpack-dev-server

1
pnpm install --save-dev webpack-dev-server
1
2
3
4
5
6
7
8
devServer: {
static: {
directory: path.join(__dirname, 'dist'),
},
compress: true,
port: 9000,
open: true,
},

使用 npm run dev 启动项目,此时浏览器自动打开本地 URL,修改页面代码会触发浏览器刷新。

实际开发过程中,我们还需要配置 proxy 来将约定某前缀路由,转发到后端 API,解决本地访问的跨域问题。

举例:项目中 fetch('/api/xxx') 此时,以 /api 开头的请求,我们都通过 proxy 转发到后端实际的开发环境服务器(生产部署使用 ng 进行转发)。

https://webpack.js.org/configuration/dev-server/#devserverproxy

  1. 在 webpack 配置文件中,使用环境变量来代表 /api 即某一类后端请求

    1
    2
    3
    new webpack.DefinePlugin({
    'process.env.POKE_API': JSON.stringify('api'),
    })
  2. 为 devServer 添加 proxy 配置,使页面再请求 origin/api/xxx 时,转发请求到后端服务地址

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    devServer: {
    // ...
    proxy: {
    '/api': {
    target: 'https://pokeapi.co',
    secure: false,
    changeOrigin: true,
    },
    }
    },
  3. 在页面上使用 fetch 发起请求

    1
    2
    3
    4
     (async (): void => {
    const res = await fetch(`${process.env.POKE_API}/v2/pokemon`);
    console.log("res: ", res);
    })();

8.1.3 “热更新”

https://webpack.js.org/guides/hot-module-replacement/

https://github.com/gaearon/react-hot-loader

过去通过 HMR 热更新完成组件变化局部更新的开发体验,从上述参考内容中,可以发现该方式已经被 React Fash Refresh 方式取代。

https://github.com/pmmmwh/react-refresh-webpack-plugin

1
pnpm add -D @pmmmwh/react-refresh-webpack-plugin react-refresh

有变动的相关 webpack.dev.js 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
devServer: {
static: {
directory: path.join(__dirname, 'dist'),
},
compress: true,
port: 9000,
open: true,
hot: true // 开启
},
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: [
{
loader: require.resolve('babel-loader'),
options: {
plugins: [require.resolve('react-refresh/babel')],
},
},
],
}
],
},
plugins: [
// other plugin
new ReactRefreshWebpackPlugin()
],

配置完成后,改变 React Component 内容,将会动态加载到页面上,而不会刷新页面。

为了让独立文件的 css 也能热更新,需要继续修改配置(移除 hash 新产物覆盖旧产物):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
plugins: [
new HtmlWebpackPlugin({
template: 'template/dev.ejs'
}),
new MiniCssExtractPlugin({
filename: "[name].css", // 移除 hash
chunkFilename: "[id].css", // 移除 hash
}),
new ReactRefreshWebpackPlugin(),
],
output: {
filename: '[name].bundle.js', // 移除 hash
path: path.resolve(__dirname, 'dist'),
clean: true,
},

8.2 生产环境配置

思考生产环境的特点:

  • mode - production (不再赘述)
  • 移除 source-map (不再赘述)
  • 压缩产物
  • 移除 console.log 调试打印

8.2.1 压缩产物

压缩 CSS

https://webpack.js.org/plugins/mini-css-extract-plugin/#minimizing-for-production

https://webpack.js.org/plugins/css-minimizer-webpack-plugin/

1
pnpm add -D css-minimizer-webpack-plugin
1
2
3
4
5
6
7
8
9
10
11
12
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");

module.exports = merge(common, {
// ...
optimization: {
minimizer: [
new CssMinimizerPlugin(),
],
},
});

npm run build 观察打包产物 CSS 文件尺寸,此时已被压缩。

其他产物压缩

https://webpack.js.org/plugins/terser-webpack-plugin/

1
pnpm add -D terser-webpack-plugin
1
2
3
4
5
6
7
8
const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
optimization: {
minimize: true,
minimizer: [new TerserPlugin()],
},
};

8.2.2 移除调试输出语句

第一个思路:使用插件及 webpack optimization 相关配置。大量插件配置简单,根据项目/团队所需,可以区分移除 log/info/warn 等语句。

第二个思路:判断环境为 production,覆盖 window 下 console.log 方法,使其不输出打印内容。

两种方法各有优劣,需要按需选择,此处以上一节用于压缩的 TerserPlugin 为例,移除所有注释和打印语句。

https://github.sheincorp.cn/terser/terser#format-options

https://github.sheincorp.cn/terser/terser#compress-options

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
optimization: {
minimize: true,
minimizer: [
new CssMinimizerPlugin(),
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true, // 移除打印语句
},
format: {
comments: false, // 移除注释
}
}
}),
],
},

8.3 其他优化配置

  • 使用 webpack-merge 来合并 开发 和 生产 环境的公共配置
  • 通过 CDN 外部引入依赖,优势:
    • 利用 CDN 的速度优势 和 浏览器并发请求资源能力加速提升程序性能
    • 抽离依赖,打包产物体积缩减
  • 多线程打包,发挥打包机器性能
  • treeShaking

8.3.1 配置合并

  1. 创建 webpack.common.js 用于存放公共配置,修改 config 命名为 prod,便于区分
  2. 分别删除 dev 和 prod 中的公共部分内容
  3. pnpm install --save-dev webpack-merge
  4. 以生产打包配置举例,使用如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// webpack.prod.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
mode: 'production',
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: 'babel-loader',
}
],
},
});

8.3.2 外部 CDN 加载

https://webpack.js.org/configuration/externals/#root

可以简单的理解为 package.json 中 dependencies 依赖库,都可以通过 CDN 加载。

以 React 为例,需要考虑以下内容:

  1. 依赖版本管理,从 package.json 转移到 index.ejs 中加载的 CDN 版本
  2. dev.js 和 prod.min.js 在不同环境需要引入的 CDN 文件不同,可以通过定义两个 html 模板,在 HtmlWebpackPlugin 中,不同的环境选择不同的模板

生产打包相关改动如下(开发类比,不再赘述):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fizzy React - Prod</title>
<style>
body,
div,
p {
margin: 0;
padding: 0;
}
</style>
</head>

<body>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<div id="app"></div>
</body>

</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = merge(common, {
mode: 'production',
externals: { // 此处放在 webpack.common.js 中
react: 'window.React',
'react-dom': 'window.ReactDOM',
'react-dom/client': 'window.ReactDOM',
},
// ...
plugins: [
new HtmlWebpackPlugin({
template: 'template/prod.ejs'
}),
],
});

继续实践处理 react-router-dom@v6,发现产生引用 undefined,追踪报错位置发现,需要先引入 @remix-run/router 和 react-router 才可以再引入 react-router-dom。

1
2
3
4
5
6
7
8
externals: {
react: 'React',
'react-dom': 'ReactDOM',
'react-dom/client': 'ReactDOM',
'@remix-run/router': 'RemixRouter',
'react-router': 'ReactRouter',
'react-router-dom': 'ReactRouterDOM'
},
1
2
3
<script crossorigin src="https://unpkg.com/@remix-run/router@1.8.0/dist/router.umd.min.js"></script>
<script crossorigin src="https://unpkg.com/react-router@6.15.0/dist/umd/react-router.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-router-dom@6.15.0/dist/umd/react-router-dom.production.min.js"></script>

image-20230907140223991

注意:react-router-dom 从 v6 开始,其 @types 由包本身导出,故此处若从 package.json 中移除依赖,会造成 ts 报错提示,安装 @types/react-router-dom 无用(版本低,缺少许多 hooks 的类型声明),故应保留依赖。

1
Cannot find module 'react-router-dom' or its corresponding type declarations. ts(2307)

8.3.3 多线程打包

https://webpack.js.org/loaders/thread-loader/

在 webpack@v4 前,有 happypack 的打包方案,后来 thread-loader 逐渐主流,配置较为简单,这里不再赘述。酌情使用,因为当项目较小时,该 loader 自身开销也需要 600ms,可能造成打包时间,不减反增的局面。

8.3.4 treeShaking

https://webpack.js.org/guides/tree-shaking/

“摇树”,作用是将没有被导入并使用的模块/方法,干净的移除掉。

在 Webpack5 中,Tree Shaking 在生产环境下默认启动。

9. 扩展

扩展章节内容,均为“按需”内容,很多项目往往并不需要这些依赖。

9.1 antd

https://ant.design/docs/react/introduce-cn

如果按照前文提到的 externals 方案,来引入 antd UI 库,应如下所述。

注意:

  1. 根据指引,我们需要在引入 antd.[min].js 之前,引入 day.js 依赖

  2. 与 react-router-dom 一样

    antd 使用 TypeScript 进行书写并提供了完整的定义文件。(不要引用 @types/antd)。

  3. 模板头部引入 reset.css 用来初始化标签样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fizzy React - Dev</title>
<link rel="stylesheet" href="https://unpkg.com/antd@5.9.1/dist/reset.css">
</head>

<body>
<div id="app"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/@remix-run/router@1.8.0/dist/router.umd.min.js"></script>
<script crossorigin src="https://unpkg.com/react-router@6.15.0/dist/umd/react-router.development.js"></script>
<script crossorigin src="https://unpkg.com/react-router-dom@6.15.0/dist/umd/react-router-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/dayjs@1.11.9/dayjs.min.js"></script>
<script crossorigin src="https://unpkg.com/antd@5.9.1/dist/antd.js"></script>
</body>

</html>
1
2
3
4
5
6
7
8
9
10
externals: {
react: 'React',
'react-dom': 'ReactDOM',
'react-dom/client': 'ReactDOM',
'@remix-run/router': 'RemixRouter',
'react-router': 'ReactRouter',
'react-router-dom': 'ReactRouterDOM',
'dayjs': 'dayjs',
'antd': 'antd',
},

虽然这么用一切正常,但观察观察 devTools 可以发现,antd.min.js 即使是 min 也有 1.4MB 需要通过网络加载,它也不像 react 和 router 一样所有页面都会用到,它只会被某些页面用到,一次性全部加载显然不合适。

不妨放弃 externals,来使 antd 能够“按需引入”(默认支持),即:

  1. 用到 Button 则只引入 Button
  2. 在进入实际使用该组件的页面时,才会加载 Button 的 “vendors-…-antd_es_button.js”

9.2 redux

https://redux.js.org/

过去的各种状态管理方案层出不穷,redux 生态相关的方案也是如此。随着时间发展,redux-toolkit 成为官方正统,同样的 react-redux 也称为 react 项目状态管理的官方正统方案。接下来,我们使用二者来为项目引入 redux 状态管理能力。

1
pnpm add @reduxjs/toolkit react-redux
  1. 创建 src/store.ts 用于创建 store,并合并各个 slice reducer

    1
    2
    3
    4
    5
    6
    7
    8
    import { configureStore } from '@reduxjs/toolkit'
    import homeSlice from '@/pages/home/reducer'

    export default configureStore({
    reducer: {
    home: homeSlice,
    },
    })
  2. 在 entry.ts 入口处,使用 Provider 加载 store.ts 中创建的 sotre

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // entry.tsx
    // import "core-js";
    import React from "react";
    import { createRoot } from "react-dom/client";
    import App from "./App";
    import store from "./store";
    import { Provider } from "react-redux";

    const rootElement = document.getElementById("app") ?? document.body;
    const root = createRoot(rootElement);
    root.render(
    <Provider store={store}>
    <App />
    </Provider>,
    );
  3. 在实际使用的页面/模块中,创建 reducer.ts 来维护 reducer-slice,在视图层使用对应 reudx API 来 dispatch action 更新数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    import { createSlice } from '@reduxjs/toolkit'

    export const homeSlice = createSlice({
    name: 'home',
    initialState: {
    value: 0,
    },
    reducers: {
    increment: (state) => {
    // Redux Toolkit allows us to write "mutating" logic in reducers. It
    // doesn't actually mutate the state because it uses the Immer library,
    // which detects changes to a "draft state" and produces a brand new
    // immutable state based off those changes.
    // Also, no return statement is required from these functions.
    state.value += 1
    },
    decrement: (state) => {
    state.value -= 1
    },
    incrementByAmount: (state, action) => {
    state.value += action.payload
    },
    },
    })

    // Action creators are generated for each case reducer function
    export const { increment, decrement, incrementByAmount } = homeSlice.actions

    export default homeSlice.reducer
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    // src/pages/home/Index.tsx
    import React from "react";
    import { useSelector, useDispatch } from "react-redux";
    import { decrement, increment } from "./reducer";

    function Counter(): React.ReactElement<any, any> {
    const count = useSelector((state) => state.home.value);
    const dispatch = useDispatch();

    return (
    <div>
    <div>
    <button
    aria-label="Increment value"
    onClick={() => dispatch(increment())}
    >
    Increment
    </button>
    <span>{count}</span>
    <button
    aria-label="Decrement value"
    onClick={() => dispatch(decrement())}
    >
    Decrement
    </button>
    </div>
    </div>
    );
    }

    export default function Home(): React.ReactElement<any, any> {
    return (
    <div>
    <h1 style={{ textAlign: "center" }}>Hello, Fizzy React!</h1>
    <Counter />
    </div>
    );
    }

    到这里,已经能够使用 redux 管理状态,但并不是终点。为什么这么说?

    • 一方面,redux 的方案,类似 router 配置,我们还需要做自动引入和按需加载。

    • 另一方面,redux 的理念很好,但是围绕状态管理,它并不是万金油。如果使用 redux 仅仅是为了把状态管理独立于视图代码外、避免层层组件传递,那么越来越多基于 hooks 的轻量框架已经足够完成(甚至只使用自定义 hooks 就可以满足)。