小さな React Chendering vDOM を構築する

Patricia Arquette
リリース: 2024-10-20 18:28:30
オリジナル
757 人が閲覧しました

Build a Tiny React Chendering vDOM

このチュートリアルはこのチュートリアルに基づいていますが、JSX、typescript、および実装がより簡単なアプローチを使用しています。私の GitHub リポジトリでメモとコードをチェックアウトできます。

この部分では、vDOM を実際の DOM にレンダリングします。さらに、React のコア構造であるファイバー ツリーについても紹介します。

vDOM のレンダリング

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 ノードを新しいノードに置き換えます。ノード上のメソッド
  • .remove(): void ドキュメントからノードを削除します。ノード上のメソッド
  • .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 では一般的な方法です。また、レンダリングではなく調整に使用される key プロパティも無視しました。

さて、レンダリングが完了したら、この章は終了です...?いいえ。

アイドル時間のレンダリング

実際の React では、レンダリング プロセスはもう少し複雑です。具体的には、requestIdleCallback を使用して、より緊急性の高いタスクを最初に実行し、自身の優先順位を下げます。

requestIdleCallback は、Safari、MacOS と iOS の両方でサポートされていないことに注意してください (Apple エンジニア、なぜですか? 少なくとも 2024 年には開発中です)。 Mac を使用している場合は、Chrome を使用するか、単純な setTimeout に置き換えてください。実際の React では、スケジューラーを使用してこれを処理しますが、基本的な考え方は同じです。

これを行うには、次の Web ネイティブ API を理解する必要があります。

  • requestIdleCallback(callback: Function): void ブラウザがアイドル状態のときに呼び出されるコールバックを要求します。コールバックには IdleDeadline オブジェクトが渡されます。コールバックには期限引数があり、これは次のプロパティを持つオブジェクトです。
    • timeRemaining():number ブラウザがアイドル状態でなくなるまでの残り時間をミリ秒単位で返します。だから、時間が過ぎる前に仕事を終わらせなければなりません。

したがって、レンダリングをチャンクに分割し、requestIdleCallback を使用して処理する必要があります。簡単な方法は、一度に 1 つのノードをレンダリングすることです。これは簡単ですが、積極的に実行しないでください。そうしないと、レンダリング中に他の作業も行う必要があるため、多くの時間を無駄にすることになります。

しかし、これから行うことの基本的なフレームワークとして次のコードを使用できます。

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)
        }
    }
}
ログイン後にコピー
ログイン後にコピー
ログイン後にコピー
ログイン後にコピー

ここで // TODO に vDOM のレンダリングを入力し、次にレンダリングされる vDOM ノードを返すと、単純なアイドル時間のレンダリングが可能になります。しかし、焦らないでください。さらに作業が必要です。

ファイバーツリー

次の章では、反応性を実装します。調整はかなり複雑です。そのため、一部のコンテンツをこの部分、つまりファイバー ツリーに移動します。

ファイバーツリーは単なる特別なデータ構造です。 React は変更を処理するときに次のプロセスを実行します。

  1. ユーザーまたは初期レンダリングなどの何かが変更をトリガーします。
  2. React は新しい vDOM ツリーを作成します。
  3. React は新しいファイバー ツリーを計算します。
  4. React は、古いファイバー ツリーと新しいファイバー ツリーの差分を計算します。
  5. React は差分を実際の DOM に適用します。

ご覧のとおり、ファイバー ツリーは React に不可欠です。

ファイバーツリーは従来のツリーとは少し異なり、ノード間に 3 種類の関係があります。

  • の子: ノードは別のノードの子です。ファイバー ツリーでは、すべてのノードが子を 1 つだけ持つことができることに注意してください。伝統的なツリー構造は、多くの兄弟を持つ子によって表されます。
  • 兄弟: ノードは別のノードの兄弟です。
  • 親: ノードは別のノードの親です。の子とは異なり、多くのノードが同じ親を共有できます。ファイバー ツリーの親ノードは、最初の子だけを気にする悪い親と考えることができますが、実際には依然として多くの子の親です。

たとえば、次の 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 は 2 次 div の子です。

レンダリングに関しては、順序は主に深さ優先ですが、少し異なります。そのため、基本的には次のルールに従います。ノードごとに、次の手順が実行されます。

  1. このノードに未処理の子がある場合は、その子を処理します。
  2. このノードに兄弟がある場合は、その兄弟を処理します。すべての兄弟が処理されるまで繰り返します。
  3. このノードを処理済みとしてマークします。
  4. その親を処理します。

それではそれを実装しましょう。ただし、最初にレンダリング プロセスをトリガーする必要があります。これは簡単です。nextUnitOfWork をファイバー ツリーのルートに設定するだけです。

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を呼び出します。ここで作業を実行します。

1 つ目は、実際の 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
ログイン後にコピー
ログイン後にコピー

これで、現在のノード用にファイバー ツリーが構築されました。それでは、ルールに従ってファイバーツリーを加工しましょう。

export function render(vDom: VDomNode, parent: HTMLElement) {
    nextUnitOfWork = {
        parent: null,
        sibling: null,
        child: null,
        vDom: vDom,
        dom: parent
    }
}
ログイン後にコピー
ログイン後にコピー

これで、vDOM をレンダリングできます。これが表示されます。 typescript は仮想 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 で正しく構築されますが、ドキュメントには追加されません。このようなイベントが送出されると、コミット関数が呼び出され、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 を解析するとき、タグは単なるラベル名であるため、これは機能しません。確かに、ネイティブ要素の場合は単なる文字列ですが、コンポーネントの場合は関数です。したがって、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')
}
ログイン後にコピー
ログイン後にコピー
ログイン後にコピー

ここで、各コンポーネントにプロップと子が必要になります。実際の React では、チェックする追加のフィールドが追加されました。想像できると思いますが、関数をクラスに置き換えるだけで、追加のフィールドができます。その後、オブジェクトを作成するための新しい関数を提供します (典型的なファクトリ パターン)。しかし、ここでは怠惰な方法を採用します。

<div>
    <p></p>
    <div>
        <h1></h1>
        <h2></h2>
    </div>
</div>
ログイン後にコピー
ログイン後にコピー
ログイン後にコピー

実際の React では、関数コンポーネントの呼び出しがファイバー構築段階まで遅れることに注意してください。それにもかかわらず、これは便宜上そうしたものであり、このシリーズの目的を実際に損なうものではありません。

断片

しかし、それでもまだ十分ではありません。以前は、フラグメントを div として扱うだけでしたが、これは正しくありません。しかし、それを文書の断片に置き換えるだけでは機能しません。その理由は、フラグメントは 1 回限りのコンテナであるためです。フラグメントから実際のものを取り出すことはできず、ネストすることもできません。また、多くの奇妙な動作が発生します (本当に、なぜうまくいかないのでしょう)もっと簡単に動作しないでしょうか...)。それで、くそー、これを掘り起こさなければなりません。

したがって、解決策は、フラグメントの 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 中国語 Web サイトの他の関連記事を参照してください。

ソース:dev.to
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
著者別の最新記事
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート