本教學是基於本教學,但使用了 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中文網其他相關文章!