首页 >web前端 >js教程 >构建一个小型 React Chendering vDOM

构建一个小型 React Chendering vDOM

Patricia Arquette
Patricia Arquette原创
2024-10-20 18:28:30761浏览

Build a Tiny React Chendering vDOM

本教程基于本教程,但使用了 JSX、Typescript 和更简单的实现方法。您可以在我的 GitHub 存储库上查看注释和代码。

这部分我们将把 vDOM 渲染为实际的 DOM。另外,我们还会介绍React中的核心结构Fiber Tree。

渲染虚拟DOM

渲染 vDOM 非常简单——太简单了。您需要了解以下 Web 原生 API。

  • document.createElement(tagName: string): HTMLElement 创建一个实际的 DOM 元素。
  • document.createTextNode(text: string): Text 创建文本节点。
  • .appendChild(child: Node): void 将子节点追加到父节点。 HTMLElement 上的方法
  • .removeChild(child: Node): void 从父节点中删除子节点。 HTMLElement 上的方法
  • .replaceChild(newChild: Node, oldChild: Node): void 用新的子节点替换子节点。 HTMLElement 上的方法
  • .replaceWith(...nodes: Node[]): void 用新节点替换节点。 Node 上的方法
  • .remove(): void 从文档中删除节点。 Node 上的方法
  • .insertBefore(newChild: Node, refChild: Node): void 在引用子节点之前插入新子节点。 HTMLElement 上的方法
  • .setAttribute(name: string, value: string): void 设置元素的属性。 HTMLElement 上的方法。
  • .removeAttribute(name: string): void 从元素中删除属性。 HTMLElement 上的方法。
  • .addEventListener(type: string,listener: Function): void 向元素添加事件监听器。 HTMLElement 上的方法。
  • .removeEventListener(type: string,listener: Function): void 从元素中删除事件侦听器。 HTMLElement 上的方法。
  • .dispatchEvent(event: Event): void 在元素上调度事件。 HTMLElement 上的方法。

哇,有点太多了吧?但您所需要做的就是将 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(callback: Function): void 请求在浏览器空闲时调用回调。回调将传递一个 IdleDeadline 对象。回调将有一个截止时间参数,它是一个具有以下属性的对象。
    • timeRemaining(): number 返回浏览器不再空闲之前的剩余时间(以毫秒为单位)。所以我们应该在时间到之前完成我们的工作。

所以我们需要将渲染分成块,并使用 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处理变化时,它会执行以下过程。

  1. 某些东西(可能是用户或初始渲染)触发了更改。
  2. React 创建一个新的 vDOM 树。
  3. React 计算新的 Fiber 树。
  4. React 计算旧 Fiber 树和新 Fiber 树之间的差异。
  5. React 将差异应用到实际 DOM。

你可以看到,Fiber Tree对于React来说是必不可少的。

纤维树与传统的树有点不同,节点之间有三种类型的关系。

  • child of:一个节点是另一个节点的子节点。请注意,在 Fiber 树中,每个节点只能有一个子节点。传统的树结构由一个孩子和许多兄弟姐妹来表示。
  • 兄弟节点:一个节点是另一个节点的兄弟节点。
  • parent of:一个节点是另一个节点的父节点。与子节点不同的是,许多节点可以共享同一个父节点。您可以将 Fiber 树中的父节点视为坏父节点,它只关心第一个子节点,但实际上仍然是许多子节点的父节点。

例如,对于以下 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 的子级。

在渲染方面,顺序主要是深度优先,但有点不同 - 所以基本上,它遵循这些规则。对于每个节点,都会经历以下步骤。

  1. 如果该节点有未处理的子节点,则处理该子节点。
  2. 如果该节点有兄弟节点,则处理该兄弟节点。重复此操作,直到处理完所有兄弟姐妹。
  3. 将此节点标记为已处理。
  4. 处理其父级。

现在让我们实现它。但首先,我们需要触发渲染过程。很简单 - 只需将 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中文网其他相关文章!

声明:
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn