前端精读《React16 新特性》:从 v16.0 到 v16.9 的完整演进图谱
前端精读《React16 新特性》:从 v16.0 到 v16.9 的完整演进图谱
导读:本文以本仓库 《React16 新特性》 为骨架,结合仓库内 React Error Boundaries、Suspense 改变开发方式、React Hooks、React's new Context API、DOM diff 原理详解 等姊妹篇,按发布时间线完整梳理 React 16 各版本(v16.0~v16.9)的核心新特性。读者读完可以掌握 Fiber 架构、Error Boundaries、Portal、Fragment、新版 Context API、生命周期更新、memo/lazy/Suspense、Hooks 等能力的原理、用法与适用边界,并理解它们如何共同推动 React 从“View 库”走向“前端框架”。
React 16 是 React 历史上变化最剧烈的一个大版本:内核被 Fiber 彻底重写,渲染、生命周期、状态共享、异步方案全部被重新定义。2017 年 9 月 26 日 Facebook 发布 React v16.0,随后以每两三个月一个小版本的速度快速迭代,到 v16.6 时已引入大量振奋人心的新特性。本文按照 React 的更新时间脉络,从 v16.0 到 v16.9 逐一精读。
1 版本全景:React v16.0 ~ v16.9 更新一览
| 版本 | 发布时间 | 核心新特性 |
|---|---|---|
| v16.0 | 2017.09 | render 返回数组与字符串、Error Boundaries、createPortal、自定义 DOM 属性、体积缩减、Fiber |
| v16.1 | 2017.10 | react-call-return |
| v16.2 | 2017.11 | Fragment |
| v16.3 | 2018.03 | createContext、createRef、forwardRef、生命周期函数更新、Strict Mode |
| v16.4 | 2018.05 | Pointer Events、更新 getDerivedStateFromProps |
| v16.5 | 2018.09 | Profiler |
| v16.6 | 2018.10 | memo、lazy、Suspense、static contextType、static getDerivedStateFromError |
| v16.7(~Q1 2019) | 规划中 | Hooks |
| v16.8(~Q2 2019) | 规划中 | Concurrent Rendering |
| v16.9(~mid 2019) | 规划中 | Suspense for Data Fetching |
(注:v16.7 之后的三个版本在原文档写作时点尚未正式发布,属于官方规划路线,后文会结合已落地版本说明其最终形态。)
2 React v16.0:Fiber 重构与新渲染能力
v16.0 是整个 React 16 的基石版本,它带来了渲染层的多项能力升级,并用 Fiber 重写了核心算法。
2.1 render 支持返回数组和字符串
在 React 15 中,组件的 render 必须返回单个根元素,所有兄弟节点都要被一个外层包裹节点装起来。React 16 允许 render 直接返回数组或字符串,数组中的每个元素会作为独立节点挂载到 DOM 上,不再需要额外的包裹元素:
// 不需要再将元素作为子元素装载到根元素下面
render() {
return [
<li/>1</li>,
<li/>2</li>,
<li/>3</li>,
];
}
这一变化降低了 DOM 层级,也为后面 Fragment 的正式登场做了铺垫(见第 4 节)。
2.2 Error Boundaries:优雅的错误处理策略
React 15 在渲染过程中遇到运行时错误,会导致整个 React 组件树崩溃,而且错误信息不明确、可读性差。React 16 支持了更优雅的错误处理策略:如果一个错误是在组件的渲染或者生命周期方法中被抛出,整个组件结构就会从根节点中卸载,而不影响其他组件的渲染,可以利用 error boundaries 进行错误的优化处理。
一个最基本的 Error Boundary 组件长这样:
class ErrorBoundary extends React.Component {
state = { hasError: false };
componentDidCatch(error, info) {
this.setState({ hasError: true });
logErrorToMyService(error, info);
}
render() {
if (this.state.hasError) {
return <h1>数据错误</h1>;
}
return this.props.children;
}
}
错误捕获的边界(重要):Error Boundary 只能捕获渲染阶段与生命周期方法中抛出的运行时错误,以下场景无法被捕获:
- 回调事件——执行时机不在渲染周期内,需要自行 try/catch;
- 异步代码——如
setTimeout、requestAnimationFrame; - 服务端渲染;
- Error Boundary 组件自身触发的错误——它只能捕获其子组件的错误。
这一点在仓库姊妹篇 《React Error Boundaries》 中有更详细的分析:即便 React 官方 API 也只能捕获运行时错误,编译时错误(编译环境错误、框架错误检查、TS/Flow 类型错误)无能为力。此外,虽然函数式组件无法定义 Error Boundary,但 Error Boundary 可以捕获函数式组件(含 Hooks)内抛出的错误——只要错误发生在渲染或生命周期回调内。
2.3 createPortal:脱离文档流的组件渲染
createPortal 的出现为弹窗、对话框等脱离文档流的组件开发提供了便利,替换了之前不稳定的 API unstable_renderSubtreeIntoContainer。在代码上可以做兼容处理:
const isReact16 = ReactDOM.createPortal !== undefined;
const getCreatePortal = () =>
isReact16
? ReactDOM.createPortal
: ReactDOM.unstable_renderSubtreeIntoContainer;
使用 createPortal 可以快速创建 Dialog 组件,且不需要牵扯到 componentDidMount、componentDidUpdate 等生命周期函数:
import React from "react";
import { createPortal } from "react-dom";
class Dialog extends React.Component {
constructor() {
super(props);
this.node = document.createElement("div");
document.body.appendChild(this.node);
}
render() {
return createPortal(<div>{this.props.children}</div>, this.node);
}
}
事件冒泡行为值得注意:通过 createPortal 渲染的 DOM,事件可以从 portal 的入口端冒泡上来,如果入口端存在 onDialogClick 等事件,createPortal 中的 DOM 也能够被调用到。这让 Portal 在保留“渲染在 body 下”的同时,依然能参与 React 组件树的事件体系,是 Modal、Tooltip 等组件的首选实现方式。
2.4 支持自定义 DOM 属性
以前的 React 版本,DOM 元素不识别除 HTML 和 SVG 支持的以外的属性。React 16 会将全部属性传递给 DOM 元素,摆脱了可用的 React DOM 属性白名单限制。原文档作者曾写过用于过滤非 DOM 属性的工具方法 filter-react-dom-props,16 之后即不再需要这类方法——这既省掉了属性过滤的样板代码,也意味着自定义 data-* 等属性可以直接透传。
2.5 减少文件体积
React 16 使用 Rollup 针对不同的目标格式进行代码打包,由于打包工具的改变,库文件大小得到缩减,官方给出的对比数据为:
- React 库大小从 20.7kb(压缩后 6.9kb)降低到 5.3kb(压缩后 2.2kb)
- ReactDOM 库大小从 141kb(压缩后 42.9kb)降低到 103.7kb(压缩后 32.6kb)
- React + ReactDOM 库大小从 161.7kb(压缩后 49.8kb)降低到 109kb(压缩后 43.8kb)
2.6 Fiber:React 核心算法的重新实现
Fiber 是对 React 核心算法的一次重新实现,它将原本同步的更新过程碎片化,避免主线程的长时间阻塞,使应用渲染更加流畅。
在 React 16 之前,更新组件时会调用各个组件的生命周期函数、计算和比对 Virtual DOM、更新 DOM 树,整个过程是同步进行、中途无法中断的。当组件树比较庞大、更新操作耗时较长时,浏览器唯一的主线程被组件更新独占,无法响应用户输入或动画渲染,严重影响用户体验。
Fiber 利用分片的思想,把一个耗时长的任务分成很多小片,每个小片运行时间很短。每个小片执行完后,把控制权交还给 React 负责任务协调的模块:如果有紧急任务就优先处理,没有就继续更新,从而给其他任务执行的机会,唯一的主线程不会被独占。
因此,组件更新时可能一个更新任务还没完成,就被更高优先级的更新打断;高优先级更新会优先处理完,而低优先级更新所做的工作会完全作废,等待机会重头再来。React Fiber 把一次更新分为两个阶段:
- 第一阶段 Reconciliation Phase:找出需要更新的 DOM,这个阶段可以被打断;
- 第二阶段 Commit Phase:无法被打断,完成 DOM 的更新并展示。
在使用 Fiber 后,需要检查与第一阶段相关的生命周期函数,避免逻辑的多次或重复调用:
componentWillMountcomponentWillReceivePropsshouldComponentUpdatecomponentWillUpdate
与第二阶段相关的生命周期函数(可以安全地依赖“只执行一次”):
componentDidMountcomponentDidUpdatecomponentWillUnmount
延伸阅读:Fiber 的分片与中断能力,本质上是为后续 Concurrent Rendering(并发渲染)打下的底层基础。关于 React 如何做 diff,仓库姊妹篇 《DOM diff 原理详解》 详细对比了 React“仅右移策略”与 Vue 双指针 + 最长递增子序列两种同层 diff 算法;而 《DOM diff 最长上升子序列》 则深入到 React 调度器在 diff 中的应用,可作为理解 Fiber 协调阶段的补充。
3 React v16.1:Call Return——父组件驱动的子组件渲染
3.1 react-call-return(独立 npm 包)
react-call-return 目前还是一个独立的 npm 包,主要针对父组件需要根据子组件的回调信息去渲染子组件的场景提供解决方案。
在 React 16 之前,针对上述场景一般有两种解决方案,且各有缺陷:
- 方案一(两次渲染):先让子组件初始化渲染,通过回调函数把信息传给父组件,父组件完成处理后更新子组件 props,触发子组件的第二次渲染。子组件需要经过两次渲染周期,可能造成渲染的抖动或闪烁等问题;
- 方案二(cloneElement):在父组件通过
children获得子组件并读取其信息,利用React.cloneElement克隆产生新元素并将新的属性传递进去,父组件 render 返回克隆产生的子元素。虽然只需要一个生命周期,但父组件代码编写会比较麻烦。
React 16 支持的 react-call-return 提供了两个函数:unstable_createCall(父组件使用)和 unstable_createReturn(子组件使用)。父组件发出 Call,子组件响应这个 Call,即 Return:
- 在父组件 render 函数中返回对
unstable_createCall的调用:第一个参数是props.children,第二个参数是一个回调函数,用于接受子组件响应 Call 所返回的信息,第三个参数是props; - 在子组件 render 函数中返回对
unstable_createReturn的调用:参数是一个对象,这个对象会在unstable_createCall第二个回调函数参数中访问到; - 当父组件下的所有子组件都完成渲染周期后,由于子组件返回的是对
unstable_createReturn的调用所以并没有渲染元素,unstable_createCall的第二个回调函数参数会被调用,这个回调函数返回的才是真正渲染子组件的元素。
import React from "react";
import { unstable_createReturn, unstable_createCall } from "react-call-return";
const Child = props => {
return unstable_createReturn({
size: props.children.length,
renderItem: (partSize, totalSize) => {
return (
<div>
{props.children} {partSize} / {totalSize}
</div>
);
}
});
};
const Parent = props => {
return (
<div>
{unstable_createCall(
props.children,
(props, returnValues) => {
const totalSize = returnValues
.map(v => v.size)
.reduce((a, b) => a + b, 0);
return returnValues.map(({ size, renderItem }) => {
return renderItem(size, totalSize);
});
},
props
)}
</div>
);
};
适用场景评估:对普通场景来说,react-call-return 有点过度设计的感觉;但在特定场景下它的作用非常明显,比如渲染瀑布流布局时,利用 react-call-return 可以先缓存子组件的 ReactElement,等必要的信息足够之后父组件再触发 render,完成渲染。需要说明的是,该 API 带有 unstable_ 前缀,属于实验性能力,最终并未以稳定 API 的形式进入正式版本。
4 React v16.2:Fragment
Fragment 组件的作用是将一些子元素添加到 DOM tree 上,且不需要为这些元素提供额外的父节点,相当于 render 返回数组元素的声明式写法:
render() {
return (
<Fragment>
Some text.
<h2>A heading</h2>
More text.
<h2>Another heading</h2>
Even more text.
</Fragment>
);
}
相比 v16.0 的数组返回值,Fragment 有两个明显优势:
- 语义清晰——它明确表达“这是一组兄弟节点”,而不是一个 JS 数组字面量;
- 可以携带
key属性,在列表渲染场景中替代数组返回值的写法。
在后来的版本中,Fragment 还获得了短语法 <>...</>(空标签),进一步简化了写法。仓库姊妹篇 《React Hooks》 中的示例也大量使用 <>...</> 短语法包裹兄弟节点,可见该能力已被社区广泛接受。
5 React v16.3:新版 Context API、Ref 规范化与新生命周期
v16.3 是 React 16 中 API 层面变化最大的一次,它引入了全新的 Context API、规范了 Ref 的获取方式,并对生命周期体系做了结构性调整。
5.1 createContext:全新的 Context API
全新的 Context API 可以很容易穿透组件而无副作用,它包含三部分:React.createContext、Provider、Consumer。
React.createContext是一个函数,它接收初始值并返回带有 Provider 和 Consumer 组件的对象;- Provider 组件是数据的发布方,一般在组件树的上层并接收一个数据的初始值;
- Consumer 组件是数据的订阅方,它的
props.children是一个函数,接收被发布的数据,并且返回 React Element;
const ThemeContext = React.createContext("light");
class ThemeProvider extends React.Component {
state = { theme: "light" };
render() {
return (
<ThemeContext.Provider value={this.state.theme}>
{this.props.children}
</ThemeContext.Provider>
);
}
}
class ThemedButton extends React.Component {
render() {
return (
<ThemeContext.Consumer>
{theme => <Button theme={theme} />}
</ThemeContext.Consumer>
);
}
}
在仓库姊妹篇 《React's new Context API》 中,对这一 API 的工程意义做了更深入的剖析:
- 替换旧的 Context API:像 react-redux、mobx-react、react-router 都使用了旧 Context API,新 API 是官方级的稳定替代,将废弃旧的
context透传机制; - 绕过 shouldComponentUpdate:像 redux、mobx-react 这类库都曾使用
forceUpdate绕过shouldComponentUpdate机制;新 Context API 同样可以在 context 改变时,直接更新即使shouldComponentUpdate: false的组件; - 多 Context 嵌套问题:多个 Context 同时存在可能出现 JSX 嵌套地狱,可以通过拆分模块,或构造聚合 Consumer(如
ThemeAndLanguageConsumer)解决。
该文还特别指出,新 Context API 给了开发者创建多个独立 context 的能力,未来一个项目可以同时并存多套数据流框架而互不影响;但它并不是银弹,数据流框架的核心竞争力不在依赖注入,而在对数据的处理。
5.2 createRef / forwardRef:Ref 获取方式规范化
React 16 规范了 Ref 的获取方式,通过 React.createRef 取得 Ref 对象:
// before React 16
···
componentDidMount() {
const el = this.refs.myRef
}
render() {
return <div ref="myRef" />
}
···
// React 16+
constructor(props) {
super(props)
this.myRef = React.createRef()
}
render() {
return <div ref={this.myRef} />
}
···
旧的字符串 ref(ref="myRef")被淘汰,createRef 返回一个 { current: null } 对象,在组件挂载后通过 this.myRef.current 访问 DOM 节点。
React.forwardRef 是 Ref 的转发,它能够让父组件访问到子组件的 Ref,从而操作子组件的 DOM。React.forwardRef 接收一个函数,函数参数有 props 和 ref:
const TextInput = React.forwardRef((props, ref) => (
<input type="text" placeholder="Hello forwardRef" ref={ref} />
));
const inputRef = React.createRef();
class App extends Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
handleSubmit = event => {
event.preventDefault();
alert("input value is:" + inputRef.current.value);
};
render() {
return (
<form onSubmit={this.handleSubmit}>
<TextInput ref={inputRef} />
<button type="submit">Submit</button>
</form>
);
}
}
注意原文档示例中 this.myRef 与 inputRef 并存,实际提交时读取的是 inputRef.current.value。forwardRef 的典型使用场景是:父组件需要直接操作子组件内部的输入框 DOM(如聚焦),而函数式子组件无法像类组件那样持有自己的实例引用,只能通过 forwardRef 把 ref 转发到内部的 DOM 元素上。
5.3 生命周期函数的更新:Fiber 两阶段模型的直接产物
React 16 采用新的内核架构 Fiber,将组件更新分为 Render Phase 和 Commit Phase 两个阶段,因此引入了 getDerivedStateFromProps、getSnapshotBeforeUpdate 及 componentDidCatch 三个全新的生命周期函数,同时也将 componentWillMount、componentWillReceiveProps 和 componentWillUpdate 标记为不安全的方法。
static getDerivedStateFromProps(nextProps, prevState)
getDerivedStateFromProps(nextProps, prevState) 的作用是根据传递的 props 来更新 state。它的一大特点是无副作用:由于处在 Render Phase 阶段,在每次更新都会触发该函数;API 设计上采用静态方法,使其无法访问实例、无法通过 ref 访问到 DOM 对象等,保证了该函数的纯粹高效。
为了配合未来的 React 异步渲染机制,React v16.4 对 getDerivedStateFromProps 做了一些改变,使其不仅在 props 更新时被调用,setState 时也会被触发。配套的使用建议:
- 如果改变 props 的同时有副作用产生,这时应该使用
componentDidUpdate; - 如果想要根据 props 计算属性,应该考虑将结果 memoization 化;
- 如果想要根据 props 变化来重置某些状态,应该考虑使用受控组件。
static getDerivedStateFromProps(props, state) {
if (props.value !== state.controlledValue) {
return {
controlledValue: props.value,
};
}
return null;
}
getSnapshotBeforeUpdate(prevProps, prevState)
getSnapshotBeforeUpdate(prevProps, prevState) 会在组件更新之前获取一个 snapshot,并可以将计算的值或从 DOM 得到的信息传递到 componentDidUpdate(prevProps, prevState, snapshot) 函数的第三个参数,常用于 scroll 位置定位等场景(如列表插入新项后保持滚动位置不跳动)。
componentDidCatch(error, info)
componentDidCatch 让开发者可以自主处理错误信息,诸如错误展示、上报错误等,用户可以创建自己的 Error Boundary 来捕获错误。它与 5.5 节的 static getDerivedStateFromError 构成了 Error Boundary 的完整双 API:前者负责“渲染 fallback UI 前的状态更新”,后者负责“错误副作用(上报、日志)”。
componentWillMount(nextProps, nextState)
componentWillMount 被标记为不安全,因为在其中获取异步数据或进行事件订阅等操作会产生一些问题,比如无法保证在 componentWillUnmount 中取消掉相应的事件订阅,或者导致多次重复获取异步数据等问题。
componentWillReceiveProps(nextProps) / componentWillUpdate(nextProps, nextState)
componentWillReceiveProps / componentWillUpdate 被标记为不安全,主要是因为操作 props 引起的 re-render 问题,并且对 DOM 的更新操作也可能导致重新渲染(在 Render Phase 阶段做 DOM 操作是 Fiber 无法接受的)。
5.4 Strict Mode:开发阶段的潜在问题探测器
StrictMode 可以在开发阶段开启严格模式,发现应用存在的潜在问题,提升应用的健壮性,主要能检测下列问题:
- 识别被标记为不安全的生命周期函数
- 对弃用的 API 进行警告
- 探测某些产生副作用的方法
- 检测是否使用
findDOMNode - 检测是否采用了老的 Context API
class App extends React.Component {
render() {
return (
<div>
<React.StrictMode>
<ComponentA />
</React.StrictMode>
</div>
);
}
}
StrictMode 只影响被包裹的子树,且在开发模式下才生效,不会影响生产构建,是渐进式迁移到新生命周期的有力工具。
5.5 static getDerivedStateFromError(v16.6 补充)
虽然该 API 在 v16.6 才正式推出,但与生命周期更新属于同一主题,在此一并说明:static getDerivedStateFromError(error) 允许开发者在 render 完成之前渲染 Fallback UI。该生命周期函数触发的条件是子组件抛出错误,它接收到错误参数后更新 state:
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}
componentDidCatch(error, info) {
// You can also log the error to an error reporting service
logErrorToMyService(error, info);
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
两个 API 的分工:只要定义了 static getDerivedStateFromError 或 componentDidCatch 任意一个,组件就会成为 Error Boundary。最佳实践是二者配合使用——getDerivedStateFromError 负责同步更新 state 触发 fallback 渲染,componentDidCatch 负责错误上报等副作用,且建议将 Error Boundary 单独封装为组件,不要与业务组件耦合(错误检测只对子组件生效)。
6 React v16.4:Pointer Events 与 getDerivedStateFromProps 更新
6.1 Pointer Events:统一指针设备事件模型
指针事件是为指针设备触发的 DOM 事件,旨在创建单个 DOM 事件模型来处理指向输入设备,例如鼠标、笔/触控笔或触摸(例如一个或多个手指)。指针是一个与硬件无关的设备,可以定位一组特定的屏幕坐标。拥有指针的单个事件模型可以简化 Web 站点和应用程序的创建,并提供良好的用户体验,无论用户硬件如何。对于需要特定于设备处理的场景,指针事件定义了一个 pointerType 属性,用于检查产生事件的设备类型。
React 新增了以下指针事件:onPointerDown / onPointerMove / onPointerUp / onPointerCancel / onGotPointerCapture / onLostPointerCapture / onPointerEnter / onPointerLeave / onPointerOver / onPointerOut。
兼容性注意:这些事件只能在支持指针事件规范的浏览器中工作。如果应用程序依赖指针事件,建议使用第三方指针事件 polyfill。
6.2 getDerivedStateFromProps 更新
如 5.3 节所述,v16.4 将 getDerivedStateFromProps 的触发时机从“仅 props 更新”扩展为“props 更新与 setState 都会触发”,这是为了配合未来的 React 异步渲染机制而做的语义调整。
7 React v16.5:Profiler——渲染性能的可视化分析
React 16.5 添加了对新的 profiler DevTools 插件的支持。这个插件使用 React 的 Profiler 实验性 API 去收集所有 component 的渲染时间,目的是找出 React App 的性能瓶颈。它将会和 React 即将发布的时间片特性完全兼容——这与 Fiber 的分片模型一脉相承:只有渲染被切分后,“每个片耗时多少”“哪些组件渲染慢”才具有可观测、可优化的意义。该能力后续演化为稳定的 <Profiler> 组件与 onRender 回调 API。
8 React v16.6:性能优化与异步能力三件套
8.1 memo:函数组件版的 shouldComponentUpdate
React.memo() 只能作用在简单的函数组件上,本质是一个高阶函数,可以自动帮助组件执行 shouldComponentUpdate(),但只是执行浅比较,其意义和价值有限:
const MemoizedComponent = React.memo(props => {
/* 只在 props 更改的时候才会重新渲染 */
});
React.memo 与 React.PureComponent 对等:前者作用于函数组件,后者作用于类组件。当父组件 re-render 时,memo 包装的组件在 props 浅比较相等的情况下跳过重新渲染,是函数组件时代最基础的性能优化手段。需要说明的是,浅比较意味着嵌套对象/数组内部变化不会被感知,必要时可传入自定义比较函数作为第二个参数。
8.2 lazy / Suspense:动态 import 与暂停渲染
React.lazy() 提供了动态 import 组件的能力,实现代码分割。Suspense 的作用是在等待组件时 suspend(暂停)渲染,并显示加载标识。在 React v16.6 中,Suspense 只支持一个场景,即使用 React.lazy() 和 <React.Suspense> 实现的动态加载组件:
import React, { lazy, Suspense } from "react";
const OtherComponent = lazy(() => import("./OtherComponent"));
function MyComponent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
);
}
lazy 接收一个返回 Promise 的函数(动态 import 返回的就是 Promise),在 Promise resolve 之前,Suspense 渲染 fallback;resolve 后渲染真正的组件。这为路由级/组件级代码分割提供了官方原生方案,替代了此前社区常用的 react-loadable 等库。
仓库姊妹篇 《Suspense 改变开发方式》 深入解释了 Suspense 的本质机制:“无论是什么异常,JavaScript 都能捕获,React 就是利用了这个语言特性,通过 ComponentDidCatch 捕获了所有生命周期函数、render 函数等,以及事件回调中的错误”。Suspense 要求代码 suspended——即抛出一个可以被捕获的 Promise 异常,在这个 Promise 结束后再渲染组件。具体规则是:
- 调用 render 函数 → 发现有异步请求;
- 暂停渲染,等待异步请求结果;
- 渲染展示数据。
该文还对比了三种异步取数方案的演进(本地状态管理 → Context 管理 → Suspense 管理),并指出 Suspense 方案的核心价值在于:“将异步的状态管理与 UI 组件分离,所有 UI 组件都无需关心 Pending 状态,而是当作同步去执行”,从而可以自由决定 Loading 状态的范围组合——在最外层加一个 Suspense 兜底,在任意层级嵌套 Suspense 控制更细粒度的 Loading。
8.3 static contextType:类组件中便捷访问 Context
static contextType 为 Context API 提供了更加便捷的使用体验,可以通过 this.context 来访问 Context:
const MyContext = React.createContext();
class MyClass extends React.Component {
static contextType = MyContext;
componentDidMount() {
const value = this.context;
}
componentDidUpdate() {
const value = this.context;
}
componentWillUnmount() {
const value = this.context;
}
render() {
const value = this.context;
}
}
static contextType = MyContext 声明后,this.context 就是最近 Provider 提供的值。相比 5.1 节的 Consumer 渲染函数模式,contextType 让类组件在生命周期函数(componentDidMount、componentDidUpdate、componentWillUnmount)中也能同步读取 Context 值——这正是 Consumer 模式做不到的(Consumer 只能在 render 中通过函数子元素读取)。它相当于为类组件提供了 Context 的“实例级”访问通道。
9 React v16.7(~Q1 2019):Hooks——状态逻辑复用的革命
9.1 Hooks 要解决的问题
Hooks 要解决的是状态逻辑复用问题,且不会产生 JSX 嵌套地狱,其特性如下:
- 多个状态不会产生嵌套,依然是平铺写法;
- Hooks 可以引用其他 Hooks;
- 更容易将组件的 UI 与状态分离。
function App() {
const [open, setOpen] = useState(false);
return (
<>
<Button type="primary" onClick={() => setOpen(true)}>
Open Modal
</Button>
<Modal
visible={open}
onOk={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</>
);
}
在仓库姊妹篇 《React Hooks》 中,Hooks 被定位为继 render-props 和高阶组件(HOC)之后的第三种状态共享(状态逻辑复用)方案:它只共享数据处理逻辑,不共享数据本身,且不会产生 JSX 嵌套地狱。Hooks 可以引用其他 Hooks,从而通过组合构建更上层的自定义 Hook:
// 底层 Hooks, 返回布尔值:是否在线
function useFriendStatusBoolean(friendID) {
const [isOnline, setIsOnline] = useState(null);
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
useEffect(() => {
ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
};
});
return isOnline;
}
// 上层 Hooks,根据在线状态返回字符串:Loading... or Online or Offline
function useFriendStatusString(props) {
const isOnline = useFriendStatusBoolean(props.friend.id);
if (isOnline === null) {
return "Loading...";
}
return isOnline ? "Online" : "Offline";
}
这个例子体现了“有状态的组件没有渲染,有渲染的组件没有状态”的理念:useFriendStatusBoolean 与 useFriendStatusString 是有状态的组件(使用 useState),没有渲染(返回非 UI 的值),可以作为 Custom Hooks 被任何 UI 组件调用;FriendListItem 与 FriendListStatus 是有渲染的组件(返回了 JSX),没有状态,是纯函数 UI 组件。
9.2 Hooks 的底层实现:不是 Proxy,而是数组/链表
Hooks 并不是通过 Proxy 或者 getters 实现,而是通过数组实现:每次 useState 都会改变下标,如果 useState 被包裹在 condition 中,那每次执行的下标就可能对不上,导致 useState 导出的 setter 更新错数据。因此产生了著名的 Hooks 调用规则:
- 只在顶层调用 Hooks,不要在循环、条件或嵌套函数中调用;
- 只在 React 函数组件或自定义 Hook 中调用 Hooks。
正因为实现依赖“调用顺序即下标”,React 官方给出了 eslint-plugin-react-hooks 插件来在编译期强制执行这一约定,这也是 React 第一次将“约定优先”理念引入框架。
9.3 useEffect:用 Hooks 组合替代细碎生命周期
useEffect 的代码既会在初始化时执行,也会在后续每次 rerender 时执行,而返回值在析构时执行(相当于 componentWillUnmount 的清理逻辑)。对比 React 版 G2 图表的调用流程,可以明显看到 Hooks 将分散在 componentDidMount / componentWillReceiveProps / componentWillUnmount 三个生命周期中的代码,整合成了一个完整的代码块:
function App() {
const ref = React.useRef(null);
let chart: G2.Chart = null;
React.useEffect(() => {
chart = new G2.Chart({
container: ReactDOM.findDOMNode(ref.current) as HTMLDivElement,
width: 500,
height: 500
});
// do something
chart.render();
return () => chart.destroy();
}, []);
return <div ref={ref} />;
}
结合仓库姊妹篇 《Function VS Class 组件》 的分析,函数组件由于不存在 this.props 的语法,props 总是不可变的(Class 中 this.props 每次访问的都是最新的 props),配合 Hooks 后函数组件的能力不断丰富,因此社区开始用 Function Component 取代 Stateless Component 的说法。
10 React v16.8(~Q2 2019):Concurrent Rendering——不阻塞主线程的渲染
10.1 并发渲染的核心思想
Concurrent Rendering 并发渲染模式是在不阻塞主线程的情况下渲染组件树,使 React 应用响应性更流畅。它允许 React 中断耗时的渲染,去处理高优先级的事件(如用户输入),还能在高速连接时跳过不必要的加载状态,用以改善 Suspense 的用户体验。
从实现原理看,并发渲染正是 Fiber 分片架构的最终形态:Fiber 把渲染拆成可中断的 Reconciliation Phase(找出需要更新的 DOM)与不可中断的 Commit Phase(提交 DOM 更新),并发渲染则进一步赋予调度器按优先级中断、暂停、恢复渲染的能力。在原文档写作时,Concurrent Rendering 尚未正式发布,也没有详细文档,需要等待 React 团队的正式发布;这一能力最终以 Concurrent Mode(并发模式) 与 createRoot API 的形式在 React 18 落地。
10.2 对开发者的影响
并发渲染带来的直接后果,是 Render Phase 的生命周期函数(componentWillMount、componentWillReceiveProps、componentWillUpdate 等)可能被多次调用或延迟调用——这正是 v16.3 标记它们为不安全、并引入无副作用的 getDerivedStateFromProps 的原因。因此,迁移到新生命周期是享受并发渲染的前提。
11 React v16.9(~mid 2019):Suspense for Data Fetching——同步写法做异步取数
11.1 用同步代码表达异步流程
Suspense 通过 ComponentDidCatch 实现用同步的方式编写异步数据的请求,并且没有使用 yield / async / await。其流程为:
调用 render 函数 → 发现有异步请求 → 暂停渲染,等待异步请求结果 → 渲染展示数据。
无论是什么异常,JavaScript 都能捕获,React 就是利用了这个语言特性,通过 ComponentDidCatch 捕获了所有生命周期函数、render 函数等,以及事件回调中的错误。如果有缓存则读取缓存数据;如果没有缓存,则会抛出一个异常 promise,利用异常做逻辑流控制。这是一种拥有较深调用堆栈时的手段,它是在虚拟 DOM 渲染层做的暂停拦截,代码可在服务端复用。
import { fetchMovieDetails } from "../api";
import { createFetch } from "../future";
const movieDetailsFetch = createFetch(fetchMovieDetails);
function MovieDetails(props) {
const movie = movieDetailsFetch.read(props.id);
return (
<div>
<MoviePoster src={movie.poster} />
<MovieMetrics {...movie} />
</div>
);
}
createFetch 封装出的 resource 对象提供 read(id) 方法:数据已就绪时同步返回数据;数据未就绪时抛出一个 Promise,让最近的 <Suspense> 捕获并暂停渲染。组件内部的代码读起来完全是同步的,这正是“Suspense 让异步代码像同步一样书写”的核心体验。
11.2 与原文档写作时的差异
原文档写作时(2019 年中),Suspense for Data Fetching 尚处于规划状态(v16.9,~mid 2019)。如仓库姊妹篇 《Suspense 改变开发方式》 所述:“在原文写作的时候,Suspense 仅能对 React.lazy 生效,但现在已经可以对任何异步状态生效了,只要符合 Pending 中 throw promise 的规则”。也就是说,只要资源对象在 Pending 状态抛出 Promise,Suspense 就能拦截,无论是组件代码分割还是数据取数都遵循同一套机制。
该文还给出了一个重要的工程提醒:fallback 最小出现时间的保护间隔(避免快速完成时 Loading 一闪而过)在当时还是一个 Open Issue;即使官方不支持,也可以通过让 fallback 组件自身实现计时逻辑来规避——200ms 以内返回 null,超过 200ms 再渲染 <Spin />。
12 总结:从 View 库到前端框架的转身
从 React 16 的一系列更新和新特性中可以窥见,React 已经不仅仅在做一个 View 展示库,而是想要发展成为一个包含 View / 数据管理 / 数据获取等场景的前端框架。把 v16.0~v16.9 串起来看,演进逻辑非常清晰:
| 能力维度 | 代表特性 | 演进主线 |
|---|---|---|
| 内核架构 | Fiber → Concurrent Rendering | 同步渲染 → 分片可中断 → 并发调度 |
| 渲染能力 | 数组/字符串返回 → Fragment → Portal | 摆脱单根节点限制,DOM 操作更自由 |
| 错误处理 | Error Boundaries → getDerivedStateFromError | 从“崩溃卸载”到“可定制 fallback + 错误上报” |
| 状态共享 | createContext → static contextType → Hooks | 从“依赖注入”到“状态逻辑复用” |
| 异步方案 | lazy/Suspense → Suspense for Data Fetching | 从“代码分割”到“数据获取的统一暂停模型” |
| 性能优化 | memo、Profiler、体积缩减 | 渲染优化 + 可观测性 |
以 React 团队的技术实力和想法,业界对其未来充满期待;不过这一系列更新也意味着学习曲线变陡——框架的复杂度被封装到了更深的地方,对开发新手们渐渐不再友好。但反过来看,正是这些新特性(尤其是 Hooks 与 Suspense)构成了今天 React 生态的基石,理解 v16 这段演进史,也就理解了现代 React 的设计哲学。
延伸阅读(本仓库相关精读):
- 精读《React Error Boundaries》 —— Error Boundary 的边界细节与 Hooks 场景
- 精读《Suspense 改变开发方式》 —— Suspense 的 throw promise 机制与三种取数方案对比
- 精读《React Hooks》 —— Hooks 的底层实现、约定与自定义 Hook 组合
- 精读《React's new Context API》 —— 新版 Context 的工程意义与数据流思考
- 精读《Function VS Class 组件》 —— 函数组件的 props 不可变性与捕获渲染
- 精读《DOM diff 原理详解》 / 精读《DOM diff 最长上升子序列》 —— Fiber 协调阶段的算法底层
- 精读《React 18》 —— 并发渲染的最终落地形态