我们将从头开始逐步重写 React(基于React16.8)。按照真实的 React 代码架构进行操作,但没有所有的优化和非必要的功能。
从头开始,这些都是我们会逐步添加到我们版本的 React 中的所有内容:
- 第一步:createElement 函数
- 第二步:render 函数
- 第三步:并发模式
- 第四步:Fiber
- 第五步:渲染和提交阶段
- 第六步:协调
- 第七步:函数组件
- 第八步:Hooks
第0步: 回顾
在开始之前,我们先来回顾一些基本概念。如果你已经对 React、JSX 和 DOM 元素的工作方式有了很好的理解,可以跳过这一步。
我们将使用这个只有三行代码的 React 应用程序进行演示。
const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
- 第一行定义了一个 React 元素
- 第二行从 DOM 中获取了一个节点
- 第三行将 React 元素渲染到容器中。
让我们删除所有与 React 有关的代码,并替换为普通的 JavaScript 代码。
元素
在第一行中,我们使用 JSX 定义了元素。它甚至不是有效的 JavaScript 代码,因此为了将其替换为普通的 JS 代码,我们首先需要将其转换为有效的 JS 代码。
const element = React.createElement(
"h1",
{ title: "foo" },
"Hello"
)
JSX 是由构建工具(如 Babel)将其转换为 JS 的。转换通常很简单:用 createElement 方法代替标签中的代码,将标签名称、属性和子元素作为参数传递。
React.createElement 从其参数中创建一个对象。除了一些验证之外,这就是它所做的全部。因此,我们可以放心地用其输出替换函数调用。
这就是元素,一个具有两个属性的对象:type 和 props(它还有更多属性,但我们只关心这两个)。
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello",
},
}
-
type 是指定要创建的 DOM 节点类型的字符串,它是您在想要创建 HTML 元素时传递给 document.createElement 的标记名称。它也可以是一个函数,但我们将在第七步中处理。
-
props 是另一个对象,它具有 JSX 属性中的所有键和值。它还有一个特殊的属性:children。
-
children 是一个字符串,但通常它是包含更多元素的数组。这就是为什么元素也是树的原因。
render
我们需要替换的另一部分 React 代码是对 ReactDOM.render 的调用。
render 是 React 更改 DOM 的地方,因此让我们自己进行更新。
- 首先,我们使用元素类型创建一个 node*,在这种情况下是 h1。然后,我们将所有元素 props 分配给该节点。这里只有标题。为了避免混淆,我将使用“element”来指代 React 元素,“node”来指代 DOM 元素。
const node = document.createElement(element.type)
node["title"] = element.props.title
- 接下来,我们创建子节点的节点。我们只有一个字符串作为子元素,因此我们创建一个文本节点。使用 textNode 而不是设置 innerText 将允许我们以后以相同的方式处理所有元素。注意我们是如何设置 nodeValue的,跟 h1 标题一样,直接赋值就行。
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
- 最后,我们将 textNode 添加到 h1 中,然后将 h1 添加到容器中。
const container = document.getElementById("root")
node.appendChild(text)
container.appendChild(node)
现在我们拥有了与之前相同的应用程序,但是没有使用 React。
第1步:createElement函数
递归调用React.createElement
让我们再用另一个应用程序重新开始。这次,我们将用我们自己的 React 版本替换 React 代码。 我们将从编写自己的 createElement 函数开始。让我们将 JSX 转换为 JS,以便我们可以看到 createElement 的调用。
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
)
const container = document.getElementById("root")
ReactDOM.render(element, container)
正如我们在上一步中所看到的,一个元素是一个带有类型和属性的对象。我们的函数需要做的唯一一件事情就是创建这个对象。
const element = React.createElement(
"div",
{ id: "foo" },
React.createElement("a", null, "bar"),
React.createElement("b")
)
其实就是对象属性的拼装
我们使用展开运算符来处理属性,使用剩余参数语法来处理子元素,这样子元素就始终是一个数组。
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] }
}
纯文本元素
子元素数组也可以包含原始值,如字符串或数字。因此,我们将不是对象的所有内容都包装在自己的元素内,并为它们创建一个特殊的类型:TEXT_ELEMENT。React 不会包装原始值,也不会在没有子元素时创建空数组,但我们这样做是为了简化代码。对于我们的库而言,我们更喜欢简单的代码而不是高性能的代码。
{
...
children: children.map(child =>
typeof child === "object"
? child
: createTextElement(child)
),
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
}
}
换成我们自己的react
我们仍然使用 React 的 createElement。
为了替换它,让我们给我们的库起个名字。我们需要一个听起来像 React 但也暗示了它的教学目的的名字。
我们称之为 Didact。
const Didact = {
createElement,
}
const element = Didact.createElement(
"div",
{ id: "foo" },
Didact.createElement("a", null, "bar"),
Didact.createElement("b")
)
JSX
但我们仍然想在这里使用 JSX。我们如何告诉 babel 使用 Didact 的 createElement 而不是 React 的呢?
如果我们有像这样的注释,当 babel 转换 JSX 时,它将使用我们定义的函数。
/** @jsx Didact.createElement */
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
)
第2步:render函数
render就是创建dom
接下来,我们需要编写我们自己的 ReactDOM.render 函数。目前,我们只关心将内容添加到 DOM 中。稍后我们再处理更新和删除。
function render(element, container) {
// TODO create dom nodes
}
const Didact = {
createElement,
render,
}
Didact.render(element, container)
递归创建
我们首先使用元素类型创建 DOM 节点,然后将新节点附加到容器中。
接下来,我们对每个子元素递归执行相同的操作。
function render(element, container) {
const dom = document.createElement(element.type)
element.props.children.forEach(child =>
render(child, dom)
)
container.appendChild(dom)
}
文本和属性
我们还需要处理文本元素,如果元素类型是 TEXT_ELEMENT,则创建一个文本节点而不是常规节点。
在这里我们需要做的最后一件事是将元素属性赋值给节点。
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 上试试吧。
第3步:同步模式
但是,在我们开始添加更多代码之前,我们需要进行重构。这个递归调用存在一个问题。
function render(element, container) {
...
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,但不同的是我们并不告诉它何时运行,而是在主线程处于空闲状态时,浏览器会执行回调函数。React 不再使用 requestIdleCallback。现在它使用了 scheduler 包。但对于这个用例而言,它在概念上是相同的。
-
requestIdleCallback 中 还给我们提供了一个截止时间参数。我们可以使用它来检查直到浏览器需要重新渲染之前还有多少时间。
-
要开始使用循环,我们需要设置第一个工作单元,然后编写 performUnitOfWork 函数,该函数不仅执行工作,而且返回下一个工作单元。
第4步:Fiber架构
Fiber结构
假设我们想要渲染以下元素树:
Didact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
在 render 中,我们将创建根 Fiber 并将其设置为 nextUnitOfWork。其余的工作将在 performUnitOfWork 函数中完成,我们将对每个 Fiber 执行以下三件事:
- 将元素添加到 DOM 中
- 创建元素子级的 Fibers
- 选择下一个工作单元
这个数据结构的目标之一是使找到下一个工作单元变得容易。这就是为什么每个 Fiber 都有一个链接到它的第一个子级、下一个兄弟和父级的原因。
Fiber的过程
当我们完成了对一个 Fiber 的工作时,如果它有一个子级,那么该 Fiber 将成为下一个工作单元。
从我们的例子来看,当我们完成对 div Fiber 的工作后,下一个工作单元将是 h1 Fiber。
如果 Fiber 没有子级,则使用它的兄弟作为下一个工作单元。
例如,在完成 p Fiber 的工作后,我们移到 a Fiber。
如果 Fiber 没有子级也没有兄弟,则进入“叔叔”:父级的兄弟。例如例子中的 a 和 h2 fibers。
此外,如果父级没有兄弟,则我们会通过父级向上移动,直到找到具有兄弟或到达根的父级为止。如果我们到达了根,这意味着我们已经完成了此渲染的所有工作。
代码实现
现在让我们将其转换为代码。首先,让我们从 render 函数中删除此代码。
props元素换为fiber树
原来的代码如此:
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)
}
let nextUnitOfWork = null
将被修改为处理fiber树:
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
初始化Fiber为根节点
在render函数中,我们将 nextUnitOfWork 设置为 Fiber 树的根。
function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
}
}
let nextUnitOfWork = null
workLoop循环处理根节点
然后,当浏览器准备好时,它将调用我们的 workLoop,我们将开始处理根节点。
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
}
首先创建fiber.dom
首先,我们创建一个新节点并将其附加到 DOM 中。
我们在 fiber.dom 属性中跟踪 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
}
每个子元素创建一个Fiber
然后对于每个子元素,我们创建一个新的 Fiber。
function performUnitOfWork(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,
}
}
// ...
}
设置子级或兄弟级节点
然后,我们将其添加到 Fiber 树中,将其设置为子级或兄弟级,具体取决于它是否是第一个子元素。
function performUnitOfWork(fiber) {
// ...
if (index === 0) {
fiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
// ...
}
搜索工作单元的顺序
最后,我们搜索下一个工作单元。首先尝试使用子级,然后是兄弟级,然后是父级的兄弟,以此类推。
function performUnitOfWork(fiber) {
// ...
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
// ...
}
这就是我们的 performUnitOfWork 函数。
第5步:Render 和 Commit 阶段
渲染过程中修改树会中断
我们这里还有另一个问题。
每次处理元素时,我们都会向 DOM 添加一个新节点。而且,请记住,在我们完成渲染整个树之前,浏览器可能会中断我们的工作。在这种情况下,用户将看到不完整的 UI。我们不希望出现这种情况。
function performUnitOfWork(fiber) {
// ...
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
// ...
}
因此,我们需要从这里删除突变 DOM 的部分。
function render(element, container) {
wipRoot = {
// ...
nextUnitOfWork = wipRoot
}
// ...
let wipRoot = null
完成一个工作单元再提交修改fiber(保证一次任务的树是稳定的)
一旦我们完成了所有工作(我们知道这点是因为没有下一个工作单元),我们将整个 Fiber 树提交到 DOM。
function commitRoot() {
// TODO add nodes to dom
}
// 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)
}
批量commit
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)
}
第6步:协调
保存上一次的fiber树
到目前为止,我们只向 DOM 添加了内容,但是更新或删除节点怎么办?
这就是我们现在要做的事情,我们需要比较我们在 render 函数中收到的元素和我们上次提交到 DOM 的 Fiber 树。
因此,我们需要在完成 commit 后保存对“上次提交到 DOM 的 Fiber 树”的引用。我们称之为 currentRoot。
我们还将 alternate 属性添加到每个 Fiber 中。该属性是与旧 Fiber 相关联的链接,旧 Fiber 是我们在上一个提交阶段中提交到 DOM 中的 Fiber。
function commitRoot() {
// commitWork(wipRoot.child)
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
提取协调代码
现在让我们从 performUnitOfWork 中提取创建新 Fiber 的代码。
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
}
}
…到一个新的 reconcileChildren 函数。
function performUnitOfWork(fiber) {
// if (!fiber.dom) {
// fiber.dom = createDom(fiber)
// }
const elements = fiber.props.children
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) {
旧的Fiber与新的元素调和
这里我们将把旧的Fiber与新的元素调和。
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
我们同时迭代旧Fiber(wipFiber.alternate)的子节点和我们想要调和的元素数组。
如果我们忽略迭代数组和链表所需的所有样板代码,我们在这个while循环中需要关注的是最重要的内容:oldFiber和element。element是我们想要呈现到DOM中的内容,而oldFiber则是上次呈现的内容。
我们需要比较它们以查看是否有任何变化需要应用于DOM。
比较新旧Fiber树
为了比较它们,我们使用类型:
-
如果旧Fiber和新元素具有相同的类型,则可以保留DOM节点并仅使用新属性进行更新。
-
如果类型不同并且有一个新元素,则意味着我们需要创建一个新的DOM节点。
-
如果类型不同并且存在旧Fiber,则需要移除旧节点。
在这里,React还使用键(keys)来实现更好的调和。例如,它会检测到元素数组中的子元素交换位置的情况。
const sameType =
oldFiber &&
element &&
element.type == oldFiber.type
if (sameType) {
// TODO update the node
}
if (element && !sameType) {
// TODO add this node
}
if (oldFiber && !sameType) {
// TODO delete the oldFiber's node
}
相同类型时,我们创建一个新的Fiber
当旧Fiber和元素具有相同类型时,我们创建一个新的Fiber,保留旧Fiber中的DOM节点以及元素中的属性。
我们还会为Fiber添加一个新属性:effectTag。稍后在提交阶段,我们将使用此属性。
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",
}
}
需要创建新DOM节点的元素
然后对于需要创建新DOM节点的元素,我们使用PLACEMENT effect tag标记新Fiber。
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
}
}
删除节点
对于需要删除节点的情况,我们没有新Fiber,因此将effect tag添加到旧Fiber中。
但是,在将Fiber树提交到DOM时,我们是从正在进行的根节点(work in progress root)进行的,这里没有旧Fiber。
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION"
deletions.push(oldFiber)
}
因此,我们需要一个数组来跟踪我们要删除的节点。
function render(element, container) {
deletions = []
}
let deletions = null
然后,在将更改提交到DOM时,我们还使用该数组中的Fiber。
function commitRoot() {
deletions.forEach(commitWork)
}
处理新的effectTag
好的,现在让我们更改commitWork函数以处理新的effectTag。
- 如果是PLACEMENT,执行新增操作
- 如果是DELETION(删除),我们执行相反的操作,即删除子节点。
- 如果是UPDATE(更新),我们需要使用已更改的props更新现有的DOM节点。
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)
}
dom更新
我们将在updateDom函数中完成此操作。
属性
我们将旧Fiber的props与新Fiber的props进行比较,删除已经不存在的props,并设置新的或更改的props。
const isProperty = key => key !== "children"
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)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom[name] = ""
})
// 设置新的或者更新属性
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom[name] = nextProps[name]
})
}
事件
如果事件处理程序发生了更改,我们将其从节点中删除。然后我们添加新的事件处理程序。
const isEvent = key => key.startsWith("on")
const isProperty = key =>
key !== "children" && !isEvent(key)
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]
)
})
// 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上尝试使用调和版本。
第7步:函数组件
下一步我们需要添加的是对函数组件的支持。
新建一个函数组件示例
首先,让我们更改示例。我们将使用这个简单的函数组件,它返回一个h1元素。
/** @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)
请注意,如果我们将JSX转换为JS,则为:
function App(props) {
return Didact.createElement(
"h1",
null,
"Hi ",
props.name
)
}
const element = Didact.createElement(App, {
name: "foo",
})
函数组件的不同
函数组件有两个不同之处:
- 函数组件的Fiber没有DOM节点
- 组件的子节点不是直接从props中获取,而是通过运行函数来获得
我们检查Fiber的类型是否为函数,并根据这一点进入不同的更新函数。
在updateHostComponent中,我们与之前一样。
function performUnitOfWork(fiber) {
const isFunctionComponent =
fiber.type instanceof Function
if (isFunctionComponent) {
updateFunctionComponent(fiber)
} else {
updateHostComponent(fiber)
}
// ...
}
function updateFunctionComponent(fiber) {
// TODO
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
reconcileChildren(fiber, fiber.props.children)
}
在updateFunctionComponent中,我们运行该函数以获取子节点。
对于我们的示例,这里的fiber.type是App函数,当我们运行它时,它返回一个h1元素。
然后,一旦我们有了子节点,调和过程的处理方式与之前相同,我们不需要在那里进行任何更改。
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
修改commit函数
我们需要更改的是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)
}
现在我们有了没有DOM节点的Fiber,因此我们需要更改两件事。 首先,为了找到DOM节点的父级,我们需要沿着Fiber树向上查找,直到找到带有DOM节点的Fiber。
function commitWork(fiber) {
// ...
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 (
}
当删除一个节点时,我们也需要持续查找,直到找到带有DOM节点的子节点。
function commitWork(fiber) {
// ...
// } else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent)
// }
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
第8步:Hooks
最后一步。现在我们有了函数组件,让我们也添加状态(state)。
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)
让我们将示例更改为经典的计数器组件。每次单击它都会将状态值增加1。
请注意,我们使用Didact.useState来获取和更新计数器值。
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
function useState(initial) {
// TODO
}
在调用函数组件之前,我们需要初始化一些全局变量,以便在useState函数内部使用它们。
首先设置进行中的Fiber。
我们还为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时,我们检查是否有旧的hook。使用钩子索引检查Fiber的alternate。
如果我们有旧的hook,则将状态从旧的hook复制到新的hook中,否则我们初始化状态。
然后我们将新的hook添加到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还应该返回一个更新状态的函数,因此我们定义了一个setState函数,它接收一个操作(对于Counter示例,该操作是将状态增加1的函数)。
我们将该操作推送到我们添加到hook的队列中。
然后我们执行类似于渲染函数中所做的操作,将新的work in progress root设置为下一个工作单元,以便工作循环可以开始进行新的渲染阶段。
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]
但是我们还没有运行这个操作。
下一次渲染组件时,我们会从旧的钩子队列中获取所有操作,然后逐个应用到新的钩子状态上,因此当我们返回状态时,它已经更新了。
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
hook.state = action(hook.state)
})
好的,这就是全部。我们已经构建了自己的React版本。
你可以在codesandbox上进行测试。
结语
除了帮助你了解React的工作原理外,本文还旨在使您更轻松地深入研究React代码库。这就是我们几乎在所有地方使用相同的变量和函数名称的原因。
例如,如果您在真实的React应用程序中的一个函数组件中添加断点,调用栈应该会显示:
workLoop performUnitOfWork updateFunctionComponent
我们没有包括许多React功能和优化。例如,这些是React不同之处的一些内容:
在Didact中,在渲染阶段期间我们遍历整个树。React通过遵循某些提示和启发式算法来跳过未发生任何变化的整个子树。 我们在提交阶段也遍历整个树。React保留了一个只有具有影响的fiber的链接列表,并且仅访问这些fiber。 每当我们构建新的工作进展树时,我们为每个fiber创建新对象。React从先前的树中回收fiber。 当Didact在渲染阶段接收到新的更新时,它会丢弃正在进行中的树,然后从根开始重新开始。React使用到期时间戳标记每个更新,并使用它来决定哪个更新具有更高的优先级。 等等...
还有一些功能可以轻松添加:
使用对象作为样式prop 扁平化子数组 useEffect钩子 通过键进行调和
如果您向Didact添加了任何这些或其他功能,请向GitHub存储库发送拉取请求,以便其他人可以查看。
感谢阅读!
如果您想发表评论、点赞或分享这篇文章,可以使用此推文:
不得不构建一个新的博客和一些工具,以便以我想要的格式发表这篇文章。花了一些时间,但终于准备好了!
📢 DIY指南更新:从头构建React ✨ t.co/RfGrl8ARYz pic.twitter.com/3kih0xLHIu
— Rodrigo Pombo (@pomber) November 13, 2019 本文翻译自link