构建你的React【下】
- 步骤五:Render & Commit
- 步骤六:Reconciliation
- 步骤七:Function Components
- 步骤八:Hooks
步骤 5:Render 和 Commit 阶段
我们这里有另一个问题。
function performUnitOfWork(fiber) {
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
}
每次处理元素时,我们都会向 DOM 添加新节点。而且,请记住,浏览器可能会在我们完成渲染整个树之前中断我们的工作。在这种情况下,用户将看到不完整的 UI。我们不希望那样。
所以我们需要从这里删除改变 DOM 的部分。
// 删除
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
相反,我们将跟 fiber 树的根。我们将其称为 work in progress root 或 wipRoot。
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element]
}
}
nextUnitOfWork = wipRoot
}
let wipRoot = null
一旦我们完成了所有工作(我们知道这一点,因为没有下一个工作单元),我们将整个 fiber 树提交到 DOM。
function commitRoot() {
// TODO add nodes to dom
}
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
// 新增
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
我们在 commitRoot 函数中执行此操作。在这里,我们递归地将所有节点附加到 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)
}
步骤 5 完整代码
debugger
/**
* performUnitOfWork在有fiber.parent的时候,直接appendChild不行,如果被浏览器暂停任务就不会显示完整页面
* 所以要分render和commit阶段,在commit阶段递归
*/
const createElement = (type, props, ...children) => {
return {
type,
props: {
...props,
children: children.map((child) => {
return typeof child === 'object' ? child : createTextElement(child)
})
}
}
}
const createTextElement = (text) => {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: []
}
}
}
// 根据fiber创建真实dom
const createDom = (fiber) => {
// 创建对应节点
const dom =
fiber.type === 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(fiber.type)
// 过滤特殊的children
const isProperty = (key) => {
return key !== 'children'
}
// 赋给props
Object.keys(fiber.props)
.filter(isProperty)
.forEach((name) => {
dom[name] = fiber.props[name]
})
return dom
}
const render = (element, container) => {
wipRoot = {
dom: container,
props: {
children: [element]
}
}
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let wipRoot = null
function commitRoot() {
// TODO add nodes to 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)
}
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
// 如果剩余时间少于 1 毫秒,则 shouldYield 被设置为 true,表示当前任务应该让出执行权。
shouldYield = deadline.timeRemaining() < 1
}
// 为什么这里就不会被中断
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
// fiber对象
// {
// type
// props
// dom
// parent
// child
// sibling
// }
// 传入fiber,创建dom,为children创建fiber,找到下一个工作单元
function performUnitOfWork(fiber) {
// 1、创建DOM
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
// if (fiber.parent) {
// fiber.parent.dom.appendChild(fiber.dom)
// }
// 2、给children创建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++
}
// 3、找到下一个工作单元
// 向下递,向上归
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
const MyReact = {
createElement,
render
}
/** @jsx MyReact.createElement */
// const element = <h1 title="foo">Hello</h1>
const element = (
<div style="background: salmon">
<h1>Hello World</h1>
<h2 style="text-align:right">from MyReact</h2>
</div>
)
const container = document.getElementById('root')
MyReact.render(element, container)
步骤 6:Reconciliation
到目前为止,我们只向 DOM 添加了一些内容,但是更新或删除节点呢?
这就是我们现在要做的,我们需要将我们在 render 函数上接收到的元素与我们提交到 DOM 的最后一个 fiber 树进行比较。
因此,我们需要在完成提交后保存对“我们提交到 DOM 的最后一个 fiber 树”的引用。我们将其称为 currentRoot。
我们还为每个 fiber 添加了 alternate 属性。此属性是指向旧 fiber 的链接,即我们在上一个提交阶段提交到 DOM 的 fiber。
function commitRoot() {
commitWork(wipRoot.child)
// 新增
currentRoot = wipRoot
wipRoot = null
}
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) {
// TODO:
}
在这里,我们将调和旧 fiber 与新元素。
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++
}
}
我们同时迭代旧 fiber (wipFiber.alternate) 的子元素和我们想要协调的元素数组。
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
if (oldFiber) {
oldFiber = oldFiber.sibling
}
if (index === 0) {
wipFiber.child = newFiber
} else if (element) {
prevSibling.sibling = newFiber
}
}
}
如果我们忽略掉同时遍历数组和链表所需的所有模板代码,我们就只剩下 while 循环中最核心的部分:oldFiber 和 element。element 是我们想要渲染到 DOM 中的内容,而 oldFiber 是我们上次渲染的内容。
我们需要比较它们,看看是否需要对 DOM 应用任何更改。
为了比较它们,我们使用类型:
- 如果旧的 fiber 和新的 element 具有相同的类型,我们可以保留 DOM 节点,只用新的 props 更新它
- 如果类型不同并且有新元素,则意味着我们需要创建一个新的 DOM 节点
- 如果类型不同并且存在旧 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
// 新增
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
}
if (oldFiber) {
oldFiber = oldFiber.sibling
}
if (index === 0) {
wipFiber.child = newFiber
} else if (element) {
prevSibling.sibling = newFiber
}
}
}
这里 React 也使用了 key,这可以更好地协调。例如,它会检测子项何时更改元素数组中的位置。
当旧 fiber 和 element 具有相同的类型时,我们创建一个新的 fiber,将 DOM 节点与旧 fiber 保持一致,并从 props 从元素中保留 props。
我们还向 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 节点的情况,我们使用 PLACEMENT 效果标签标记新 fiber。
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: 'PLACEMENT'
}
}
对于需要删除节点的情况,我们没有新的 fiber,因此我们将 effect 标签添加到旧 fiber 中。
if (oldFiber && !sameType) {
oldFiber.effectTag = 'DELETION'
deletions.push(oldFiber)
}
但是当我们把 fiber tree 提交到 DOM 时,我们从 work in progress 根开始做这件事,它没有旧的 fibers。
因此,我们需要一个数组来跟踪我们想要删除的节点。
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
然后,当我们将更改提交到 DOM 时,我们还使用来自该数组的 fibers。
function commitRoot() {
// 新增
deletions.forEach(commitWork)
commitWork(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}
现在,让我们更改 commitWork 函数来处理新的 effectTags。
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
如果 fiber 具有 PLACEMENT 效果标签,我们将像以前一样,将 DOM 节点附加到父 fiber 中的节点。
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
if (
fiber.effectTag === "PLACEMENT" &&
fiber.dom != null
) {
domParent.appendChild(fiber.dom)
}
commitWork(fiber.child)
commitWork(fiber.sibling)
}
如果是 DELETION,我们反其道而行之,删除 child。
if (fiber.effectTag === 'PLACEMENT' && fiber.dom != null) {
domParent.appendChild(fiber.dom)
} else if (fiber.effectTag === 'DELETION') {
domParent.removeChild(fiber.dom)
}
如果是 UPDATE,我们需要用更改的 props 更新现有的 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)
}
我们将在这个 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]
})
}
我们需要更新的一种特殊类型的 prop 是事件监听器,因此如果 prop 名称以 “on” 前缀开头,我们将以不同的方式处理它们。
const isEvent = (key) => key.startsWith('on')
const isProperty = (key) => key !== 'children' && !isEvent(key)
如果事件处理程序发生更改,我们会将其从节点中删除。
function updateDom(dom, prevProps, nextProps) {
//删除事件
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(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2)
dom.addEventListener(eventType, nextProps[name])
})
在 codesandbox 上尝试具有 reconciliation 功能的版本。
步骤 6 完整代码
步骤 7 Function Components
接下来我们需要添加的是 function components 的支持。
首先,让我们改变一下例子。我们将使用这个简单的函数组件,它返回一个 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获取它们
function performUnitOfWork(fiber) {
// 要改动的代码
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
const elements = fiber.props.children
reconcileChildren(fiber, elements)
// 省略之前的代码
}
我们检查 fiber type 是否是一个函数,并根据它我们转到不同的 update 函数。
在 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 元素。
然后,一旦我们有了孩子,reconciliation 就会以同样的方式进行,我们不需要在那里做任何改变。
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
我们需要更改的是 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) {
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)
}
当删除一个节点时,我们还需要继续前进,直到找到一个具有 DOM 节点的子节点。
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
步骤 8 Hooks
最后一步。现在我们有了函数组件,让我们也添加 state。
让我们将示例更改为经典的 counter 组件。每次我们单击它时,它都会将状态增加 1。
请注意,我们使用 Didact.useState 来获取和更新 counter 值。
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)
这是我们从示例中调用 Counter 函数的地方。在这个函数中,我们称之为 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)
}
function useState(initial) {
// TODO
}
当函数组件调用 useState 时,我们检查是否有旧的 hook。我们使用 hook 索引检查 fiber 的 alternate 。
如果我们有一个旧的 hook,我们将 state 从旧的 hook 复制到新的 hook 上,如果没有,我们初始化 state。
然后我们将新钩子添加到 fiber 中,将钩子索引增加 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,因此我们定义了一个接收 action 的 setState 函数(对于 Counter 示例,这个 action 是将 state 递增 1 的函数)。
我们将该操作推送到我们添加到 hook 的队列中。
然后我们做一些类似于我们在 render 函数中所做的事情,将一个新的正在进行的工作根设置为下一个工作单元,以便工作循环可以开始新的渲染阶段。
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 队列中获取所有 action,然后将它们一一应用到新的 hook 状态,因此当我们返回状态时,它会被更新。
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 上使用它。
结语
除了帮助你理解 React 的工作原理外,这篇文章的目标之一是让你更容易更深入地了解 React 代码库。这就是为什么我们几乎在所有地方都使用相同的变量和函数名称。
例如,如果你在真实 React 应用程序的一个函数组件中添加了一个断点,调用堆栈应该向你显示:
- workLoop workLoop 工作循环
- performUnitOfWork
- updateFunctionComponent
我们没有包含很多 React 功能和优化。例如,以下是 React 的不同之处:
- 在 Didact 中,我们在渲染阶段遍历整个树。相反,React 遵循一些提示和启发式方法来跳过没有任何变化的整个子树。
- 我们还在 commit 阶段遍历整个 tree。React 保留一个链表,其中仅包含具有 effect 的 fibers,并且只访问这些 fiber。
- 每次我们构建一个新的 work in progress 树时,我们都会为每个 fiber 创建新的对象。React 回收了之前树的 fiber。
- 当 Didact 在渲染阶段收到新的更新时,它会丢弃正在进行的工作树并从根重新开始。React 使用过期时间戳标记每个更新,并使用它来决定哪个更新具有更高的优先级。
- 还有更多...
您还可以轻松添加一些功能:
- 为 style 属性使用对象
- 展平子数组
- useEffect 钩子
- 根据 key 做 reconciliation
如果您向 Didact 添加了这些或其他功能中的任何一个,请向 GitHub 存储库发送拉取请求,以便其他人可以看到它。
感谢阅读!