Reactを自作しよう
この記事は Build your own React を翻訳したものです。
Reactを1から書き直していきます。 実際のReactコードのアーキテクチャに従いますが、最適化機能と必須ではない機能は今回は実装しません。
-
Step 1:
createElement
関数 -
Step 2:
render
関数 - Step 3: 並列モード
- Step 4: ファイバー
- Step 5: Render Phase と Commit Phase
- Step 6: 差分検出
- Step 7: 関数コンポーネント
- Step 8: Hooks
Step 0 復習
最初にいくつかの基本的な概念を確認しましょう。
React、JSX、およびDOM要素がどのように機能するかをすでに理解している場合は、この章はスキップしても構いません。
今回は、次のわずか3行のコードをReactアプリの例として使用します。
const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
最初の行ではReact要素を定義します。
次の行ではDOMからノードを取得します。
最後の行では、React要素をコンテナにレンダリングします。
React固有のコードをすべて削除して、生のJavaScriptに置き換えましょう。
最初の行には、JSXで定義された要素があります。
有効なJavaScriptでないため、バニラJSに置き換えるには、まず有効なJSに置き換える必要があります。
JSXは、BabelなどのビルドツールによってJSに変換されます。
変換は基本的に簡単です。タグ内のコードをcreateElement
関数の呼び出しに置き換え、タグ名、props、子をパラメーターとして渡します。
const element = React.createElement(
"h1",
{ title: "foo" },
"Hello"
)
React.createElement
は、引数からオブジェクトを作成します。
作成の際に、引数の検証が行われていますが、メインの処理はオブジェクトの作成だけです。
したがって、関数呼び出しをその結果に置き換えてしまっても問題ありません。
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello",
},
}
そして、これが要素であり、type
とprops
の2つのプロパティを持つオブジェクトです。(本当はもっとありますが、今回はこれら2つだけを考えます)
type
は、作成するDOMノードのタイプを指定する文字列です。これは、HTML要素を作成するときにdocument.createElement
に渡すtagName
です。 関数にすることもできますが、それはStep7で行います。
props
は別のオブジェクトであり、JSX属性のすべてのキーと値を持っています。 また、children
という特別なプロパティもあります。
今回のchildren
は文字列ですが、通常はより多くの要素を含む配列です。 そのため、要素も木構造です。
もう1つ置き換える必要のあるReactコードがあります。ReactDOM.render
の呼び出しです。
ReactDOM.render(element, container)
レンダリングはReactがDOMを変更する場所です。 生のJSで置き換えるためには、DOM APIを使って自分で更新を行う必要があります。
const node = document.createElement(element.type)
node["title"] = element.props.title
まず、element
のtype
(この場合はh1
)を使用してノードを作成します。
次に、element
のすべてのprops
をそのノードに割り当てます。 これは単なるタイトルです。
*混乱を避けるために、今後、要素(element)と言ったときはReact要素を参照し、ノードと言った時は実際のDOM要素を参照します。
次に、children
のノードを作成します。 今回は文字列しかないため、テキストノードを作成します。
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
innerText
を設定する代わりにtextNode
を使用すると、後ですべての要素を同じように扱うことができます。
h1
のtitle
で行ったようにnodeValue
を設定する方法にも注意してください。これは、文字列にprops: {nodeValue: "hello"}
があるかのようです。
const container = document.getElementById("root")
node.appendChild(text)
container.appendChild(node)
最後に、textNode
をh1
に追加し、h1
をcontainer
に追加します。
これで、Reactを使わずにReactアプリと同じことができました。
createElement
関数
Step 1 今回のサンプルコードは次のようになっています。
const element = (
<div id="foo">
<a>bar</a>
<b/>
</div>
)
const container = document.getElementById("root")
ReactDOM.render(element, container)
今回は、このReactコードを自作Reactのコードで置き換えます。
まず、独自のcreateElement
を作成します。
JSX
をJS
に変換して、createElement
の呼び出しを確認しましょう。
const element = React.createElement(
"div",
{ id: "foo" },
React.createElement("a", null, "bar"),
React.createElement("b")
)
前のステップで見たように、element
はtype
とprops
を持つオブジェクトです。
createElement
の役割は、そのオブジェクトを作成することだけです。
props
にはスプレッド演算子を使用し、children
にはRestパラメータを使用します。これにより、children
のprops
は常に配列になります。
function createElement(type, props, ...children) { // ...children => Restパラメータ
return {
type,
props: {
...props, // ...props => spread演算子
children,
},
}
}
実行例を以下に上げます。
> createElement("div")
{
"type": "div",
"props": { "children": [] }
}
> createElement("div", null, a)
{
"type": "div",
"props": { "children": [a] }
}
> createElement("div", null, a, b)
{
"type": "div",
"props": { "children": [a, b] }
}
children
の配列には、文字列や数値などのプリミティブ値を含めることもできます。
そのため、オブジェクトではないものはすべて独自の要素内にラップし、それらを表す特別なタイプTEXT_ELEMENT
を作成します。
Reactは、children
がない場合にプリミティブ値をラップしたり、空の配列を作成したりしませんが、今回はコードを単純化するためにこれを行います。(ここでは、パフォーマンスの高いコードよりも単純なコードを優先するようにしています。)
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object"
? child
: createTextElement(child)
),
},
}
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
}
}
まだReactのcreateElement
を使用している部分があります。
const element = React.createElement(
"div",
{ id: "foo" },
React.createElement("a", null, "bar"),
React.createElement("b")
)
置き換えるために、ライブラリ(自作Reactのこと)に名前を付けましょう。 Reactのように聞こえるだけでなく、あくまで教育的な目的を示唆する名前を意図してDidact
と名前を付けました。
const Didact = {
createElement,
}
const element = Didact.createElement(
"div",
{ id: "foo" },
Didact.createElement("a", null, "bar"),
Didact.createElement("b")
)
ただし、ここではJSXを使用したいと思います。 Reactの代わりにDidact
のcreateElement
を使用するようにbabelに指示するにはどうすればよいでしょうか?
このようなコメントがある場合、babelがJSXをトランスパイルすると、定義した関数が使用されます。
/** @jsx Didact.createElement */
const element = (
<div id="foo">
<a>bar</a>
<b/>
</div>
)
Render
関数
Step 2 次に自作するのはReactDOM.render
関数(に相当する関数)です。
ReactDOM.render(element, container)
今の段階ではDOMに何かを追加することだけを考えます。
更新と削除は後のステップで実装します。
function render(element, container) {
// TODO create dom nodes
}
const Didact = {
createElement,
render,
}
Didact.render(element, container)
element.type
を使用してDOMノードを作成して、その後、作成したノードをcontainer
に追加します。
function render(element, container) {
const dom = document.createElement(element.type)
container.appendChild(dom)
}
それぞれの子要素に対して同じことを再帰的に行います。
function render(element, container) {
const dom = document.createElement(element.type)
element.props.children.forEach(child =>
render(child, dom)
)
container.appendChild(dom)
}
テキスト要素も処理する必要があります。element.type
がTEXT_ELEMENTの場合、通常のノードではなくテキストノードを作成します。
function render(element, container) {
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type)
// ...
}
最後に、element.props
をノードに割り当てる必要があります。
function render(element, container) {
// ...
const isProperty = key => key !== "children"
Object.keys(element.props)
.filter(isProperty)
.forEach(name => {
dom[name] = element.props[name]
})
// ...
}
以上です。 これでJSXをDOMにレンダリングできるライブラリができました。
ここまでの内容をぜひ codesandbox で試してみてください。
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object"
? child
: createTextElement(child)
),
},
}
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
}
}
function render(element, container) {
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type)
const isProperty = key => key !== "children"
Object.keys(element.props)
.filter(isProperty)
.forEach(name => {
dom[name] = element.props[name]
})
element.props.children.forEach(child =>
render(child, dom)
)
container.appendChild(dom)
}
const Didact = {
createElement,
render,
}
/** @jsx Didact.createElement */
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
)
const container = document.getElementById("root")
Didact.render(element, container)
Step 3 並列モード
ここまで順調に進んできましたが、これ以上コードを追加する前に、リファクタリングが必要です。
この再帰呼び出しには問題があります。
レンダリングを開始すると、1つの要素ツリーを完全にレンダリングするまで停止しません。
要素ツリーが大きい場合、メインスレッドを長時間ブロックする可能性があります。
また、ブラウザがユーザー入力の処理やアニメーションのスムーズな維持などの優先度の高い処理を実行する必要がある場合は、レンダリングが終了するまでそれらの処理を行うことができずユーザー体験を損なうことになります。
function render(element, container) {
// ...
element.props.children.forEach(child =>
render(child, dom)
)
// ...
}
そのため、作業を小さなユニット(作業単位)に分割し、各作業単位の処理が終了した後、他に実行すべきことがあれば、ブラウザにレンダリングを中断させます。
let nextUnitOfWork = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
function performUnitOfWork(nextUnitOfWork) {
// TODO
}
requestIdleCallback
を使用してループを作成します。
requestIdleCallback
はsetTimeout
と考えることができますが、いつ実行するかを指示する代わりに、メインスレッドがアイドル状態のときにブラウザがコールバックを実行します。
ReactはrequestIdleCallback
を使用しなくなりました。 現在はスケジューラパッケージを使用しています。 ただし、このユースケースでは、概念的には同じです。
function workLoop(deadline) {
// ...
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
requestIdleCallback
は、タイムアウトパラメータも提供します。 これを使用して、ブラウザに再び制御を戻すまでどれくらい猶予があるのかをコールバックから確認できます。
workLoop
の使用を開始するには、最初の作業単位を設定してから、作業を実行するだけでなく、次の作業単位を返すperformUnitOfWork
関数を作成する必要があります。
最初の作業単位の設定とperformUnitOfWork
関数の作成は次のステップで行います。
Step 4 ファイバー
作業単位を整理するには、ファイバーツリーというデータ構造が必要です。
ファイバーは、通常、非常に軽量な実行スレッドを表しますが、Reactのファイバーは更新処理に優先度を付けられるように設定された作業単位のことです。
element
ごとに1本のファイバーがあり、上で述べたように各ファイバーが作業単位になります。
サンプルコードを使った例を挙げましょう。
次のような要素ツリーをレンダリングするとします。
Didact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
render
では、ルートファイバーを作成し、それをnextUnitOfWork
として設定します。
残りの作業はperformUnitOfWork
関数で行われ、ファイバーごとに3つのことを行います。
- 要素をDOMに追加する
- 子要素のためのファイバーを作成
- 次の作業単位を選択
このデータ構造の目標の1つは、次の作業単位つまり次に作業を行う要素を簡単に見つけられるようにすることです。
そのため、各ファイバーには、最初の子、次の兄弟、および親へのリンクがあります。
また、ファイバーに子も兄弟もいない場合は、「おじ」、つまり親の兄弟に移動します。上の例ではaファイバー -> h2ファイバー
が該当します。
また、親に兄弟がいない場合は、兄弟がいる人が見つかるまで、またはルートに到達するまで、親を調べ続けます。
ルートに到達した場合は、このrender
のすべての作業の実行が終了したことを意味します。
ここまで説明したことをコードに落とし込んでいきましょう。
まず、render
関数からcreateDOM
関数に一部のコードを移動しましょう。
function createDom(fiber) {
const dom =
fiber.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type)
const isProperty = key => key !== "children"
Object.keys(fiber.props)
.filter(isProperty)
.forEach(name => {
dom[name] = fiber.props[name]
})
return dom
}
function render(element, container) {
// TODO set next unit of work
}
let nextUnitOfWork = null
render
関数で、nextUnitOfWork
をファイバーツリーのルートに設定します。
function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
}
}
let nextUnitOfWork = null
次に、ブラウザの準備ができると、workLoop
が呼び出され、ルートでの作業が開始されます。
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
function performUnitOfWork(fiber) {
// TODO add dom node
// TODO create new fibers
// TODO return next unit of work
}
さて、performUnitOfWork
の肉付けを行っていきましょう。
まず、新しいノードを作成し、それをDOMに追加します。
fiber.dom
プロパティでDOMノードを追跡します。
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
// TODO create new fibers
// TODO return next unit of work
}
次に、子要素ごとに新しいファイバーを作成します。
function performUnitOfWork(fiber) {
// ...
const elements = fiber.props.children
let index = 0
let prevSibling = null
while (index < elements.length) {
const element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
}
}
// TODO return next unit of work
}
そして、それが最初の子要素であるかどうかに応じて、子または兄弟として設定するファイバーツリーに追加します。
function performUnitOfWork(fiber) {
// ...
const elements = fiber.props.children
let index = 0
let prevSibling = null
while (index < elements.length) {
// ...
if (index === 0) {
fiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
// TODO return next unit of work
}
最後に、次の作業単位を検索します。 最初に子要素、次に兄弟、次におじというように試します。
function performUnitOfWork(fiber) {
// ...
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
これでperformUnitOfWork
関数の完成です!
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
const elements = fiber.props.children
let index = 0
let prevSibling = null
while (index < elements.length) {
const element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
}
if (index === 0) {
fiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
Step 5 Render Phase と Commit Phase
Render Phase と Commit Phase についてあまり馴染みがない人もいるでしょう。
ページをレンダリングするとき(通常はthis.setState
を呼び出すことによって発生します)、ReactはWebページを更新するために2段階に分けて処理を実行します。
最初のフェーズは、"Render Phase"(レンダリングフェーズ)と呼ばれます。
レンダリングフェーズでは、Reactは仮想DOMを作成しています。つまり、実際にページを変更することなく、ページがどのように表示されるかを決定します。
Reactは最上位のコンポーネントでRenderを呼び出し、それが何を返したかを調べ、子要素ごとにRenderを呼び出します。これはページ全体がどのように表示されるかがわかるまで再帰的に行われます。
2番目のフェーズは、"Commit Phase"(コミットフェーズ)と呼ばれます。
ページがどのように表示されるかがわかったので、仮想DOMと一致するように実際のDOMを更新する必要があります。
そのため、レンダリングフェーズから取得した最新の仮想DOMを、最後にレンダリングしたときに取得した仮想DOMと比較し、ページを最新の状態にするために行うべき更新処理を(最小限になるように)計算します。
これが Render Phase と Commit Phase です。
さて続きに戻りましょう。
step5では Render Phase と Commit Phase の実装を必要とするような問題があります。
要素で作業するたびに、DOMに新しいノードを追加しています。
また、ツリー全体のレンダリングが完了する前に、ブラウザが作業を中断する可能性があることを忘れないでください。
その場合、ユーザーには不完全なUIが表示されます。それは望ましいことではありません。
したがって、ここからDOMを変更する部分を削除する必要があります。
// performUnitOfWork からこの部分を削除
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
代わりに、ファイバーツリーのルートを追跡します。 これをprogress root
またはwipRoot
と呼びます。
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
}
nextUnitOfWork = wipRoot
}
let wipRoot = null
そして次の作業単位がnull
、つまりすべての作業が終了したら、ファイバーツリー全体をDOMにコミットします。
function commitRoot() {
// TODO add nodes to dom
}
function workLoop(deadline) {
// ...
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
コミット作業はcommitRoot
関数で行います。 ここでは、すべてのノードをDOMに再帰的に追加します。
function commitRoot() {
commitWork(wipRoot.child)
wipRoot = null
}
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
Step 6 差分検出
これまではDOMにノードを追加しただけですが、ノードの更新や削除についてはどうでしょうか。
これがこのステップでやろうとしていることです。
render
関数で受け取ったelement
を、DOMにコミットした最後のファイバーツリーと比較する必要があります。
したがって、コミットが完了したら、その"DOMにコミットした最後のファイバーツリー"への参照を保存する必要があります。 これをcurrentRoot
と呼びます。
また、すべてのファイバーにalternate
プロパティを追加します。 このプロパティは、前のコミットフェーズでDOMにコミットした古いファイバーへのリンクです。
function commitRoot() {
commitWork(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
}
let currentRoot = null
それでは、以前作成した新しいファイバーを作成するperformUnitOfWork
から子要素のファイバーを作成する部分のコードを、新たに作成した関数reconcileChildren
に移動しましょう。
function 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
}
}
function reconcileChildren(wipFiber, elements) {
let index = 0
let prevSibling = null
while (index < elements.length) {
const element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
parent: wipFiber,
dom: null,
}
if (index === 0) {
wipFiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
}
ここでは、古いファイバーと新しい要素を比較します。
function 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
// TODO compare oldFiber to element
if (oldFiber) {
oldFiber = oldFiber.sibling
}
古いファイバーの子要素(wipFiber.alternate
)と、調整する要素の配列を同時に繰り返し処理します。
配列(elements
)とリンクリスト(oldFiber
はsibling
を通したリンクリストになっています)を同時にループ処理するために必要なコードをすべて無視すると、oldFiber
とelement
というこの処理で最も重要なものが残ります。
element
はDOMにレンダリングしたいものであり、oldFiber
は前回レンダリングしたものです。
それらを比較して、DOMに適用すべき変更があるかどうかを確認する必要があります。
function 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 (sameType) {
// TODO ノードの更新
}
if (element && !sameType) {
// TODO ノードの追加
}
if (oldFiber && !sameType) {
// TODO 古いファイバーノードを削除
}
if (oldFiber) {
oldFiber = oldFiber.sibling
}
比較した結果は次の3つのどれかになります。
- 古いファイバーと新しい要素が同じタイプの場合、DOMノードを保持し、新しい
props
で更新するだけです。 - タイプが異なり、新しい要素がある場合は、新しいDOMノードを作成する必要があることを意味します
- タイプが異なり、古いファイバーがある場合は、古いノードを削除する必要があります
ここでReactはkey
も使用するため、差分検出の効率が向上します。 たとえば、子要素が要素配列内の位置を変更したことを検出します。Didact
ではkey
の実装を行いません。
古いファイバーとelement
が同じタイプの場合、DOMノードを古いファイバーからprops
をelement
から保持するように、新しいファイバーを作成します。
また、ファイバーに新しいプロパティeffectTag
を追加します。 このプロパティは、後でコミットのときに使用します。
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
}
}
次に、要素に新しいDOMノードが必要な場合は、新しいファイバーにPLACEMENT
をタグ付けします。
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
}
}
また、ノードを削除する必要がある場合は、新しいファイバーがないため、古いファイバーにエフェクトタグを追加します。
ただし、ファイバーツリーをDOMにコミットするときは、古いファイバーがない進行中のprogress rootからコミットします。
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION"
deletions.push(oldFiber)
}
したがって、削除するノードを追跡するための配列が必要です。
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
deletions = []
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
let deletions = null
そして、DOMに変更をコミットするときは、配列deletions
のファイバーも使用します。
それでは、新しく追加したeffectTag
を処理するようにcommitWork
関数を変更しましょう。
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
ファイバーのeffectTag
にPLACEMENT
がある場合は、前と同じように、DOMノードを親ファイバーのノードに追加します。
DELETION
の場合は、逆の操作を行い、子要素を削除します。
また、それがUPDATE
の場合は、変更されたprops
で既存のDOMノードを更新する必要があります。
function 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)
}
DOMノードの更新作業はupdateDom
関数で行います。
function updateDom(dom, prevProps, nextProps) {
// TODO
}
古いファイバーのprops
を新しいファイバーのprops
と比較し、なくなったprops
を削除して、新しいまたは変更されたprops
を設定します。
const isProperty = key => key !== "children"
const isNew = (prev, next) => key => prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
// Remove old properties
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom[name] = ""
})
// Set new or changed properties
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom[name] = nextProps[name]
})
}
更新する必要がある特別な種類のprops
の1つはイベントリスナーです。
したがって、props
名がon
で始まる場合は、それらを異なる方法で処理します。
const isEvent = key => key.startsWith("on")
const isProperty = key => key !== "children" && !isEvent(key)
イベントハンドラが変更された場合は、ノードから削除します。
//Remove old or changed 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]
)
})
そして新しいハンドラを追加します。
// Add event listeners
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name
.toLowerCase()
.substring(2)
dom.addEventListener(
eventType,
nextProps[name]
)
})
codesandboxで 差分検出処理 を実装したバージョンを試してください。
Step 7 関数コンポーネント
次に実装する必要があるのは、関数コンポーネントのサポートです。
まず、サンプルとして使うコードを変更しましょう。 h1
要素を返すこの単純な関数コンポーネントを使用します。
/** @jsx Didact.createElement */
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
const container = document.getElementById("root")
Didact.render(element, container)
jsxをjsに変換すると、次のようになることに注意してください。
function App(props) {
return Didact.createElement(
"h1",
null,
"Hi ",
props.name
)
}
const element = Didact.createElement(App, {
name: "foo",
})
const container = document.getElementById("root")
Didact.render(element, container)
関数コンポーネントは、次の2つの点でクラスコンポーネントと異なっています。
- 関数コンポーネントからのファイバーにはDOMノードがありません
- 子要素は、
props
から直接取得するのではなく、関数を実行することから来ます
performUnitOfWork
では、ファイバーの型が関数であるかどうかを確認し、それに応じて別の更新関数に処理を移します。
以前と同じ処理は、updateHostComponent
で実行するようにし、関数コンポーネントの場合は、updateFunctionComponent
で処理を実行します。
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function
if (isFunctionComponent) {
updateFunctionComponent(fiber)
} else {
updateHostComponent(fiber)
}
// ...
}
function updateFunctionComponent(fiber) {
// TODO
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
reconcileChildren(fiber, fiber.props.children)
}
updateFunctionComponent
では、関数を実行して子を取得します。
この例では、fiber.type
はApp
関数であり、実行するとh1
要素を返します。
その後、子要素ができたら、差分検出機能は同じように機能します。差分検出の部分は何も変更する必要はありません。
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
変更する必要があるのはcommitWork
関数です。
DOMノードのないファイバーができたので、2つ変更すべき箇所があります。
まず、DOMノードの親を見つけるには、DOMノードを持つファイバーが見つかるまでファイバーツリーを上に移動する必要があります。
そして、ノードを削除するときは、DOMノードを持つ子が見つかるまで続行する必要もあります。
function 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)
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
Step 8 Hooks
最後のステップです。
関数コンポーネントができたので、stateも追加しましょう。
サンプルコードをhooksの例としてありがちな、カウンターを持ったコンポーネントに変更しましょう。 クリックするたびに、stateのカウンター値が1つ増えます。
Didact.useState
を使用して、カウンター値を取得および更新していることに注意してください。
const Didact = {
createElement,
render,
useState,
}
/** @jsx Didact.createElement */
function Counter() {
const [state, setState] = Didact.useState(1)
return (
<h1 onClick={() => setState(c => c + 1)}>
Count: {state}
</h1>
)
}
const element = <Counter />
const container = document.getElementById("root")
Didact.render(element, container)
ここでは、例で紹介した関数コンポーネントのCounter
関数を呼び出します。 そして、その関数内でuseState
を呼び出します。
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
function useState(initial) {
// TODO
}
useState
関数内で使用できるように、関数コンポーネントを呼び出す前にいくつかのグローバル変数を初期化する必要があります。
まず、作業中のファイバーを格納する変数wipFiber
を設定します。
また、同じコンポーネントでuseStateを複数回呼び出すことをサポートするために、hooksの配列(wipFiber.hooks
)をファイバーに追加します。 そして、現在のフックインデックス(hookIndex
)を追跡します。
let wipFiber = null
let hookIndex = null
function updateFunctionComponent(fiber) {
wipFiber = fiber
hookIndex = 0
wipFiber.hooks = []
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
関数コンポーネントがuseState
を呼び出すとき、古いフックがあるかどうかを確認します。 フックインデックスを使用して、wipFiber.alternate
をチェックします。
古いフックがある場合は、stateを古いフックから新しいフックにコピーします。そうでない場合は、stateを初期化します。
次に、新しいフックをファイバーに追加し、フックインデックスを1つインクリメントして、stateを返します。
function useState(initial) {
const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state]
}
useState
はstateを更新する関数も返す必要があるため、アクションを受け取るsetState
関数を定義します(カウンターの例では、このアクションはstateを1つインクリメントする関数です)。
そのアクションを、フックに追加したキューにPushします。
次に、render
関数で行ったのと同様のことを行い、作業ループが新しいレンダリングフェーズを開始できるように、新しいwipRoot
を次の作業単位として設定します。
function useState(initial) {
const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
}
const setState = action => {
hook.queue.push(action)
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
deletions = []
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state, setState]
}
しかし、まだアクションを実行していません。
次回コンポーネントをレンダリングするときにこれを行い、古いフックキューからすべてのアクションを取得し、それらを1つずつ新しいフックのstateに適用して、stateを返すときに更新されます。
function useState(initial) {
// ...
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
hook.state = action(hook.state)
})
// ...
}
これで自作Reactは完成です! ここまでお疲れ様でした!
ソースコードの全文は以下のようになっています。
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map((child) => (typeof child === "object" ? child : createTextElement(child))),
},
};
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
function createDom(fiber) {
const dom = fiber.type == "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom;
}
const isEvent = (key) => key.startsWith("on");
const isProperty = (key) => key !== "children" && !isEvent(key);
const isNew = (prev, next) => (key) => prev[key] !== next[key];
const isGone = (prev, next) => (key) => !(key in next);
function updateDom(dom, prevProps, nextProps) {
//Remove old or changed 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]);
});
// Remove old properties
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach((name) => {
dom[name] = "";
});
// Set new or changed properties
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
dom[name] = nextProps[name];
});
// Add event listeners
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[name]);
});
}
function commitRoot() {
deletions.forEach(commitWork);
commitWork(wipRoot.child);
currentRoot = wipRoot;
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) {
return;
}
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);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(fiber.child, domParent);
}
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
};
deletions = [];
nextUnitOfWork = wipRoot;
}
let nextUnitOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
function 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;
}
}
let wipFiber = null;
let hookIndex = null;
function updateFunctionComponent(fiber) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function 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.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}
function 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 (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
};
}
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 if (element) {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
const Didact = {
createElement,
render,
useState,
};
/** @jsx Didact.createElement */
function Counter() {
const [state, setState] = Didact.useState(1);
return <h1 onClick={() => setState((c) => c + 1)}>Count: {state}</h1>;
}
const element = <Counter />;
const container = document.getElementById("root");
Didact.render(element, container);
完成品を、codesandbox か Github で遊ぶことができます!
エピローグ
この記事の目標は、Reactがどう機能するかを理解してもらうことに加えて、React本体のコードベースをより深く掘り下げる手助けをすることです。
そのため、Reactのソースコードをみた時に違和感を感じないように、なるべくReactと同じ変数名と関数名を使用しました。
実際にReactアプリの関数コンポーネントの1つにブレークポイントを追加すると、コールスタックに次のようなものが見えるでしょう。
workLoop
performUnitOfWork
updateFunctionComponent
これらはDidact
で作ったAPIと全く同じ名前です!
しかし、今回作ったDidact
にはReactの機能や最適化手法で実装していないものがたくさんありました。
- Didactでは、レンダリングフェーズ中にツリー全体を歩いています。 代わりに、Reactはいくつかのヒントと経験則に従って、何も変更されていないサブツリー全体をスキップします。
- また、コミットフェーズではツリー全体を歩いています。 Reactは、変更点のあるファイバーのみを含むリンクリストを保持し、それらのファイバーのみにアクセスします。
- wipな作業ツリーを構築するたびに、ファイバーごとに新しいオブジェクトを作成します。 Reactは前の木からファイバーをリサイクルします。
- Didactは、レンダリングフェーズ中に新しい更新を検知すると、構築中の作業ツリーを破棄し、ルートから再開します。 Reactは、各更新に有効期限のタイムスタンプでタグを付け、それを使用して、どの更新の優先度が高いかを判断します。
他にも異なる点はありますがこれ以上は割愛します。
また、実装していない機能のうち、比較的簡単な機能もいくつかあります。例えば、
-
style
プロパティにオブジェクトを使用する - 子要素の配列をflatにする
useEffect
-
key
を用いた差分検出
などがあります。よかったら自分で実装してみましょう。
これで終わりです。
ここまで読んでくれてありがとうございます!
Discussion