React 架構(gòu)的演變 - 從遞歸到循環(huán)
遞歸更新的實(shí)現(xiàn)
React 15 的遞歸更新邏輯是先將需要更新的組件放入臟組件隊(duì)列(這里在上篇文章已經(jīng)介紹過(guò),沒(méi)看過(guò)的可以先看看《React 架構(gòu)的演變 - 從同步到異步》),然后取出組件進(jìn)行一次遞歸,不停向下尋找子節(jié)點(diǎn)來(lái)查找是否需要更新。
下面使用一段代碼來(lái)簡(jiǎn)單描述一下這個(gè)過(guò)程:
- updateComponent (prevElement, nextElement) {
- if (
- // 如果組件的 type 和 key 都沒(méi)有發(fā)生變化,進(jìn)行更新
- prevElement.type === nextElement.type &&
- prevElement.key === nextElement.key
- ) {
- // 文本節(jié)點(diǎn)更新
- if (prevElement.type === 'text') {
- if (prevElement.value !== nextElement.value) {
- this.replaceText(nextElement.value)
- }
- }
- // DOM 節(jié)點(diǎn)的更新
- else {
- // 先更新 DOM 屬性
- this.updateProps(prevElement, nextElement)
- // 再更新 children
- this.updateChildren(prevElement, nextElement)
- }
- }
- // 如果組件的 type 和 key 發(fā)生變化,直接重新渲染組件
- else {
- // 觸發(fā) unmount 生命周期
- ReactReconciler.unmountComponent(prevElement)
- // 渲染新的組件
- this._instantiateReactComponent(nextElement)
- }
- },
- updateChildren (prevElement, nextElement) {
- var prevChildren = prevElement.children
- var nextChildren = nextElement.children
- // 省略通過(guò) key 重新排序的 diff 過(guò)程
- if (prevChildren === null) { } // 渲染新的子節(jié)點(diǎn)
- if (nextChildren === null) { } // 清空所有子節(jié)點(diǎn)
- // 子節(jié)點(diǎn)對(duì)比
- prevChildren.forEach((prevChild, index) => {
- const nextChild = nextChildren[index]
- // 遞歸過(guò)程
- this.updateComponent(prevChild, nextChild)
- })
- }
為了更清晰的看到這個(gè)過(guò)程,我們還是寫一個(gè)簡(jiǎn)單的Demo,構(gòu)造一個(gè) 3 * 3 的 Table 組件。
Table
- // https://codesandbox.io/embed/react-sync-demo-nlijf
- class Col extends React.Component {
- render() {
- // 渲染之前暫停 8ms,給 render 制造一點(diǎn)點(diǎn)壓力
- const start = performance.now()
- while (performance.now() - start < 8)
- return <td>{this.props.children}</td>
- }
- }
- export default class Demo extends React.Component {
- state = {
- val: 0
- }
- render() {
- const { val } = this.state
- const array = Array(3).fill()
- // 構(gòu)造一個(gè) 3 * 3 表格
- const rows = array.map(
- (_, row) => <tr key={row}>
- {array.map(
- (_, col) => <Col key={col}>{val}</Col>
- )}
- </tr>
- )
- return (
- <table className="table">
- <tbody>{rows}</tbody>
- </table>
- )
- }
- }
然后每秒對(duì) Table 里面的值更新一次,讓 val 每次 + 1,從 0 ~ 9 不停循環(huán)。
Table Loop
- // https://codesandbox.io/embed/react-sync-demo-nlijf
- export default class Demo extends React.Component {
- tick = () => {
- setTimeout(() => {
- this.setState({ val: next < 10 ? next : 0 })
- this.tick()
- }, 1000)
- }
- componentDidMount() {
- this.tick()
- }
- }
完整代碼的線上地址:https://codesandbox.io/embed/react-sync-demo-nlijf。Demo 組件每次調(diào)用 setState,React 會(huì)先判斷該組件的類型有沒(méi)有發(fā)生修改,如果有就整個(gè)組件進(jìn)行重新渲染,如果沒(méi)有會(huì)更新 state,然后向下判斷 table 組件,table 組件繼續(xù)向下判斷 tr 組件,tr 組件再向下判斷 td 組件,最后發(fā)現(xiàn) td 組件下的文本節(jié)點(diǎn)發(fā)生了修改,通過(guò) DOM API 更新。
Update
通過(guò) Performance 的函數(shù)調(diào)用堆棧也能清晰的看到這個(gè)過(guò)程,updateComponent 之后 的 updateChildren 會(huì)繼續(xù)調(diào)用子組件的 updateComponent,直到遞歸完所有組件,表示更新完成。
調(diào)用堆棧
遞歸的缺點(diǎn)很明顯,不能暫停更新,一旦開(kāi)始必須從頭到尾,這與 React 16 拆分時(shí)間片,給瀏覽器喘口氣的理念明顯不符,所以 React 必須要切換架構(gòu),將虛擬 DOM 從樹(shù)形結(jié)構(gòu)修改為鏈表結(jié)構(gòu)。
可循環(huán)的 Fiber
這里說(shuō)的鏈表結(jié)構(gòu)就是 Fiber 了,鏈表結(jié)構(gòu)最大的優(yōu)勢(shì)就是可以通過(guò)循環(huán)的方式來(lái)遍歷,只要記住當(dāng)前遍歷的位置,即使中斷后也能快速還原,重新開(kāi)始遍歷。
我們先看看一個(gè) Fiber 節(jié)點(diǎn)的數(shù)據(jù)結(jié)構(gòu):
- function FiberNode (tag, key) {
- // 節(jié)點(diǎn) key,主要用于了優(yōu)化列表 diff
- this.key = key
- // 節(jié)點(diǎn)類型;FunctionComponent: 0, ClassComponent: 1, HostRoot: 3 ...
- this.tag = tag
- // 子節(jié)點(diǎn)
- this.child = null
- // 父節(jié)點(diǎn)
- this.return = null
- // 兄弟節(jié)點(diǎn)
- this.sibling = null
- // 更新隊(duì)列,用于暫存 setState 的值
- this.updateQueue = null
- // 節(jié)點(diǎn)更新過(guò)期時(shí)間,用于時(shí)間分片
- // react 17 改為:lanes、childLanes
- this.expirationTime = NoLanes
- this.childExpirationTime = NoLanes
- // 對(duì)應(yīng)到頁(yè)面的真實(shí) DOM 節(jié)點(diǎn)
- this.stateNode = null
- // Fiber 節(jié)點(diǎn)的副本,可以理解為備胎,主要用于提升更新的性能
- this.alternate = null
- }
下面舉個(gè)例子,我們這里有一段普通的 HTML 文本:
- <table class="table">
- <tr>
- <td>1</td>
- <td>1</td>
- </tr>
- <tr>
- <td>1</td>
- </tr>
- </table>
在之前的 React 版本中,jsx 會(huì)轉(zhuǎn)化為 createElement 方法,創(chuàng)建樹(shù)形結(jié)構(gòu)的虛擬 DOM。
- const VDOMRoot = {
- type: 'table',
- props: { className: 'table' },
- children: [
- {
- type: 'tr',
- props: { },
- children: [
- {
- type: 'td',
- props: { },
- children: [{type: 'text', value: '1'}]
- },
- {
- type: 'td',
- props: { },
- children: [{type: 'text', value: '1'}]
- }
- ]
- },
- {
- type: 'tr',
- props: { },
- children: [
- {
- type: 'td',
- props: { },
- children: [{type: 'text', value: '1'}]
- }
- ]
- }
- ]
- }
Fiber 架構(gòu)下,結(jié)構(gòu)如下:
- // 有所簡(jiǎn)化,并非與 React 真實(shí)的 Fiber 結(jié)構(gòu)一致
- const FiberRoot = {
- type: 'table',
- return: null,
- sibling: null,
- child: {
- type: 'tr',
- return: FiberNode, // table 的 FiberNode
- sibling: {
- type: 'tr',
- return: FiberNode, // table 的 FiberNode
- sibling: null,
- child: {
- type: 'td',
- return: FiberNode, // tr 的 FiberNode
- sibling: {
- type: 'td',
- return: FiberNode, // tr 的 FiberNode
- sibling: null,
- child: null,
- text: '1' // 子節(jié)點(diǎn)僅有文本節(jié)點(diǎn)
- },
- child: null,
- text: '1' // 子節(jié)點(diǎn)僅有文本節(jié)點(diǎn)
- }
- },
- child: {
- type: 'td',
- return: FiberNode, // tr 的 FiberNode
- sibling: null,
- child: null,
- text: '1' // 子節(jié)點(diǎn)僅有文本節(jié)點(diǎn)
- }
- }
- }
Fiber
循環(huán)更新的實(shí)現(xiàn)
那么,在 setState 的時(shí)候,React 是如何進(jìn)行一次 Fiber 的遍歷的呢?
- let workInProgress = FiberRoot
- // 遍歷 Fiber 節(jié)點(diǎn),如果時(shí)間片時(shí)間用完就停止遍歷
- function workLoopConcurrent() {
- while (
- workInProgress !== null &&
- !shouldYield() // 用于判斷當(dāng)前時(shí)間片是否到期
- ) {
- performUnitOfWork(workInProgress)
- }
- }
- function performUnitOfWork() {
- const next = beginWork(workInProgress) // 返回當(dāng)前 Fiber 的 child
- if (next) { // child 存在
- // 重置 workInProgress 為 child
- workInProgress = next
- } else { // child 不存在
- // 向上回溯節(jié)點(diǎn)
- let completedWork = workInProgress
- while (completedWork !== null) {
- // 收集副作用,主要是用于標(biāo)記節(jié)點(diǎn)是否需要操作 DOM
- completeWork(completedWork)
- // 獲取 Fiber.sibling
- let siblingFiber = workInProgress.sibling
- if (siblingFiber) {
- // sibling 存在,則跳出 complete 流程,繼續(xù) beginWork
- workInProgress = siblingFiber
- return;
- }
- completedWork = completedWork.return
- workInProgress = completedWork
- }
- }
- }
- function beginWork(workInProgress) {
- // 調(diào)用 render 方法,創(chuàng)建子 Fiber,進(jìn)行 diff
- // 操作完畢后,返回當(dāng)前 Fiber 的 child
- return workInProgress.child
- }
- function completeWork(workInProgress) {
- // 收集節(jié)點(diǎn)副作用
- }
Fiber 的遍歷本質(zhì)上就是一個(gè)循環(huán),全局有一個(gè) workInProgress 變量,用來(lái)存儲(chǔ)當(dāng)前正在 diff 的節(jié)點(diǎn),先通過(guò) beginWork 方法對(duì)當(dāng)前節(jié)點(diǎn)然后進(jìn)行 diff 操作(diff 之前會(huì)調(diào)用 render,重新計(jì)算 state、prop),并返回當(dāng)前節(jié)點(diǎn)的第一個(gè)子節(jié)點(diǎn)( fiber.child)作為新的工作節(jié)點(diǎn),直到不存在子節(jié)點(diǎn)。然后,對(duì)當(dāng)前節(jié)點(diǎn)調(diào)用 completedWork 方法,存儲(chǔ) beginWork 過(guò)程中產(chǎn)生的副作用,如果當(dāng)前節(jié)點(diǎn)存在兄弟節(jié)點(diǎn)( fiber.sibling),則將工作節(jié)點(diǎn)修改為兄弟節(jié)點(diǎn),重新進(jìn)入 beginWork 流程。直到 completedWork 重新返回到根節(jié)點(diǎn),執(zhí)行 commitRoot將所有的副作用反應(yīng)到真實(shí) DOM 中。
Fiber work loop
在一次遍歷過(guò)程中,每個(gè)節(jié)點(diǎn)都會(huì)經(jīng)歷 beginWork、completeWork ,直到返回到根節(jié)點(diǎn),最后通過(guò) commitRoot 將所有的更新提交,關(guān)于這部分的內(nèi)容可以看:《React 技術(shù)揭秘》。
時(shí)間分片的秘密
前面說(shuō)過(guò),F(xiàn)iber 結(jié)構(gòu)的遍歷是支持中斷恢復(fù),為了觀察這個(gè)過(guò)程,我們將之前的 3 * 3 的 Table 組件改成 Concurrent 模式,線上地址:https://codesandbox.io/embed/react-async-demo-h1lbz。由于每次調(diào)用 Col 組件的 render 部分需要耗時(shí) 8ms,會(huì)超出了一個(gè)時(shí)間片,所以每個(gè) td 部分都會(huì)暫停一次。
- class Col extends React.Component {
- render() {
- // 渲染之前暫停 8ms,給 render 制造一點(diǎn)點(diǎn)壓力
- const start = performance.now();
- while (performance.now() - start < 8);
- return <td>{this.props.children}</td>
- }
- }
在這個(gè) 3 * 3 組件里,一共有 9 個(gè) Col 組件,所以會(huì)有 9 次耗時(shí)任務(wù),分散在 9 個(gè)時(shí)間片進(jìn)行,通過(guò) Performance 的調(diào)用棧可以看到具體情況:
異步模式的調(diào)用棧
在非 Concurrent 模式下,F(xiàn)iber 節(jié)點(diǎn)的遍歷是一次性進(jìn)行的,并不會(huì)切分多個(gè)時(shí)間片,差別就是在遍歷的時(shí)候調(diào)用了 workLoopSync 方法,該方法并不會(huì)判斷時(shí)間片是否用完。
- // 遍歷 Fiber 節(jié)點(diǎn)
- function workLoopSync() {
- while (workInProgress !== null) {
- performUnitOfWork(workInProgress)
- }
- }
同步模式的調(diào)用棧
通過(guò)上面的分析可以看出, shouldYield 方法決定了當(dāng)前時(shí)間片是否已經(jīng)用完,這也是決定 React 是同步渲染還是異步渲染的關(guān)鍵。如果去除任務(wù)優(yōu)先級(jí)的概念,shouldYield 方法可以說(shuō)很簡(jiǎn)單,就是判斷了當(dāng)前的時(shí)間,是否已經(jīng)超過(guò)了預(yù)設(shè)的 deadline。
- function getCurrentTime() {
- return performance.now()
- }
- function shouldYield() {
- // 獲取當(dāng)前時(shí)間
- var currentTime = getCurrentTime()
- return currentTime >= deadline
- }
deadline 又是如何得的呢?可以回顧上一篇文章(《React 架構(gòu)的演變 - 從同步到異步》)提到的 ChannelMessage,更新開(kāi)始的時(shí)候會(huì)通過(guò) requestHostCallback(即:port2.send)發(fā)送異步消息,在 performWorkUntilDeadline (即:port1.onmessage)中接收消息。performWorkUntilDeadline 每次接收到消息時(shí),表示已經(jīng)進(jìn)入了下一個(gè)任務(wù)隊(duì)列,這個(gè)時(shí)候就會(huì)更新 deadline。
異步調(diào)用棧
- var channel = new MessageChannel()
- var port = channel.port2
- channel.port1.onmessage = function performWorkUntilDeadline() {
- if (scheduledHostCallback !== null) {
- var currentTime = getCurrentTime()
- // 重置超時(shí)時(shí)間
- deadline = currentTime + yieldInterval
- var hasTimeRemaining = true
- var hasMoreWork = scheduledHostCallback()
- if (!hasMoreWork) {
- // 已經(jīng)沒(méi)有任務(wù)了,修改狀態(tài)
- isMessageLoopRunning = false;
- scheduledHostCallback = null;
- } else {
- // 還有任務(wù),放到下個(gè)任務(wù)隊(duì)列執(zhí)行,給瀏覽器喘息的機(jī)會(huì)
- port.postMessage (null);
- }
- } else {
- isMessageLoopRunning = false;
- }
- }
- requestHostCallback = function (callback) {
- //callback 掛載到 scheduledHostCallback
- scheduledHostCallback = callback
- if (!isMessageLoopRunning) {
- isMessageLoopRunning = true
- // 推送消息,下個(gè)隊(duì)列隊(duì)列調(diào)用 callback
- port.postMessage (null)
- }
- }
超時(shí)時(shí)間的設(shè)置就是在當(dāng)前時(shí)間的基礎(chǔ)上加上了一個(gè) yieldInterval, 這個(gè) yieldInterval的值,默認(rèn)是 5ms。
- deadline = currentTime + yieldInterval
同時(shí) React 也提供了修改 yieldInterval 的手段,通過(guò)手動(dòng)指定 fps,來(lái)確定一幀的具體時(shí)間(單位:ms),fps 越高,一個(gè)時(shí)間分片的時(shí)間就越短,對(duì)設(shè)備的性能要求就越高。
- forceFrameRate = function (fps) {
- if (fps < 0 || fps > 125) {
- // 幀率僅支持 0~125
- return
- }
- if (fps > 0) {
- // 一般 60 fps 的設(shè)備
- // 一個(gè)時(shí)間分片的時(shí)間為 Math.floor(1000/60) = 16
- yieldInterval = Math.floor(1000 / fps)
- } else {
- // reset the framerate
- yieldInterval = 5
- }
- }
總結(jié)
下面我們將異步邏輯、循環(huán)更新、時(shí)間分片串聯(lián)起來(lái)。先回顧一下之前的文章講過(guò),Concurrent 模式下,setState 后的調(diào)用順序:
- Component.setState()
- => enqueueSetState()
- => scheduleUpdate()
- => scheduleCallback(performConcurrentWorkOnRoot)
- => requestHostCallback()
- => postMessage()
- => performWorkUntilDeadline()
scheduleCallback 方法會(huì)將傳入的回調(diào)(performConcurrentWorkOnRoot)組裝成一個(gè)任務(wù)放入 taskQueue 中,然后調(diào)用 requestHostCallback 發(fā)送一個(gè)消息,進(jìn)入異步任務(wù)。performWorkUntilDeadline 接收到異步消息,從 taskQueue 取出任務(wù)開(kāi)始執(zhí)行,這里的任務(wù)就是之前傳入的 performConcurrentWorkOnRoot 方法,這個(gè)方法最后會(huì)調(diào)用workLoopConcurrent(workLoopConcurrent 前面已經(jīng)介紹過(guò)了,這個(gè)不再重復(fù))。如果 workLoopConcurrent 是由于超時(shí)中斷的,hasMoreWork 返回為 true,通過(guò) postMessage 發(fā)送消息,將操作延遲到下一個(gè)任務(wù)隊(duì)列。
流程圖
到這里整個(gè)流程已經(jīng)結(jié)束,希望大家看完文章能有所收獲,下一篇文章會(huì)介紹 Fiber 架構(gòu)下 Hook 的實(shí)現(xiàn)。
本文轉(zhuǎn)載自微信公眾號(hào)「更了不起的前端」,可以通過(guò)以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系更了不起的前端公眾號(hào)。