首頁  >  文章  >  web前端  >  建造一個小型 React Chendering vDOM

建造一個小型 React Chendering vDOM

Patricia Arquette
Patricia Arquette原創
2024-10-20 18:28:30663瀏覽

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