有个显示时间的组件每秒 setState 一次,里面套着的子组件明明什么都没变,控制台里 I am rendering 还是一秒打一次。这类问题在 React 16 之前只能把子组件改写成 class 再继承 PureComponent,一个纯展示的函数组件被迫升级成类,代码一下子胖了一圈。
React.memo 就是来解决这个的。这篇把 React 16 那一批新特性串起来讲一遍,重点在 memo、lazy、Suspense 三个,把它们各自解决什么问题、边界在哪、以及今天在 React 19 上是什么状态说清楚。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- React 16 带来了哪些结构性变化,各自解决什么问题
React.memo怎么用,第二个比较函数的返回值语义为什么容易搞反React.lazy做代码分割的完整链路,以及模块必须默认导出这个硬要求Suspense当年的设想是什么,今天真正落地成了什么- Hooks 在这套演进里处于什么位置
- 这些 API 在 React 19 上还有哪些变化
react 16 给我们带来的一系列重要变化:
render/纯组件能够return任何数据结构,以及createPortalAPI- 新的
context api,尝试代替一部分redux的职责 babel的<>操作符,方便用户返回数组- 异步渲染/时间切片(time slicing),成倍提高性能
componentDidCatch,错误边界,框架层面上提高用户debug的能力- 未来的
Suspense,优雅处理异步副作用 - 未来的
hooks
这份清单是当年写的,现在回头看,除了「时间切片」那条的描述偏乐观(它最后落地成了 React 18 的并发渲染,收益取决于场景,不是无脑提速),其余几条基本都兑现了。createPortal 成了 Modal、Tooltip 这类组件的标准做法;React.createContext 确实吃掉了一部分轻量全局状态的场景;错误边界现在还多了一个 static getDerivedStateFromError 配套。
下面挑三个最常被问到的展开。
# 一、memo
React v16.6.0 出了一些新的包装函数(wrapped functions),其中一种是用于函数组件的、相当于 PureComponent / shouldComponentUpdate 形式的 React.memo()。
React.memo() 是一个高阶函数,它与 React.PureComponent 类似,但作用对象是函数组件而不是类。
先看问题本身。现在有一个显示时间的组件,每一秒都会重新渲染一次,对于 Child 组件我们肯定不希望也跟着渲染:
import React from 'react';
export default class extends React.Component {
constructor(props){
super(props);
this.state = {
date : new Date()
}
}
componentDidMount(){
setInterval(()=>{
this.setState({
date:new Date()
})
},1000)
}
render(){
return (
<div>
<Child seconds={1}/>
<div>{this.state.date.toString()}</div>
</div>
)
}
}
React 的默认行为是父组件重渲染,整棵子树跟着重渲染,不管子组件的 props 有没有变。所以这里 Child 每秒都会被调用一次,哪怕 seconds 一直是 1。
在 16.6 之前,唯一的办法是把它写成类:
class Child extends React.PureComponent {
render(){
console.log('I am rendering');
return (
<div>I am update every {this.props.seconds} seconds</div>
)
}
}
React.memo() 让我们不必为了跳过渲染而放弃函数组件:
function Child({seconds}){
console.log('I am rendering');
return (
<div>I am update every {seconds} seconds</div>
)
};
export default React.memo(Child)
# 1.1 第二个参数的语义
React.memo() 可接受 2 个参数,第一个是函数组件,第二个用于对比 props 控制是否刷新:
import React from "react";
function Child({seconds}){
console.log('I am rendering');
return (
<div>I am update every {seconds} seconds</div>
)
};
function areEqual(prevProps, nextProps) {
if(prevProps.seconds===nextProps.seconds){
return true
}else {
return false
}
}
export default React.memo(Child,areEqual)
这里有个坑要注意。原文说这个参数「与 shouldComponentUpdate() 功能类似」,功能上确实是一回事,但返回值的语义是反的:areEqual 返回 true 表示两次 props 相等、跳过渲染,而 shouldComponentUpdate 返回 true 表示需要更新、执行渲染。
我一开始也是照着 shouldComponentUpdate 的直觉写的,结果写出来的组件一次都不更新,盯着看了半天才反应过来是把布尔值写反了。判断方法很简单,看函数名,它叫 areEqual 而不是 shouldUpdate。
# 1.2 memo 的默认比较是浅比较
不传第二个参数时,React.memo 对 props 做的是浅比较,也就是逐个 key 用 Object.is 比。那结果就是下面这种写法,memo 完全不生效:
<Child config={{ size: 'large' }} onClick={() => doSomething()} />
对象字面量和箭头函数每次渲染都是新引用,浅比较必然不等。想让 memo 真正起作用,父组件那边得配合 useMemo 稳住对象、useCallback 稳住函数。
所以 memo 不是加上就有效的开关,它是一条需要父子两端配合的约定。
这也是为什么我不建议无脑给所有组件套 memo。比较本身有成本,一个渲染开销很小的叶子组件,套上 memo 之后大概率是负优化,还多了一层心智负担。我的判断标准是:这个组件的渲染成本明显(长列表项、图表、富文本),并且它的 props 在父组件频繁更新时确实是稳定的,两个条件同时成立才加。
顺带提一句,React 团队在推进 React Compiler,目标就是自动完成这类记忆化,落地之后手写 memo 的必要性会下降。具体的版本节奏和适用范围建议直接看官方文档,我这边只在 demo 项目里试过,没在生产上跑过。
# 二、lazy
React.lazy 用于做 Code-Splitting,代码拆分。类似于按需加载,渲染的时候才加载代码:
import React, {lazy} from 'react';
const OtherComponent = lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
<div>
<OtherComponent />
</div>
);
}
lazy(() => import('./OtherComponent')) 使用 es6 的 import() 返回一个 promise,类似于:
lazy(() => new Promise(resolve =>
setTimeout(() =>
resolve(
// 模拟ES Module
{
// 模拟export default
default: function render() {
return <div>Other Component</div>
}
}
),
3000
)
));
这段模拟代码其实把 React.lazy 的契约写得很清楚了:它要的是一个返回 Promise 的函数,Promise resolve 出来的对象必须有 default 字段。
所以有个硬要求,被 lazy 加载的模块必须有默认导出。只用具名导出的模块直接 lazy 会拿到 undefined,报的错还挺不直观。绕法是包一层:
const OtherComponent = lazy(() =>
import('./components').then(module => ({ default: module.OtherComponent }))
);
还有一点要说在前面,import() 的路径不能是完全动态拼出来的字符串。打包器需要在构建期静态分析出可能的模块,才能切出对应的 chunk。import('./pages/' + name) 这种写法在 webpack 下会把整个 pages 目录都打进去,在 Vite 下则需要走 import.meta.glob。这个我踩过,页面越加越多之后发现「按需加载」的 chunk 里塞满了根本没用到的路由。
# 2.1 lazy 需要 Suspense 兜底
组件还没加载完的这段时间,页面上得有个东西顶着,这就要引出 Suspense:
const OtherComponent = React.lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
);
}
- 在我们的业务场景中,
OtherComponent可以代表多个条件渲染组件,我们全部加载完成才取消loading - 只要
promise没执行到resolve,suspense都会返回fallback中的loading - 代码简洁,
loading可提升至祖先组件,易聚合,相当优雅地解决了条件渲染
fallback 的粒度值得琢磨一下。放在整个页面外层,用户看到的是整页 loading,体验最差但代码最简单;放在每个懒加载块外层,页面骨架先出来、内容逐块补齐,体验最好但要处理布局跳动。我一般的做法是按视觉分区放,fallback 用和真实内容等高的骨架屏,避免内容加载完之后页面往下窜。
网络失败的情况 Suspense 是不管的,import() 的 Promise reject 之后需要错误边界来接。所以完整的写法通常是 ErrorBoundary 包 Suspense 包 lazy 组件,三层。部署了新版本导致旧 chunk 404 是线上最常见的触发场景,错误边界里做一次 location.reload() 是很多团队的兜底方案。