React 源码: 主要内部结构和运行流程
目录
免责声明
本文出现的部分代码和解释可能与 React 源代码有明显出入,这些功能实现在 React 源码中所处的位置和情形都比本文的代码示例复杂很多。为了降低理解难度,几乎所有代码都被简化,并附带 pseudo 标示,但我会让伪代码尽量贴近源代码。
本文所述版本 & 分支
- React Repo:v18.2.0(9e3b772b8cabbd8cadc7522ebe3dde3279e79d9e)
时间分片
一个使用组件库的前端项目,可以渲染出大量的 DOM Node。作为包揽页面逻辑、节点管理、样式管理、动画效果等功能的UI框架,React 需要具备调度大量运算资源的能力。
React 使用时间分片来管理运算资源,它的主要作用是及时让出资源给浏览器。
JavaScript引擎一般是单线程执行,以事件循环形式接收外部消息,所以我们一般的JavaScript代码是线程安全的。浏览器运行期间,引擎会循环做这些事情:
- 宏任务(如:用户代码,setTimeout 回调)
- 微任务(如:Promise 回调)
- HTML DOM Node 渲染,处理用户输入,处理网络响应
如果宏任务、微任务非常庞大,运算用时非常多,那么就会延后步骤3页面渲染,用户可能看到浏览器界面卡住了,体验不好。
因此需要在步骤1,2做优化,暂停耗时任务,让引擎往下执行,让出渲染资源。剩余的耗时任务在下一次循环继续。所以 React 的时间分片并不是硬件级的分片,它主要针对递归函数(Function)做优化,而 React 内部的很多方法就有很强的递归性质(如:从父组件到子组件)。
对于 for、while 这类的硬循环,时间分片无能为力,该卡住还是会卡住。
// pseudo
// 假设这个方法从父节点渲染到子节点
// 它返回一个方法,我们可以暂存执行,也可以立即全部执行
function renderConcurrent(Component, props, node) {
node.rendered = Component(props)
return node.child && render.bind(null, node.Component, props, node.child)
}
// 同样的动作,使用 while 就只能全量执行
function renderSync(Component, props, node) {
while (node !== null) {
node.rendered = Component(props)
node = node.child
}
}
调度任务的方法
调度是把按照资源需求,选择任务暂存起来,等待空闲之行、或立即执行的过程。React 时间分片得到一个未完成的任务时,需要把这个任务暂存到下一个事件循环的宏任务执行,以释放计算资源。常用的调度宏任务的方法有这些:
-
setTimeout(callback, ms)
经常用来延后执行任务的计时方法。其中的回调会暂存到下一个事件循环执行。
在嵌套调度的场景,setTimeout 存在最低 4ms 延迟问题;而时间分片不等于所有任务都要延后做,有紧急任务必须立即做,一般情况下时间分片调度的任务都要求 0ms(立即执行,但先让出资源给渲染)。
-
setImmediate(callback)
这个方法用来暂存 callback 直到下一个事件循环,它很好地解决了时间分片的需求。
但这个接口只出现在 IE、Node.js
-
Message Channel
消息通道是浏览器 JavaScript 的接口,用来和 iframe 通讯。但它可以把消息回调调度到下一个事件循环,而且没有延时(0ms),因此满足时间分片的需求。
下面的例子可以在浏览器模拟出类似 setImmediate 的功能。
function setImmediateLike(callback) { let chan = new MessageChannel(); chan.port1.onmessage = callback; chan.port2.postMessage(null) }
严格来讲,React 内部很多代码都是平台无关的(跨平台),所以时间分片可以根据不同的引擎宿主选择最好的实现。
任务和队列
React 时间分片定义的计算任务有这些字段
// 优先级、回调、调度开始时间、调度截止时间
class Task { priority, callback, startTime, expirationTime }
任务的优先级决定任务的调度截止时间,如果超出截止时间,这个任务就被视为紧急任务。
React 使用最小堆队列表示任务列表,以任务的剩余可调度时间排序,队头的剩余时间最小,为最紧急的任务。
调度模块有2个最小堆:TaskQueue
,TimerQueue
。前者代表立即调度执行的任务,后者表示延后执行的任务(相当于setTimeout任务,但统一管理)。有些任务会有“未执行完”的状态,比如渲染,函数执行完会得到下一步的函数,此时这个任务就继续待在队列。当调度器开始一个宏任务时:
- 把过期的延后任务移到立即调度任务
- 在本次宏任务的时间片内,尽可能执行所有的立即任务
- 调度下一次宏任务
时间片限制
一次宏任务允许的时间片是一个时间范围,如果执行任务耗时超出了这个范围,那么页面可能会出现卡顿。假设页面渲染要求 60 帧,那么一次宏任务最大用时为 1000 / 60 ~= 16ms,超过 16 ms 的任务,会导致页面卡顿。
// pseudo
// shouldYield 是否超过了当前时间片
let shouldYield = () => now() - startTime > 16ms
// haveTimedoutInstantTask 是否已经有任务过期了
let haveTimedoutInstantTask = () => {/* ... */}
while (haveTimedoutInstantTask() || !shouldYield()) {
flushTask()
}
虚拟节点 Fiber
Fiber 是 React 框架内部表示类似 DOM Node 的结构。Fiber 比 DOM Node 更轻量,会携带每个阶段的上下文数据。Fiber 一般是组合起来成为树形结构,参与 React 渲染和提交(Commit)的过程。
React Element
React 的项目代码中普遍存在 React Element,它用来描述 DOM Node 的结构。可以用以下方法创建:
import React from 'react'
// 代表 DOM Node: <div id="app"></div>
let header = React.createElement('div', {id: "app"}, null)
一个复杂 React 组件会创建大量的 React Element。通常用户代码会引入转译器(如:Babel,TypeScript、SWC),通过特定领域语言(JSX、TSX)的描述,能显著减少代码量:
let header
// 转译前
header = <div id="app" />
// 转译后
header = React.createElement('div', {id: "app"}, null)
React Element 只带 tag、attributes、children,会在用户代码中频繁创建。
在 React 内部,进入渲染阶段时,React Element 会被转换成 Fiber 对象,Fiber 除了包含它,还携带优先级、更新队列等内部调度的属性。
暂存环境(Pending Work)
从 React Element 创建的 Fiber 对象由于时间分片不会被立即处理,所以它会有 “调度前”、“调度后” 的状态,有时出现击中缓存的情况,Fiber 甚至不会进入调度。
比如 Props,所有全新创建的 Fiber,Props 都从 pendingProps 开始,经过计算变成 memoizedProps。
// 当前挂载的 Props
prevProps = fiber.memoizedProps
// 新的 Props,未经过渲染计算
nextProps = fiber.pendingProps
车道 Lane & Lanes
Lanes 表示 Fiber 的优先级,有些 Fiber 会渲染出 HTML input,当响应用户输入时,这些 Fiber 会被赋上更高的优先级,让 React 优先调度、更新。
Lane 是单个优先级的表示,它以一个位元表示;Lanes 以多个位元表示。他们都是以整数的形式表现出来,通过位运算,或合并,或分开。
// SyncLane 的值最小,代表优先级最高;IdleLane 与之相对
const SyncLane = 0b0000000000000000000000000000001
const RetryLane = 0b0000000010000000000000000000000
const IdleLane = 0b0100000000000000000000000000000
// myLanes 包含了 RetryLane、SyncLane
let myLanes = 0b0000000010000000000000000000001
// 等同于两条 Lane 或运算
myLanes = SyncLane | RetryLane
// 与运算+取反操作,删除 SyncLane,剩下 RetryLane
myLanes &= ~SyncLane
// 与运算,增加 SyncLane
myLanes |= SyncLane
如果此时有一个 Fiber 出错了正在重试,React 会给它分配一个 RetryLane1 进入调度;若 Fiber 仍然出错,React 给它一个 RetryLane2 进入调度;如此往下,当出现第5次错误时,这个 Fiber 携带 RetryLane5,它的调度优先级最低,那么几乎要等 React 把其他的调度任务执行完后,才会处理这个 Fiber。
const RetryLane1 = 0b0000000010000000000000000000000
const RetryLane2 = 0b0000000100000000000000000000000
const RetryLane3 = 0b0000001000000000000000000000000
const RetryLane4 = 0b0000010000000000000000000000000
const RetryLane5 = 0b0000100000000000000000000000000
标识 Flag & Flags
Fiber 持有的 Flags 标记了 Fiber 当前的状态,在提交阶段(Commit Phase)会用到,描述了 Fiber 将如何部署到 DOM。
Flags 和 Lanes 都用位元表示,多个 Flags 以整数形式存在。
// 这个 Fiber 已经完成了渲染工作
const PerformedWork = 0b00000000000000000000000001
// 提交阶段,这个 Fiber 需要添加到 DOM (insertBefore, appendChild)
const Placement = 0b00000000000000000000000010
// 提交阶段,这个 Fiber 需要从 DOM 删除
const Deletion = 0b00000000000000000000001000
// 这个 Fiber 渲染完了,需要添加到 DOM
let AFiberFlags = 0b00000000000000000000000011
钩子 Hooks
钩子是 React 项目中很常用的工具。以函数式编程的角度,它们给纯函数提供了副作用。它们可以持久化组件产生的数据,部署组件的回调。当然这些钩子只能在渲染期间才有效果。
得益于 JavaScript 的单线程特性,包括 React、MobX、Vue 的很多响应式框架都可以实现无参数取钩子,原因是在取钩子之前,框架就已经知道哪个地方需要取钩子,并提前准备好环境。缺点是取钩子的那段代码必须遵循框架的生命周期,过了生命周期,那个环境就是为其他代码块准备的了。
在组件渲染期间,每取一个钩子,React 就为当前渲染的组件 Fiber 添加一个 Hook 对象,多个钩子以链表的形式存在。
A.next = B
B.next = C
C.next = null
// [A] → [B] -> [C] -> null
Hook 保存了组件需要的数据,比如 useState(1)
,React 将生成一个 Hook 对象,它保存了一个 1
,这个钩子即时返回 1
和一个更新函数;第二次渲染时,React 从 Fiber 钩子表中按顺序取出一个 Hook 对象,此时它的值为 1
。
如果钩子没有按顺序取用,React 会打印出一个钩子不完整(缺了钩子或者多出钩子)的警告。钩子作为链表,相当于按顺序存储了组件的值,所以取值也要按顺序。
// 第1次渲染:
let [a, _] = useState(1) // a = 1
let [b, _] = useState(2) // b = 2
let [c, _] = useState(3) // c = 3
// 第2次渲染:
let [a, _] = useState(1) // a = 1
// let [b, _] = useState(2)
let [c, _] = useState(3) // c = 2,c 拿了 b 的值
组件首次渲染时,所属 Fiber 的钩子表是空的,所以此时不应该打印不完整警告;为了处理这个特殊情况,钩子的取用分了2中情况:
- 挂载时,取钩子给 Fiber 添加 Hook 对象
- 重新渲染时,取钩子先验证前一次 Hook 完整性,再读取 Hook 的值
他们的具体实现分别是 mountXXX
和 useXXX
,React 隐藏了这个细节,在用户代码中,取用钩子始终是 useXXX
。
副作用 Effects
Effect 是带响应式的回调,Effect 可以带有依赖,在提交阶段,若观察到依赖变化,Effect 会执行回调。用户代码中一般用 Effect 设定组件挂载和卸载的事件回调。Effect 的依赖存储在所属钩子的值,通过遍历元素地址进行依赖对比。
Effect 有加载和卸载函数,当 Effect 加载时,可以从加载的返回值获得卸载函数,类似 Rx 里面的一次性订阅(Disposable)。
// 用户代码
useEffect(function creationFn() {
return function destroyFn() {}
}, [props])
// 内部 Effect 加载时
effect.destroy = effect.create()
effect.deps = [props]
// 内部 Effect 卸载时
effect.destroy()
多个 Effect 对象会组合成循环链表,循环链表可以提供更低的内存占用。可以通过判断指针是否指向头部获知循环链表遍历结束。
A.next = B
B.next = C
C.next = A
// [A] → [B]
// ↖︎ ↙︎
// [C]
function iterate (first, callback) {
item = first
do {
callback(item)
item = item.next
} while (item !== first)
}
更新队列 Update Queue
更新队列是在 React 多个不连续执行的更新中确保计算结果完整性的重要功能,每个 Fiber 维护一个更新队列,调用更新方法(Dispatch)会给该队列新增一个 Update 对象,更新方法随着钩子分发,比如 [a, setState] = setState()
的 setState。
更新队列用于批量更新的暂存,比如这个例子:
// 4 个 setState 分别给上一个结果增加一个字符
let [state, setState] = useState({ str: "" })
setState(({str}) => ({ str: str + "A" })) // Operation A
setState(({str}) => ({ str: str + "B" })) // Operation B
setState(({str}) => ({ str: str + "C" })) // Operation C
setState(({str}) => ({ str: str + "D" })) // Operation D
setState
在 React 内部是一个异步方法,更新触发的任务可能会被安排到下一个时间片,所以同时 setState 并不会保证按顺序更新,最终结果可能也不是有序的。但实际上,上方的例子始终是 “ABCD”,其中更新队列保证了正确的结果。
像很多文章所说,这种机制会让部分 setState 重复执行。确实是这样,假设上方的例子中,C 被跳过了:
小写
s
代表这个更新被跳过了
第一次更新:
UpdateQueue = [ A, B, Cs, D]
NewState = ""
BaseState = ""
// 执行了 A、B 的更新
// C 跳过了,但是归入下一个时间片的更新队列
// 执行了 D 更新,但是 D 前面有更新被跳过了,所以 D 也跟着归入下一个时间片的更新队列
Result NewState = "ABD"
Result BaseState = "AB"
Next UpdateQueue = [ C, D ]
注意 BaseState,虽然执行了 D 导致结果不正确,但自从 C 跳过后,BaseState 就像检查点那样不再更新。而继续下一个时间片:
UpdateQueue = [ C, D ]
NewState = "ABD"
BaseState = "AB"
// 执行了 C、D 更新
Result NewState = "ABCD"
Result BaseState = "ABCD"
在第二次更新中,基于 BaseState 再次执行更新。在这个过程中,可以注意到 D 被执行了 2 次。此时 BaseState 计算出了正确结果 “ABCD”,于是应用到新的 State。
Fiber 树和双缓冲
继续上一个更新队列的例子,其中第一次更新时,界面没有显示错误的结果(ABD),是因为在知道第二次更新之前,React 不会把计算结果提交部署(Commit Phase)。
整个 React 内部运作,React 不会在完成任一组件的更新时立即把结果刷新到界面。对于 DOM 界面一个根元素(如:div#app),React 为它维护了 2 棵 Fiber 树:
- 当前 Fiber 树(展示到界面上)
- 后台 Fiber 树(在后台执行计算任务)
当 React 从一棵 Fiber 树收到更新时,所有的更新任务会交给后台 Fiber 树去处理,直到后台 Fiber 树的任务完成,React 把它们交换一次,保持同步。
root.current = finishedWork
最终 DOM 界面看到的是当前 Fiber 树,它的每一个节点都可以映射到具体的 DOM 节点。这种树交换的方法可以参考计算机图形中的双缓冲。
渲染阶段
调度器是 React 的具体引擎实现,日常项目中引用的 React 包仅仅用来定义 React Element。
以下情况会触发调度器的调度工作
- 挂载根元素 (
mount("#app")
) - 接收状态更新 (
setState()
)
每棵 Fiber 树有一个根 Fiber(Fiber Root)。接收更新时,调度器需要当前更新的 Fiber,以及所处的根,根主要用来帮助调度器:
- 定位错误产生时,所在的 Fiber 树
- 在提交阶段后,与当前 Fiber 树交换
- 刷新 Effect
调度器的一次更新任务分成2个阶段,注意渲染阶段受时间分片影响,是断续执行的:
- 渲染阶段(Unit Of Work Render)
- 提交阶段(Commit FinishedWork)
渲染事务
Unit Of Work(工作单元),代表事务。意思是渲染工作会从每一个组件开始,直到所有组件渲染完毕,这是一个断续执行的任务,所以基于 JavaScript 单线程特性,在渲染阶段,React 直接在模块中主要声明几个全局变量:
workInProgress
当前处理的 Fiber,它始终是请求更新的 Fiber 或它的 ChildworkInProgressRoot
当前处理的 Fiber 所属的根
假设这个阶段所有的任务都是断续执行的,以突出时间分片的功能。渲染阶段主要有这些
-
为 Fiber 调度更新
为 Fiber 安排一次更新,此时事务开始,调度器会设置全局变量,更新会被安排到下一个时间片。
// pseudo 入口 function scheduleUpdateOnFiber(root, fiber) { workInProgress = fiber workInProgressRoot = root ensureRootIsScheduled(root) } // 安排到下一个时间片 function ensureRootIsScheduled(root) { let callback = performConcurrentWorkOnRoot.bind(null, root) postMacroTask(callback) }
-
事务的开始和结束标志
由于组件渲染存在递归性质,事务会从 WorkInProgress Fiber 一直递归到所有的 Children,再从 Children 一路回来,回到 WorkInProgress Fiber 即表示渲染事务结束。
// pseudo 时间片触发 function performConcurrentWorkOnRoot(root) { renderRootConcurrent() if (exitStatus === Completed) commitRoot(); // 渲染结束,提交 else ensureRootIsScheduled() // 继续下一个 Fiber 的渲染 } function renderRootConcurrent() { workLoopConcurrent() } // 断续执行,在时间片结束以前,执行工作单元(Fiber)的渲染 function workLoopConcurrent() { let workLoopDone = () => workInProgress === null let sliceTimeout = () => Scheduler.shouldYield() while (!workLoopDone && !sliceTimeout) { performUnitOfWork() // unit of work } }
-
具体到每一个 Fiber 的工作单元
在可用的时间片内,调度器为每一个 Fiber 当作一个工作单元执行。工作单元又分为
beginWork
,completeUnitOfWork
,前者处理渲染和调度,后者负责 Flags 和指针收尾工作。BeginWork:断续执行,从 Fiber 到最深的 Child,执行组件的渲染方法,更新到对应的 Fiber,并执行调度。每次只为一个 Fiber 服务,返回 Child。当 Child 不存在时,表示到达最深迭代。
// pseudo function beginWork() { let FC = workInProgress.type let props = workInProgress.pendingProps let newChildren = FC(props) reconcileChildren(workInProgress, newChildren) return workInProgress.next } // 若 A 有 B,B 有 C,则 // wip=A, beginWork(), next=B // wip=B, beginWork(), next=C // wip=C, beginWork(), next=null, 到达最深
BeginWork 只会迭代并返回 Fiber 的第一个 Child,如果有一个 Child 存在并排关系(Sibling),BeginWork 会无视掉。下方的例子中,
beginwork
表示被 BeginWork 处理过了:// 注意 HipHop 作为 Rap 的 Sibling,没有被 BeginWork 处理 function MyComponent() { return <Track beginwork> <Genre beginwork> <Rap beginwork /> <HipHop /> </Genre> </Track> }
CompleteUnitOfWork(Complete):直到 BeginWork 到达最深的 Child 以后,它返回了 null。接下来 Complete 会检查最后一个可达的 Child 是否真正完成了渲染,然后尝试把指针移动到 Sibling,如果没有 Sibling,则指针往外移(Parent)。
// pseudo function completeUnitOfWork(completedFiber) { let sibling = completedFiber.sibling let parent = completedFiber.return if (sibling) { workInProgress = sibling } else { workInProgress = parent } }
简单来说,BeginWork 一直往 Child 迭代(深度优先遍历),而 Complete 往 Sibling 和 Parent 往外迭代(广度优先遍历)。
直到 Complete 让指针移回最初的 WorkInProgress,则说明事务已经结束。
// pseudo function performUnitOfWork() { let nextChildFiber = beginWork() if (nextChildFiber === null) completeWork(nextChildFiber); else workInProgress = nextChildFiber }
钩子环境
在上文已经有提到,钩子取用分了2种情况:首次渲染、之后的渲染。
在 React 内部,调用钩子最终指向了一个变量:ReactCurrentDispatcher
,并有不同的宿主提供钩子实现。
根据阶段不同,宿主会时刻变化,取用钩子会有不同的反馈:
-
ContextOnlyDispatcher
只能使用
useContext
,使用其他钩子会直接报错。没有组件渲染时指向这个宿主 -
HooksDispatcherOnMount
当组件首次渲染时指向这个宿主,取用的钩子只会给当前渲染的 Fiber 添加 Hook 对象
-
HooksDispatcherOnUpdate
当组件再次渲染时指向这个宿主,取用的钩子会验证当前渲染的 Fiber 持有的 Hook
ReactCurrentDispatcher
会在渲染开始时更换宿主,在渲染结束时重置。
function renderWithHooks(current, workInProgress, Component, props) {
currentRenderingFiber = workInProgress
let isFirstMount = current === null
// 准备钩子宿主
if (isFirstMount)
ReactCurrentDispatcher.current = HooksDispatcherOnMount
else
ReactCurrentDispatcher.current = HooksDispatcherOnUpdate
// 渲染
let children = Component(props)
// 重置宿主
ReactCurrentDispatcher.current = ContextOnlyDispatcher
}
调度
调度是定位渲染前后的每个元素的位置变化,每个组件状态更新时,渲染出来的 React Element 可能与上一次的渲染结果不一样。React 不能直接把现有的元素全部卸载又全部挂载,这样非常浪费性能,所以调度器需要对组件渲染前后的元素做对比,找出变化的元素,执行局部更新。
组件每次渲染的结果都是全新的 React Element,所以调度器只能为元素的属性做对比。
const MyComponent = () => <div />
// 1st render
let first = MyComponent()
// 2nd render
let second = MyComponent()
first === second
// false
判断单个元素是否有变化,调度器先后检查 key
和 Type
。key 是渲染列表中经常用来标记子项的字段;Type 则表示 FC 本身,比如 function Swiper()
、function Collapse()
。就是说,即使元素 key 相等,Type 不相等时,调度器也会强制更新这个元素,重新渲染。所以 key 一般用在子元素渲染,是因为一般用户代码中的列表元素,它们的 Type 都一样的,如 <ListItem />
,这时候就需要用 key 去区分。
调度器能调度单元素的机会很少,很多时候都在调度多个元素,项目中的 Fragment、List 在 React 内部都视为列表,而 List 更突出子元素 Type 相同的性质,React 会重点检查列表中缺 key、重复 key 的情况。
// Fragment
const myFragment = <>
<Header />
<Container />
<Footer />
</>
// List
const myList = [
<ListItem key="item1" />
<ListItem key="item2" />
<ListItem key="item3" />
]
判断列表中的元素比判断单元素复杂得多,调度器需要找出 Fiber 前后的变更位置。它先假设旧 Fiber 全部被扔到垃圾桶,然后通过下标和 key 匹配来找回旧的 Fiber 复用,在源码中这个过程称为恢复(restore)。
下方的例子是调度器经常遇见的新旧列表对比情形:现有已提交的一个 Fiber,它更新后,有一组新元素需要与 Fiber Children 做对比。
// 现有的 Children
declare const OldList: ArrayLike<OldFiber>
// 渲染后得到的 React Element 列表
declare const NewList: ArrayLike<ReactElement>
// 现在需要给 2 个列表做对比,尽量找出新元素对应的旧 Fiber
// 匹配出旧的 Fiber 做复用,避免产生大量 Fiber 增删的开销
调度器有一系列的步骤处理这个用例,成功恢复的 Fiber 会与它的元素做单元素对比(key、type 对比):
-
遍历 OldList,尝试对比每个 OldFiber 与对应下标的 NewElement。这是常用的恢复方法。
// pseudo const restoredFibers = new LinkedList<OldFiber>() for (let newIdx; newIdx < NewList.length; newIdx++) { const OldFiber = OldList.next() const NewElem = NewList[newIdx] const restoredFiber = updateSlot(OldFiber, NewElem) restoredFibers.add(restoredFiber) }
遍历结束后产生3个结果:NewList 全匹配;OldList 全匹配;OldList、NewList 都没完全匹配
-
结果1:NewList 全匹配
OldList 没匹配完的时候,NewList 已经匹配完了。这个结果表示调度完成了。OldList 中未被匹配的 Fiber 会被卸载、删除。
调度器不会马上删除 Fiber,而是给它赋一个
Deletion
Flag,待到提交阶段,React 连着 DOM 节点一起从整棵树上移除。// pseudo if (newIdx === NewList.length) { deleteRemain(OldList) // 删掉剩余的 Fiber return newFiberChildren // 结束调度过程 }
-
结果2:OldList 全匹配
和结果1反过来,NewList 还没匹配完时,OldList 已有的 Fiber 已经恢复完了。这个结果也表示调度完成了,调度结果 = 已匹配到的 Fiber + 剩余 NewList。
// pseudo if (OldList.next() === null) { // 剩余的新元素转换成 Fiber 后添加到调度结果 NewList.foreachRemain(elem => { let newFiber = createNewFiberFrom(elem) newFiberChildren.add(newFiber) }) return newFiberChildren }
-
结果3:OldList、NewList 都没匹配完
出现这种情况是因为下标遍历中,有些新元素从中间插入。就像下方的组件中插入的 Container。
function MyComponent() { let [i, setI] = useState(0) return <> <Header /> {i % 2 && <Container /> || null} <Footer /> </> }
这个例子中,Header Fiber 能够恢复,而匹配 Footer 时,得到的可能是 Container。所以到达这个结果,调度器只能通过剩余的下标和 key 匹配方式,尽力尝试恢复旧 Fiber。
调度器为剩余的 OldList 建立一个 Map,接下来的匹配以 key、index 查找(优先匹配 key,这个情况下 index 很难匹配到对应的 OldFiber),匹配不到的,直接创建新 Fiber。
// pseudo remainMap = new Map<Key|Index, OldFiber>() // 建立 Map,这个操作在源码中称为 fastpath OldList.foreachRemain((OldFiber) => { remainMap.put(oldFiber.key ?? oldFiber.index, oldFiber) }) // 剩余的新元素随缘匹配,匹配不到就创建 NewList.foreachRemain((NewElem, remainIdx)=> { const mappedFiber = remainMap.get(NewElem.key ?? remainIdx) if (mappedFiber) { // 尝试恢复,恢复不到就创建一个新的 const newFiber = updateXXX(newFiber, NewElem, remainIdx) newFiberChildren.add(newFiber) } }) return newFiberChildren
Bailout (Cache)
Bailout 类似缓存复用,在渲染过程中,React 会跳过一些不需要更新的 Fiber。
如何找出不需要更新的 Fiber?在 BeginWork
给每个组件渲染前,会用三等判断新旧 Props,新旧 Props 相等的组件会被标记成 Bailout,React 会跳过这个组件的渲染。
// pseudo
function beginWork(current, workInProgress) {
let oldProps = current.memoizedProps
let newProps = workInProgress.pendingProps
if (oldProps === newProps) {
attemptEarlyBailoutIfNoScheduledUpdate(current, workInProgress)
// -> bailoutOnAlreadyFinishedWork(current, workInProgress)
// -> return null
// 返回 null 跳过这个 Fiber 的渲染
}
}
在用户代码中,通过 Props 传递的元素,在子组件更新时就会被 Bailout。因此对于一些不常更新的组件,可以通过模块内常量,或者 useMemo
减少新建 React Element 的次数。
// header, footer 第1次渲染后
// 就一直保持 Bailout 状态,Effect 只会触发1次
// 相当于 props = {};
// 1st: return props;
// 2nd: return props;
const header = <Header />
const footer = <Footer />
function MyComponent() {
// body 在每次组件更新都会重新渲染,触发 Effect
// 相当于
// 1st: return {};
// 2nd: return {};
const body = <Body />
return <>
{header}
{body}
{footer}
</>
}
提交阶段(Commit Phase)
渲染阶段结束后,需要更新的 Fiber,它们的 Flags 会带上一些属性,如:Update、Placement、Deletion。接下来的提交阶段会用到这些 Flag。
刷新 Effect(Commit Effect)
Effect 是监听到依赖变化时触发回调的观察者,在提交阶段时,要为有变化的 Fiber 刷新 Effect。
不是所有的 Fiber 都需要刷新一遍,React 检查 Fiber subtreeFlags
是否 而且一般也只针对某些 Effect 刷新。subtreeFlags
代表 Fiber 整棵树的 Flags,由 completeWork
计算得到。
刷新 Effect 又分成几个子任务:
-
Commit Layout Effects
刷新 Layout Effect。从最外围的 Fiber 开始,迭代到所有带
PassiveMask
的 Children。加载
LayoutEffect
// pseudo if (fiber.subtreeFlags & PassiveMask !== NoFlags) { commitLayoutEffectMount(fiber) }
-
Commit Deletion Effects
刷新 Deletion Effect。从最外围的 Fiber 开始,迭代到所有 Children。
卸载
InsertionEffect
、LayoutEffect
// pseudo commitInsertionEffectUnmount(fiber) commitLayoutEffectUnmount(fiber)
-
Commit Reconciliation Effects
刷新调度 Effect。从最外围的 Fiber 开始,迭代到所有带
Placement
的 Children。刷新调度 Effect 会执行 DOM 修改操作,关联外部接口
insertBefore
、appendChild
,React 有专门的包实现 DOM 操作。 -
Commit Mutation Effect
刷新 Mutation Effect,会在 Fiber 树有增删时执行,这个操作是 Deletion、Reconciliation、Insertion、Layout 的组合形式。从最外围的 Fiber 开始,迭代到所有带
Mutation
的 Children。// pseudo // Deletion fiber.deletions.forEach(commitDeletionEffects) // Reconciliation commitReconciliationEffects(fiber) if (fiber.flags & Update) { // Insertion commitInsertionEffectUnmount(fiber) commitInsertionEffectMmount(fiber) } // Layout commitLayoutEffectUnmount(fiber) commitLayoutEffectMmount(fiber)
上方的 Effect 操作可以解释第一次渲染会出现 2 次组件 Layout 卸载加载,分别是 Layout Effect 和部署到 DOM 触发的 Mutation Effect。并且在多次渲染时,组件虽然触发了 Layout Effect,但对应的 DOM 节点不一定执行了真正的卸载加载,如果要观察 DOM 节点的卸载加载,应使用 useInsertionEffect
。
平台特定的 Framework 实现
软件包 react
是面向用户代码的 React Element 标准包,它给用户提供了创建 React Element 的方式。
软件包 react-reconciler
是脱离了 DOM 的调度器实现。
因此 React 可以是跨平台的框架,它的调度器模块要求的适配器为 HostConfig
,只要实现 HostConfig 要求的方法即可与调度器集成,实现自定义的跨平台 React 引擎。
DOM Renderer
软件包 react-dom
是 React web 项目专用的 React 引擎,它的适配器依赖 HTML DOM 接口,提供 SPA、SSR。
import ReactDOM from 'react-dom'
// Bootstrap the world
React Native Renderer
软件包 react-native-renderer
是 React Native(RN)的渲染引擎,适用移动 App 开发。
实际项目开发中,移动 App 有多个分发渠道(如:Android flavor),为了适配多个渠道,RN 提供的 CLI 工具隐藏了渲染入口的细节,通过约定项目的入口文件渲染根组件。
import ReactNativeRenderer from 'react-native-renderer'
// Bootstrap the mobile world