React 18 一:架构与核心概念
React 18 的改动都绕着同一件事:让一次渲染在中途停得下来。Reconciler 算差异、Scheduler 决定什么时候算、Renderer 落到 DOM,Fiber 和 lane 是让它们停得下来、排得出先后的那两样东西。
一个搜索框,下面挂着几千条结果。每敲一个字符光标都要顿一下 —— 而你没有任何办法告诉 React「先把输入框里的字画出来,列表慢慢来」。
顿的原因不在 diff 快不快。React 16 之前,一次 setState 引发的渲染是一趟递归:从根组件往下一层层调用,调用栈压下去就没有出口。栈上的东西没法存盘,所以这趟活要么一口气跑完,要么整个作废从头再来 —— 中间没有任何一个地方能插进一帧。
React 18 能停。后面几篇要拆的东西 —— 可中断的渲染、Suspense、流式 SSR —— 全都建在「停得下来」这一件事上。
停不下来,是因为渲染跑在调用栈上
Fiber 是 React 16 换掉的那套协调引擎,它解决的就是上面这个问题,办法是把调用栈搬到堆上。
递归之所以停不下来,是因为「现在处理到哪个组件、父组件是谁、还剩哪几个兄弟没处理」这些进度信息存在调用栈的栈帧里,而栈帧是引擎管的,你拿不到也存不住。Fiber 做的事是给每个组件建一个普通 JavaScript 对象,把这些进度写进对象的字段里:return 指向父节点、child 指向第一个子节点、sibling 指向下一个兄弟节点。
有了这三个指针,遍历整棵树就不需要递归了 —— 一个 while 循环加指针移动就能走完。想停的时候,把「下一个该处理谁」记在一个模块级变量里,return 出去,主线程立刻还给浏览器;下次接着从那个变量往下走。
所以 Fiber 首先是一种数据结构,不是一种算法。 它没让 diff 变快,它让 diff 变得可以中断。代价是每个组件多一个对象,以及多一份「上一次渲染的那棵树」—— 这两笔开销是换取可中断渲染的定价。
Fiber 节点上到底有哪些字段、那趟循环具体怎么走,是下一篇的事。
三个包,各管一段
React 的渲染被切成三块,在 npm 上是三个独立的包:
| 包 | 管什么 | 认不认识 DOM |
|---|---|---|
react-reconciler |
算出新旧树的差异,在 Fiber 节点上打标记 | 不认识 |
scheduler |
决定这段工作什么时候在主线程上跑、跑多久该让出去 | 不认识 |
react-dom |
把标记翻译成 appendChild、setAttribute 这类真实操作 |
认识 |
中间那个包最容易被忽略。scheduler 是独立发布的包,它的 API 里没有任何 React 的概念 —— 你给它一个回调和一个优先级,它负责挑时机执行,并且提供一个「该让出去了吗」的查询函数。它不知道 Fiber 是什么。
这条分界线就是可中断渲染的落脚点:走可中断那条路径时,Reconciler 每处理完一个节点就问一次 Scheduler「还有时间吗」,没有就存好进度返回。判断权在 Scheduler,进度存在 Fiber 上,两边都不越界。 哪些更新走这条路、哪些一口气跑完,第三篇再拆。
最右边那一块是可替换的。同一个 Reconciler 接上 react-native 就是另一套宿主操作,接上 react-test-renderer 就产出一棵 JSON 树。这也是为什么「React 是一个 UI 库而不是一个 DOM 库」这句话在源码层面成立。
优先级被压成了一个整数
Reconciler 要排序,就得先有优先级。React 18 用的是 lane 模型:每个更新被分配到一个「车道」上,车道本质是一个 31 位整数里的某一位。
用位而不是用数字,是因为「这次渲染要处理哪些更新」天然是一个集合问题。一个根节点上可能同时堆着好几个待处理更新:一次点击、一次数据回填、一次低优先级的预取。用整数当集合之后,这些问题全变成位运算 —— 合并两批更新是按位或,判断某批更新里有没有紧急的是按位与,取出优先级最高的那一条是 lanes & -lanes(保留最低位的 1)。这些都是单条指令。
位越靠右,优先级越高:最右边那一位是同步更新,最左边几位留给空闲和离屏。完整的阶梯放在第三篇讲调度的时候展开,这里只要记住两件事:优先级不是你填的,是 React 按更新的触发来源判定的;以及它是一个集合,不是一个队列。
你能碰到的只有几个 API
上面这些东西没有一个是暴露给你调的。React 18 给出的接口只有这么几个:
createRoot—— 换掉ReactDOM.render,这是所有并发能力的前提。startTransition/useTransition—— 把某次更新标成「不紧急」,它就可以被后来的紧急更新打断。useDeferredValue—— 同一件事的另一种写法,标的是值而不是更新。<Suspense>—— 画一条边界,告诉 React 哪一块可以先用占位内容顶着。useSyncExternalStore—— 从 React 之外的可变数据源里读一份一致的快照。
没有一个叫「开启并发模式」的开关。 换到 createRoot 之后,默认行为和以前基本一致;只有被标成 transition 的那些更新才真的走可中断的路径。这也是为什么 React 官方后来不再用 Concurrent Mode 这个说法,改叫并发特性 —— 它不是一种模式,是一组按更新逐次生效的能力。
想亲眼看一次,把下面这段跑起来:
import { useState, useTransition } from 'react';
export default function Search() { const [text, setText] = useState(''); const [list, setList] = useState([]); const [isPending, startTransition] = useTransition();
function onChange(e) { const next = e.target.value; setText(next); // 紧急:输入框必须立刻跟上手速
startTransition(() => { setList(buildHugeList(next)); // 不紧急:可以被下一次按键打断 }); }
return ( <> <input value={text} onChange={onChange} /> {isPending && <span>正在算…</span>} <ul>{list.map((item) => <li key={item.id}>{item.label}</li>)}</ul> </> );}把 buildHugeList 写成生成一两万条数据的函数,然后按住一个键不放。打开 Performance 面板录一段:去掉 startTransition 的版本会录到一条很长的任务,输入框在这段时间里不刷新;包进去之后,同样的工作被切成一串短任务,中间穿插着输入框的重绘。
注意 setText 没有被包进去。这一行如果也标成不紧急,输入框自己就会开始掉字 —— transition 不是「让更新变快」,是「允许这次更新被丢掉重来」,而用户正在打的字不能丢。什么该包、什么不该包,是这个系列后面反复要回答的问题。
这篇是 React 18的第 1 篇,后一篇是 React 18二:Fiber 架构。