React 设计原理
# 1. 前端框架原理概览
AOT:代码在构建时,被称为 AOT(Ahead Of Time,提前编译或预编译),宿主环境获得的是编译后的代码。
JIT:代码在宿主环境执行时,被称为 JIT(Just In Time,即时编译),代码在宿主环境中编译并执行。
如果模板上有错误。使用 AOT 时,会在编译阶段报出;而 JIT 需要等到在浏览器中执行到错误代码时,才会报出。
使用 JIT 的应用在首次加载时慢于使用 AOT 的应用,因为其需要先编译代码;而使用 AOT 的应用已经在构建时完成编译,可以直接执行代码。
使用 JIT 的应用代码体积可能大于使用 AOT 的应用,因为其在运行时会增加编译器代码。
框架 | 重渲染层级分类 | 编译技术分类 |
---|---|---|
Svelte | 元素级框架 | 极致的编译时框架 |
Vue | 组件级框架 | 拥有两者的特效(AOT 和 VDOM),比较均衡 |
React | 应用级框架 | 极致的运行时框架 |
# 2. React 理念
- Fiber Reconciler 采用双缓冲的更新机制。对于每个应用,同时存在两颗 Fiber Tree,Current Fiber Tree 对应真实 UI,Wip Fiber Tree 对应“正在内存中构建的 UI”。
# 3. render 阶段
Fiber Reconciler 是从 Stack Reconciler 重构而来,通过遍历的方式实现可中断的递归,因此 performUnitOfWork 的工作可以分为两部分:“递”和“归”。
- “递”阶段会从 HostRootFiber 开始向下以 DFS 的方式遍历,为“遍历到的每个 fiberNode”执行 beginWork 方法。该方法会根据传入的 fiberNode 创建下一级 fiberNode。当遍历到叶子元素(不包含子 fiberNode)时,performUnitOfWork 就会进入“归”阶段。
- “归”阶段会调用 completeWork 方法处理 fiberNode。当某个 fiberNode 执行完 completeWork 方法后,如果其存在兄弟 fiberNode(fiberNode.sibling !== null),会进入其兄弟 fiberNode 的“递”阶段。如果不存在兄弟 fiberNode,则进入父 fiberNode 的“归”阶段。“递”阶段和“归”阶段会交错执行直至 HostRootFiber 的“归”阶段。至此,render 阶段的工作结束。
function App() { return ( <div> Hello <span>World</span> </div> ) } /** * 1. HostRootFiber beginWork(生成 App fiberNode) * 2. App fiberNode beginWork(生成 DIV fiberNode) * 3. DIV fiberNode beginWork(生成 'Hello'、SPAN fiberNode) * 4. 'Hello' fiberNode beginWork(叶子元素) * 5. 'Hello' fiberNode completeWork * 6. SPAN fiberNode beginWork(叶子元素) * 7. SPAN fiberNode completeWork * 8. DIV fiberNode completeWork * 9. APP fiberNode completeWork * 10. HostRootFiber completeWork */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21beginWork 会根据当前 fiberNode 创建下一级 fiberNode,在 update 时标记 Placement(新增、移动)、ChildDeletion(删除)。completeWork 在 mount 时会构建 DOM Tree,初始化属性,在 update 时标记 Update(属性更新),最终执行 flags 冒泡。
# 4. commit 阶段
render 阶段流程可能会被打断,而 commit 阶段一旦开始就会同步执行直到完成。
- 开始前准备的工作,比如判断 “是否有副作用需要执行”
- 处理副作用(BeforeMutation 阶段、Mutation 阶段、Layout 阶段)
- 结束后的收尾工作,比如调度新的更新
Fiber Tree 的切换会在 Mutation 阶段完成后,Layout 阶段还未开始时执行。
Fiber 架构的早期版本并没有使用 subtreeFlags,而是使用一种被称为 Effects list 的链表结构保存 “被标记副作用的 fiberNode”。虽然 subtreeFlags 遍历子树的操作需要比 Effect list 遍历更多节点,但是 v18 中 Suspense 的行为恰恰需要遍历子树。React Effects List 大重构,是为了他? (opens new window)
function App() { return ( <Suspense fallback={<h3>loading...</h3>}> <LazyCpn /> <Sibling /> </Suspense> ) } function Sibling() { useEffect(() => { console.log('Sibling effect') }, []) return <h1>Sibling</h1> }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16<!-- 旧Suspense,并且会打印 Sibling effect --> <div id="root"> <h1 style="display: none !important;">Sibling</h1> <h3>loading</h3> </div> <!-- 新Suspense --> <div id="root"> <h3>loading</h3> </div>
1
2
3
4
5
6
7
8
9
10
# 5. schedule 阶段
为了更灵活地控制宏任务的执行时机,React 实现了一套基于 lane 模型的优先级算法,并基于这套算法实现了 Batched Update(批量更新)、任务打断/恢复机制等低级特性。
Scheduler 预置了五种优先级,优先级依次降低:
- ImmediatePriority(最高优先级,同步执行)
- UserBlockingPriority
- NormalPriority
- LowPriority
- IdlePriority(最低优先级)
为了解决饥饿问题,当一个 work 长时间未执行完,随着时间推移,当前时间离 work.expirationTime 越近,代表 work 优先级越高。当 work.expirationTime 小于当前时间,代表该 work 过期,表现为 didTimeout === true,过期 work 会被同步执行。
使用 “小顶堆” 实现优先级队列。因为在 Scheduler 中经常需要获取 timerQueue、taskQueue “排序依据最小的值” 对应 task,所以选用 “小顶堆” 这一数据结构。
宏任务的选择。
requestIdleCallback
未采用
,rIC 的设计初衷是 “能够在事件循环中执行低优先级工作,减少对动画、用户输入等高优先级事件的影响”。这意味着 rIC 的应用场景被局限在 “低优先级工作” 中。这与 Scheduler 中 “多种优先级的调度策略” 不符。- 浏览器兼容性。
- 执行频率不稳定,受很多因素影响。(比如:当切换浏览器 Tab 后,之前 Tab 注册的 rIC 执行的频率会大幅降低)
- 应用场景局限。
requestAnimationFrame
未采用
,由于 rAF 的执行取决于 “每一帧 Paint 前的时机”,即 “它的执行与帧相关”,执行频率并不高,因此 Scheduler 并没有选择它。满足条件的备选项应该在一帧时间内可以执行多次,并且执行时机越早越好。setImmediate,在支持 setImmediate 的环境(Node.js、旧版本 IE)中,Scheduler 使用 setImmediate 调度宏任务。
- 不同于 MessageChannel,他不会阻止 Node.js 进程退出。
- 相比 MessageChannel,执行时机更早。
MessageChannel,在支持 MessageChannel 的环境(浏览器、Worker)中,使用 MessageChannel 调度宏任务。
setTimeout,其余情况则使用 setTimeout 调度宏任务,如果使用 setTimeout 调度新的宏任务,那么 Time Slice 之间会用 “被浪费的时间”。考虑 setTimeout 存在最小延迟时间,且在一帧中其执行时机晚于上诉两个 API,所以 “被浪费的时间” 应该略大于最小延迟时间。
v18 的 Batched Updates(批量更新)被称为 Automatic Bathing(自动批量更新),是因为在 v18 中,Batched Updates 是由 “基于 lane 模型的调度策略” 自动完成的。之前版本的 React 中则是半自动批量更新与手动批量更新。
不管是同步还是异步,所有更新都会经历 schedule 阶段,v18 将自动批量更新交由 schedule 阶段的调度策略完成,实现了自动化。具体来讲,v18 将 “优先级” 作为自动批量更新的依据。对于 SyncLane,更新会在微任务队列中被调度执行。对于非 SyncLane,当有 work 正在调度时产生了 “同优先级” 的新 work,新 work 会命中该逻辑,不会产生新的调度。这意味着在上诉 “setTimeout 回调中触发多次更新” 的场景中,第一次更新会产生调度,后续更新都会命中上诉逻辑,不会产生新的调度。这就是 v18 中 “自动批量更新” 的实现原理。
React 同时存在 “使用微任务调度的同步调度策略” 与 “使用宏任务调度的并发调度策略”,这使得其自动批量更新也有两种可能。
let count = 0 let dom const onClick = () => { // 三次更新合并为一次 count++ count++ count++ console.log('同步结果:', dom.innerText) Promise.resolve().then(() => { console.log('微任务结果:', dom.innerText) }) setTimeout(() => { console.log('宏任务结果:', dom.innerText) }) } /** * Vue3 * 同步结果:0;微任务结果:3;宏任务结果:3 * * Svelte * 同步结果:0;微任务结果:3;宏任务结果:3 * * Legacy Mode React * 同步结果:0;微任务结果:3;宏任务结果:3 * * Concurrent Mode React * 同步结果:0;微任务结果:0;宏任务结果:3 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 6. 状态更新流程
在触发更新时存在一种性能优化策略 —— eagerState。进入 render 阶段后存在一种性能优化策略 —— bailout。React 内部让人迷惑的性能优化策略 (opens new window)
function App { const [num, updateNum] = useState(0); console.log('App render', num); useEffect(() => { setInterval(() => { updateNum(1); }, 1000) }, []) return <Child/>; } function Child() { console.log('child render'); return <span>child</span>; } /** * App render 0(未命中策略) * child render * App render 1(未命中策略) * child render * App render 1(命中bailout) * (命中eagerState) * (命中eagerState) */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 7. FC 与 Hooks 实现
代数效应 (opens new window)是函数式编程中的一个概念,用于 “将副作用从函数调用中分离”。
let workInProgressHook // 当前工作的 Hook let isMount = true // true 代表首次挂载 // App 组件对应的 fiber const fiber = { memoizedState: null, // Hooks 链表 stateNode: App, // render } // 调度更新 function schedule() { // 更新前将 workInProgressHook 重置为第一个 Hook workInProgressHook = fiber.memoizedState // render const app = fiber.stateNode() isMount = false return app } // 更新 Hook(updateNum) function dispatchAction(queue, action) { // 更新队列:环状单向链表(方便取第一个元素和添加元素) const update = { action, // updateNum 回调 next: null, // 下个 update } // 创建 update 单向环状链表 // queue.pending 为最后一个添加的元素,queue.pending.next 为第一个添加的元素 if (queue.pending === null) { update.next = update } else { update.next = queue.pending.next queue.pending.next = update } queue.pending = update schedule() } function useState(initialState) { let hook if (isMount) { // 首次挂载 hook = { // 更新队列 queue: { pending: null, }, memoizedState: initialState, // Hook 当前状态(num) next: null, // 下个 Hook } // 创建 Hooks 单向链表 if (!fiber.memoizedState) { fiber.memoizedState = hook } else { workInProgressHook.next = hook } workInProgressHook = hook } else { // 更新 hook = workInProgressHook workInProgressHook = workInProgressHook.next // 移动到下一个 Hook } let baseState = hook.memoizedState // 保存更新前状态 // 判断当前 Hook 更新队列是否为空 if (hook.queue.pending) { let firstUpdate = hook.queue.pending.next // 执行更新队列函数,获取最后的 Hook 状态 do { const action = firstUpdate.action baseState = action(baseState) firstUpdate = firstUpdate.next } while (firstUpdate !== hook.queue.pending) // 清空更新队列 hook.queue.pending = null } // 更新 Hook 当前状态 hook.memoizedState = baseState return [baseState, dispatchAction.bind(null, hook.queue)] } function App() { const [num, updateNum] = useState(0) console.log(`${isMount ? 'mount' : 'update'} num: `, num) return { click() { updateNum(num => num + 1) }, } } window.app = schedule()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99