從零開始寫一個 Fiber 版的 React

React 是目前最流行的前端框架,很多讀者用 React 很溜,但想要深入學(xué)習(xí) React 的原理就會被官方源碼倉庫浩瀚如煙的代碼繞的暈頭轉(zhuǎn)向。今天我們通過不依賴任何第三方庫的方式,拋棄邊界處理、性能優(yōu)化、安全性等弱相關(guān)代碼手寫一個基礎(chǔ)版的 React,供大家學(xué)習(xí)和理解 React 的核心原理。

本文實(shí)現(xiàn)的是包含現(xiàn)代 React 最新特性?Hooks?和?Concurrent Mode?的版本,傳統(tǒng) class 組件的方式稍有不同,不影響理解核心原理。本文函數(shù)、變量等標(biāo)識符命名都和官方盡量貼近,方便以后深入官方源碼。
建議桌面端瀏覽本文,并且跟著文章手動敲一遍代碼加深理解。
目錄總覽
0: 從一次最簡單的 React 渲染說起
I: 實(shí)現(xiàn) createElement 函數(shù)
II: 實(shí)現(xiàn) render 函數(shù)
III: 并發(fā)模式 / Concurrent Mode
IV: Fibers 數(shù)據(jù)結(jié)構(gòu)
V: render 和 commit 階段
VI: 更新和刪除節(jié)點(diǎn)/Reconciliation
VII: 函數(shù)組件
VIII: 函數(shù)組件 Hooks
const element = <h1 title="hello">Hello World!h1>;
const container = document.getElementById("root");
ReactDOM.render(element, container);上面這三行代碼是一個再簡單不過的 React 應(yīng)用:在?root?根結(jié)點(diǎn)上渲染一個?Hello World!?h1 節(jié)點(diǎn)。
第一步的目標(biāo)是用原生 DOM 方式替換 React 代碼。
JSX
熟悉 React 的讀者都知道,我們直接在組件渲染的時候返回一段類似 html 模版的結(jié)構(gòu),這個就是所謂的?JSX。JSX 本質(zhì)上還是 JS,是語法糖而不是 html 模版(相比 html 模版要學(xué)習(xí)千奇百怪的語法比如:{{#if value}},JSX 可以直接使用 JS 原生的?&& || map reduce?等語法更易學(xué)表達(dá)能力也更強(qiáng))。一般需要?babel?配合@babel/plugin-transform-react-jsx?插件(babel 轉(zhuǎn)換過程不是本文重點(diǎn),感興趣可以閱讀插件源碼)轉(zhuǎn)換成調(diào)用?React.createElement,函數(shù)入?yún)⑷缦拢?/p>
React.createElement(
type,
[props],
[...children]
)例如上面的例子中的?,換成?Hello World!
createElement?調(diào)用就是:
const element = React.createElement(
'h1',
{ title: 'hello' },
'Hello World!'
);React.createElement?返回一個包含元素(element)信息的對象,即:
const element = {
type: "h1",
props: {
title: "hello",
// createElement 第三個及之后參數(shù)移到 props.children
children: "Hello World!",
},
};react 官方實(shí)現(xiàn)還包括了很多額外屬性,簡單起見本文未涉及,參看官方定義。
這個對象描述了 React 創(chuàng)建一個節(jié)點(diǎn)(node)所需要的信息,type?就是 DOM 節(jié)點(diǎn)的名字,比如這里是?h1,也可以是函數(shù)組件,后面會講到。props?包含所有元素的屬性(比如 title)和特殊屬性 children,children 可以包含其他元素,從根到葉也就能構(gòu)成一顆完整的樹,也就是描述了整個 UI 界面。
為了避免含義不清,“元素”特指 “React elements”,“節(jié)點(diǎn)”特指 “DOM elements”。
ReactDOM.render
下面替換掉?ReactDOM.render?調(diào)用,這里 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?創(chuàng)建節(jié)點(diǎn),再把非 children 屬性(這里是 title)賦值給節(jié)點(diǎn)。
然后創(chuàng)建 children 節(jié)點(diǎn),由于 children 是字符串,故創(chuàng)建?textNode?節(jié)點(diǎn),并把字符串賦值給?nodeValue,這里之所以用?createTextNode?而不是?innerText,是為了方便之后統(tǒng)一處理。
再把 children 節(jié)點(diǎn) text 插到元素節(jié)點(diǎn)的子節(jié)點(diǎn)上,最后把元素節(jié)點(diǎn)插到根結(jié)點(diǎn)即完成了這次 React 的替換。
像上面代碼?element?這樣 JSX 轉(zhuǎn)成的描述 UI 界面的對象就是所謂的?虛擬 DOM,相對的?node?即?真實(shí) DOM。render/渲染?過程就是把虛擬 DOM 轉(zhuǎn)換成真實(shí) DOM 的過程。
I: 實(shí)現(xiàn) createElement 函數(shù)
第一步首先實(shí)現(xiàn)?createElement?函數(shù),把 JSX 轉(zhuǎn)換成 JS。以下面這個新的渲染為例,createElement?就是把 JSX 結(jié)構(gòu)轉(zhuǎn)成元素描述對象。
const element = (
<div id="foo">
<a>bara>
<b />
div>
);
// 等價轉(zhuǎn)換 ?
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 的元素對象,描述節(jié)點(diǎn)信息。
// 這里用了最新 ECMAScript 剩余參數(shù)和展開語法(Rest parameter/Spread syntax),
// 參考 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Spread_syntax
// 注意:這里 children 始終是數(shù)組
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 可能包含字符串或者數(shù)字這類基礎(chǔ)類型值,給這里值包裹成?TEXT_ELEMENT?特殊類型,方便后面統(tǒng)一處理。
注意:React 并不會包裹字符串這類值,如果沒有 children 也不會創(chuàng)建空數(shù)組,這里簡單起見,統(tǒng)一這樣處理可以簡化我們的代碼。
我們把本文的框架叫做?redact,以區(qū)別?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);但是我們還是習(xí)慣用 JSX 來寫組件,這里還能用嗎?答案是能的,只需要加一行注釋即可。
/** @jsx Redact.createElement */
const element = (
<div id="foo">
<a>bara>
<b />
div>
);
const container = document.getElementById("root");
ReactDOM.render(element, container);注意第一行注釋?@jsx?告訴 babel 用?Redact.createElement?替換默認(rèn)的?React.createElement。或者直接修改?.babelrc?配置文件的?pragma?項(xiàng),就不用每次都添加注釋了。
{
"presets": [
[
"@babel/preset-react",
{
"pragma": "Redact.createElement",
}
]
]
}II: 實(shí)現(xiàn) render 函數(shù)
實(shí)現(xiàn)我們的 render 函數(shù),目前只需要添加節(jié)點(diǎn)到 DOM,刪除和更新操作后面再加。
function render(element, container) {
// 創(chuàng)建節(jié)點(diǎn)
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]
});
// 遞歸遍歷子節(jié)點(diǎn)
element.props.children.forEach(child =>
render(child, dom)
);
// 插入父節(jié)點(diǎn)
container.appendChild(dom);
}上面的代碼放在了 CodeSandbox(在線開發(fā)環(huán)境),項(xiàng)目基于?Create React App?模版,試一試改下面的代碼驗(yàn)證下。
redact-1
III: 并發(fā)模式 / Concurrent Mode
在我們深入其他 React 功能之前,先對代碼重構(gòu),引入 React 最新的并發(fā)模式(截止本文發(fā)表該功能還未正式發(fā)布)。
可能讀者會疑惑我們目前連最基本的組件狀態(tài)更新都還沒實(shí)現(xiàn)就先實(shí)現(xiàn)并發(fā)模式,其實(shí)目前代碼邏輯還十分簡單,現(xiàn)在重構(gòu),比之后實(shí)現(xiàn)所有功能再回頭要容易很多,所謂積重難返就是這個道理。
有經(jīng)驗(yàn)的開發(fā)者很容易發(fā)現(xiàn)上面的?render?代碼有一個問題,渲染子節(jié)點(diǎn)時遞歸遍歷了整棵樹,當(dāng)我們頁面非常復(fù)雜時很容易阻塞主線程(和 stack over flow, 堆棧溢出),我們都知道每個頁面是單線程的(不考慮 worker 線程),主線程阻塞會導(dǎo)致頁面不能及時響應(yīng)高優(yōu)先級操作,如用戶點(diǎn)擊或者渲染動畫,頁面給用戶 “很卡,難用” 的負(fù)面印象,這肯定不是我們想要的。
因此,理想情況下,我們應(yīng)該把?render?拆成更細(xì)分的單元,每完成一個單元的工作,允許瀏覽器打斷渲染響應(yīng)更高優(yōu)先級的的工作,這個過程即 “并發(fā)模式”。
這里我們用?requestIdleCallback?這個瀏覽器 API 來實(shí)現(xiàn)。這個 API 有點(diǎn)類似?setTimeout,不過不是我們告訴瀏覽器什么時候執(zhí)行回調(diào)函數(shù),而是瀏覽器在線程空閑(idle)的時侯主動執(zhí)行回調(diào)函數(shù)。
React 目前已經(jīng)不用這個 API?了,而是用?調(diào)度器/scheduler?這個包,自己實(shí)現(xiàn)調(diào)度算法。但它們核心思路是類似的,簡化起見用 requestIdleCallback 足矣。
let nextUnitOfWork = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
// 回調(diào)函數(shù)入?yún)?deadline 可以告訴我們在這個渲染周期還剩多少時間可用
// 剩余時間小于1毫秒就退出回調(diào),等待瀏覽器再次空閑
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
// 注意,這個函數(shù)執(zhí)行完本次單元任務(wù)之后要返回下一個單元任務(wù)
function performUnitOfWork(nextUnitOfWork) {
// TODO
}IV: Fibers 數(shù)據(jù)結(jié)構(gòu)為了方便描述渲染樹和單元任務(wù),React 設(shè)計(jì)了一種數(shù)據(jù)結(jié)構(gòu) “fiber 樹”。每個元素都是一個 fiber,每個 fiber 就是一個單元任務(wù)。
假如我們渲染如下這樣一棵樹:
Redact.render(
<div>
<h1>
<p />
<a />
h1>
<h2 />
div>,
container
)用 Fiber 樹來描述就是:

在?render?函數(shù)我們創(chuàng)建根 fiber,再把它設(shè)為?nextUnitOfWork。在 workLoop 函數(shù)把?nextUnitOfWork?給?performUnitOfWork?執(zhí)行,主要包含以下三步:
把元素添加到 DOM
為元素的后代創(chuàng)建 fiber 節(jié)點(diǎn)
選擇下一個單元任務(wù),并返回
為了完成這些目標(biāo)需要設(shè)計(jì)的數(shù)據(jù)結(jié)構(gòu)方便找到下一個任務(wù)單元。所以每個 fiber 直接鏈接它的第一個子節(jié)點(diǎn)(child),子節(jié)點(diǎn)鏈接它的兄弟節(jié)點(diǎn)(sibling),兄弟節(jié)點(diǎn)鏈接到父節(jié)點(diǎn)(parent)。?示意圖如下(注意不同節(jié)點(diǎn)之間的高亮箭頭):

當(dāng)我們完成了一個 fiber 的單元任務(wù),如果他有一個?子節(jié)點(diǎn)/child?則這個節(jié)點(diǎn)作為?nextUnitOfWork。如下圖所示,當(dāng)完成?div?單元任務(wù)之后,下一個單元任務(wù)就是?h1。

如果一個 fiber 沒有?child,我們用?兄弟節(jié)點(diǎn)/sibling?作為下一個任務(wù)單元。如下圖所示,p?節(jié)點(diǎn)沒有?child?而有?sibling,所以下一個任務(wù)單元是?a?節(jié)點(diǎn)。

如果一個 fiber 既沒有?child?也沒有?sibling,則找到父節(jié)點(diǎn)的兄弟節(jié)點(diǎn),。如下圖所示的?a?和?h2。

如果父節(jié)點(diǎn)沒有兄弟節(jié)點(diǎn),則繼續(xù)往上找,直到找到一個兄弟節(jié)點(diǎn)或者到達(dá) fiber 根結(jié)點(diǎn)。到達(dá)根結(jié)點(diǎn)即意味本次?render?任務(wù)全部完成。
把這個思路用代碼表達(dá)如下:
// 之前 render 的邏輯挪到這個函數(shù)
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) {
// 創(chuàng)建根 fiber,設(shè)為下一次的單元任務(wù)
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);
}
// 一旦瀏覽器空閑,就觸發(fā)執(zhí)行單元任務(wù)
requestIdleCallback(workLoop);
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 子節(jié)點(diǎn) DOM 插到父節(jié)點(diǎn)之后
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
// 每個子元素創(chuàng)建新的 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
};
// 根據(jù)上面的圖示,父節(jié)點(diǎn)只鏈接第一個子節(jié)點(diǎn)
if (index === 0) {
fiber.child = newFiber;
} else {
// 兄節(jié)點(diǎn)鏈接弟節(jié)點(diǎn)
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
// 返回下一個任務(wù)單元(fiber)
// 有子節(jié)點(diǎn)直接返回
if (fiber.child) {
return fiber.child;
}
// 沒有子節(jié)點(diǎn)則找兄弟節(jié)點(diǎn),兄弟節(jié)點(diǎn)也沒有找父節(jié)點(diǎn)的兄弟節(jié)點(diǎn),
// 循環(huán)遍歷直至找到為止
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}V: render 和 commit 階段
我們的代碼還有一個問題。
每完成一個任務(wù)單元都把節(jié)點(diǎn)添加到 DOM 上。請記住,瀏覽器是可以打斷渲染流程的,如果還沒渲染完整棵樹就把節(jié)點(diǎn)添加到 DOM,用戶會看到殘缺不全的 UI 界面,給人一種很不專業(yè)的印象,這肯定不是我們想要的。因此需要重構(gòu)節(jié)點(diǎn)添加到 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
}
// 新增函數(shù),提交根結(jié)點(diǎn)到 DOM
function commitRoot() {
commitWork(wipRoot.child);
wipRoot = null;
}
// 新增子函數(shù)
function commitWork(fiber) {
if (!fiber) {
return;
}
const domParent = fiber.parent.dom;
domParent.appendChild(fiber.dom);
// 遞歸子節(jié)點(diǎn)和兄弟節(jié)點(diǎn)
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function render(element, container) {
// render 時記錄 wipRoot
wipRoot = {
dom: container,
props: {
children: [element],
},
};
nextUnitOfWork = wipRoot;
}
let nextUnitOfWork = null;
// 新增變量,跟蹤渲染進(jìn)行中的根 fiber
let wipRoot = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
);
shouldYield = deadline.timeRemaining() < 1;
}
// 當(dāng) nextUnitOfWork 為空則表示渲染 fiber 樹完成了,
// 可以提交到 DOM 了
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
// 一旦瀏覽器空閑,就觸發(fā)執(zhí)行單元任務(wù)
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: 更新和刪除節(jié)點(diǎn)/Reconciliation
目前我們只添加節(jié)點(diǎn)到 DOM,還沒考慮更新和刪除節(jié)點(diǎn)的情況。要處理這2種情況,需要對比上次渲染的 fiber 和當(dāng)前渲染的 fiber 的差異,根據(jù)差異決定是更新還是刪除節(jié)點(diǎn)。React 把這個過程叫?Reconciliation。
因此我們需要保存上一次渲染之后的 fiber 樹,我們把這棵樹叫?currentRoot。同時,給每個 fiber 節(jié)點(diǎn)添加?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);
// 新增函數(shù),更新 DOM 節(jié)點(diǎn)屬性
function updateDom(dom, prevProps = {}, nextProps = {}) {
// 以 “on” 開頭的屬性作為事件要特別處理
// 移除舊的或者變化了的的事件處理函數(shù)
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 規(guī)定 style 內(nèi)聯(lián)樣式是駝峰命名的對象,
// 根據(jù)規(guī)范給 style 每個屬性單獨(dú)賦值
if (name === "style") {
Object.entries(nextProps[name]).forEach(([key, value]) => {
dom.style[key] = value;
});
} else {
dom[name] = nextProps[name];
}
});
// 添加新的事件處理函數(shù)
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 函數(shù)
reconcileChildren(fiber, elements);
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
// 新增函數(shù)
function reconcileChildren(wipFiber, elements) {
let index = 0;
// 上次渲染完成之后的 fiber 節(jié)點(diǎn)
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;
// 扁平化 props.children,處理函數(shù)組件的 children
elements = elements.flat();
while (index < elements.length || oldFiber != null) {
// 本次需要渲染的子元素
const element = elements[index];
let newFiber = null;
// 比較當(dāng)前和上一次渲染的 type,即 DOM tag 'div',
// 暫不考慮自定義組件
const sameType = oldFiber && element && element.type === oldFiber.type;
// 同類型節(jié)點(diǎn),只需更新節(jié)點(diǎn) props 即可
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom, // 復(fù)用舊節(jié)點(diǎn)的 DOM
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE" // 新增屬性,在提交/commit 階段使用
};
}
// 不同類型節(jié)點(diǎn)且存在新的元素時,創(chuàng)建新的 DOM 節(jié)點(diǎn)
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT" // PLACEMENT 表示需要添加新的節(jié)點(diǎn)
};
}
// 不同類型節(jié)點(diǎn),且存在舊的 fiber 節(jié)點(diǎn)時,
// 需要移除該節(jié)點(diǎn)
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION";
// 當(dāng)最后提交 fiber 樹到 DOM 時,我們是從 wipRoot 開始的,
// 此時沒有上一次的 fiber,所以這里用一個數(shù)組來跟蹤需要
// 刪除的節(jié)點(diǎn)
deletions.push(oldFiber);
}
if (oldFiber) {
// 同步更新下一個舊 fiber 節(jié)點(diǎn)
oldFiber = oldFiber.sibling;
}
if (index === 0) {
wipFiber.child = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}注意:這個過程中 React 還用了?key?來檢測數(shù)組元素變化了位置的情況,避免重復(fù)渲染以提高性能。簡化起見,本文未實(shí)現(xiàn)。
下面 CodeSandbox 代碼用了個小技巧,重復(fù)執(zhí)行?render?實(shí)現(xiàn)更新界面的效果,動手改改試試。
redact-2
VII: 函數(shù)組件
目前我們還只考慮了直接渲染 DOM 標(biāo)簽的情況,不支持組件,而組件是 React 是靈魂,下面我們來實(shí)現(xiàn)函數(shù)組件。
以一個非常簡單的組件代碼為例。
/** @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);函數(shù)組件有2個不同點(diǎn):
函數(shù)組件的 fiber 節(jié)點(diǎn)沒有對應(yīng) DOM
函數(shù)組件的 children 來自函數(shù)執(zhí)行結(jié)果,而不是像標(biāo)簽元素一樣直接從 props 獲取,因?yàn)?children 不只是函數(shù)組件使用時包含的子孫節(jié)點(diǎn),還需要組合組件本身的結(jié)構(gòu)
注意以下代碼省略了未改動部分。
function commitWork(fiber) {
if (!fiber) {
return;
}
// 當(dāng) fiber 是函數(shù)組件時節(jié)點(diǎn)不存在 DOM,
// 故需要遍歷父節(jié)點(diǎn)以找到最近的有 DOM 的節(jié)點(diǎn)
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 函數(shù)
commitDeletion(fiber, domParent);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
// 新增函數(shù),移除 DOM 節(jié)點(diǎn)
function commitDeletion(fiber, domParent) {
// 當(dāng) child 是函數(shù)組件時不存在 DOM,
// 故需要遞歸遍歷子節(jié)點(diǎn)找到真正的 DOM
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(fiber.child, domParent);
}
}
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function;
// 原本邏輯挪到 updateHostComponent 函數(shù)
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;
}
}
// 新增函數(shù),處理函數(shù)組件
function updateFunctionComponent(fiber) {
// 執(zhí)行函數(shù)組件得到 children
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
// 新增函數(shù),處理原生標(biāo)簽組件
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}VIII: 函數(shù)組件 Hooks
支持了函數(shù)組件,還需要支持組件狀態(tài) / state 才能實(shí)現(xiàn)刷新界面。
我們的示例也跟著更新,用 hooks 實(shí)現(xiàn)經(jīng)典的 counter,點(diǎn)擊計(jì)數(shù)器加1。
/** @jsx Redact.createElement */
function Counter() {
const [state, setState] = Redact.useState(1)
return (
setState(c => c + 1)}>
Count: {state}
);
}
const element = ;
const container = document.getElementById("root");
Redact.render(element, container);注意以下代碼省略了未變化部分。
// 新增變量,渲染進(jìn)行中的 fiber 節(jié)點(diǎn)
let wipFiber = null;
// 新增變量,當(dāng)前 hook 的索引
let hookIndex = null;
function updateFunctionComponent(fiber) {
// 更新進(jìn)行中的 fiber 節(jié)點(diǎn)
wipFiber = fiber;
// 重置 hook 索引
hookIndex = 0;
// 新增 hooks 數(shù)組以支持同一個組件多次調(diào)用 `useState`
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function useState(initial) {
// alternate 保存了上一次渲染的 fiber 節(jié)點(diǎn)
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
// 第一次渲染使用入?yún)ⅲ诙武秩緩?fù)用前一次的狀態(tài)
state: oldHook ? oldHook.state : initial,
// 保存每次 setState 入?yún)⒌年?duì)列
queue: []
};
const actions = oldHook ? oldHook.queue : [];
actions.forEach(action => {
// 根據(jù)調(diào)用 setState 順序從前往后生成最新的 state
hook.state = action instanceof Function ? action(hook.state) : action;
});
// setState 函數(shù)用于更新 state,入?yún)?action
// 是新的 state 值或函數(shù)返回新的 state
const setState = action => {
hook.queue.push(action);
// 下面這部分代碼和 render 函數(shù)很像,
// 設(shè)置新的 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 代碼如下,點(diǎn)擊 Count 試試:
redact-3
結(jié)語
除了幫助讀者理解 React 核心工作原理外,本文很多變量都和 React 官方代碼保持一致,比如,讀者在 React 應(yīng)用的任何函數(shù)組件里斷點(diǎn),再打開調(diào)試工作能看到下面這樣的調(diào)用棧:
updateFunctionComponent
performUnitOfWork
workLoop

注意本文是教學(xué)性質(zhì)的,還缺少很多 React 的功能和性能優(yōu)化。比如:在這些事情上 React 的表現(xiàn)和 Redact 不同。
Redact 在渲染階段遍歷了整棵樹,而 React 用了一些啟發(fā)性算法,可以直接跳過某些沒有變化的子樹,以提高性能。(比如 React 數(shù)組元素推薦帶 key,可以跳過無需更新的節(jié)點(diǎn),參考官方文檔)
Redact 在 commit 階段遍歷整棵樹, React 用了一個鏈表保存變化了的 fiber,減少了很多不必要遍歷操作。
Redact 每次創(chuàng)建新的 fiber 樹時都是直接創(chuàng)建 fiber 對象節(jié)點(diǎn),而 React 會復(fù)用上一個 fiber 對象,以節(jié)省創(chuàng)建對象的性能消耗。
Redact 如果在渲染階段收到新的更新會直接丟棄已渲染的樹,再從頭開始渲染。而 React 會用時間戳標(biāo)記每次更新,以決定更新的優(yōu)先級。
源碼還有很多優(yōu)化等待讀者去發(fā)現(xiàn)。。。
推薦閱讀
我的公眾號能帶來什么價值?(文末有送書規(guī)則,一定要看)
每個前端工程師都應(yīng)該了解的圖片知識(長文建議收藏)
為什么現(xiàn)在面試總是面試造火箭?
