Reactを自作するための学習の記録の続き
この続き
参照ドキュメント
現在ここに記述しているコードは、下記のドキュメントのほぼ写経となっています。
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) }
commitRoot
、 commitWork
関数をそれぞれ加える。
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) }
ちなみに上でコンソールログを出しているが、こんな感じのものが処理が行われるたびに表示される。
差分検出を実装する
上の処理までで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ノードの更新作業は行うようにする。
また、isNew
と isGone
という filter
内で利用する関数も新たに追加。
さらにpropsの中でイベントリスナーは更新する必要がある。
そのため、 props
が on
で始まる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()
を使用している。
日々のコーディングの中で当たり前のように関数をコンポーネントに設定しているが、実際にdomのイベントリスナーを設定する際にはこのような処理( addEventListener
を使って設定するというような基本的な処理) が裏で動いているということに気づけて、親近感が湧いた。
学んだ感想
それにしても差分検出あたりまで来ると、結構複雑になってくると感じる。
一気に読もうとすると、なかなか疲れてくるが、とりあえず大枠だけ理解できたという感じ。
これを踏まえて1から書いていけば、より理解できそうだと思った。
当たり前のように日々Reactを使っているが、裏で当然のように行われているDOM操作を見るというのはやはり大切なんだろうなと思った。