根據《Build your own React》實現簡易React
構建簡易Reac
build-your-own-react是一篇操作說明書,指導使用者一步步實現一個簡易的React,從中瞭解到React的大體工作流程。這篇文章是我的觀後整理和記錄,或許對大家會有所幫助。
構建簡易React,分為九個階段:
- 介紹
createElement
與render
- 實現
createElement
- 實現
render
- 介紹併發模式
- 實現 Fibers
- render 和 commit 階段
- 實現協調
- 支援函式元件
- 實現 Hooks
介紹 createElement
與 render
JSX描述結構,由Babel轉譯為對createElement
的呼叫;
createElement
render
接收 ReactElement 物件和掛載節點,產生渲染效果。
實現createElement
createElement
做以下幾件事:
-
props
中包括key
和ref
,需要做一次分離 -
children
子項可能是String/Number
這類原始型別資料。原始型別資料與文字節點對應,因此將其統一處理為TEXT_ELEMENT
型別的物件 - 將
children
附加到props
物件上 - 返回 ReactElement 物件
function createElement (type, config, ...children) { let key = null; let ref = null; let props = {}; // 從 props 中分離 key 和 ref if (config) { for (const name in config) if (Object.prototype.hasOwnProperty.call(config, name)) { if (name === "key") { key = config[name]; } else if (name === "ref") { ref = config[name]; } else { props[name] = config[name]; } } } } // 處理 children 項,並將 children 附加到 props 上 props.children = children.map((child) => typeof child === "object" ? child : { type: "TEXT_ELEMENT", props: { nodeValue: child, children: [], }, } ); return { type, key, ref, props, }; }
實現render
render
接收到的 ReactElement 物件,其實可以說是虛擬DOM結構的根,通過props.children
連線子 ReactElement 物件
render
的目的是產生渲染效果。最直觀的方法是從根 ReactElement 開始進行深度優先遍歷,生成整棵 DOM 樹後掛載到根節點上。
function render(element, container) { const { type, props } = element; // 前序建立節點 const dom = type === "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(type); Object.keys(props).forEach((name) => { if (isProperty(name)) { dom[name] = props[name]; } }); props.children.filter(Boolean).forEach((child) => this.render(child, dom)); // 後序掛載節點 container.appendChild(dom); }
這其實類似於React v16之前的 stack reconciler。其特點在於利用呼叫棧實現遍歷。
介紹併發模式
按照目前的方式進行更新時,需要將整顆虛擬DOM樹一次性處理完畢。當樹層級結構變得複雜,JS計算將長時間佔用主執行緒,會導致卡頓、無法響應的糟糕體驗。
能否實現增量渲染。具體來說,能否將虛擬DOM樹處理劃分為一個個小任務,並在主執行緒上併發執行呢?
依賴於呼叫棧,難以將整個過程中斷,也就無法實現任務拆分。不如在記憶體中自行維護一個支援 DFS 的資料結構,代替呼叫棧的功能。React控制主動權,自主做任務拆分和維護。這個資料結構就是 Fiber 樹了。
那麼如何在主執行緒上併發執行,或者說怎麼確定任務的執行時機。瀏覽器的主執行緒需要處理HTML解析、樣式計算、佈局、系統級任務、JavaScript執行、垃圾回收等一眾任務,由任務佇列排程。當主執行緒處於空閒狀態時安排上 Fiber 處理那是最好不過。恰好,瀏覽器端提供了一個API——requestIdleCallback(callback)
,當瀏覽器空閒時會主動執行 callback
函式。但是,可惜的是這個方法目前在各瀏覽器的支援度和穩定性還無法得到保證。因此 React 團隊自行實現了 Scheduler 庫來代替requestIdleCallback
實現任務排程。
上面說的兩個過程就是任務分片和任務排程了,他們一個由 Fiber 實現,一個由 Scheduler 實現。
Fibers
Fiber和ReactElement的關係
ReactElement 物件已經是虛擬DOM的一種表示方法了,一個 ReactElement 物件對應一個 FiberNode,只需給 FiberNode 加上核心資訊 type
和props
。
FiberNode {
type: element.type,
props: element.props,
child: Fiber,
sibling: Fiber,
parent: Fiber
}
Fiber如何支援DFS
Fiber 結構的最大特點是child/sibling/parent
三個指標,分別指向第一個子節點、緊跟著的兄弟節點、父節點。這三個指標使深度優先遍歷成為可能。
root - div - h1 - p - a - h2
- 沿著 child 指標向下遍歷,直到葉子節點。
- 葉子節點依賴 sibling 指標向右遍歷該層兄弟節點。
- 兄弟節點遍歷完畢再沿 parent 指標回到上一層
- 直到回到根節點停止
Fiber和任務分片
前文說過 Fiber 的作用在任務分片。在虛擬DOM樹的處理過程中,最小的處理粒度是一個節點。我們把處理單個FiberNode的任務稱為“unitOfWork”,方便起見,下文稱之為單位任務。
總結
- 一個 ReactElement 物件對應 一個 Fiber 節點,一個 Fiber 節點對應一個單位任務。
- Fiber 節點通過
parent/child/sibing
三個指標構成 Fiber 樹,Fiber 樹支撐深度優先遍歷。
任務排程
在主執行緒上,每個空閒的時間片長度不一。我們希望在一個時間片有限的時間內儘量多的執行任務。
因此在處理完一個單位任務之後查詢是否還有空閒,再決定是否執行下一個單位任務。這部分程式碼由workLoop
函式實現。
// 依賴requestIdleCallback實現排程
let nextOfUnitWork = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextOfUnitWork && !shouldYield) {
nextOfUnitWork = performUnitOfWork(nextOfUnitWork);
shouldYield = deadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
處理單位任務
處理單位任務的函式是performUnitOfWork
,在這個函式裡做了三件事:
- 建立DOM;
- 為當前 Fiber 的所有子元素建立 Fiber,並且構建連線;
- 按照深度優先遍歷的順序(child > sibling > parent),確定下一個待處理任務。
是的,“構建Fiber樹” 和 “Fiber節點處理” 是自上而下同步進行的。
const isProperty = (prop) => prop !== "children";
const SimactDOM = {
render(element, container) {
nextOfUnitWork = {
dom: container,
props: {
children: [element],
},
};
},
};
// workLoop依賴requestIdleCallback實現排程
let nextOfUnitWork = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextOfUnitWork && !shouldYield) {
nextOfUnitWork = performUnitOfWork(nextOfUnitWork);
shouldYield = deadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
// 處理 unitOfWork
function performUnitOfWork(fiber) {
// 建立DOM
if (!fiber.dom) {
fiber.dom = createDOM(fiber);
}
// 掛載DOM
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
const elements = fiber.props.children;
let index = 0;
let prevSibling = null;
// 建立 children fibers
while (index < elements.length) {
const element = elements[index];
const newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: fiber,
};
if (index === 0) {
fiber.child = newFiber;
}
if (prevSibling) {
prevSibling.sibling = newFiber;
}
index++;
prevSibling = newFiber;
}
// 返回 next unitOfWork
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
export { SimactDOM as default };
仔細閱讀上面的程式碼,會發現render
呼叫和任務排程執行,在程式碼上並沒有順序聯絡。這和我們常見的程式碼結構有些許不同。
render 和 commit 階段
在一個任務中直接進行DOM掛載,同時任務分散在多個時間片內併發執行。這會導致部分檢視已更新,部分檢視未更新 的現象。
那麼如何防止DOM發生突變(mutate),儘量將其模擬成一個不可變物件呢?方法是將 Fiber樹處理過程和掛載DOM樹過程分離開。就是說分為兩個階段:render 和 commit。
render 階段增量處理Fiber節點,commit階段將結果一次性提交到DOM樹上。
render 階段負責:
- 生成 Fiber 樹
- 為 Fiber 建立對應的 DOM 節點。確保進入 commit 前,每一個 Fiber 上都有節點。但 DOM 節點的更新、插入、刪除由 commit 負責。
commit 階段再次遍歷 Fiber 樹,將 DOM 節點掛載到文件上。
在記憶體中維護一顆 Fiber 樹(workInProgress)充當處理的目標物件。整棵 Fiber 樹處理完畢後,一次性渲染到檢視上。
function render(element, container) {
// workInProgress Tree 充當目標
wipRoot = {
dom: container,
props: {
children: [element],
},
}
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let wipRoot = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
// 進入commit階段的判斷條件:有一棵樹在渲染流程中,並且render階段已執行完畢
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
function commitRoot() {
// commit階段遞迴遍歷fiber樹,掛載DOM節點
commitWork(wipRoot.child)
wipRoot = null
}
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
實現協調
我們開始考慮狀態更新的情況,上述程式碼重複執行render
將會導致 DOM 節點追加,而非更新。虛擬DOM進行協調簡單來說是實現一顆新樹,比較和記錄新樹和老樹之間的差異。
workInProgress樹負責生成新樹。我們需要一顆老樹,和新樹做對比。這顆老樹也是與檢視對應的Fiber樹,稱為current樹 。workInProgress樹和current樹的關係,類似於緩衝區和顯示區。緩衝區處理完畢,複製給顯示區。
計算兩棵樹的最小修改策略的 Diffing 演算法,由
的時間複雜度降維到
,關鍵因素在於三點:
- 節點很少出現跨層級移動,因此只比較同一層級節點
- 兩個不同型別的節點往往會產生不同的樹。因此當節點型別不同時,不再比較其子樹,直接銷燬並建立新子樹
- 同一層級節點可以通過
key
標識對應關係
我們來實現 Diffing 演算法。
- 依賴
alternate
確定節點的對應關係 - render階段:根據節點型別變化確定更新策略
effectTag
- commit階段:根據
effectTag
應用具體DOM操作
如何確定兩棵樹中節點的對應關係?
Fiber節點上alternate
屬性記錄同一層級對應位置的老Fiber節點。而alternate
屬性的賦值是在建立子Fiber節點時進行的。
- 根節點
workInProgressRoot.alternate = currentRoot
- 建立子Fiber節點時,依賴child指標和sibling指標找到current樹中的對應老Fiber節點
- 通過
alternate
建立新老子層節點的對應關係,到下一層遞迴
這一部分程式碼應該更能直觀說明:
let workInProgressRoot = null;
let currentRoot = null;
// 根節點建立聯絡
const SimactDOM = {
render(element, container) {
deletions = [];
workInProgressRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
};
nextOfUnitWork = workInProgressRoot;
},
};
function performUnitOfWork () {
...
let oldFiber = fiber.alternate && fiber.alternate.child;
...
// 處理一個Fiber節點時,建立其子節點。
// 依賴對應老節點的child指標和子節點的sibling指標,確定子節點對應關係
// 通過alternate建立新老子層節點的對應關係,到下一層遞迴
let index = 0;
while (index < elements.length) {
const newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: fiber,
alternate: oldFiber,
};
....
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
index++;
}
...
}
// 渲染完畢後,更新current樹,重置workInProgress樹
function commitRoot() {
commitWork(workInProgressRoot.child);
currentRoot = workInProgressRoot;
workInProgressRoot = null;
}
render階段 :根據節點型別確定更新策略
在 render 階段記錄節點對應的操作標識,由Fiber的effectTag
記錄;
- 同類型節點複用DOM元素,只需進行屬性更改(
"UPDATE"
) - 不同型別的節點銷燬原有DOM元素(
"DELETION"
),建立新的DOM元素("PLACEMENT"
)
const deletions = [];
function reconcileChildren(fiber, elements) {
// create children fibers
let oldFiber = fiber.alternate && fiber.alternate.child;
let index = 0;
let prevSibling = null;
while (index < elements.length || oldFiber) {
let newFiber = null;
const element = elements[index];
// 判斷型別是否相同
const isSameType = element && oldFiber && element.type === oldFiber.type;
// 同類型,複用dom,並建立alternate聯絡
if (isSameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: fiber,
alternate: oldFiber,
effectTag: "UPDATE",
};
}
// 不同型別,建立新dom,並切斷子樹比較
if (element && !isSameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: fiber,
alternate: null,
effectTag: "PLACEMENT",
};
}
// 不同型別,銷燬舊dom
if (oldFiber && !isSameType) {
deletions.push(oldFiber);
oldFiber.effectTag = "DELETION";
}
if (index === 0) {
fiber.child = newFiber;
}
if (prevSibling) {
prevSibling.sibling = newFiber;
}
index++;
prevSibling = newFiber;
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
}
}
function performUnitOfWork(fiber) {
// create dom
if (!fiber.dom) {
fiber.dom = createDOM(fiber);
}
// create chilren fibers
reconcileChildren(fiber, fiber.props.children);
// return next unitOfWork
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
commit階段 :應用DOM操作
在 commit 階段根據effectTag
應用不同的DOM操作 。
-
"DELETION"
:移除要刪除的DOM節點 -
"PLACEMENT"
:掛載新建立的DOM節點 -
"UPDATE"
:更新DOM節點屬性
function commitRoot() {
deletions.forEach(commitWork);
commitWork(workInProgressRoot.child);
currentRoot = workInProgressRoot;
workInProgressRoot = null;
}
function commitWork(fiber) {
if (!fiber) {
return;
}
const domParent = fiber.parent.dom;
if (fiber.effectTag === "DELETION") {
domParent.removeChild(fiber.dom)
} else if (fiber.effectTag === "PLACEMENT" && fiber.dom !== null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom !== null) {
updateDOM(fiber.dom, fiber.alternate.props, fiber.props);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
const isProperty = (prop) => prop !== "children";
const isEvent = (prop) => prop.startsWith("on");
const isNew = (prev, next) => (key) => prev[key] !== next[key];
const isGone = (_prev, next) => (key) => !key in next;
function updateDOM(dom, prevProps, nextProps) {
Object.keys(prevProps).forEach((name) => {
if (isEvent(name) && (!(name in prevProps) || isNew(name))) {
dom.removeEventListener(name.toLowerCase().substring(2), prevProps[name]);
}
if (isProperty(name) && isGone(name)) {
dom[name] = "";
}
});
Object.keys(nextProps).forEach((name) => {
if (isEvent(name) && isNew(name)) {
dom.addEventListener(name.toLowerCase().substring(2), nextProps[name]);
}
if (isProperty(name) && isNew(name)) {
dom[name] = nextProps[name];
}
});
}
支援函式元件
函式元件和原生元素的區別在於:
- ReactElement 物件的
type
值是元件的定義函式,執行定義函式返回子 ReactElement 物件。因此在performUnitOfWork
中無需建立 DOM 節點,並且需要呼叫定義函式獲得子代。 - 函式元件對應一個 Fiber 節點,但其沒有對應的 DOM 節點。因此在 commit 階段進行DOM操作需要找到真正的父子節點。
function performUnitOfWork(fiber) {
if (fiber.type instanceof Function) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
}
// 更新函式元件
function updateFunctionComponent(fiber) {
// 呼叫元件定義函式,獲取子ReactElement物件
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
// 更新原生元素
function updateHostComponent(fiber) {
// create dom
if (!fiber.dom) {
fiber.dom = createDOM(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}
function commitWork(fiber) {
if (!fiber) {
return;
}
let parentFiber = fiber.parent;
// 插入和更新操作需要找到真正的父dom節點
while (parentFiber.dom === null) {
parentFiber = parentFiber.parent;
}
const domParent = parentFiber.dom;
if (fiber.effectTag === "DELETION") {
commitDeletion(domParent, fiber);
} else if (fiber.effectTag === "PLACEMENT" && fiber.dom !== null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom !== null) {
updateDOM(fiber.dom, fiber.alternate.props, fiber.props);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function commitDeletion(domParent, fiber) {
// 刪除操作需要找到真正的子dom節點
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(domParent, fiber.child);
}
}
支援Hooks
全域性變數workInProgressFiber
儲存當前正在處理的 Fiber 節點,以供useState
訪問。
為了支援在一個元件中多次使用useState
,hooks 作為佇列在 Fiber 節點中維護。全域性變數hookIndex
維持useState
執行順序和hook的關係。
Fiber {
hooks: [ // hook按呼叫順序存放
{
state,
queue: [action]
// 任務分片執行,在未處理到當前節點前。更改狀態將重新執行渲染流程,需要保留未生效的修改
}
]
}
let workInProgressFiber = null;
let hookIndex = null;
function updateFunctionComponent(fiber) {
workInProgressFiber = fiber;
hookIndex = 0;
workInProgressFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function useState(initial) {
const oldHook =
workInProgressFiber.alternate &&
workInProgressFiber.alternate.hooks &&
workInProgressFiber.alternate.hooks[hookIndex];
// 根據老節點的hook確定初始狀態
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
// 應用狀態更新
if (oldHook) {
oldHook.queue.forEach((action) => {
hook.state = action(hook.state);
});
}
const setState = (action) => {
// 加入更新佇列,在下一次渲染流程中應用。
// 開啟渲染流程
hook.queue.push(action);
deletions = [];
workInProgressRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextOfUnitWork = workInProgressRoot;
};
workInProgressFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
後記
React的功能和優化並沒有完全在上述過程中實現,包括:
- 在render階段,我們遍歷了整棵Fiber樹。而在React中使用啟發式演算法跳過未修改的子樹
- 在commit階段,我們同樣遍歷了整棵Fiber樹。而在React中則是依賴Effect List儲存有修改的Fiber,避免對 Fiber樹的再次遍歷
- 在處理單位任務時,我們會為workInProgress樹建立新的Fiber節點 。而在React中會重複使用current樹中的老節點
- 我們在render階段接收到新的狀態會重新開始渲染流程。而在React中會為每個更新標記一個expiration timestamp,比較更新的優先順序。
同時,你也可以自行新增一些功能,比如:
- 支援
style prop
的物件定義 - 支援列表元素
- 實現
useEffect
- 在協調過程中支援
key
標識
https://pomb.us/build-your-own-react/pomb.us
跟隨原文動手實現一遍,對React的大致工作流程會有更深刻的理解。同時,對React優化的歷程和出發點也有一些體會,不僅僅知道它是怎麼做的,還有它為什麼要這麼做。另外,動手實現的樂趣和成就感是無可替代的。
所以,快跟著原文實現一遍吧。