at backyard

Color my life with the chaos of trouble.

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

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

この続き

shinshin86.hateblo.jp

参照ドキュメント

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

pomb.us

zenn.dev

関数コンポーネントに対応する

現在の実装では関数コンポーネントに対応していない。

例えば下記のようなコードを書いた場合、ブラウザアクセス時にエラーが発生する。

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

/** @jsxRuntime classic */
/** @jsx createElement */
const App = props => (
  <div>
    <h1>Hi {props.name}</h1>
    <ul>
      <li>aaa</li>
      <li>bbb</li>
      <li>ccc</li>
    </ul>
  </div>
) 

const element = <App name="foo" />

const container = document.getElementById("root")

render(element, container)

エラーの内容はこんなやつ

Uncaught DOMException: Failed to execute 'createElement' on 'Document': The tag name provided ('props => Object(_Didact__WEBPACK_IMPORTED_MODULE_0__["createElement"])("div", {
  __self: undefined,
・
・
・

関数コンポーネントは、クラスコンポーネントとは下記のような異なる点があるため、現在の実装では正常に処理が行えない。

  • 関数コンポーネントからのファイバーにはDOMノードが存在しない
  • 子要素は、propsから直接取得するのではなく、関数を実行することから取得できる

そのため、 performUnitOfWork関数でファイバーの型が関数かどうかを確認して、処理を振り分けるようにする必要がある。

以前と同じ処理は、updateHostComponentで実行するようにし、関数コンポーネントの場合は、 updateFunctionComponent で処理を実行するようにコードを追加していくことにする。
(参照元のドキュメントに倣っています)

渡されたコンポーネントが関数だった場合、 updateFunctionComponent では関数を実行して子を取得するようにしていく。 子要素を取得できれば、差分検出の処理などは同じように機能するため、以後の処理に変更を加える必要はない。

ただし、commitWork関数については変更の必要がある。
関数コンポーネントの場合、DOMノードがないfiberであるため、DOMノードの親を見つける必要がある。

よって、下記のようなコードとなる。 (説明放棄)

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) => {
    // Remove or modify event listeners
    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]
            )
        })

    // Delete old properties
    Object.keys(prevProps)
        .filter(isProperty)
        .filter(isGone(prevProps, nextProps))
        .forEach(name => {
            dom[name] = ""
        })

    // Set a new or changed property
    Object.keys(nextProps)
        .filter(isProperty)
        .filter(isNew(prevProps, nextProps))
        .forEach(name => {
            dom[name] = nextProps[name]
        })

    // Add a new event listener
    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
    }

    // DOMノードを持つファイバーが見つかるまでファイバーツリーを上に移動
    let domParentFiber = fiber.parent
    while (!domParentFiber.dom) { domParentFiber = domParentFiber.parent }
    const domParent = domParentFiber.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") {
        commitDeletion(fiber, domParent) // ノードを削除するときは、DOMノードを持つ子が見つかるまで探索を続行
    }

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

const commitDeletion = (fiber, domParent) => {
    if (fiber.dom) {
        domParent.removeChild(fiber.dom)
    } else {
        commitDeletion(fiber.child, domParent)
    }
}

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 => {
    // 関数かどうかを確認
    const isFunctionComponent = fiber.type instanceof Function

    if (isFunctionComponent) {
        updateFunctionComponent(fiber)
    } else {
        updateHostComponent(fiber)
    }


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

    let nextFiber = fiber

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

        nextFiber = nextFiber.parent
    }
}

const updateFunctionComponent = fiber => {
    const children = [fiber.type(fiber.props)]

    reconcileChildren(fiber, children)
}

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

    reconcileChildren(fiber, fiber.props.children)
}

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

        // If the old fiber and the new element are of the same type, keep the DOM node and update it with the new props
        if (sameType) {
            // Update process for nodes
            newFiber = {
                type: oldFiber.type,
                props: element.props,
                dom: oldFiber.dom,
                parent: wipFiber,
                alternate: oldFiber,
                effectTag: "UPDATE",
            }
        }

        // If the type is different and there is a new element, a new DOM node needs to be created
        if (element && !sameType) {
            // Adding a node
            newFiber = {
                type: element.type,
                props: element.props,
                dom: null,
                parent: wipFiber,
                alternate: null,
                effectTag: "PLACEMENT",
            }
        }

        // If different types and old fibers are present, delete the old node
        if (oldFiber && !sameType) {
            // Deletion process of old fiber nodes
            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
}

useState(hooks)に対応させる

関数コンポーネントの対応の次は useStateにも対応させる。

クリックするたびに、stateのカウンター値が1つ増えるような、サンプルとしてよくある例にコードを変更する。

index.jsを下記のように変更して、これが動くようにDidact.jsを修正していく。

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

/** @jsxRuntime classic */
/** @jsx createElement */
const App = () => {
  const [ count, setCount ] = useState(1)

  return (
    <h1 onClick={() => setCount(count => count + 1)}>
      Count: {count}
    </h1>
  )
}

const element = <App name="foo" />

const container = document.getElementById("root")

render(element, container)

関数コンポーネントuseState を呼び出すとき、既に古いフックがあるかどうかを確認し、 hookIndexを用いてwipFiber.alternate をチェックするようにする。
もし古いフックがある場合は、stateを古いフックから新しいフックにコピーする。そうでない場合は初期化。

新しいフックをファイバーに追加する際は、 hookIndexを1つインクリメントしてstateを返すようにする。

また useState はstateを更新する関数を返す必要があるため(ここでは setCount 関数)、アクションを受け取る setState関数を定義する。

そのアクションをフックに追加したキューにpushする。

そして、作業のループが新しいレンダリングフェーズを開始できるように、新しい wipRootを次の作業単位として設定する。

ここらへんのコードは下記のように実装。
(何度もしつこいですが、参照元の写経です)

const useState = initial => {
    const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
    const hook = {
        state: oldHook ? oldHook.state : initial,
        queue: []
    }

    const actions = oldHook ? oldHook.queue : [];
    actions.forEach((action) => {
        hook.state = action(hook.state);
    });

    const setState = action => {
        hook.queue.push(action)
        wipRoot = {
            dom: currentRoot.dom,
            props: currentRoot.props,
            alternate: currentRoot,
          }
        nextUnitOfWork = wipRoot
        deletions = []
    }


    wipFiber.hook.push(hook)
    hookIndex++
    return [hook.state, setState]
}

・
・
・

export {
    createElement,
    render,
    useState
}

最後に

ひとまずドキュメントを写経しながらという形でReact自作に向けた学習を進めてきた。

が、一旦この学習はここで中断する。

他にやりたいタスクが残っているのと、Reactを1から自作するというのはなかなか時間がかかりそうだということがわかり、それに対して今は時間を確保できなそうだからだ。

あと、Fiber周りの仕組みなどがまだまだ理解が追いついていないので、ここについてはもう少し復習しても良さそうに思える。

実際のReactのコードも参照しつつ進めていたが、React本家のコードを細かく追っていくのもなかなかに骨が折れる作業だということも分かった。

ただ、Reactがどういう仕組で実際にHTMLを生成していくのか、差分検出はどのような考え方で実行されているのか?など今までほぼほぼブラックボックスとなっていた中身が想像できるようになったのは、今後もReactを触っていく上では大きいことのように思う。

また時間を見つけてReact自作に向けた学習は進めていく予定。

なお、ここまでのコードは下記に載せている。
後々またこのリポジトリにコードを追加していくかもしれないので、コミットログと併せてご参照ください。
(この時点での最新のコードはコミットハッシュが 23bc60265d2a1813f1751708536d4b4d9298eecc となっています)

github.com