本教程基于本教程,但使用了 JSX、Typescript 和更简单的实现方法。您可以在我的 GitHub 存储库上查看注释和代码。
这部分我们将把 vDOM 渲染为实际的 DOM。另外,我们还会介绍React中的核心结构Fiber Tree。
渲染 vDOM 非常简单——太简单了。您需要了解以下 Web 原生 API。
哇,有点太多了吧?但您所需要做的就是将 vDOM 的创建镜像到实际 DOM。这是一个简单的例子。
function render(vDom: VDomNode, parent: HTMLElement) { if (typeof vDom === 'string') { parent.appendChild(document.createTextNode(vDom)) } else if (vDom.kind === 'element') { const element = document.createElement(vDom.tag) for (const [key, value] of Object.entries(vDom.props ?? {})) { if (key === 'key') continue if (key.startsWith('on')) { element.addEventListener(key.slice(2).toLowerCase(), value as EventListener) } else { element.setAttribute(key, value as string) } } for (const child of vDom.children ?? []) { render(child, element) } parent.appendChild(element) } else { for (const child of vDom.children ?? []) { render(child, parent) } } }
我们注册以on开头的属性作为事件监听器,这是React中的常见做法。另外,我们忽略了关键属性,它用于协调,而不是渲染。
好的,渲染完成了,本章就结束了……?没有。
在真实的react中,渲染过程要复杂一些。更具体地说,它会使用requestIdleCallback,让更紧急的任务先完成,降低自己的优先级。
请注意,Safari、MacOS 和 iOS 均不支持 requestIdleCallback(Apple 工程师,请问这是为什么?至少他们正在努力解决这个问题,2024 年)。如果您使用的是 Mac,请使用 chrome,或将其替换为简单的 setTimeout。在真正的React中,它使用调度程序来处理这个问题,但基本思想是相同的。
为此,我们需要了解以下 Web 原生 API。
所以我们需要将渲染分成块,并使用 requestIdleCallback 来处理它。一种简单的方法是一次只渲染一个节点。这很容易 - 但不要急于这样做 - 否则你会浪费很多时间,因为我们还需要在渲染时完成其他工作。
但是我们可以使用以下代码作为我们要做的事情的基本框架。
function render(vDom: VDomNode, parent: HTMLElement) { if (typeof vDom === 'string') { parent.appendChild(document.createTextNode(vDom)) } else if (vDom.kind === 'element') { const element = document.createElement(vDom.tag) for (const [key, value] of Object.entries(vDom.props ?? {})) { if (key === 'key') continue if (key.startsWith('on')) { element.addEventListener(key.slice(2).toLowerCase(), value as EventListener) } else { element.setAttribute(key, value as string) } } for (const child of vDom.children ?? []) { render(child, element) } parent.appendChild(element) } else { for (const child of vDom.children ?? []) { render(child, parent) } } }
如果您现在用渲染 vDOM 填充 // TODO,并返回下一个要渲染的 vDOM 节点,您可以进行简单的空闲时间渲染。但别着急——我们需要更多的工作。
在下一章中,我们将实现反应性,而协调相当复杂 - 所以我们将一些内容移到这部分,即 Fiber 树。
纤维树只是一种特殊的数据结构。当react处理变化时,它会执行以下过程。
你可以看到,Fiber Tree对于React来说是必不可少的。
纤维树与传统的树有点不同,节点之间有三种类型的关系。
例如,对于以下 DOM,
import { createDom, VDomNode } from "./v-dom" interface Fiber { parent: Fiber | null sibling: Fiber | null child: Fiber | null vDom: VDomNode, dom: HTMLElement | Text | null } let nextUnitOfWork: Fiber | null = null function workLoop(deadline: IdleDeadline) { let shouldYield = false while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork( nextUnitOfWork ) shouldYield = deadline.timeRemaining() < 1 } requestIdleCallback(workLoop) } requestIdleCallback(workLoop) function performUnitOfWork(nextUnitOfWork: Fiber | null): Fiber | null { // TODO throw new Error('Not implemented') }
我们可以将它表示为一棵树。
<div> <p></p> <div> <h1></h1> <h2></h2> </div> </div>
p 是根 div 的子级,但次要 div 不是根 div 的子级,而是 p 的兄弟级。 h1 和 h2 是次要 div 的子级。
在渲染方面,顺序主要是深度优先,但有点不同 - 所以基本上,它遵循这些规则。对于每个节点,都会经历以下步骤。
现在让我们实现它。但首先,我们需要触发渲染过程。很简单 - 只需将 nextUnitOfWork 设置为 Fiber 树的根即可。
function render(vDom: VDomNode, parent: HTMLElement) { if (typeof vDom === 'string') { parent.appendChild(document.createTextNode(vDom)) } else if (vDom.kind === 'element') { const element = document.createElement(vDom.tag) for (const [key, value] of Object.entries(vDom.props ?? {})) { if (key === 'key') continue if (key.startsWith('on')) { element.addEventListener(key.slice(2).toLowerCase(), value as EventListener) } else { element.setAttribute(key, value as string) } } for (const child of vDom.children ?? []) { render(child, element) } parent.appendChild(element) } else { for (const child of vDom.children ?? []) { render(child, parent) } } }
触发渲染后,浏览器将调用performUnitOfWork,这就是我们执行工作的地方。
首先是我们需要创建实际的 DOM 元素。我们可以通过创建一个新的 DOM 元素并将其附加到父 DOM 元素来做到这一点。
import { createDom, VDomNode } from "./v-dom" interface Fiber { parent: Fiber | null sibling: Fiber | null child: Fiber | null vDom: VDomNode, dom: HTMLElement | Text | null } let nextUnitOfWork: Fiber | null = null function workLoop(deadline: IdleDeadline) { let shouldYield = false while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork( nextUnitOfWork ) shouldYield = deadline.timeRemaining() < 1 } requestIdleCallback(workLoop) } requestIdleCallback(workLoop) function performUnitOfWork(nextUnitOfWork: Fiber | null): Fiber | null { // TODO throw new Error('Not implemented') }
<div> <p></p> <div> <h1></h1> <h2></h2> </div> </div>
这是作品的第一部分。现在我们需要构建从当前光纤分支出来的光纤。
div ├── p └── div ├── h1 └── h2
现在我们为当前节点构建了一个 Fiber 树。现在让我们按照我们的规则来处理纤维树。
export function render(vDom: VDomNode, parent: HTMLElement) { nextUnitOfWork = { parent: null, sibling: null, child: null, vDom: vDom, dom: parent } }
现在我们可以渲染 vDOM,就在这里。请注意,打字稿在这里很愚蠢,因为它无法告诉我们虚拟 DOM 的类型,我们在这里需要一个丑陋的绕过。
function isString(value: VDomNode): value is string { return typeof value === 'string' } function isElement(value: VDomNode): value is VDomElement { return typeof value === 'object' } export function createDom(vDom: VDomNode): HTMLElement | Text | DocumentFragment { if (isString(vDom)) { return document.createTextNode(vDom) } else if (isElement(vDom)) { const element = document.createElement(vDom.tag === '' ? 'div' : vDom.tag) Object.entries(vDom.props ?? {}).forEach(([name, value]) => { if (value === undefined) return if (name === 'key') return if (name.startsWith('on') && value instanceof Function) { element.addEventListener(name.slice(2).toLowerCase(), value as EventListener) } else { element.setAttribute(name, value.toString()) } }) return element } else { throw new Error('Unexpected vDom type') } }
现在您的 vDOM 已渲染为实际 DOM。恭喜!你做得很好。但我们还没有完成。
当前的实现会存在一个问题 - 如果我们有太多节点会减慢整个过程,用户将看到渲染是如何完成的。当然,不会泄露商业机密什么的,但是这样的体验并不好。我们宁愿将 dom 创建隐藏在幕后,立即提交。
解决方案很简单 - 我们不直接提交到文档,而是创建一个元素而不将其添加到文档中,完成后将其添加到文档中。这称为累积提交。
function performUnitOfWork(nextUnitOfWork: Fiber | null): Fiber | null { if(!nextUnitOfWork) { return null } if(!nextUnitOfWork.dom) { nextUnitOfWork.dom = createDom(nextUnitOfWork.vDom) } if(nextUnitOfWork.parent && nextUnitOfWork.parent.dom) { nextUnitOfWork.parent.dom.appendChild(nextUnitOfWork.dom) } // TODO throw new Error('Not implemented') }
现在,我们从performUnitOfWork中删除appendChild,即下面的部分,
const fiber = nextUnitOfWork if (isElement(fiber.vDom)) { const elements = fiber.vDom.children ?? [] let index = 0 let prevSibling = null while (index < elements.length) { const element = elements[index] const newFiber: Fiber = { parent: fiber, dom: null, sibling: null, child: null, vDom: element, } if (index === 0) { fiber.child = newFiber } else { prevSibling!.sibling = newFiber } prevSibling = newFiber index++ } }
现在,如果我们完成了所有工作,我们就已经使用它们的 DOM 正确构建了所有 Fiber,但它们不会添加到文档中。当此类事件调度时,我们调用提交函数,该函数会将 DOM 添加到文档中。
if (fiber.child) { return fiber.child } let nextFiber: Fiber | null = fiber while (nextFiber) { if (nextFiber.sibling) { return nextFiber.sibling } nextFiber = nextFiber.parent } return null
现在,提交功能很简单 - 只需将所有子 DOM 递归添加到 wip,然后将 wip 提交到 DOM。
import { render } from "./runtime"; import { createElement, fragment, VDomNode } from "./v-dom"; function App() { return <> <h1>a</h1> <h2>b</h2> </> } const app = document.getElementById('app') const vDom: VDomNode = App() as unknown as VDomNode render(vDom, app!)
您可以通过向 commitChildren 函数添加超时来测试这一点。以前渲染是一步步完成的,现在是一次性完成的。
您可以尝试嵌套函数 - 如下所示,
function render(vDom: VDomNode, parent: HTMLElement) { if (typeof vDom === 'string') { parent.appendChild(document.createTextNode(vDom)) } else if (vDom.kind === 'element') { const element = document.createElement(vDom.tag) for (const [key, value] of Object.entries(vDom.props ?? {})) { if (key === 'key') continue if (key.startsWith('on')) { element.addEventListener(key.slice(2).toLowerCase(), value as EventListener) } else { element.setAttribute(key, value as string) } } for (const child of vDom.children ?? []) { render(child, element) } parent.appendChild(element) } else { for (const child of vDom.children ?? []) { render(child, parent) } } }
但是这是行不通的,因为解析 JSX 时,tag 只是标签名称。当然,对于原生元素来说,它只是一个字符串,但对于组件来说,它是一个函数。所以在将 JSX 转换为 vDOM 的过程中,我们需要检查该标签是否是一个函数,如果是,则调用它。
import { createDom, VDomNode } from "./v-dom" interface Fiber { parent: Fiber | null sibling: Fiber | null child: Fiber | null vDom: VDomNode, dom: HTMLElement | Text | null } let nextUnitOfWork: Fiber | null = null function workLoop(deadline: IdleDeadline) { let shouldYield = false while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork( nextUnitOfWork ) shouldYield = deadline.timeRemaining() < 1 } requestIdleCallback(workLoop) } requestIdleCallback(workLoop) function performUnitOfWork(nextUnitOfWork: Fiber | null): Fiber | null { // TODO throw new Error('Not implemented') }
现在,每个组件都需要 props 和 child。在真正的 React 中,他们添加了额外的字段来检查 - 你可以想象,只需用类替换函数,这样你就有了额外的字段 - 然后你提供新的函数来创建对象,这是典型的工厂模式 - 但我们在这里采取了懒惰的态度。
<div> <p></p> <div> <h1></h1> <h2></h2> </div> </div>
请注意,在真实的 React 中,函数组件调用会延迟到 Fiber 构建阶段。尽管如此,我们这样做是为了方便,并没有真正损害本系列的目的。
但是,这还不够。之前我们只是把fragment当成div,这是不正确的。但如果你只是用文档片段替换它,那是行不通的。这样做的原因是因为片段是一次性容器 - 这会导致一种奇怪的行为 - 就像你不能从中取出真实的东西,你不能嵌套它们,以及许多奇怪的东西(真的,为什么它会赢)工作更简单......)。所以,他妈的,我们需要把这个东西挖出来。
所以解决方案是,我们不为片段创建 DOM - 我们找到正确的父级来添加 DOM。
我们需要,
div ├── p └── div ├── h1 └── h2
并更改渲染,
export function render(vDom: VDomNode, parent: HTMLElement) { nextUnitOfWork = { parent: null, sibling: null, child: null, vDom: vDom, dom: parent } }
现在,片段已正确处理。
以上是构建一个小型 React Chendering vDOM的详细内容。更多信息请关注PHP中文网其他相关文章!