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