作者:devrsi0n
https://segmentfault.com/a/1190000021689852
React 是目前最流行的前端架構,很多讀者用 React 很溜,但想要深入學習 React 的原理就會被官方源碼倉庫浩瀚如煙的代碼繞的暈頭轉向。今天我們通過不依賴任何第三方庫的方式,抛棄邊界處理、性能優化、安全性等弱相關代碼手寫一個基礎版的 React,供大家學習和了解 React 的核心原理。

本文實作的是包含現代 React 最新特性 Hooks 和 Concurrent Mode 的版本,傳統 class 元件的方式稍有不同,不影響了解核心原理。本文函數、變量等辨別符命名都和官方盡量貼近,友善以後深入官方源碼。
建議桌面端浏覽本文,并且跟着文章手動敲一遍代碼加深了解。
目錄總覽
- 0: 從一次最簡單的 React 渲染說起
- I: 實作 createElement 函數
- II: 實作 render 函數
- III: 并發模式 / Concurrent Mode
- IV: Fibers 資料結構
- V: render 和 commit 階段
- VI: 更新和删除節點/Reconciliation
- VII: 函數元件
- VIII: 函數元件 Hooks
0: 從一次最簡單的 React 渲染說起
const element = <h1 title="hello">Hello World!</h1>;
const container = document.getElementById("root");
ReactDOM.render(element, container);
複制
上面這三行代碼是一個再簡單不過的 React 應用:在
root
根結點上渲染一個
Hello World!
h1 節點。
第一步的目标是用原生 DOM 方式替換 React 代碼。
JSX
熟悉 React 的讀者都知道,我們直接在元件渲染的時候傳回一段類似 html 模版的結構,這個就是所謂的 JSX。JSX 本質上還是 JS,是文法糖而不是 html 模版(相比 html 模版要學習千奇百怪的文法比如:
{{#if value}}
,JSX 可以直接使用 JS 原生的
&& || map reduce
等文法更易學表達能力也更強)。一般需要 babel 配合@babel/plugin-transform-react-jsx 插件(babel 轉換過程不是本文重點,感興趣可以閱讀插件源碼)轉換成調用
React.createElement
,函數入參如下:
React.createElement(
type,
[props],
[...children]
)
複制
例如上面的例子中的
<h1 title="hello">Hello World!</h1>
,換成
createElement
調用就是:
const element = React.createElement(
'h1',
{ title: 'hello' },
'Hello World!'
);
複制
React.createElement
傳回一個包含元素(element)資訊的對象,即:
const element = {
type: "h1",
props: {
title: "hello",
// createElement 第三個及之後參數移到 props.children
children: "Hello World!",
},
};
複制
react 官方實作還包括了很多額外屬性,簡單起見本文未涉及,參看官方定義。
這個對象描述了 React 建立一個節點(node)所需要的資訊,
type
就是 DOM 節點的名字,比如這裡是
h1
,也可以是函數元件,後面會講到。
props
包含所有元素的屬性(比如 title)和特殊屬性 children,children 可以包含其他元素,從根到葉也就能構成一顆完整的樹,也就是描述了整個 UI 界面。
為了避免含義不清,“元素”特指 “React elements”,“節點”特指 “DOM elements”。
ReactDOM.render
下面替換掉
ReactDOM.render
調用,這裡 React 會把元素更新到 DOM。
const element = {
type: "h1",
props: {
title: "hello",
children: ["Hello World!"],
},
};
const container = document.getElementById("root");
const node = document.createElement(element.type);
node["title"] = element.props.title;
const text = document.createTextNode("");
text["nodeValue"] = element.props.children;
node.appendChild(text);
container.appendChild(node);
複制
對比元素對象,首先用
element.type
建立節點,再把非 children 屬性(這裡是 title)指派給節點。
然後建立 children 節點,由于 children 是字元串,故建立
textNode
節點,并把字元串指派給
nodeValue
,這裡之是以用
createTextNode
而不是
innerText
,是為了友善之後統一處理。
再把 children 節點 text 插到元素節點的子節點上,最後把元素節點插到根結點即完成了這次 React 的替換。
像上面代碼
element
這樣 JSX 轉成的描述 UI 界面的對象就是所謂的 虛拟 DOM,相對的
node
即 真實 DOM。
render/渲染
過程就是把虛拟 DOM 轉換成真實 DOM 的過程。
I: 實作 createElement 函數
第一步首先實作
createElement
函數,把 JSX 轉換成 JS。以下面這個新的渲染為例,
createElement
就是把 JSX 結構轉成元素描述對象。
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
);
// 等價轉換 ?
const element = React.createElement(
"div",
{ id: "foo" },
React.createElement("a", null, "bar"),
React.createElement("b")
);
const container = document.getElementById("root");
ReactDOM.render(element, container);
複制
就像之前示例那樣,
createElement
傳回一個包含 type 和 props 的元素對象,描述節點資訊。
// 這裡用了最新 ECMAScript 剩餘參數和展開文法(Rest parameter/Spread syntax),
// 參考 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Spread_syntax
// 注意:這裡 children 始終是數組
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object"
? child
: createTextElement(child)
),
},
}
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
}
}
複制
children 可能包含字元串或者數字這類基礎類型值,給這裡值包裹成
TEXT_ELEMENT
特殊類型,友善後面統一處理。
注意:React 并不會包裹字元串這類值,如果沒有 children 也不會建立空數組,這裡簡單起見,統一這樣處理可以簡化我們的代碼。
我們把本文的架構叫做
redact
,以差別
react
。示例 app 如下。
const element = Redact.createElement(
"div",
{ id: "foo" },
Redact.createElement("a", null, "bar"),
Redact.createElement("b")
);
const container = document.getElementById("root");
ReactDOM.render(element, container);
複制
但是我們還是習慣用 JSX 來寫元件,這裡還能用嗎?答案是能的,隻需要加一行注釋即可。
/** @jsx Redact.createElement */
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
);
const container = document.getElementById("root");
ReactDOM.render(element, container);
複制
注意第一行注釋
@jsx
告訴 babel 用
Redact.createElement
替換預設的
React.createElement
。或者直接修改
.babelrc
配置檔案的
pragma
項,就不用每次都添加注釋了。
{
"presets": [
[
"@babel/preset-react",
{
"pragma": "Redact.createElement",
}
]
]
}
複制
II: 實作 render 函數
實作我們的 render 函數,目前隻需要添加節點到 DOM,删除和更新操作後面再加。
function render(element, container) {
// 建立節點
const dom =
element.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type);
// 指派屬性(props)
const isProperty = key => key !== "children";
Object.keys(element.props)
.filter(isProperty)
.forEach(name => {
dom[name] = element.props[name]
});
// 遞歸周遊子節點
element.props.children.forEach(child =>
render(child, dom)
);
// 插入父節點
container.appendChild(dom);
}
複制
上面的代碼放在了 CodeSandbox(線上開發環境),項目基于 Create React App 模版,試一試改下面的代碼驗證下。
redact-1
III: 并發模式 / Concurrent Mode
在我們深入其他 React 功能之前,先對代碼重構,引入 React 最新的并發模式(截止本文發表該功能還未正式釋出)。
可能讀者會疑惑我們目前連最基本的元件狀态更新都還沒實作就先實作并發模式,其實目前代碼邏輯還十分簡單,現在重構,比之後實作所有功能再回頭要容易很多,所謂積重難返就是這個道理。
有經驗的開發者很容易發現上面的
render
代碼有一個問題,渲染子節點時遞歸周遊了整棵樹,當我們頁面非常複雜時很容易阻塞主線程(和 stack over flow, 堆棧溢出),我們都知道每個頁面是單線程的(不考慮 worker 線程),主線程阻塞會導緻頁面不能及時響應高優先級操作,如使用者點選或者渲染動畫,頁面給使用者 “很卡,難用” 的負面印象,這肯定不是我們想要的。
是以,理想情況下,我們應該把
render
拆成更細分的單元,每完成一個單元的工作,允許浏覽器打斷渲染響應更高優先級的的工作,這個過程即 “并發模式”。
這裡我們用 requestIdleCallback 這個浏覽器 API 來實作。這個 API 有點類似
setTimeout
,不過不是我們告訴浏覽器什麼時候執行回調函數,而是浏覽器線上程空閑(idle)的時侯主動執行回調函數。
React 目前已經不用這個 API 了,而是用 排程器/scheduler 這個包,自己實作排程算法。但它們核心思路是類似的,簡化起見用 requestIdleCallback 足矣。
let nextUnitOfWork = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
// 回調函數入參 deadline 可以告訴我們在這個渲染周期還剩多少時間可用
// 剩餘時間小于1毫秒就退出回調,等待浏覽器再次空閑
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
// 注意,這個函數執行完本次單元任務之後要傳回下一個單元任務
function performUnitOfWork(nextUnitOfWork) {
// TODO
}
複制
IV: Fibers 資料結構
為了友善描述渲染樹和單元任務,React 設計了一種資料結構 “fiber 樹”。每個元素都是一個 fiber,每個 fiber 就是一個單元任務。
假如我們渲染如下這樣一棵樹:
Redact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
複制
用 Fiber 樹來描述就是:
在
render
函數我們建立根 fiber,再把它設為
nextUnitOfWork
。在 workLoop 函數把
nextUnitOfWork
給
performUnitOfWork
執行,主要包含以下三步:
- 把元素添加到 DOM
- 為元素的後代建立 fiber 節點
- 選擇下一個單元任務,并傳回
為了完成這些目标需要設計的資料結構友善找到下一個任務單元。是以每個 fiber 直接連結它的第一個子節點(child),子節點連結它的兄弟節點(sibling),兄弟節點連結到父節點(parent)。 示意圖如下(注意不同節點之間的高亮箭頭):
當我們完成了一個 fiber 的單元任務,如果他有一個
子節點/child
則這個節點作為
nextUnitOfWork
。如下圖所示,當完成
div
單元任務之後,下一個單元任務就是
h1
。
如果一個 fiber 沒有
child
,我們用
兄弟節點/sibling
作為下一個任務單元。如下圖所示,
p
節點沒有
child
而有
sibling
,是以下一個任務單元是
a
節點。
如果一個 fiber 既沒有
child
也沒有
sibling
,則找到父節點的兄弟節點,。如下圖所示的
a
和
h2
。
如果父節點沒有兄弟節點,則繼續往上找,直到找到一個兄弟節點或者到達 fiber 根結點。到達根結點即意味本次
render
任務全部完成。
把這個思路用代碼表達如下:
// 之前 render 的邏輯挪到這個函數
function createDom(fiber) {
const dom =
fiber.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
const isProperty = key => key !== "children";
Object.keys(fiber.props)
.filter(isProperty)
.forEach(name => {
dom[name] = fiber.props[name];
});
return dom;
}
function render(element, container) {
// 建立根 fiber,設為下一次的單元任務
nextUnitOfWork = {
dom: container,
props: {
children: [element]
}
};
}
let nextUnitOfWork = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}
// 一旦浏覽器空閑,就觸發執行單元任務
requestIdleCallback(workLoop);
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 子節點 DOM 插到父節點之後
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
// 每個子元素建立新的 fiber
const elements = fiber.props.children;
let index = 0;
let prevSibling = null;
while (index < elements.length) {
const element = elements[index];
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null
};
// 根據上面的圖示,父節點隻連結第一個子節點
if (index === 0) {
fiber.child = newFiber;
} else {
// 兄節點連結弟節點
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
// 傳回下一個任務單元(fiber)
// 有子節點直接傳回
if (fiber.child) {
return fiber.child;
}
// 沒有子節點則找兄弟節點,兄弟節點也沒有找父節點的兄弟節點,
// 循環周遊直至找到為止
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
複制
V: render 和 commit 階段
我們的代碼還有一個問題。
每完成一個任務單元都把節點添加到 DOM 上。請記住,浏覽器是可以打斷渲染流程的,如果還沒渲染完整棵樹就把節點添加到 DOM,使用者會看到殘缺不全的 UI 界面,給人一種很不專業的印象,這肯定不是我們想要的。是以需要重構節點添加到 DOM 這部分代碼,整棵樹(fiber)渲染完成之後再一次性添加到 DOM,即 React commit 階段。
具體來說,去掉
performUnitOfWork
的
fiber.parent.dom.appendChild
代碼,換成如下代碼。
function createDom(fiber) {
const dom =
fiber.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type)
const isProperty = key => key !== "children"
Object.keys(fiber.props)
.filter(isProperty)
.forEach(name => {
dom[name] = fiber.props[name]
})
return dom
}
// 新增函數,送出根結點到 DOM
function commitRoot() {
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);
}
function render(element, container) {
// render 時記錄 wipRoot
wipRoot = {
dom: container,
props: {
children: [element],
},
};
nextUnitOfWork = wipRoot;
}
let nextUnitOfWork = null;
// 新增變量,跟蹤渲染進行中的根 fiber
let wipRoot = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
);
shouldYield = deadline.timeRemaining() < 1;
}
// 當 nextUnitOfWork 為空則表示渲染 fiber 樹完成了,
// 可以送出到 DOM 了
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
// 一旦浏覽器空閑,就觸發執行單元任務
requestIdleCallback(workLoop);
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
const elements = fiber.props.children;
let index = 0;
let prevSibling = null;
while (index < elements.length) {
const element = elements[index];
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
};
if (index === 0) {
fiber.child = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
複制
VI: 更新和删除節點/Reconciliation
目前我們隻添加節點到 DOM,還沒考慮更新和删除節點的情況。要處理這2種情況,需要對比上次渲染的 fiber 和目前渲染的 fiber 的差異,根據差異決定是更新還是删除節點。React 把這個過程叫
Reconciliation
。
是以我們需要儲存上一次渲染之後的 fiber 樹,我們把這棵樹叫
currentRoot
。同時,給每個 fiber 節點添加
alternate
屬性,指向上一次渲染的 fiber。
代碼較多,建議按
render ⟶ workLoop ⟶ performUnitOfWork ⟶ reconcileChildren ⟶ workLoop ⟶ commitRoot ⟶ commitWork ⟶ updateDom
順序閱讀。
function createDom(fiber) {
const dom =
fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom;
}
const isEvent = key => key.startsWith("on");
const isProperty = key => key !== "children" && !isEvent(key);
const isNew = (prev, next) => key => prev[key] !== next[key];
const isGone = (prev, next) => key => !(key in next);
// 新增函數,更新 DOM 節點屬性
function updateDom(dom, prevProps = {}, nextProps = {}) {
// 以 “on” 開頭的屬性作為事件要特别處理
// 移除舊的或者變化了的的事件處理函數
Object.keys(prevProps)
.filter(isEvent)
.filter(key => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.removeEventListener(eventType, prevProps[name]);
});
// 移除舊的屬性
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom[name] = "";
});
// 添加或者更新屬性
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
// React 規定 style 内聯樣式是駝峰命名的對象,
// 根據規範給 style 每個屬性單獨指派
if (name === "style") {
Object.entries(nextProps[name]).forEach(([key, value]) => {
dom.style[key] = value;
});
} else {
dom[name] = nextProps[name];
}
});
// 添加新的事件處理函數
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[name]);
});
}
function commitRoot() {
deletions.forEach(commitWork);
commitWork(wipRoot.child);
currentRoot = wipRoot;
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) {
return;
}
const domParent = fiber.parent.dom;
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);
} else if (fiber.effectTag === "DELETION") {
domParent.removeChild(fiber.dom);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element]
},
alternate: currentRoot
};
deletions = [];
nextUnitOfWork = wipRoot;
}
let nextUnitOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
const elements = fiber.props.children;
// 原本添加 fiber 的邏輯挪到 reconcileChildren 函數
reconcileChildren(fiber, elements);
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
// 新增函數
function reconcileChildren(wipFiber, elements) {
let index = 0;
// 上次渲染完成之後的 fiber 節點
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;
// 扁平化 props.children,處理函數元件的 children
elements = elements.flat();
while (index < elements.length || oldFiber != null) {
// 本次需要渲染的子元素
const element = elements[index];
let newFiber = null;
// 比較目前和上一次渲染的 type,即 DOM tag 'div',
// 暫不考慮自定義元件
const sameType = oldFiber && element && element.type === oldFiber.type;
// 同類型節點,隻需更新節點 props 即可
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom, // 複用舊節點的 DOM
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE" // 新增屬性,在送出/commit 階段使用
};
}
// 不同類型節點且存在新的元素時,建立新的 DOM 節點
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT" // PLACEMENT 表示需要添加新的節點
};
}
// 不同類型節點,且存在舊的 fiber 節點時,
// 需要移除該節點
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION";
// 當最後送出 fiber 樹到 DOM 時,我們是從 wipRoot 開始的,
// 此時沒有上一次的 fiber,是以這裡用一個數組來跟蹤需要
// 删除的節點
deletions.push(oldFiber);
}
if (oldFiber) {
// 同步更新下一個舊 fiber 節點
oldFiber = oldFiber.sibling;
}
if (index === 0) {
wipFiber.child = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
複制
注意:這個過程中 React 還用了
key
來檢測數組元素變化了位置的情況,避免重複渲染以提高性能。簡化起見,本文未實作。
下面 CodeSandbox 代碼用了個小技巧,重複執行
render
實作更新界面的效果,動手改改試試。
redact-2
VII: 函數元件
目前我們還隻考慮了直接渲染 DOM 标簽的情況,不支援元件,而元件是 React 是靈魂,下面我們來實作函數元件。
以一個非常簡單的元件代碼為例。
/** @jsx Redact.createElement */
function App(props) {
return <h1>Hi {props.name}</h1>;
};
// 等效 JS 代碼 ?
function App(props) {
return Redact.createElement(
"h1",
null,
"Hi ",
props.name
)
}
const element = <App name="foo" />;
const container = document.getElementById("root");
Redact.render(element, container);
複制
函數元件有2個不同點:
- 函數元件的 fiber 節點沒有對應 DOM
- 函數元件的 children 來自函數執行結果,而不是像标簽元素一樣直接從 props 擷取,因為 children 不隻是函數元件使用時包含的子孫節點,還需要組合元件本身的結構
注意以下代碼省略了未改動部分。
function commitWork(fiber) {
if (!fiber) {
return;
}
// 當 fiber 是函數元件時節點不存在 DOM,
// 故需要周遊父節點以找到最近的有 DOM 的節點
let domParentFiber = fiber.parent;
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent;
}
const domParent = domParentFiber.dom;
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);
} else if (fiber.effectTag === "DELETION") {
// 直接移除 DOM 替換成 commitDeletion 函數
commitDeletion(fiber, domParent);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
// 新增函數,移除 DOM 節點
function commitDeletion(fiber, domParent) {
// 當 child 是函數元件時不存在 DOM,
// 故需要遞歸周遊子節點找到真正的 DOM
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(fiber.child, domParent);
}
}
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function;
// 原本邏輯挪到 updateHostComponent 函數
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
// 新增函數,處理函數元件
function updateFunctionComponent(fiber) {
// 執行函數元件得到 children
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
// 新增函數,處理原生标簽元件
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}
複制
VIII: 函數元件 Hooks
支援了函數元件,還需要支援元件狀态 / state 才能實作重新整理界面。
我們的示例也跟着更新,用 hooks 實作經典的 counter,點選計數器加1。
/** @jsx Redact.createElement */
function Counter() {
const [state, setState] = Redact.useState(1)
return (
<h1 onClick={() => setState(c => c + 1)}>
Count: {state}
</h1>
);
}
const element = <Counter />;
const container = document.getElementById("root");
Redact.render(element, container);
複制
注意以下代碼省略了未變化部分。
// 新增變量,渲染進行中的 fiber 節點
let wipFiber = null;
// 新增變量,目前 hook 的索引
let hookIndex = null;
function updateFunctionComponent(fiber) {
// 更新進行中的 fiber 節點
wipFiber = fiber;
// 重置 hook 索引
hookIndex = 0;
// 新增 hooks 數組以支援同一個元件多次調用 `useState`
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function useState(initial) {
// alternate 儲存了上一次渲染的 fiber 節點
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
// 第一次渲染使用入參,第二次渲染複用前一次的狀态
state: oldHook ? oldHook.state : initial,
// 儲存每次 setState 入參的隊列
queue: []
};
const actions = oldHook ? oldHook.queue : [];
actions.forEach(action => {
// 根據調用 setState 順序從前往後生成最新的 state
hook.state = action instanceof Function ? action(hook.state) : action;
});
// setState 函數用于更新 state,入參 action
// 是新的 state 值或函數傳回新的 state
const setState = action => {
hook.queue.push(action);
// 下面這部分代碼和 render 函數很像,
// 設定新的 wipRoot 和 nextUnitOfWork
// 浏覽器空閑時即開始重新渲染。
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot
};
nextUnitOfWork = wipRoot;
deletions = [];
};
// 儲存本次 hook
wipFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
複制
完整 CodeSandbox 代碼如下,點選 Count 試試:
redact-3
結語
除了幫助讀者了解 React 核心工作原理外,本文很多變量都和 React 官方代碼保持一緻,比如,讀者在 React 應用的任何函數元件裡斷點,再打開調試工作能看到下面這樣的調用棧:
- updateFunctionComponent
- performUnitOfWork
- workLoop
注意本文是教學性質的,還缺少很多 React 的功能和性能優化。比如:在這些事情上 React 的表現和 Redact 不同。
- Redact 在渲染階段周遊了整棵樹,而 React 用了一些啟發性算法,可以直接跳過某些沒有變化的子樹,以提高性能。(比如 React 數組元素推薦帶 key,可以跳過無需更新的節點,參考官方文檔)
- Redact 在 commit 階段周遊整棵樹, React 用了一個連結清單儲存變化了的 fiber,減少了很多不必要周遊操作。
- Redact 每次建立新的 fiber 樹時都是直接建立 fiber 對象節點,而 React 會複用上一個 fiber 對象,以節省建立對象的性能消耗。
- Redact 如果在渲染階段收到新的更新會直接丢棄已渲染的樹,再從頭開始渲染。而 React 會用時間戳标記每次更新,以決定更新的優先級。
- 源碼還有很多優化等待讀者去發現。。。
參考
征得原作者同意,本文參考了 build-your-own-react 部分内容,推薦英文水準不錯讀者直接在桌面端閱讀原文以獲得最佳閱讀體驗。
- build your own react
- wtf is jsx
- gooact react in 160 lines of javascript