Reactを自作するための学習の記録の続き
この続き
参照ドキュメント
現在ここに記述しているコードは、下記のドキュメントのほぼ写経となっています。
関数コンポーネントに対応する
現在の実装では関数コンポーネントに対応していない。
例えば下記のようなコードを書いた場合、ブラウザアクセス時にエラーが発生する。
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
となっています)