at backyard

Color my life with the chaos of trouble.

Reactを自作するための学習メモ #4

Reactを自作するための学習の記録の続き

この続き

shinshin86.hateblo.jp

参照ドキュメント

現在ここに記述しているコードは、下記のドキュメントのほぼ写経となっています。

pomb.us

zenn.dev

Render Phase と Commit Phase のおはなし

Reactはページをレンダリングするとき、2段階に分けて処理を行う。

Render Phase

↓

Commit Phase

という順番で処理が行われるそうで、最初のRender PhaseではReactは仮想DOMを作成する。

この段階で仮想DOMが作成されるので、まだ画面上に変化はないが、実際にどのような画面が作成されるかが決定する。

Reactは最上位のコンポーネントでRenderを呼び出し、さらにその中のコンポーネント内のRenderを見に行く、といった形で子要素ごとにRenderを呼び出しページ全体がどの様になっているかが分かるまで再帰的にこの処理が行われる。

その次のフェーズとして、Commit Phaseというのがある。

仮想DOMの内容が判明したら、その内容と一致するように実際のDOMを更新していくフェーズというのが、このCommit Phaseとなる。

  • Render Phaseから取得した最新のDOMを、
  • 最後に取得した画面上での最新のDOMと比較し、
  • ページを最小限の処理で、最新の状態に更新するように計算していく

ここまでCommit Phaseとなる。

Render Phase と Commit Phase の実装を加える

実はここまでの実装では、ツリー全体のレンダリングが完了する前に、ブラウザが作業を中断すると不完全なUIが表示されるような状態となってしまっている。

これらを解消すために、このRender Phase と Commit Phase の実装を加えていく。

まずはDOMノードを追跡する箇所を削除する。

if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
}

代わりに #3 に登場したファイバツリーのルートを追跡していくようにする。
そこで wipRoot という変数を用いることにする。

const render = (element, container) => {
    wipRoot = {
        dom:container, 
        props: {
            children: [element]
        }
    }

    nextUnitOfWork = wipRoot
}

let wipRoot = null

そして次の作業単位( nextUnitOfWork )がnullとなった場合、ファイバーツリー全体をDOMにコミットする処理を行う。 (作業単位がnullになったということは、=すべての作業が終了したことを意味する)

workLoop 関数を以下のように書き換え、

const workLoop = deadline => {
    let shouldYield = false

    while (nextUnitOfWork && !shouldYield) {
        nextUnitOfWork = performUnitOfWork(
            nextUnitOfWork
        )

        shouldYield = deadline.timeRemaining() < 1
    }

    if (!nextUnitOfWork && wipRoot) {
        commitRoot()
    }

    requestIdleCallback(workLoop)
}

commitRootcommitWork 関数をそれぞれ加える。

const commitRoot = () => {
    commitWork(wipRoot.child)
    wipRoot = null
}

const commitWork = fiber => {
    console.log(fiber); // ログをコンソールに出す

    if (!fiber) {
        return
    }

    const domParent = fiber.parent.dom
    domParent.appendChild(fiber.dom)
    commitWork(fiber.child)
    commitWork(fiber.sibling)
}

ちなみに上でコンソールログを出しているが、こんな感じのものが処理が行われるたびに表示される。

f:id:shinshin86:20210709220648p:plain
commitWork関数内のログ

差分検出を実装する

上の処理まででDOMの追加を行うようになったが、ノードの更新や削除を実装する必要がある。

render関数で受け取ったelementと、DOMにコミット済みの最新のファイバーツリーとを比較する処理を追加していく。

が、比較するには、最後にそのDOMにコミットした際のファイバーツリーの参照を覚えておく必要がある。
そこで、これを currentRoot という変数に持たせるようにする。

さらにすべてのファイバーに alternate プロパティを追加し、ここに前回のDOMコミット時のファイバーツリー参照をもたせる。

const commitRoot = () => {
    commitWork(wipRoot.child)
    currentRoot = wipRoot // ファイバーツリーの参照を渡しておく
    wipRoot = null
}
const render = (element, container) => {
    wipRoot = {
        dom: container,
        props: {
            children: [element]
        },
        alternate: currentRoot, // ここに前回のDOMコミット時のファイバーツリー参照をもたせる
    }

    nextUnitOfWork = wipRoot
}

let wipRoot = null
let currentRoot = null
let nextUnitOfWork = null;

さらには performUnitOfWork 関数内にある、子要素のファイバーを作成するコードを新たに作成した reconcileChildren関数に移動させる さらに reconcileChildren 関数内でで古いファイバーと新しい要素を比較するようにする。
この関数内で登場する element はDOMにレンダリングしたいものであり、oldFiber は前回レンダリングしたもの。
それらを比較して、DOMに適用すべき変更があるかどうかを確認していく処理を reconcileChildren内に実装していく。
(正直ここらへんが難しい)

とにかく書いてみたコードを下に載せる(Didact.js)

const createElement = (type, props, ...children) => {
    return {
        type,
        props: {
            ...props,
            children: children.map(child =>
                typeof child === "object"
                    ? child
                    : createTextElement(child)
            ),
        }
    };
}

const createTextElement = text => {
    return {
        type: "TEXT_ELEMENT",
        props: {
            nodeValue: text,
            children: [],
        },
    }
}


const isNew = (prev, next) => key => prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
const isEvent = key => key.startsWith("on")
const isProperty = key => key !== "children" && !isEvent(key)

const createDom = fiber => {
    const dom = fiber.type === "TEXT_ELEMENT"
        ? document.createTextNode("")
        : document.createElement(fiber.type)

    updateDom(dom, {}, fiber.props)

    return dom
}

const updateDom = (dom, prevProps, nextProps) => {
    // 削除、または変更したイベントリスナーを削除する
    Object.keys(prevProps)
        .filter(isEvent)
        .filter(
            key =>
                !(key in nextProps) ||
                isNew(prevProps, nextProps)(key)
        )
        .forEach(name => {
            const eventType = name
                .toLowerCase()
                .substring(2)
            dom.removeEventListener(
                eventType,
                prevProps[name]
            )
        })

    // 古いプロパティを削除する
    Object.keys(prevProps)
        .filter(isProperty)
        .filter(isGone(prevProps, nextProps))
        .forEach(name => {
            dom[name] = ""
        })

    // 新規 or 変更したプロパティをセットする
    Object.keys(nextProps)
        .filter(isProperty)
        .filter(isNew(prevProps, nextProps))
        .forEach(name => {
            dom[name] = nextProps[name]
        })

    // 新しいイベントリスナーを追加
    Object.keys(nextProps)
        .filter(isEvent)
        .filter(isNew(prevProps, nextProps))
        .forEach(name => {
            const eventType = name
                .toLowerCase()
                .substring(2)
            dom.addEventListener(
                eventType,
                nextProps[name]
            )
        })
}

const render = (element, container) => {
    wipRoot = {
        dom: container,
        props: {
            children: [element]
        },
        alternate: currentRoot,
    }

    // 削除するノードを追跡するための配列が必要になるので追加
    deletions = []

    nextUnitOfWork = wipRoot
}

let wipRoot = null
let currentRoot = null
let nextUnitOfWork = null;
let deletions = null

const commitRoot = () => {
    commitWork(wipRoot.child)
    currentRoot = wipRoot
    wipRoot = null
}

const commitWork = fiber => {
    if (!fiber) {
        return
    }

    const domParent = fiber.parent.dom

    if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
        domParent.appendChild(fiber.dom)
    } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
        updateDom(fiber.dom, fiber.alternate.props, fiber.props)
    } else if (fiber.effectTag === "DELETION") {
        domParent.removeChild(fiber.dom)
    }

    commitWork(fiber.child)
    commitWork(fiber.sibling)
}

const workLoop = deadline => {
    let shouldYield = false

    while (nextUnitOfWork && !shouldYield) {
        nextUnitOfWork = performUnitOfWork(
            nextUnitOfWork
        )

        shouldYield = deadline.timeRemaining() < 1
    }

    if (!nextUnitOfWork && wipRoot) {
        commitRoot()
    }

    requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

const performUnitOfWork = fiber => {
    if (!fiber.dom) {
        fiber.dom = createDom(fiber)
    }

    const elements = fiber.props.children
    reconcileChildren(fiber, elements)

    if (fiber.child) {
        return fiber.child
    }

    let nextFiber = fiber

    while (nextFiber) {
        if (nextFiber.sibling) {
            return nextFiber.sibling
        }

        nextFiber = nextFiber.parent
    }
}

const reconcileChildren = (wipFiber, elements) => {
    let index = 0
    let oldFiber = wipFiber.alternate && wipFiber.alternate.child
    let prevSibling = null

    while (index < elements.length || oldFiber != null) {
        const element = elements[index]
        let newFiber = null

        const sameType = oldFiber && element && element.type === oldFiber.type

        // 古いファイバーと新しい要素が同じタイプの場合、DOMノードを保持し、新しいpropsで更新するだけでOK
        if (sameType) {
            // ノードの更新処理
            newFiber = {
                type: oldFiber.type,
                props: element.props,
                dom: oldFiber.dom,
                parent: wipFiber,
                alternate: oldFiber,
                effectTag: "UPDATE",
            }
        }

        // タイプが異なり、新しい要素がある場合は、新しいDOMノードを作成する必要があることを意味する
        if (element && !sameType) {
            // ノードの追加処理
            newFiber = {
                type: element.type,
                props: element.props,
                dom: null,
                parent: wipFiber,
                alternate: null,
                effectTag: "PLACEMENT",
            }
        }

        // タイプが異なり、古いファイバーがある場合は、古いノードを削除する
        if (oldFiber && !sameType) {
            // 古いファイバーノードの削除処理
            oldFiber.effectTag = "DELETION"
            deletions.push(oldFiber)
        }

        if (oldFiber) {
            oldFiber = oldFiber.sibling
        }

        if (index === 0) {
            wipFiber.child = newFiber
        } else {
            prevSibling.sibling = newFiber
        }

        prevSibling = newFiber
        index++
    }
}

export {
    createElement,
    render
}

そして index.jsも今回の機能追加に合わせて、書き換えた。 inputフォームの値を反映するようなサンプルになっている。
(これも参照元のドキュメントの写経である)

// index.js
import { createElement, render } from "./Didact";

const container = document.getElementById("root")

const updateValue = e => {
  rerender(e.target.value)
}

const rerender = value => {
  /** @jsxRuntime classic */
  /** @jsx createElement */
  const element = (
    <div>
      <input onInput={updateValue} value={value} />
      <h2>Hello {value}</h2>
    </div>
  )
  render(element, container)
}

rerender("World")

差分検出実装のポイント

なお、↓の処理ではReactは key も使用するため、差分検出の効率が向上しているらしい。 たとえば、子要素が要素配列内の位置を変更したことを検出するなど。
ただ、Didactではkeyの実装は行わないので、下記のようになっている。

const sameType = oldFiber && element && element.type === oldFiber.type

ファイバーの effectTag 内の文字列を参照して処理を行うようになった。

  • PLACEMENT がある場合は、前と同じように、DOMノードを親ファイバーのノードに追加
  • DELETION の場合は、逆の操作を行い、子要素を削除
  • UPDATEの場合は、変更されたpropsで既存のDOMノードを更新する

また、updateDom関数を作成し、そちらでDOMノードの更新作業は行うようにする。

また、isNewisGone という filter内で利用する関数も新たに追加。

さらにpropsの中でイベントリスナーは更新する必要がある。
そのため、 propson で始まるpropsは検知して、異なる方法で処理する必要がある。

ここの処理は updateDom 内で行われていて、実際のコードは下記のようになる。

    // 新しいイベントリスナーを追加
    Object.keys(nextProps)
        .filter(isEvent)
        .filter(isNew(prevProps, nextProps))
        .forEach(name => {
            const eventType = name
                .toLowerCase()
                .substring(2)
            dom.addEventListener(
                eventType,
                nextProps[name]
            )
        })

考えてみれば当たり前のことなのだが、関数を設定するのに EventTarget.addEventListener() を使用している。

developer.mozilla.org

日々のコーディングの中で当たり前のように関数をコンポーネントに設定しているが、実際にdomのイベントリスナーを設定する際にはこのような処理( addEventListener を使って設定するというような基本的な処理) が裏で動いているということに気づけて、親近感が湧いた。

学んだ感想

それにしても差分検出あたりまで来ると、結構複雑になってくると感じる。

一気に読もうとすると、なかなか疲れてくるが、とりあえず大枠だけ理解できたという感じ。

これを踏まえて1から書いていけば、より理解できそうだと思った。

当たり前のように日々Reactを使っているが、裏で当然のように行われているDOM操作を見るというのはやはり大切なんだろうなと思った。