深入理解 Render 階段 Fiber 樹的初始化與更新
作者:抱枕同學
https://juejin.cn/post/7202085514400038969
一、前言
爲什麼有這篇文章?當時有人問我下面這個點擊button
,網頁應該變成什麼樣? 注意他們的key
是相同的
import React, { useState } from "react";
function Demo2() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((i) => i + 1)}>點擊Count+1</button>
<h3 key={count}>大{count}</h3>
<h2 key={count}>舌{count}</h2>
<h1 key={count}>頭{count}</h1>
</div>
);
}
export default Demo2;
答案和你想象的一樣嗎??不一樣就繼續往下看看唄!!!結尾有答案
滴
二、前置概念
react 框架可以用來表示,輸入狀態 —> 吐出 ui。
const ui = fn(state)
react 架構是什麼?
可以分爲如下三層:
-
scheduler(調度器):用來分發優先級更高的任務。
-
render 階段(協調器):找出哪些節點發生了變化,並且給相應的 fiber 打上標籤。
-
commit 階段(渲染器):將打好標籤的節點渲染到視圖上。遍歷 effectList 執行對應的 dom 操作或部分生命週期
-
輸入: 將每一次更新 (如: 新增, 刪除, 修改節點之後) 視爲一次更新需求(目的是要更新 DOM 節點).
-
註冊調度任務: react-reconciler 收到更新需求之後, 並不會立即構造 fiber 樹, 而是去調度中心 scheduler 註冊一個新任務 task, 即把更新需求轉換成一個 task.
-
執行調度任務 (輸出): 調度中心 scheduler 通過任務調度循環來執行 task
-
fiber 構造循環是 task 的實現環節之一, 循環完成之後會構造出最新的 fiber 樹.
-
commitRoot 是 task 的實現環節之二, 把最新的 fiber 樹最終渲染到頁面上, task 完成.
主幹邏輯就是輸入到輸出這一條鏈路, 爲了更好的性能 (如批量更新, 可中斷渲染等功能), react 在輸入到輸出的鏈路上做了很多優化策略, 任務調度循環和 fiber 構造循環相互配合就可以實現可中斷渲染.
ReactElement, Fiber, DOM 三者的關係
上面我們大概提及了一下 react 的架構和更新的粗略流程,考慮到本文的重點是 Render 階段發生了啥,接下來上重量級嘉賓 JSX,ReactElement, Fiber, DOM。以下面這個 jsx 代碼爲例,講解三者的關係
function Test() {
const [showName, setShowName] = useState(true);
return (
<div>
<div>今天肯德基瘋狂星期八,和我一起玩彩虹六?</div>
<ul>
<li>抱枕一號</li>
{showName && <li>抱枕二號</li>}
</ul>
<div
onClick={() => {
setShowName(false);
}}
>
點擊讓高啓強少一個小弟
</div>
</div>
);
}
createElement源碼
所有采用JSX
語法書寫的節點, 都會被編譯器轉換, 最終會以React.createElement(...)
的方式, 創建出來一個與之對應的ReactElement
對象.
這也是爲什麼在每個使用JSX
的 JS 文件中,你必須顯式的聲明 import React from 'react';
(17 版本後不需要)否則在運行時該模塊內就會報未定義變量 React 的錯誤。
ReactElement 數據結構和內存結構(結合上面 jsx 示例代碼)
數據結構
export type ReactElement = {
// 用於辨別ReactElement對象形式
$$typeof: any,
// 內部屬性
type: any, // 表明其種類
key: any,
ref: any,
props: any,
// ReactFiber 記錄創建本對象的Fiber節點, 還未與Fiber樹關聯之前, 該屬性爲null
_owner: any,
// __DEV__ dev環境下的一些額外信息, 如文件路徑, 文件名, 行列信息等
_store: {validated: boolean, ...},
_self: React$Element<any>,
_shadowChildren: any,
_source: Source,
};
內存結構
Fiber 對象數據結構
數據結構
export type Fiber = {|
tag: WorkTag,
key: null | string, // 和ReactElement組件的 key 一致.
elementType: any,//一般來講和ReactElement組件的 type 一致 比如div ul
type: any, // 一般來講和fiber.elementType一致. 一些特殊情形下, 比如在開發環境下爲了兼容熱更新
stateNode: any, // 真實DOM是誰
return: Fiber | null, //爹是誰
child: Fiber | null, //孩子是誰
sibling: Fiber | null, //兄弟是誰
index: number,
ref:
| null
| (((handle: mixed) => void) & { _stringRef: ?string, ... })
| RefObject, //指向在ReactElement組件上設置的 ref
pendingProps: any, // 從`ReactElement`對象傳入的 props. 用於和`fiber.memoizedProps`比較可以得出屬性是否變動
memoizedProps: any, // 上一次生成子節點時用到的屬性, 生成子節點之後保持在內存中
updateQueue: mixed, // 存儲state更新的隊列, 當前節點的state改動之後, 都會創建一個update對象添加到這個隊列中.
memoizedState: any, // 用於輸出的state, 最終渲染所使用的state
dependencies: Dependencies | null, // 該fiber節點所依賴的(contexts, events)等
mode: TypeOfMode, // 二進制位Bitfield,繼承至父節點,影響本fiber節點及其子樹中所有節點. 與react應用的運行模式有關(有ConcurrentMode, BlockingMode, NoMode等選項).
// 優先級相關
lanes: Lanes, // 本fiber節點的優先級
childLanes: Lanes, // 子節點的優先級
alternate: Fiber | null, // 雙fiber緩存 指向內存中的另一個fiber, 每個被更新過fiber節點在內存中都是成對出現(current和workInProgress)
|};
內存結構
ReactElement, Fiber, DOM 三者的關係
React 的啓動過程發生了啥
接下來介紹的都是當前穩定版legacy
模式
ReactDOM.render(<App />, document.getElementById('root'), dom => {});
在沒有進入render
階段(react-reconciler
包)之前,reactElement(<App/>)
和 DOM 對象div#root
之間沒有關聯。
在 react 初始化的時候,會創建三個全局對象,在三個對象創建完畢的時候,react 初始化完畢。
-
ReactDOMRoot對象
-
屬於
react-dom
包,該對象暴露有 render,unmount 方法, 通過調用該實例的ReactDOM.render
方法, 可以引導 react 應用的啓動. -
fiberRoot對象
-
屬於
react-reconciler
包, 在運行過程中的全局上下文, 保存 fiber 構建過程中所依賴的全局狀態, -
其大部分實例變量用來存儲
fiber構造循環
過程的各種狀態,react 應用內部, 可以根據這些實例變量的值, 控制執行邏輯。 -
HostRootFiber對象
-
屬於
react-reconciler
包,這是 react 應用中的第一個 Fiber 對象, 是 Fiber 樹的根節點, 節點的類型是HostRoot
.
這 3 個對象是 react 體系得以運行的基本保障, 除非卸載整個應用,否則不會再銷燬
此刻內存中各個對象的引用情況表示出來,此時reactElement(<App/>)
還是獨立在外的, 還沒有和目前創建的 3 個全局對象關聯起來
到此爲止, react
內部經過一系列運轉, 完成了初始化。
三、render 階段發生了啥
以下所有示例按照下面的代碼 請注意
class App extends React.Component {
state = {
list: ['A', 'B', 'C'],
};
onChange = () => {
this.setState({ list: ['C', 'A', 'X'] });
};
componentDidMount() {
console.log(`App Mount`);
}
render() {
return (
<>
<Header key='d' />
<button key='e'>change</button>
<div class key='f'>
{this.state.list.map(item => (
<p key={item}>{item}</p>
))}
</div>
</>
);
}
}
class Header extends React.PureComponent {
render() {
return (
<>
<h1>title</h1>
<h2>title2</h2>
</>
);
}
}
雙緩衝 fiber 技術
在上文我們梳理了ReactElement, Fiber, DOM三者的關係
, fiber樹
的構造過程, 就是把ReactElement
轉換成fiber樹
的過程. 但是在這個過程中, 內存裏會同時存在 2 棵fiber樹
:
-
其一: 代表當前界面的
fiber
樹 (已經被展示出來, 掛載到fiberRoot.current
上). 如果是初次構造 (初始化渲染
), 頁面還沒有渲染, 此時界面對應的 fiber 樹爲空 (fiberRoot.current = null
). -
其二: 正在構造的
fiber
樹 (即將展示出來, 掛載到HostRootFiber.alternate
上, 正在構造的節點稱爲workInProgress
). 當構造完成之後, 重新渲染頁面, 最後切換fiberRoot.current = workInProgress
, 使得fiberRoot.current
重新指向代表當前界面的fiber
樹.
React 入口初始化內存情況
在進入react-reconciler
包之前, 也就是還沒render
時, 內存狀態圖如下,和上面啓動過程的圖對應:
fiber 樹構造方式
-
初次創建: 在
React
應用首次啓動時, 界面還沒有渲染, 此時並不會進入對比過程, 相當於直接構造一棵全新的樹. -
對比更新:
React
應用啓動後, 界面已經渲染. 如果再次發生更新, 創建新fiber
之前需要和舊fiber
進行對比. 最後構造的 fiber 樹有可能是全新的, 也可能是部分更新的.
在深度優先遍歷中, 每個fiber
節點都會經歷 2 個階段:
-
探尋階段
beginWork
-
回溯階段
completeWork
beginWork
探尋階段發生了什麼源碼地址 [1]
-
創建節點:根據
ReactElement
對象創建所有的fiber
節點, 最終構造出 fiber 樹形結構 (設置return
和sibling
指針) -
給節點打標籤:設置
fiber.flags
(二進制形式變量, 用來標記 fiber 節點 的增, 刪, 改狀態, 等待completeWork
階段處理) -
設置真實 DOM 的局部狀態:設置
fiber.stateNode
局部狀態 (如 Class 類型節點:fiber.stateNode=new Class()
)
completeWork
回溯階段發生了什麼源碼地址 [2]
-
調用
completeWork
-
給
fiber
節點 (tag=HostComponent, HostText) 創建 DOM 實例, 設置fiber.stateNode
局部狀態 (如tag=HostComponent, HostText
節點: fiber.stateNode 指向這個 DOM 實例). -
爲 DOM 節點設置屬性, 綁定事件 (
合成事件原理
). -
設置
fiber.flags
標記 -
把當前
fiber
對象的副作用隊列 (firstEffect
和lastEffect
) 添加到父節點的副作用隊列之後, 更新父節點的firstEffect
和lastEffect
指針. -
識別
beginWork
階段設置的fiber.flags
, 判斷當前fiber
是否有副作用 (增, 刪, 改), 如果有, 需要將當前fiber
加入到父節點的effects
隊列, 等待commit
階段處理.
初次創建
這有一個動畫,具體如果想看流程圖可以點擊 https://yzcoyzhsws.feishu.cn/docx/IB0gdVMjAo1TDdxLDXwcx7oan9c
下面標註了生成時期的 beginWork
和 completeWork
執行過程
// 將最新的fiber樹掛載到root.finishedWork節點上 下面綠色粗線表示指針
const finishedWork: Fiber = (root.current.alternate: any);
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
// 進入commit階段
commitRoot(root);
動畫演示了初次創建fiber樹
的全部過程, 跟蹤了創建過程中內存引用的變化情況. fiber樹構造循環
負責構造新的fiber
樹, 構造過程中同時標記fiber.flags
, 最終把所有被標記的fiber
節點收集到一個副作用隊列中, 這個副作用隊列被掛載到根節點上 (HostRootFiber.alternate.firstEffect
). 此時的fiber樹
和與之對應的DOM節點
都還在內存當中, 等待commitRoot
階段進行渲染
對比更新的時候發生了什麼
1. 優化原則
- 只對同級節點進行對比,如果 DOM 節點跨層級移動,則 react 不會複用
-
我們可以從同級的節點數量將 Diff 分爲兩類:
- 當newChild類型爲JSX對象、number、string,代表同級只有一個節點 - 當newChild類型爲Array,同級有多個節點
-
不同類型的元素會產出不同的結構,會銷燬老的結構,創建新的結構
-
可以通過 key 標示移動的元素
-
類型一致的節點纔有繼續 diff 的必要性
單節點
對應演示, 可以去瀏覽器的Elements
->Properties
查看
多節點
對應演示
diff 算法介紹
-
單節點
-
如果是新增節點, 直接新建 fiber, 沒有多餘的邏輯
-
如果是對比更新
-
如果
key
和type
都相同,則複用 -
否則新建
單節點的邏輯比較簡明, 源碼 [3]
-
多節點
-
多節點一般會存在兩輪遍歷,第一輪尋找公共序列,第二輪遍歷剩餘非公共序列
-
第一次循環 源碼 [4]
-
key
不同導致不可複用,立即跳出整個遍歷,第一輪遍歷結束。 -
key
相同type
不同導致不可複用,會將oldFiber
標記爲DELETION
,並繼續遍歷
-
如果
newChildren
遍歷完(即i === newChildren.length - 1
)或者oldFiber
遍歷完(即oldFiber.sibling === null
),跳出遍歷,第一輪遍歷結束。 -
let i = 0
,遍歷newChildren
,將newChildren[i]
與oldFiber
比較,判斷DOM節點
是否可複用。 -
如果可複用,
i++
,繼續比較newChildren[i]
與oldFiber.sibling
,可以複用則繼續遍歷。 -
如果不可複用,分兩種情況:
- 第二次循環: 遍歷剩餘
非公共
序列, 優先複用 oldFiber 序列中的節點。
-
如果
newChildren
與oldFiber
同時遍歷完,diff 結束 -
如果
newChildren
沒遍歷完,oldFiber
遍歷完,意味着沒有可以複用的節點了,遍歷剩下的newChildren
爲生成的workInProgress fiber
依次標記Placement
。 -
如果
newChildren
遍歷完,oldFiber
沒遍歷完,意味着有節點被刪除了,需要遍歷剩下的oldFiber
,依次標記Deletion
。 -
如果
newChildren
與oldFiber
都沒遍歷完(重點)
源碼 [5]- 先去`聲明map數據結構`,遍歷一遍老節點,把老fiber的key做映射 \{元素的key:老的fiber節點\}, - 繼續遍歷新`jsx`,如果`map`有`key`,會把`key`從`map`中刪除,說明可以複用,把當前節點標記爲`更新`。新地位高的不動,新地位低的動(中間插入鏈表比鏈表屁股插入費勁)所以地位低的動動。 - `lastPlaceIndex`指針,指向最後一個不需要動的老節點的`key`。每次新jsx複用到節點,`lastPlaceIndex`會指向老節點的最後一個成功複用的老`fiber`節點。如果新複用的節點key小於`lastPlaceIndex`,說明老`fiber`節點的順序在新`jsx`之前,需要挪動位置接到新`jsx`節點後面。 - 如果`jsx`沒有複用的老`fiber`,直接插入新的 - `map`中只剩還沒被複用的節點,等着新的`jsx`數組遍歷完,`map`裏面的`fiber`節點全部設置爲刪除
下面動畫展示了 fiber 的對比更新過程 每一張流程圖鏈接 [6]
四、檢驗學習成果
爲什麼網頁會變成那個樣子?
import React, { useState } from "react";
function Demo2() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((i) => i + 1)}>點擊Count+1</button>
<h3 key={count}>大{count}</h3>
<h2 key={count}>舌{count}</h2>
<h1 key={count}>頭{count}</h1>
</div>
);
}
export default Demo2;
五、參考
1、7km:https://7kms.github.io/react-illustration-series/main/macro-structure%EF%BC%88%E5%BC%BA%E7%83%88%E6%8E%A8%E8%8D%90%EF%BC%89
2、冴羽:https://juejin.cn/post/7160981608885927972
3、卡頌:https://react.iamkasong.com/preparation/idea.html
4、https://xiaochen1024.com/article_item/600ac4384bf83f002edaf54a
參考資料
[1]
https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberBeginWork.old.js#L3083-L3494: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Ffacebook%2Freact%2Fblob%2Fv17.0.2%2Fpackages%2Freact-reconciler%2Fsrc%2FReactFiberBeginWork.old.js%23L3083-L3494
[2]
https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L1670-L1802: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Ffacebook%2Freact%2Fblob%2Fv17.0.2%2Fpackages%2Freact-reconciler%2Fsrc%2FReactFiberWorkLoop.old.js%23L1670-L1802
[3]
源碼: https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactChildFiber.old.js#L1135-L1233
[4]
源碼: https://github.com/facebook/react/blob/1fb18e22ae66fdb1dc127347e169e73948778e5a/packages/react-reconciler/src/ReactChildFiber.new.js#L818
[5]
源碼: https://github.com/facebook/react/blob/1fb18e22ae66fdb1dc127347e169e73948778e5a/packages/react-reconciler/src/ReactChildFiber.new.js#L893
[6]
https://yzcoyzhsws.feishu.cn/docx/NLDDdzRPLo9qU8x7ek2clIN6nWc: https://yzcoyzhsws.feishu.cn/docx/NLDDdzRPLo9qU8x7ek2clIN6nWc
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/AepnZut5fUizApUIhIC4VQ