[译]Build your own react
date
Mar 4, 2021
slug
build-your-own-react
status
Published
tags
React
翻译
summary
type
Post
MindMap
我们将一步步从头重写 React。整个步骤将会遵循真实的 React 代码架构,但不包含所有的优化和非必要的功能。
我们从头开始构建的 React 包含以下内容:
- 第一步:
createElement
函数
- 第二步:
render
函数
- 第三步:Concurrent Mode
- 第四步:Fibers
- 第五步:Render 与 Commit 两大阶段(Phases)
- 第六步:调和算法 Reconciliation
- 第七步:函数组件 Function Components
- 第八步:Hooks
Step Zero: 回顾Step I: createElement JSX ⇒ JSchildren 的处理Step II: render创建 DOM nodeStep III: Concurrent ModeStep IV: FibersFiber 的执行Step VII: Function ComponentsFunction Component 的判断Commit 时需要寻找 DOM nodeStep VIII: Hooks准备工作useState 内部逻辑Epilogue
Step Zero: 回顾
首先让我们回顾一些基本概念。如果你已经对 React、JSX 和 DOM 元素的工作方式有了很好的了解,则可以跳过此步骤。
const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
我们将使用以上这个仅包含三行代码的 React App。
- 第一行:定义了一个 React element。
- 第二行:从 DOM 中获取了一个 DOM node。
- 第三行:将 React element 渲染到 DOM node 上。
接下来让我们把 React 代码替换成原生 JavaScript 代码。
在第一行中的 React element 是由 JSX 定义的。这其实不是合法的 JavaScript 代码,首先我们需要把它替换成合法的 JavaScript 代码。
JSX 转换为 JS 代码的过程由 Babel 之类的构建工具来完成。转换过程通常很简单:使用 createElement 函数的调用来替换 Tag 内的代码,并将 Tag 名、props 和 children 作为参数传入:
const element = React.createElement(
"h1",
{ title: "foo" },
"Hello"
)
除了一下参数校验,
React.createElement
所做的就是根据传入的参数创建了一对象。因此我们可以安全地将函数调用代码替换为其输出:const element = {
type: "h1",
props: {
title: "foo",
children: "Hello",
},
}
以上就是一个 React Element ,一个具有两个属性(type与props)的对象。(嗯,它其实具有更多的属性,但在这里我们只关心这两个。
type
是一个字符串,用于指定我们要创建的 DOM node 的类型,它就是创建 HTML 元素时传递给
document.createElement
的 tagName
。它也可以是一个函数,这部分我们将留在 Step VII 中介绍。props
是另一个对象,它具有 JSX attributes 中的所有键值对。它还有一个特殊的属性: children
。children
在这个例子中是一个字符串,但它通常是一个包含更多 elements 的数组。这就是为什么 elements 也是 trees 的原因。我们需要替换的另一部分 React 代码是对
ReacDOM.render
的调用。render
函数是 React 改变 DOM 的地方,所以在这里我们手动实现一下 DOM 的更新:
const node = document.createElement(element.type)
node["title"] = element.props.title
首先我们使用 element type 属性创建了一个 DOM 节点 ,在这个例子中是
h1
。然后我们将所有 element props 分配到这个 DOM 节点 中,在这里是只有一个 title。
为了避免混淆,我将使用元素(element)来指代 React elements,节点(node)来指代 DOM elements。
接下来我们为 children 创建 DOM 节点。我们只有一个字符串作为 children,所以我们将创建一个文本节点。
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
使用
textNode
而不是设置 innerText
,我们会在接下来的步骤中以相同的规则方式对待所有 React Element。这个过程与给 h1
设置 title prop 类似,这就像是字符串中带有这样一个 props: {nodeValue: "hello"}
。在最后,我们将
textNode
添加至 h1
,将 h1
添加至 container
。node.appendChild(text)
container.appendChild(node)
现在我们得到了一个与之前相同的 App,只不过没有用 React 特定的代码。
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello",
},
}
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)
Step I: createElement
让我们从另一个 App 重新开始。这次,我们将用自己的 React 版本替换原有的 React 代码。
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
)
const container = document.getElementById("root")
ReactDOM.render(element, container)
我们将从编写自己的
createElement
函数开始。JSX ⇒ JS
让我们将 JSX 转换为 JS,这样我们就能实现
createElement
函数的调用了。在之前的步骤中我们可以看到,一个 element 是一个含有 type 和 props 的对象。我们这个函数唯一要做的是就是创建这个对象
const element = React.createElement(
"div",
{ id: "foo" },
React.createElement("a", null, "bar"),
React.createElement("b")
);
children 的处理
我们使用展开运算符(spread operator)来处理 props,剩余参数语法(rest parameter syntax)处理children。这样一来 children 就永远都是数组了。
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children,
},
}
}
举个例子,
createElement("div")
返回:{
"type": "div",
"props": { "children": [] }
}
createElement("div", null, a)
返回:{
"type": "div",
"props": { "children": [a] }
}
createElement("div", null, a, b)
返回{
"type": "div",
"props": { "children": [a, b] }
}
children 数组也可以包含字符串和数字这样的原始类型。所以我们为所有不是对象的内容创建一个独立的元素,并为其创建一个特殊的类型:
TEXT_ELEMENT
。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: [],
},
}
}
React 并不会为原始类型进行包装,或者在没有 children 时创建一个空数组,我们这样做是为了简化我们的代码,对于我们的库来讲,我们更偏向于简单的代码而非高性能代码。
目前我们扔在使用 React 的
createElement
函数。为了替换它,我们将为我们的库取个名。我们需要一个听起来像 React 同时又能暗示它的教学目的。我们称它为 Didact。
const Didact = {
createElement,
}
const element = Didact.createElement(
"div",
{ id: "foo" },
Didact.createElement("a", null, "bar"),
Didact.createElement("b")
)
但是我们仍让想在这里使用 JSX。我们该如何告诉 babel 去使用 Didact的
createElement
而非 React 的呢?如果我们有这样的注释,当 babel 转译 JSX 时,它将使用我们定义的功能。
/** @jsx Didact.createElement */
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
)
Step II: render
创建 DOM node
接下来,我们需要编写自己版本
ReactDOM.render
函数。目前,我们只需要关心向 DOM 中添加内容。我们稍后会处理更新和删除。function render(element, container) {
// TODO create dom nodes
}
const Didact = {
createElement,
render,
}
我们首先使用 element type 创建 DOM 节点,然后将新节点附加到 container 中。
function render(element, container) {
const dom = document.createElement(element.type)
container.appendChild(dom)
}
接下来递归地为每个 child 做同样的事。
function render(element, container) {
const dom = document.createElement(element.type)
element.props.children.forEach(child =>
render(child, dom)
)
container.appendChild(dom)
}
同样的,我们需要处理文本元素,如果 element type 为
TEXT_ELEMENT
,我们将创建一个文本节点而非一个普通的节点:function render(element, container) {
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type)
element.props.children.forEach(child =>
render(child, dom)
)
container.appendChild(dom)
}
我们在这里要做的最后一件事是将 element props 分配给 DOM node:
function render(element, container) {
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type)
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)
}
就是这样。现在,我们有了一个可以将 JSX 渲染到 DOM 的库。
在 codesandbox 上可以进行尝试。
Step III: Concurrent Mode
在我们开始实现功能之前我们需要一次重构。
element.props.children.forEach(child =>
render(child, dom)
)
这里的递归调用便是症结所在。
一旦我们开始渲染,在整棵 element tree 渲染完成之前程序是不会停止的。如果这棵 element tree 过于庞大,它有可能会阻塞主进程太长时间。如果浏览器需要做类似于用户输入或者保持动画流畅这样的高优先级任务,则必须等到渲染完成为止。
因此,我们将渲染工作分成几个小部分,在完成每个单元后,如果需要执行其他操作,我们将让浏览器中断渲染。
// element.props.children.forEach(child =>
// render(child, dom)
// )
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(nextUnitOfWork) {
// TODO
}
我们使用
requestIdleCallback
构建循环。你可以把 requestIdleCallback
当作是一个 setTimeout
,但在这里浏览器将在主线程空闲时进行回调,而不是指定回调何时运行。requestIdleCallback
还为我们提供了 deadline 参数。我们可以用它来检查在浏览器需要再次控制之前我们有多少时间。在 2019 年 11 月份,Concurrent Mode 在 React 还未真正稳定。稳定版本的循环更像是这样:
while (nextUnitOfWork) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
}
要开始循环检查之前,我们需要设置第一个工作单元,然后编写
performUnitOfWork
函数。该函数不仅会执行工作单元,还会返回下一个工作单元。Step IV: Fibers
为了组织各个工作单元,我们需要一个数据结构:fiber tree。
我们将为每一个 element 分配一个 fiber,而每个 fiber 将成为一个工作单元。
Fiber 的执行
举个例子,假设我们像渲染这样一个 element tree:
Didact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
在
render
函数中我们将会创建 root fiber,将其设置为 nextUnitOfWork。剩下的工作将在 performUnitOfWork
中进行,在那里我们将为每个 fiber 做三件事:- 将 element 添加至 DOM
- 为 element 的 children 创建 fiber
- 选出下一个工作单元
设计这个数据结构的目标之一是:使查找下一个工作单元变得更加容易。这就是为什么每一个 Fiber 都会链接到其第一个子节点,下一个兄弟姐妹节点和其父节点。(在下文,用
child
、sibling
和parent
分别指代子节点、兄弟姐妹节点和父节点)。当我们完成对 Fiber 的工作时,如果它有
child
,那么这个 Fiber 会被当作是下一个工作单元。在我们的示例中,当我们完成
div
fiber 时,下一个工作单元将是 div
fiber。如果该 fiber 没有
child
,我们会把这个 fiber 的兄弟姐妹节点当作是下一个工作单元。在我们的示例中,
p
fiber 并没有 child
,下一个工作单元将是 a
fiber。如果该 fiber 既没有
child
也没有 sibling
,那我们会寻找它的「叔叔节点」:其parent
的 sibling
。就像这个例子中的 a
和 h2
。同样的,如果
parent
没有sibling
,我们将不断检查父节点的父节点,直到找到有sibling
的parent
节点,或者直接找到根节点 root
位置。如果达到根节点,则意味着我们以及完成了此次渲染的所有工作。我们把上述理论转换为代码:
- 首先我们删除
render
函数中的原有代码。将创建 DOM node 的部分代码抽离处理,稍后进行填充:
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) {
// TODO set next unit of work
}
let nextUnitOfWork = null
- 在
render
函数中,我们将nextUnitOfWork
设置为 Fiber Tree 的根节点:
function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
}
}
let nextUnitOfWork = null
- 接下来,当浏览器准备好的时候,它将会调用我们的
workLoop
函数,从根节点开始执行performUnitOfWork
。
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
function performUnitOfWork(fiber) {
// TODO add dom node
// TODO create new fibers
// TODO return next unit of work
}
- 首先,我们创建一个 node 节点然后将其添加至 DOM,将这个 DOM node 保存在
fiber.dom
属性中以持续跟踪。
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
// TODO create new fibers
// TODO return next unit of work
}
- 接着,为每一个
chid
创建一个新的 fiber
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
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,
}
}
// TODO return next unit of work
}
- 将其添加到 Fiber Tree 中,它是
child
还是sibling
,取决于它是否是第一个child
。
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
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++
}
// TODO return next unit of work
}
- 最后,我们选出下一个工作单元。首先寻找
child
,其次sibling
,然后是uncle
(parent
的sibling
)。 - 如果老的 Fiber 和新的 element 拥有相同的 type,我们可以保留 DOM 节点并仅使用新的 Props 进行更新。这里我们会创建一个新的 Fiber 来使 DOM 节点与旧的 Fiber 保持一致,而props 与新的 element 保持一致。
- 我们还向 Fiber 中添加了一个新的属性
effectTag
,这里的值为UPDATE
。为稍后我们将在 commit 阶段使用这个属性。 - 如果两者的
type
不一样并且有一个新的 element,这意味着我们需要创建一个新的 DOM 节点。 - 在这种情况下,我们会用
PLACEMENT
effect tag 来标记新的 Fiber。 - 如果两者的
type
不一样,并且有一个旧的 Fiber,我们需要删除旧节点。 - 在这种情况下,我们没有新的 Fiber,所以我们会把
DELETION
effect tag 添加到旧 Fiber 中。 PLACEMENT
:这个 DOM 节点添加到父 Fiber 的节点上DELETION
:删除这个child
UPDATE
:使用最新的 props 来更新现有的 DOM 节点- 这部分动作将有
updateDOM
函数来完成:我们将旧 Fiber 的props 与 新 Fiber 的 props 可进行比较,删除旧的 props,并设置新的或者变更之后的 props。 - 针对 event listener 这种特殊的 prop,我们将以不同的方式处理:如果 event listener 发生了变更我们会把它从 node 中移除,然后设置一个新的 event listener。
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
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
}
}
以上就是我们的
performUnitOfWork
函数。Step V: Render and Commit Phases
将「添加节点至 DOM」这个动作延迟至所有节点 render 完成。这个动作也被称为 commit。
为什么要分阶段
现在我们又有另一个问题。
每当我们在处理一个 React element 时,我们都会添加一个新的节点到 DOM 中,而浏览器在渲染完成整个树之前可能会中断我们的工作。在这种情况下,用户将会看不到完整的 UI。
如何分阶段
所以我们需要删除那部分对 DOM 进行修改的代码:
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
// if (fiber.parent) {
// fiber.parent.dom.appendChild(fiber.dom)
// }
//...
}
相反地,我们会跟踪 Fiber Tree 的根节点。我们称它为「进行中的 root」—— wipRoot。
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
}
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let wipRoot = null
一旦完成所有工作(直到没有
nextUnitOfWork
),我们便将整个 Fiber Tree 交给 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) {
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
}
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
我们将这个步骤在
commitRoot
函数中完成。在这里我们将所有节点递归附加到 DOM 中。Step VI: Reconciliation
更新和删除节点的过程:调和 Reconciliation
目前我们只做了「添加节点至 DOM」这个动作,那么更新或删除节点是怎么实现的呢?
这就是我们现在要做的,我们需要将在
render
函数上接收到的 elements
与我们提交给 DOM 的最后一棵 Fiber Tree 进行比较。保存当前渲染的 Fiber Tree
因此,在完成 commit 之后,我们需要对「最后一次 commit 到 DOM 的一棵 Fiber Tree」 的引用进行保存。我们称它为
currentRoot
。同时我们也对每个 Fiber 添加了一个 alternate
属性。这个属性是对旧 Fiber 的链接,这个旧 Fiber 是我们在在上一个 commit phase 向 DOM commit 的 Fiber。function commitRoot() {
commitWork(wipRoot.child)
// currentRoot: 最后一次 commit 到 DOM 的一棵 Fiber Tree
currentRoot = wipRoot
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) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
Reconcile
Reconcile 的过程会在执行工作单元时完成。
现在我们把
performUnitOfWork
中用来创建新 Fiber 的部分代码抽离成一个新的 reconcileChildren
函数。function reconcileChildren(wipFiber, elements) {
let index = 0
let prevSibling = null
while (index < elements.length) {
const element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
parent: wipFiber,
dom: null,
}
if (index === 0) {
wipFiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
}
在这里我们将会旧 Fibers 与新 elements进行调和(reconcile):
function reconcileChildren(wipFiber, elements) {
let index = 0
let oldFiber =
wipFiber.alternate && wipFiber.alternate.child
let prevSibling = null
while (
index < elements.length ||
oldFiber != null
) {
const element = elements[index]
let newFiber = null
// TODO compare oldFiber to element
....
}
}
我们使用
type
属性对它们进行比较:// 对于新旧 element 的处理
const sameType =
oldFiber &&
element &&
element.type == oldFiber.type
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
};
}
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
}
}
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION"
deletions.push(oldFiber) // 这里使用了一个数组来追踪我们想要删除的 node
}
在这里 React 使用了
keys
,这样可以更好地实现 reconciliation
。例如,它会检测 children 何时更改 element 数组中的位置。变更 commitWork 以处理不同类型的变化
接下来,当我们要 commit 这些变更到 DOM 时,我们就会用到 deletions 这个数组中的 fibers。
function commitRoot() {
deletions.forEach(commitWork)
commitWork(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}
为了处理前面定义的各种 effectTags,我们也需要对
commitWork
函数进行变更: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 updateDom(dom, prevProps, nextProps) {
//Remove old or changed event listeners
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]
)
})
// Remove old properties
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom[name] = ""
})
// Set new or changed properties
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom[name] = nextProps[name]
})
// Add event listeners
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name
.toLowerCase()
.substring(2)
dom.addEventListener(
eventType,
nextProps[name]
)
})
}
在 codesandbox 上可以试用带有 Reconciliation 的最新版本。`
Step VII: Function Components
下一个我们要增添的是对 Function Components 的支持。
首先让我们更改样例,将 JSX 声明修改为 Function Component:
/** @jsx Didact.createElement */
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
const container = document.getElementById("root")
Didact.render(element, container)
Function Component 在两种方面存在差异:
- 来自 Function Component 的 Fiber 并没有 DOM node
children
从运行函数中而来,而非直接从 props 中获取
Function Component 的判断
我们检查
fiber.type
是否是 function
,根据不同的结果来使用不同的更新函数。function performUnitOfWork(fiber) {
const isFunctionComponent =
fiber.type instanceof Function
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) {
// TODO
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
reconcileChildren(fiber, fiber.props.children)
}
在
updateFunctionComponent
中,我们执行函数以获取children
。一旦我们拿到了 children
,reconciliation 的过程其实是一样的。function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
Commit 时需要寻找 DOM node
由于来自 Function Component 的 Fiber 并没有 DOM node,我们需要修改的是
commitWork
这个函数:- 修改寻找 DOM 父节点的逻辑:顺着 Fiber Tree 向上找直到找到有 DOM node 的 Fiber。
- 当删除节点是,我们也需要向下寻找知道找到有
child
的 DOM node。
function commitWork(fiber) {
if (!fiber) {
return
}
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") {
commitDeletion(fiber, domParent)
}
commitWork(fiber.child)
commitWork(fiber.sibling)
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
Step VIII: Hooks
最后一步,既然我们有了 Function Components,那么我们还可以添加 Hooks。
首先把我们的 App 改写成一个传统的 Counter Component,使用自定义的 useState 来变更状态:
const Didact = {
createElement,
render,
useState,
}
/** @jsx Didact.createElement */
function Counter() {
const [state, setState] = Didact.useState(1)
return (
<h1 onClick={() => setState(c => c + 1)}>
Count: {state}
</h1>
)
}
const element = <Counter />
const container = document.getElementById("root")
Didact.render(element, container);
准备工作
定义 hook 需要用到的全局变量;引入 Fiber 进行调度工作
回顾一下,Function Component 是在
updateFunctionComponent
函数中被调用的,那么同样我们也会在这个函数中调用 useState。function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
function useState(initial) {
// TODO
}
我们需要在调用 Function Component 之前初始化一些全局变量,以便可以再 useState 函数中使用它们。
首先我们把要执行的工作添加至正在执行的 Fiber (
wipFiber
);再给 Fiber 添加一个 hooks 数组,以支持在同意组建中多次调用 useState。同时我们还能跟踪当前 Hook 的索引。let wipFiber = null
let hookIndex = null
function updateFunctionComponent(fiber) {
wipFiber = fiber
hookIndex = 0
wipFiber.hooks = []
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
useState 内部逻辑
每当 Function Component 调用 useState 时,会检查是否有旧的 hook。我们使用 hook 的索引在 fiber 的 alternate 属性中进行查询。
如果存在旧的 hook,那么我们将 state 从旧 hook 中复制到新的 hook;否则我们将初始化 state。
然后我们将向 Fiber 添加新的 hook,同时索引也递增加1,并返回状态。
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state]
}
useState 还应该返回一个用于更新 state 的函数。因此我们定义了一个 setState 函数,该函数接受变更 state 的动作。
我们将这些动作添加添加至我们给 hook 对应的队列中。
接下来的操作与我们在
render
函数中所做的类似,将新的工作进行中的 root 设置为下一个工作单元,以便 work loop 可以开始新的渲染阶段。function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
}
const setState = action => {
hook.queue.push(action)
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
deletions = []
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state, setState]
}
对于存储在 hook.queue 中的 actions,我们将在下一次渲染该组件时进行执行。
首先从旧 hook 中拿到所有 actions,并将它们逐个应用到新 hook 中的 state 中。所以当我们返回 state 时,该 state 已经被更新了。
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
}
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
hook.state = action(hook.state)
})
const setState = action => {
hook.queue.push(action)
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
deletions = []
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state, setState]
}
就这样。我们已经构建了自己版本的 React。你可以在 codesandbox 或 Github 上体验。
Epilogue
除了帮助你了解 React 的工作原理外,本文的目的之一是使你更轻松地深入 React 源代码。这就是为什么我们几乎在所有地方都使用相同的变量和函数名的原因。
例如,如果你在 Function Component 中打断点,你能看到以下调用栈:
workLoop
performUnitOfWork
updateFunctionComponent
我们并没有把所有 React 的功能和优化点包含进来。例如:
- 在 Didact 中,我们在 render 阶段遍历整棵树,而 React 会根据一些标记来跳过一些没有发生变化的子树。
- 我们还在 commit 阶段遍历整棵树。React 仅保留有影响的 Fiber 链接列表,并且也仅只访问这些 Fiber。
- 每当我们构建一个新的 WIP Tree 时,我们会为每个 Fiber 创建新的对象。而 React 会从旧的 Fiber Tree 中回收 Fiber。
- 当 Didact 在 render 阶段收到新的更新时,它会丢弃 WIP Tree 中的工作,并重新从根节点开始。而 React 使用有效期时间戳来标记每次更新,并用它来决定哪个更新具有更高的优先级。
- ...
另外,你还可以添加更多的特性:
- style prop 的处理
- flatten children 数组
- useEffect hook
- reconciliation by key