この記事はBuild your own React を翻訳したものです。
Reactを1から書き直していきます。 実際のReactコードのアーキテクチャに従いますが、最適化機能と必須ではない機能は今回は実装しません。
createElement
関数render
関数最初にいくつかの基本的な概念を確認しましょう。
React、JSX、およびDOM要素がどのように機能するかをすでに理解している場合は、この章はスキップしても構いません。
今回は、次のわずか3行のコードをReactアプリの例として使用します。
const element=<h1title="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
関数今回のサンプルコードは次のようになっています。
const element=(<divid="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
は常に配列になります。
functioncreateElement(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
がない場合にプリミティブ値をラップしたり、空の配列を作成したりしませんが、今回はコードを単純化するためにこれを行います。(ここでは、パフォーマンスの高いコードよりも単純なコードを優先するようにしています。)
functioncreateElement(type, props,...children){return{ type,props:{...props,children: children.map(child=>typeof child==="object"? child:createTextElement(child)),},}}functioncreateTextElement(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
と名前を付けました。
constDidact={ 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
関数次に自作するのはReactDOM.render
関数(に相当する関数)です。
ReactDOM.render(element, container)
今の段階ではDOMに何かを追加することだけを考えます。
更新と削除は後のステップで実装します。
functionrender(element, container){// TODO create dom nodes}constDidact={ createElement, render,}Didact.render(element, container)
element.type
を使用してDOMノードを作成して、その後、作成したノードをcontainer
に追加します。
functionrender(element, container){const dom=document.createElement(element.type) container.appendChild(dom)}
それぞれの子要素に対して同じことを再帰的に行います。
functionrender(element, container){const dom=document.createElement(element.type) element.props.children.forEach(child=>render(child, dom)) container.appendChild(dom)}
テキスト要素も処理する必要があります。element.type
がTEXT_ELEMENTの場合、通常のノードではなくテキストノードを作成します。
functionrender(element, container){const dom= element.type=="TEXT_ELEMENT"?document.createTextNode(""):document.createElement(element.type)// ...}
最後に、element.props
をノードに割り当てる必要があります。
functionrender(element, container){// ...constisProperty=key=> key!=="children"Object.keys(element.props).filter(isProperty).forEach(name=>{ dom[name]= element.props[name]})// ...}
以上です。 これでJSXをDOMにレンダリングできるライブラリができました。
ここまでの内容をぜひcodesandbox で試してみてください。
functioncreateElement(type, props,...children){return{ type,props:{...props,children: children.map(child=>typeof child==="object"? child:createTextElement(child)),},}}functioncreateTextElement(text){return{type:"TEXT_ELEMENT",props:{nodeValue: text,children:[],},}}functionrender(element, container){const dom= element.type=="TEXT_ELEMENT"?document.createTextNode(""):document.createElement(element.type)constisProperty=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)}constDidact={ createElement, render,}/**@jsx Didact.createElement */const element=(<div id="foo"><a>bar</a><b/></div>)const container=document.getElementById("root")Didact.render(element, container)
ここまで順調に進んできましたが、これ以上コードを追加する前に、リファクタリングが必要です。
この再帰呼び出しには問題があります。
レンダリングを開始すると、1つの要素ツリーを完全にレンダリングするまで停止しません。
要素ツリーが大きい場合、メインスレッドを長時間ブロックする可能性があります。
また、ブラウザがユーザー入力の処理やアニメーションのスムーズな維持などの優先度の高い処理を実行する必要がある場合は、レンダリングが終了するまでそれらの処理を行うことができずユーザー体験を損なうことになります。
functionrender(element, container){// ... element.props.children.forEach(child=>render(child, dom))// ...}
そのため、作業を小さなユニット(作業単位)に分割し、各作業単位の処理が終了した後、他に実行すべきことがあれば、ブラウザにレンダリングを中断させます。
let nextUnitOfWork=nullfunctionworkLoop(deadline){let shouldYield=falsewhile(nextUnitOfWork&&!shouldYield){ nextUnitOfWork=performUnitOfWork( nextUnitOfWork) shouldYield= deadline.timeRemaining()<1}requestIdleCallback(workLoop)}requestIdleCallback(workLoop)functionperformUnitOfWork(nextUnitOfWork){// TODO}
requestIdleCallback
を使用してループを作成します。
requestIdleCallback
はsetTimeout
と考えることができますが、いつ実行するかを指示する代わりに、メインスレッドがアイドル状態のときにブラウザがコールバックを実行します。
ReactはrequestIdleCallback
を使用しなくなりました。 現在はスケジューラパッケージを使用しています。 ただし、このユースケースでは、概念的には同じです。
functionworkLoop(deadline){// ...requestIdleCallback(workLoop)}requestIdleCallback(workLoop)
requestIdleCallback
は、タイムアウトパラメータも提供します。 これを使用して、ブラウザに再び制御を戻すまでどれくらい猶予があるのかをコールバックから確認できます。
workLoop
の使用を開始するには、最初の作業単位を設定してから、作業を実行するだけでなく、次の作業単位を返すperformUnitOfWork
関数を作成する必要があります。
最初の作業単位の設定とperformUnitOfWork
関数の作成は次のステップで行います。
作業単位を整理するには、ファイバーツリーというデータ構造が必要です。
ファイバーは、通常、非常に軽量な実行スレッドを表しますが、Reactのファイバーは更新処理に優先度を付けられるように設定された作業単位のことです。
element
ごとに1本のファイバーがあり、上で述べたように各ファイバーが作業単位になります。
サンプルコードを使った例を挙げましょう。
次のような要素ツリーをレンダリングするとします。
Didact.render(<div><h1><p/><a/></h1><h2/></div>, container)
render
では、ルートファイバーを作成し、それをnextUnitOfWork
として設定します。
残りの作業はperformUnitOfWork
関数で行われ、ファイバーごとに3つのことを行います。
このデータ構造の目標の1つは、次の作業単位つまり次に作業を行う要素を簡単に見つけられるようにすることです。
そのため、各ファイバーには、最初の子、次の兄弟、および親へのリンクがあります。
また、ファイバーに子も兄弟もいない場合は、「おじ」、つまり親の兄弟に移動します。上の例ではaファイバー -> h2ファイバー
が該当します。
また、親に兄弟がいない場合は、兄弟がいる人が見つかるまで、またはルートに到達するまで、親を調べ続けます。
ルートに到達した場合は、このrender
のすべての作業の実行が終了したことを意味します。
ここまで説明したことをコードに落とし込んでいきましょう。
まず、render
関数からcreateDOM
関数に一部のコードを移動しましょう。
functioncreateDom(fiber){const dom= fiber.type=="TEXT_ELEMENT"?document.createTextNode(""):document.createElement(fiber.type)constisProperty=key=> key!=="children"Object.keys(fiber.props).filter(isProperty).forEach(name=>{ dom[name]= fiber.props[name]})return dom}functionrender(element, container){// TODO set next unit of work}let nextUnitOfWork=null
render
関数で、nextUnitOfWork
をファイバーツリーのルートに設定します。
functionrender(element, container){ nextUnitOfWork={dom: container,props:{children:[element],},}}let nextUnitOfWork=null
次に、ブラウザの準備ができると、workLoop
が呼び出され、ルートでの作業が開始されます。
functionworkLoop(deadline){let shouldYield=falsewhile(nextUnitOfWork&&!shouldYield){ nextUnitOfWork=performUnitOfWork( nextUnitOfWork) shouldYield= deadline.timeRemaining()<1}requestIdleCallback(workLoop)}requestIdleCallback(workLoop)functionperformUnitOfWork(fiber){// TODO add dom node// TODO create new fibers// TODO return next unit of work}
さて、performUnitOfWork
の肉付けを行っていきましょう。
まず、新しいノードを作成し、それをDOMに追加します。
fiber.dom
プロパティでDOMノードを追跡します。
functionperformUnitOfWork(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}
次に、子要素ごとに新しいファイバーを作成します。
functionperformUnitOfWork(fiber){// ...const elements= fiber.props.childrenlet index=0let prevSibling=nullwhile(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}
そして、それが最初の子要素であるかどうかに応じて、子または兄弟として設定するファイバーツリーに追加します。
functionperformUnitOfWork(fiber){// ...const elements= fiber.props.childrenlet index=0let prevSibling=nullwhile(index< elements.length){// ...if(index===0){ fiber.child= newFiber}else{ prevSibling.sibling= newFiber} prevSibling= newFiber index++}// TODO return next unit of work}
最後に、次の作業単位を検索します。 最初に子要素、次に兄弟、次におじというように試します。
functionperformUnitOfWork(fiber){// ...if(fiber.child){return fiber.child } let nextFiber= fiber while(nextFiber){if(nextFiber.sibling){ return nextFiber.sibling} nextFiber= nextFiber.parent }}
これでperformUnitOfWork
関数の完成です!
functionperformUnitOfWork(fiber){if(!fiber.dom){ fiber.dom=createDom(fiber)}if(fiber.parent){ fiber.parent.dom.appendChild(fiber.dom)}const elements= fiber.props.childrenlet index=0let prevSibling=nullwhile(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= fiberwhile(nextFiber){if(nextFiber.sibling){return nextFiber.sibling} nextFiber= nextFiber.parent}}
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
と呼びます。
functionrender(element, container){ wipRoot={dom: container,props:{children:[element],},} nextUnitOfWork= wipRoot}let wipRoot=null
そして次の作業単位がnull
、つまりすべての作業が終了したら、ファイバーツリー全体をDOMにコミットします。
functioncommitRoot(){// TODO add nodes to dom}functionworkLoop(deadline){// ...if(!nextUnitOfWork&& wipRoot){commitRoot()}requestIdleCallback(workLoop)}
コミット作業はcommitRoot
関数で行います。 ここでは、すべてのノードをDOMに再帰的に追加します。
functioncommitRoot(){commitWork(wipRoot.child) wipRoot=null}functioncommitWork(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にコミットした古いファイバーへのリンクです。
functioncommitRoot(){commitWork(wipRoot.child) currentRoot= wipRoot wipRoot=null}functionrender(element, container){ wipRoot={dom: container,props:{children:[element],},alternate: currentRoot,} nextUnitOfWork= wipRoot}let currentRoot=null
それでは、以前作成した新しいファイバーを作成するperformUnitOfWork
から子要素のファイバーを作成する部分のコードを、新たに作成した関数reconcileChildren
に移動しましょう。
functionperformUnitOfWork(fiber){if(!fiber.dom){ fiber.dom=createDom(fiber)}const elements= fiber.props.childrenreconcileChildren(fiber, elements)if(fiber.child){return fiber.child}let nextFiber= fiberwhile(nextFiber){if(nextFiber.sibling){return nextFiber.sibling} nextFiber= nextFiber.parent}}functionreconcileChildren(wipFiber, elements){let index=0let prevSibling=nullwhile(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++}}
ここでは、古いファイバーと新しい要素を比較します。
functionreconcileChildren(wipFiber, elements){let index=0let oldFiber= wipFiber.alternate&& wipFiber.alternate.childlet prevSibling=nullwhile(index< elements.length|| oldFiber!=null){const element= elements[index]let newFiber=null// TODO compare oldFiber to elementif(oldFiber){ oldFiber= oldFiber.sibling}
古いファイバーの子要素(wipFiber.alternate
)と、調整する要素の配列を同時に繰り返し処理します。
配列(elements
)とリンクリスト(oldFiber
はsibling
を通したリンクリストになっています)を同時にループ処理するために必要なコードをすべて無視すると、oldFiber
とelement
というこの処理で最も重要なものが残ります。
element
はDOMにレンダリングしたいものであり、oldFiber
は前回レンダリングしたものです。
それらを比較して、DOMに適用すべき変更があるかどうかを確認する必要があります。
functionreconcileChildren(wipFiber, elements){let index=0let oldFiber= wipFiber.alternate&& wipFiber.alternate.childlet prevSibling=nullwhile(index< elements.length|| oldFiber!=null){const element= elements[index]let newFiber=nullconst sameType= oldFiber&& element&& element.type== oldFiber.typeif(sameType){// TODO ノードの更新}if(element&&!sameType){// TODO ノードの追加}if(oldFiber&&!sameType){// TODO 古いファイバーノードを削除}if(oldFiber){ oldFiber= oldFiber.sibling}
比較した結果は次の3つのどれかになります。
props
で更新するだけです。ここで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)}
したがって、削除するノードを追跡するための配列が必要です。
functionrender(element, container){ wipRoot={dom: container,props:{children:[element],},alternate: currentRoot,} deletions=[] nextUnitOfWork= wipRoot}let nextUnitOfWork=nulllet currentRoot=nulllet wipRoot=nulllet deletions=null
そして、DOMに変更をコミットするときは、配列deletions
のファイバーも使用します。
それでは、新しく追加したeffectTag
を処理するようにcommitWork
関数を変更しましょう。
functioncommitWork(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ノードを更新する必要があります。
functioncommitWork(fiber){if(!fiber){return}const domParent= fiber.parent.domif(fiber.effectTag==="PLACEMENT"&& fiber.dom!=null){ domParent.appendChild(fiber.dom)}elseif(fiber.effectTag==="UPDATE"&& fiber.dom!=null){updateDom(fiber.dom, fiber.alternate.props, fiber.props)}elseif(fiber.effectTag==="DELETION"){ domParent.removeChild(fiber.dom)}commitWork(fiber.child)commitWork(fiber.sibling)}
DOMノードの更新作業はupdateDom
関数で行います。
functionupdateDom(dom, prevProps, nextProps){// TODO}
古いファイバーのprops
を新しいファイバーのprops
と比較し、なくなったprops
を削除して、新しいまたは変更されたprops
を設定します。
constisProperty=key=> key!=="children"constisNew=(prev, next)=>key=> prev[key]!== next[key]constisGone=(prev, next)=>key=>!(keyin next)functionupdateDom(dom, prevProps, nextProps){// Remove old propertiesObject.keys(prevProps).filter(isProperty).filter(isGone(prevProps, nextProps)).forEach(name=>{ dom[name]=""})// Set new or changed propertiesObject.keys(nextProps).filter(isProperty).filter(isNew(prevProps, nextProps)).forEach(name=>{ dom[name]= nextProps[name]})}
更新する必要がある特別な種類のprops
の1つはイベントリスナーです。
したがって、props
名がon
で始まる場合は、それらを異なる方法で処理します。
constisEvent=key=> key.startsWith("on")constisProperty=key=> key!=="children"&&!isEvent(key)
イベントハンドラが変更された場合は、ノードから削除します。
//Remove old or changed event listenersObject.keys(prevProps).filter(isEvent).filter(key=>!(keyin nextProps)||isNew(prevProps, nextProps)(key)).forEach(name=>{const eventType= name.toLowerCase().substring(2) dom.removeEventListener( eventType, prevProps[name])})
そして新しいハンドラを追加します。
// Add event listenersObject.keys(nextProps).filter(isEvent).filter(isNew(prevProps, nextProps)).forEach(name=>{const eventType= name.toLowerCase().substring(2) dom.addEventListener( eventType, nextProps[name])})
codesandboxで 差分検出処理 を実装したバージョンを試してください。
次に実装する必要があるのは、関数コンポーネントのサポートです。
まず、サンプルとして使うコードを変更しましょう。h1
要素を返すこの単純な関数コンポーネントを使用します。
/** @jsx Didact.createElement */functionApp(props){return<h1>Hi{props.name}</h1>}const element=<Appname="foo"/>const container=document.getElementById("root")Didact.render(element, container)
jsxをjsに変換すると、次のようになることに注意してください。
functionApp(props){returnDidact.createElement("h1",null,"Hi ", props.name)}const element=Didact.createElement(App,{name:"foo",})const container=document.getElementById("root")Didact.render(element, container)
関数コンポーネントは、次の2つの点でクラスコンポーネントと異なっています。
props
から直接取得するのではなく、関数を実行することから来ますperformUnitOfWork
では、ファイバーの型が関数であるかどうかを確認し、それに応じて別の更新関数に処理を移します。
以前と同じ処理は、updateHostComponent
で実行するようにし、関数コンポーネントの場合は、updateFunctionComponent
で処理を実行します。
functionperformUnitOfWork(fiber){const isFunctionComponent= fiber.typeinstanceofFunctionif(isFunctionComponent){updateFunctionComponent(fiber)}else{updateHostComponent(fiber)}// ...}functionupdateFunctionComponent(fiber){// TODO}functionupdateHostComponent(fiber){if(!fiber.dom){ fiber.dom=createDom(fiber)}reconcileChildren(fiber, fiber.props.children)}
updateFunctionComponent
では、関数を実行して子を取得します。
この例では、fiber.type
はApp
関数であり、実行するとh1
要素を返します。
その後、子要素ができたら、差分検出機能は同じように機能します。差分検出の部分は何も変更する必要はありません。
functionupdateFunctionComponent(fiber){const children=[fiber.type(fiber.props)]reconcileChildren(fiber, children)}
変更する必要があるのはcommitWork
関数です。
DOMノードのないファイバーができたので、2つ変更すべき箇所があります。
まず、DOMノードの親を見つけるには、DOMノードを持つファイバーが見つかるまでファイバーツリーを上に移動する必要があります。
そして、ノードを削除するときは、DOMノードを持つ子が見つかるまで続行する必要もあります。
functioncommitWork(fiber){if(!fiber){return}// DOMノードを持つファイバーが見つかるまでファイバーツリーを上に移動let domParentFiber= fiber.parentwhile(!domParentFiber.dom){ domParentFiber= domParentFiber.parent}const domParent= domParentFiber.domif(fiber.effectTag==="PLACEMENT"&& fiber.dom!=null){ domParent.appendChild(fiber.dom)}elseif(fiber.effectTag==="UPDATE"&& fiber.dom!=null){updateDom(fiber.dom, fiber.alternate.props, fiber.props)}elseif(fiber.effectTag==="DELETION"){commitDeletion(fiber, domParent)// ノードを削除するときは、DOMノードを持つ子が見つかるまで探索を続行}commitWork(fiber.child)commitWork(fiber.sibling)}functioncommitDeletion(fiber, domParent){if(fiber.dom){ domParent.removeChild(fiber.dom)}else{commitDeletion(fiber.child, domParent)}}
最後のステップです。
関数コンポーネントができたので、stateも追加しましょう。
サンプルコードをhooksの例としてありがちな、カウンターを持ったコンポーネントに変更しましょう。 クリックするたびに、stateのカウンター値が1つ増えます。
Didact.useState
を使用して、カウンター値を取得および更新していることに注意してください。
constDidact={ createElement, render, useState,}/** @jsx Didact.createElement */functionCounter(){const[state, setState]=Didact.useState(1)return(<h1onClick={()=>setState(c=> c+1)}> Count:{state}</h1>)}const element=<Counter/>const container=document.getElementById("root")Didact.render(element, container)
ここでは、例で紹介した関数コンポーネントのCounter
関数を呼び出します。 そして、その関数内でuseState
を呼び出します。
functionupdateFunctionComponent(fiber){const children=[fiber.type(fiber.props)]reconcileChildren(fiber, children)}functionuseState(initial){// TODO}
useState
関数内で使用できるように、関数コンポーネントを呼び出す前にいくつかのグローバル変数を初期化する必要があります。
まず、作業中のファイバーを格納する変数wipFiber
を設定します。
また、同じコンポーネントでuseStateを複数回呼び出すことをサポートするために、hooksの配列(wipFiber.hooks
)をファイバーに追加します。 そして、現在のフックインデックス(hookIndex
)を追跡します。
let wipFiber=nulllet hookIndex=nullfunctionupdateFunctionComponent(fiber){ wipFiber= fiber hookIndex=0 wipFiber.hooks=[]const children=[fiber.type(fiber.props)]reconcileChildren(fiber, children)}
関数コンポーネントがuseState
を呼び出すとき、古いフックがあるかどうかを確認します。 フックインデックスを使用して、wipFiber.alternate
をチェックします。
古いフックがある場合は、stateを古いフックから新しいフックにコピーします。そうでない場合は、stateを初期化します。
次に、新しいフックをファイバーに追加し、フックインデックスを1つインクリメントして、stateを返します。
functionuseState(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
を次の作業単位として設定します。
functionuseState(initial){const oldHook= wipFiber.alternate&& wipFiber.alternate.hooks&& wipFiber.alternate.hooks[hookIndex]const hook={state: oldHook? oldHook.state: initial,queue:[],}constsetState=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を返すときに更新されます。
functionuseState(initial){// ...const actions= oldHook? oldHook.queue:[] actions.forEach(action=>{ hook.state=action(hook.state)})// ...}
これで自作Reactは完成です! ここまでお疲れ様でした!
ソースコードの全文は以下のようになっています。
functioncreateElement(type, props,...children){return{ type,props:{...props,children: children.map((child)=>(typeof child==="object"? child:createTextElement(child))),},};}functioncreateTextElement(text){return{type:"TEXT_ELEMENT",props:{nodeValue: text,children:[],},};}functioncreateDom(fiber){const dom= fiber.type=="TEXT_ELEMENT"?document.createTextNode(""):document.createElement(fiber.type);updateDom(dom,{}, fiber.props);return dom;}constisEvent=(key)=> key.startsWith("on");constisProperty=(key)=> key!=="children"&&!isEvent(key);constisNew=(prev, next)=>(key)=> prev[key]!== next[key];constisGone=(prev, next)=>(key)=>!(keyin next);functionupdateDom(dom, prevProps, nextProps){//Remove old or changed event listenersObject.keys(prevProps).filter(isEvent).filter((key)=>!(keyin nextProps)||isNew(prevProps, nextProps)(key)).forEach((name)=>{const eventType= name.toLowerCase().substring(2); dom.removeEventListener(eventType, prevProps[name]);});// Remove old propertiesObject.keys(prevProps).filter(isProperty).filter(isGone(prevProps, nextProps)).forEach((name)=>{ dom[name]="";});// Set new or changed propertiesObject.keys(nextProps).filter(isProperty).filter(isNew(prevProps, nextProps)).forEach((name)=>{ dom[name]= nextProps[name];});// Add event listenersObject.keys(nextProps).filter(isEvent).filter(isNew(prevProps, nextProps)).forEach((name)=>{const eventType= name.toLowerCase().substring(2); dom.addEventListener(eventType, nextProps[name]);});}functioncommitRoot(){ deletions.forEach(commitWork);commitWork(wipRoot.child); currentRoot= wipRoot; wipRoot=null;}functioncommitWork(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);}elseif(fiber.effectTag==="UPDATE"&& fiber.dom!=null){updateDom(fiber.dom, fiber.alternate.props, fiber.props);}elseif(fiber.effectTag==="DELETION"){commitDeletion(fiber, domParent);}commitWork(fiber.child);commitWork(fiber.sibling);}functioncommitDeletion(fiber, domParent){if(fiber.dom){ domParent.removeChild(fiber.dom);}else{commitDeletion(fiber.child, domParent);}}functionrender(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;functionworkLoop(deadline){let shouldYield=false;while(nextUnitOfWork&&!shouldYield){ nextUnitOfWork=performUnitOfWork(nextUnitOfWork); shouldYield= deadline.timeRemaining()<1;}if(!nextUnitOfWork&& wipRoot){commitRoot();}requestIdleCallback(workLoop);}requestIdleCallback(workLoop);functionperformUnitOfWork(fiber){const isFunctionComponent= fiber.typeinstanceofFunction;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;functionupdateFunctionComponent(fiber){ wipFiber= fiber; hookIndex=0; wipFiber.hooks=[];const children=[fiber.type(fiber.props)];reconcileChildren(fiber, children);}functionuseState(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);});constsetState=(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];}functionupdateHostComponent(fiber){if(!fiber.dom){ fiber.dom=createDom(fiber);}reconcileChildren(fiber, fiber.props.children);}functionreconcileChildren(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;}elseif(element){ prevSibling.sibling= newFiber;} prevSibling= newFiber; index++;}}constDidact={ createElement, render, useState,};/**@jsx Didact.createElement */functionCounter(){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の機能や最適化手法で実装していないものがたくさんありました。
他にも異なる点はありますがこれ以上は割愛します。
また、実装していない機能のうち、比較的簡単な機能もいくつかあります。例えば、
style
プロパティにオブジェクトを使用するuseEffect
key
を用いた差分検出などがあります。よかったら自分で実装してみましょう。
これで終わりです。
ここまで読んでくれてありがとうございます!