这篇笔记最早是我 2019 年边读边整理出来的,那时候 React 的正式版还停在 16.x,class 组件是绝对主流,Hooks 才刚从 alpha 走出来。后来我把它翻出来过好几次,发现里面讲的那些东西,组件怎么拆、逻辑怎么复用、状态放在哪、服务端渲染卡在哪一步,跟版本号其实没多大关系,换到 React 19 一样成立。
所以这次我没有把老内容删掉重写,而是原样保留 2019 年的判断和代码,再在每一节后面另起一段,补一句现在通常怎么做。你能看到的不只是一份最佳实践清单,还有 React 这几年是怎么一步步走到今天的。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 组件设计的三条原则,以及为什么
renderXXXX这种拆法是个坑 - 高阶组件、render props、提供者模式、组合组件四种经典模式的取舍
- 为什么 React 单元测试选 Jest 而不是 Mocha,以及 shallow 和 mount 的区别
setState为什么不同步,函数式setState到底解决了什么- Mobx 和 Redux 在数据处理哲学上的根本分歧
- react-router v4 的「动态路由」到底动在哪里
- 服务端渲染的脱水和注水,以及 Next.js 的
getInitialProps为什么巧妙 - Fiber 异步渲染怎么把生命周期切成两个阶段,哪些老生命周期因此被判了死刑
- Suspense 用同步代码写异步操作的原理,以及 Hooks 带来的模式转变
- 每一节对应的现代写法补注,从 React 16 到 React 19
# 一、组件实践
# 1.1 设计原则
先说结论,组件设计上真正管用的就三条:
- 保持接口小,
props数量要少 - 根据数据边界来划分组件,充分利用组合
- 把 state 往上层组件提取,让下层组件只需要实现为纯函数
这三条看着很虚,但每一条背后都对应一类具体的痛。
props 数量多了,第一个受伤的是调用方,每次用这个组件都要回去翻一遍它到底要哪些参数;第二个受伤的是重构,props 就是组件的对外接口,接口越宽,将来想改就越难。按数据边界拆,说的是别按视觉区块拆,而是看这块 UI 依赖哪几个字段,依赖同一份数据的放一起。至于把 state 往上提,好处是下层组件退化成纯函数之后,它的行为完全由入参决定,测试好写,shouldComponentUpdate 也好判断。
这块我自己踩过的坑是第三条用力过猛,把所有 state 一股脑提到根组件,结果任何一个输入框敲一下字整棵树都要 re-render。state 提到「所有需要它的组件的最近公共父节点」就够了,再往上就是负担。
# 1.2 组件划分
任何一个复杂组件都是从简单组件开始的,一开始我们在 render 函数里写的代码不多,但是随着逻辑的复杂,JSX 代码越来越多,于是,就需要拆分函数中的内容
组件长大到什么程度该拆?我的经验是 render 里出现第二层视觉分组的时候就该动手了。但怎么拆很关键,下面这种拆法是最常见的错误示范。
- 在 React 中,有一个误区,就是把 render 中的代码分拆到多个 renderXXXX 函数中去,比如下面这样
class StopWatch extends React.Component {
render() {
const majorClock = this.renderMajorClock();
const controlButtons = this.renderControlButtons();
const splitTimes = this.renderSplitTimes();
return (
<div>
{majorClock}
{controlButtons}
{splitTimes}
</div>
);
}
renderMajorClock() {
//TODO: 返回数字时钟的JSX
}
renderControlButtons() {
//TODO: 返回两个按钮的JSX
}
renderSplitTimes() {
//TODO: 返回所有计次时间的JSX
}
}
用上面的方法组织代码,当然比写一个巨大的 render 函数要强,但是,实现这么多 renderXXXX 函数并不是一个明智之举,因为这些 renderXXXX 函数访问的是同样的 props 和 state,这样代码依然耦合在了一起。更好的方法,是把这些 renderXXXX 重构成各自独立的 React 组件,像下面这样
class StopWatch extends React.Component {
render() {
return (
<div>
<MajorClock>
<ControlButtons>
<SplitTimes>
</div>
);
}
}
const MajorClock = (props) => {
//TODO: 返回数字时钟的JSX
};
const ControlButtons = (props) => {
//TODO: 返回两个按钮的JSX
};
const SplitTimes = (props) => {
//TODO: 返回所有计次时间的JSX
}
我们创造了
MajorClock、ControlButtons和SplitTimes这三个组件,目前,我们并不知道它们是否应该有自己的state,但是从简单开始,首先假设它们没有自己的state,定义为函数形式的无状态组件
拆成独立组件之后,多出来的那层价值是「边界」。原来 renderMajorClock 想读什么就读什么,现在 MajorClock 只能拿到你显式传给它的 props,依赖关系一眼可见。顺带还多了两个好处,一是这三个组件可以单独写单元测试,二是它们各自有了独立的重渲染判断点。
这里有个坑要注意,上面这段示例代码里 <MajorClock> 这些标签没有闭合,是原文顺手写的伪代码。实际项目里要写成 <MajorClock /> 这种自闭合形式,不然 JSX 会把后面的内容当成 children 一路吃进去。
组件 props 的设计
props 定下来了,还得让别人知道每个 props 是什么类型、哪些是必填。老项目里这件事交给 propTypes。
使用 propTypes 来定义组件的 props
const ControlButtons = (props) => {
//TODO: 返回两个按钮的JSX
};
ControlButtons.propTypes = {
activated: PropTypes.bool,
onStart: PropTypes.func.isRequired,
onPause: PropTypes.func.isRequired,
onSplit: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
splits: PropTypes.arrayOf(PropTypes.number)
};
这份 propTypes 写出来其实就是一份接口文档。activated 是可选的布尔量,四个 on 开头的回调都是必填,splits 是数字数组。开发环境下一旦传错类型,控制台会直接给出警告,能挡掉相当一部分低级错误。
顺着上面聊,propTypes 只在开发环境生效,生产构建会被剥掉,所以它不是运行时校验,指望它挡住后端返回的脏数据是不行的。
现在的做法:prop-types 这个包还能用,但主流已经换成 TypeScript,直接给组件的 props 定义 interface,在编译期就把类型问题拦掉,编辑器里还能自动补全。React 官方文档现在也把类型检查这一节的重心放在了 TypeScript 上。如果项目还没上 TS,propTypes 依然是有价值的兜底。
# 1.3 组件内部实现
组件拆好、接口定好之后,剩下的是组件文件内部怎么组织。这三条是老项目里最容易被忽略的细节。
- 尽量每个组件都有自己专属的源代码文件
- 用解构赋值(destructuring assignment)的方法获取参数 props 的每个属性值
- 利用属性初始化(property initializer)来定义 state 和成员函数
一个文件一个组件,好处是文件名就是组件名,找代码不用全局搜索;解构赋值则是把这个组件用到哪些 props 摆在函数第一行,读代码的人不用往下扫二十行才拼出依赖列表。
属性初始化方法
尽量不要在 JSX 中写内联函数(inline function),比如这样写,是很不恰当的
<ControlButtons
activated={this.state.isStarted}
onStart={() => { /* TODO */}}
onPause={() => { /* TODO */}}
onReset={() => { /* TODO */}}
onSplit={() => { /* TODO */}}
/>
当然,按照上面那种写法,也可以完成程序的功能,但是,会带来性能的代价。首先,每一次渲染这段
JSX,都会产生全新的函数对象,这是一种浪费;其次,因为每一次传给ControlButtons的都是新的props,这样ControlButtons也无法通过shouldComponentUpdate对props的检查来避免重复渲染
正确的写法是用类的属性初始化语法,把这些回调定义成组件的成员:
class StopWatch extends React.Component {
state = {
isStarted: false
};
onStart = () => {
this.setState({ isStarted: true });
};
render() {
return (
<ControlButtons
activated={this.state.isStarted}
onStart={this.onStart}
/>
);
}
}
用箭头函数配合属性初始化定义成员方法,还顺手解决了 this 绑定问题,不用再在构造函数里写一堆 this.onStart = this.onStart.bind(this)。整个组件生命周期内 this.onStart 都是同一个函数对象,传给子组件的 props 就稳定了。
那内联函数是不是一律不能写?也不是。如果子组件本来就不做 props 比较,或者这个子组件是个原生 <button>,内联函数带来的开销小到可以忽略,为了这点开销把代码写得七零八落反而得不偿失。真正要在意的是列表渲染里给上百个子项各挂一个新函数这种场景。
现在的做法:函数组件里对应的手段是 useCallback 把回调缓存住,配合 React.memo 做 props 浅比较。React 19 之后官方推的 React Compiler 会自动做这类记忆化,理论上不用再手写 useCallback,不过它的启用方式和适用范围还在演进,具体以官方文档为准。我自己目前的做法还是老老实实手写。
# 二、组件设计模式
这一章是整篇笔记里最值钱的部分。高阶组件、render props、提供者模式、组合组件这四个东西,回答的都是同一个问题,怎么在不同组件之间复用逻辑而不是复用 UI。理解了这四种模式的取舍,再去看 Hooks 为什么长成那个样子,会顺畅很多。