読む:Reactを自作しよう
Build your own Reactの翻訳記事を見ていく
Step 0 復習
ReactDOM.render(element, container)
って書くのは意外だな。実際は
container.render(element)
って書くと思うので。
というかroot.render(element)
かな
これに似てる
Step 1 createElement関数
前聞いたDidactというのはこれのことだったのか
置き換えるために、ライブラリ(自作Reactのこと)に名前を付けましょう。 Reactのように聞こえるだけでなく、あくまで教育的な目的を示唆する名前を意図してDidactと名前を付けました。
Step 2 Render関数
やはり定番の再帰処理
それぞれの子要素に対して同じことを再帰的に行います。
テキストノードだけ特別扱いするのも定番
element.typeがTEXT_ELEMENTの場合、通常のノードではなくテキストノードを作成します。
Step 3 並列モード
なるほど、子要素の再帰処理はメインスレッドを長時間ブロックしてしまうかもしれないという問題
この再帰呼び出しには問題があります。
レンダリングを開始すると、1つの要素ツリーを完全にレンダリングするまで停止しません。
要素ツリーが大きい場合、メインスレッドを長時間ブロックする可能性があります。
へぇ!なるほど、それにworkLoopとかperformUnitOfWorkとかが関わってるのか
そのため、作業を小さなユニット(作業単位)に分割し、各作業単位の処理が終了した後、他に実行すべきことがあれば、ブラウザにレンダリングを中断させます。
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
}
performUnitOfWorkとかは、レンダーを中断する余地を生んでおくために、作業単位で実行/終了という枠組みを表現したものということか
requestIdleCallbackというのを知らない
web apiとして用意されてるのか
なるほど、理解
window.requestIdleCallback() メソッドを利用すると、ブラウザーがアイドル状態の時に実行される関数をキューに登録できます。これにより、アニメーションや入力への応答など、遅延が問題となる処理に影響を与えることなく、優先度の低いバックグラウンド処理をメインスレッド内で実行することができます。キューに登録された関数は原則として先に登録したものが先に実行されますが、指定された timeout を守るために必要であれば優先して実行されることがあります。
requestIdleCallback() をアイドルコールバック関数の中で呼び出すことで、別のコールバックを次のイベントループ以降すぐに実行されるようスケジュールすることもできます。
引数
callback
イベントループがアイドル状態になったときに実行されるコールバック関数への参照。コールバック関数は引数に IdleDeadline オブジェクトを受け取り、処理に使える残り時間や、この呼び出しがタイムアウト時間の経過によるものかどうかを知ることができます。
返値
コールバックをキャンセルする時に Window.cancelIdleCallback() メソッドに渡す ID を返します。
「ブラウザが(イベントループが)アイドル状態つまり忙しくない状態になったときに実行する関数を予約できる関数」 だな
いつアイドリング終わるかわからないので、中断されても問題ない低優先度の処理がrequestIdleCallbackのコールバックに使われるっぽい
今回で言うとrender作業を小さく分けた各作業つまりperformUnitOfWorkとそのループのことだな
これ表現分かりやすい
ふむ
requestIdleCallbackはsetTimeoutと考えることができますが、いつ実行するかを指示する代わりに、メインスレッドがアイドル状態のときにブラウザがコールバックを実行します。
なるほど、setTimeoutは時間切れのときに実行されるけど、requestIdleCallbackはアイドル時に実行されるという違いがあるだけで、非同期処理という意味では同じだよ~みたいなことか
てかアイドル時であるか否かって何を基準に判定してるんだろう?
あと、deadlineとdeadline.timeRemaining()ってよく考えたらおかしくないか?
いつまでアイドリングするかどうかはユーザーがいつまでじっとしてるかどうかに依るから予測不可では?
あ、実際には使われてないんだ。(スケジューラパッケージとは?)
ReactはrequestIdleCallbackを使用しなくなりました。 現在はスケジューラパッケージを使用しています。 ただし、このユースケースでは、概念的には同じです。
てかなんでrequestIdleCallbackを2回実行してるんだ?
function workLoop(deadline) {
// ...
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
あーそういうことかなるほど(GPT)
workLoop関数外のrequestIdleCallbackは初期化時に必要で、それ以降workLoop関数を外部から使われる時にその都度予約するためにworkLoop関数内にもrequestIdleCallbackがあるってことか、理解
Step 4 ファイバー
なるほど
また、ファイバーに子も兄弟もいない場合は、「おじ」、つまり親の兄弟に移動します。上の例ではaファイバー -> h2ファイバーが該当します。
また、親に兄弟がいない場合は、兄弟がいる人が見つかるまで、またはルートに到達するまで、親を調べ続けます。
ルート到達で終了か
ルートに到達した場合は、このrenderのすべての作業の実行が終了したことを意味します。
preformUnitOfWork関数完成したけど、自分で作ったわけじゃなくてポンポンできていった感じだからあんまりよくわかってないなぁ
これで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
}
}
なのでせめて言語化したい
まずいつperformUnitOfWork関数が呼ばれるのか、というと...
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
}
これはブラウザが暇なとき(アイドリング時)に実行されるやつ
んでそのnextUnitOfWorkは最初nullだけどルートになるのはいつだ?というと...
render関数によってnextUnitOfWorkが更新される。domプロパティがcontainerになっているが、containerというのはルートなので、OKやね
function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
}
}
let nextUnitOfWork = null
んでまずそのルートfiberがperformUnitOfWorkの引数に渡され、以下実行
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
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
}
fiberに対応するdomを作って返してるだけ
なので戻ると、
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
-
もしルートファイバーのdomプロパティが無かったら、ルートファイバーをdomに変えて、それをルートファイバーのdomプロパティに代入
-
もしルートファイバーの両親があったら、ルートファイバーの親のdomプロパティにルートファイバーのdomプロパティを子として追加
つまり受け取ったfiberのdomプロパティや親fiberのdomプロパティを追加してるな
次
次に、子要素ごとに新しいファイバーを作成します。
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
}
fiber.props.childrenの数だけ回るループが開始
さっきまでは自分自身と親のfiberのdom関連の処理をしていたが、今回は子fiberをnewFiberという名前でそのまま保持している
次
そして、それが最初の子要素であるかどうかに応じて、子または兄弟として設定するファイバーツリーに追加します。
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
}
その子fiberをルートファイバーのchildプロパティに代入(index === 0なので)
ループ用にprevSiblingに子fiberを代入 & index++
これにより次のwhileループではindex === 0ではなくなるのでprevSibling.sibling = newFiberの方が実行される。
次のwhileループということは次のchildrenなので、それ(newFiber)はprevSibling(=直前の子)のいとこであるという認識は正しい
なので新しい子であるnewFiberはprevSibling.siblingとして代入される
子を巡っていくwhileループが終わったら、最後に以下をやる
最後に、次の作業単位を検索します。 最初に子要素、次に兄弟、次におじというように試します。
function performUnitOfWork(fiber) {
// ...
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
子が最優先。ということは...
子があったら次は子に向かっちゃうということ
んん?それでいいのか?まだ自分自身の兄弟は見てないよな?
いや!それでいいのか!というかそれこそがReactの仕組みなのか!
なぜなら深さ優先探索だから!それはこの記事で見たやつだ
Reactは深さ優先探索でどんどん子を見ていくということか、知見だぁ
そして、どんどんと言っても再帰的に子を見ていくというよりは、呼び出し元であるworkLoop関数はブラウザがアイドルのときに繰り返し呼び出されるっぽいので、再帰というよりただの繰り返し実行って感じだな。おもろい、なるほど
んで子がなかったら兄弟、次に親の兄弟、それも無かったら更にその親の兄弟、...とルートにいくまでずっとやっていく感じ
ということでperformUnitOfWork関数をまとめると...
-
自分自身fiberと親fiberはリアルdomに対応づけてる。
-
ただ、子に関してはdomに対応づけず、fiberのまま追加してるだけ。
多分次のループでその子fiberが「自分自身fiber」になるので、その際にdom変換できるからOKということか。
なるほどなぁ、分かった気がする
子はfiberのものってどゆことだ...?じゃあそもそもfiber.props.childrenはfiberじゃないってこと?それともリアルdom?いやでもリアルdomが最初から入ってるのはおかしいよな、変換処理があるんだから
あぁ!こう言ってるので、それはつまり、
混乱を避けるために、今後、要素(element)と言ったときはReact要素を参照し、ノードと言った時は実際のDOM要素を参照します。
さっきのconst elements = fiber.props.children
は、React要素であるということか。
うん、やっぱchildrenにはReact要素入ってるっぽいわ、見返してみたら
const element = React.createElement(
"div",
{ id: "foo" },
React.createElement("a", null, "bar"),
React.createElement("b")
)
> 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] }
}
つまりReact要素 -> Fiberへの変換をしてるのか!
...それって実際のroot.renderと一緒だな。
てことは今回はrenderじゃなくてperformWorkOfUnitにその処理を委譲した感じなのかな?
ということでもう一回performUnitOfWork関数をまとめると...
-
自分自身fiberと親fiberはリアルdomに対応づけてる。
-
ただ、子に関してはdomに対応づけず、React要素->fiber変換をしてる。
これは深さ優先探索で行われて、末端についたら逆走して今度はルートまで戻ってくる。
まじでこの図のイメージが大事だったのか
なんならこれだな
子、兄弟、おじ(おば)の順だからまじで深さ優先探索だな、へぇ
んで、fiberツリーを作り上げたのであればそこからstateNode構築とかはどうするんだ?
...あぁでもすでにdom変換はしてるから、domプロパティを見ればokか
てことは最後にrender関数を実行してdomプロパティを使ってルートにappendChildする感じだな多分
あーでもrender関数はReact要素からdom作ってるのかぁ...
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)
うーん、なんでだろ。てかrender関数をよくわかってないことに気付いた。
fiberを介さずに直接react要素->dom構築って、じゃあfiber変換とかしてるperformUnitOfWork要らないやないかい、てなっちゃわない?
豆知識
この@jsxなんちゃらっていう書き方、pragma directivesって呼ぶらしい(?)
あ、でもrenderのところで
今の段階ではDOMに何かを追加することだけを考えます。
更新と削除は後のステップで実装します。
って言ってるから後でちゃんとfiberや.domプロパティなどのperformWorkが関係してるやつ使うのか?
読み進めてみよう
Step 5 Render Phase と Commit Phase
なるほど、てことは...
そして次の作業単位がnull、つまりすべての作業が終了したら、ファイバーツリー全体をDOMにコミットします。
function commitRoot() {
// TODO add nodes to dom
}
function workLoop(deadline) {
// ...
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
fiber単位でrender->commitと言う感じではなく、renderがすべてのfiberに対して終わって、ルートまで来たら、やっとまたすべてのfiberに対してcommitが開始されるみたいな感じかな
だからrenderフェーズが全部終わった後にコミットフェーズが始まる感じか。
fiber単位でrender->commitと少しずつ消化されていく訳ではなく。
そっか、それだとrender中断されたらcommitも中断されてUIが不完全になってしまうのか
その場合、ユーザーには不完全なUIが表示されます。それは望ましいことではありません。
commitRoot()内でcommitWork()を呼び出してる。commitWorkが実際の再帰処理
コミット作業は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)
}
この感じだと、commitは深さ優先探索でも幅優先探索でも無いって感じ?
子だけじゃなくて兄弟にも同じように進んでいるもんな
renderとcommitでアルゴリズムが違うのかぁおもろいな
Step 6 差分検出
たしかに
これまではDOMにノードを追加しただけですが、ノードの更新や削除についてはどうでしょうか。
これがこのステップでやろうとしていることです。
alternateは、Fiber.alternateとしてソースコードで見かけたやつか
また、すべてのファイバーにalternateプロパティを追加します。 このプロパティは、前のコミットフェーズで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
// TODO compare oldFiber to element
if (oldFiber) {
oldFiber = oldFiber.sibling
}
&&
(論理AND)なので、左辺がfalsyだったら左辺が評価、左辺がtruthyだったら右辺が評価される
なのでlet oldFiber = wipFiber.alternate && wipFiber.alternate.child
は、
wipFiber.alternateがfalsyだったらwipFiber.alternateのfalsyな値がそのままoldFiberに入り、
wipFiber.alternateがtruthyだったらwipFiber.alternate.childの値がoldFiberに入る
どゆこと?(そういや前にもLinked Listってソースコードで見たけどなんなんだあれ)
(oldFiberはsiblingを通したリンクリストになっています)
あ、Linked Listって連結リストのことだったのか!
???
配列(elements)とリンクリスト(oldFiberはsiblingを通したリンクリストになっています)を同時にループ処理するために必要なコードをすべて無視すると、oldFiberとelementというこの処理で最も重要なものが残ります。
ほう
elementはDOMにレンダリングしたいものであり、oldFiberは前回レンダリングしたものです。
ん-なるほど
それらを比較して、DOMに適用すべき変更があるかどうかを確認する必要があります。
比較した結果は次の3つのどれかになります。
- 古いファイバーと新しい要素が同じタイプの場合、DOMノードを保持し、新しいpropsで更新するだけです。
- タイプが異なり、新しい要素がある場合は、新しい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
}
そうそう、keyがあるなら移動だと後で認識できるかもしれないから削除はしないでおくみたいなことをしてた気がする
ここでReactはkeyも使用するため、差分検出の効率が向上します。 たとえば、子要素が要素配列内の位置を変更したことを検出します。Didactではkeyの実装を行いません。
うん、やっぱ追加/削除はせずに更新で済ませるんよな
(削除せず保持しておく場合はどんな実装になるかとか覚えてないなぁ・・・)
初見
また、ファイバーに新しいプロパティ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",
}
}
削除は処理少ないな
また、ノードを削除する必要がある場合は、新しいファイバーがないため、古いファイバーにエフェクトタグを追加します。
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION"
deletions.push(oldFiber)
}
なるほど
ファイバーの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)
}
updateDom関数はこれ、でかいな
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]
)
})
}
でもやってることはめっちゃ単純かも
へぇ、賢いし読みやすいしちょっと感動
古いファイバーの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]
})
}
先にfilterの引数であるコールバック関数を用途ごとに分けて作ってあるのか。
んでそのコールバック関数は「関数を返す関数」
なるほど
更新する必要がある特別な種類の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]
)
})
んーと、気になるのはこの条件のとこだな
.filter(
key =>
!(key in nextProps) ||
isNew(prevProps, nextProps)(key)
)
isNewの上の!(key in nextProps)
は実質さっきのisGoneだと思うので、それで削除するのはわかる。
だが、isNewは変更の意味なので、削除はせず更新するのが普通だと思ったしさっきもそうやっているが、イベントリスナに関してはこれも同じように削除してしまうということだろう。
なぜ?
GPTに聞いてみたけど、やはり本質的にはそれで問題無いと思う。ただイベントリスナはadd/ removeが前提で作られてるから予期せぬ不具合が起こる可能性があるから一応それでお願いします~的なことだと思われる。理解
あ、ていうか自分で試してみたらなるほど、になった
const a = document.getElementById('fire');
a.addEventListener('click', () => {
console.log('fire');
})
function func() {
console.log('aiueo');
}
a.onclick = func;
// で、devtoolsのconsoleタブでgetEventListeners(a)を実行
実行してからクリックするとこうなる
そもそもaddEventListenerしたものをDOMのプロパティから見ることが不可能だったので、内部的に保持されてるっぽい。addしたのにonclickというプロパティはnullだった。
それ以外に、それっぽいプロパティ探したけど無かった。
だからonclick = 〇〇しても、実質2個目のリスナ追加になっちゃうっぽい。
だからイベントリスナは更新だろうが削除だろうが問答無用でadd /remove しないといけないということだと思う
知見だ~~
とにかく、そのあと追加して辻褄を合わせている
そして新しいハンドラを追加します。
// 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]
)
})
commitWork関数内のコミット処理をまとめると、
- 追加はappendChiildして削除はremoveChildするだけでいいんだけど、
- 更新の場合はそんな便利メソッドないからいろいろJSで地道に代入とかして更新を実現しないといけなくて、それをupdateDom関数で実装してる
という感じ!
Step 7 関数コンポーネント
ふむ
関数コンポーネントは、次の2つの点でクラスコンポーネントと異なっています。
- 関数コンポーネントからのファイバーにはDOMノードがありません
- 子要素は、propsから直接取得するのではなく、関数を実行することから来ます
ほう、performUnitOfWorkを改造する、というか中身を他の関数に分けるのか
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)
}
ちなみに元々の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
}
}
多分updateHostComponentの方は実質同じみたいな感じになるんじゃないかな
ふむふむ
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)
}
}
まぁ要はFunctionComponentのことを考え出したことに伴って.domプロパティが無い可能性が出てきて、それに対処する必要が出てきたので、それを今書いてるってことよな
このif (fiber.effectTag === "PLACEMENT" && fiber.dom != null)
のfiber.dom != null
のおかげで、FunctionComponentがdom追加されちゃうみたいな意味不明なことは避けられるね、ok
追加/更新はわかった。
削除があんまりイメージできていない。
そして、ノードを削除するときは、DOMノードを持つ子が見つかるまで続行する必要もあります。
とりあえず、いきなりremoveChildするのではなく、commitDeletionという関数をかまして、その中でremoveChildするように変更してるな
あー理解できた
ほんとにFunctionComponentはdom構築において完全に無視されてるっていうだけの話だなこれ
追加も削除も、FCを追加/削除するみたいなことはあり得ないので、ただそこは飛ばして考えてるってだけ。
んで追加だったらその追加先の親を見ていくにあたって、FC意外の親を見つけるまで登る
削除だったら削除されるdom自体を持ってるfiberを見つけるまで、つまりFC以外のFiberを見つけるまで下がる
って感じだな
Step 8 Hooks
なるほど
また、同じコンポーネントで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)
}
たしか実際はindexという単純なものでフックの順番が管理されている訳ではないが、連結リストとして良い感じに管理されてたような
この記事でもcursorというindexを使っている単純化したモデルを紹介してたが、実際にそうやって使われてる訳ではなかった気がする
あー前からFiberに対応した複数のフックスって実際どゆこと?と疑問だったが、updateFunctionComponentでしか使っていないことから、実質FCに対応したフックスと捉えて良さそうだね。それなら理解できる。
なるほど
useStateはstateを更新する関数も返す必要があるため、アクションを受け取るsetState関数を定義します(カウンターの例では、このアクションはstateを1つインクリメントする関数です)。
そのアクションを、フックに追加したキューにPushします。
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)
ん-と、そもそもrender関数って何やってんだっけ
次に、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]
}
これだけか、まじで少ないな
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element]
},
alternate: currentRoot
};
deletions = [];
nextUnitOfWork = wipRoot;
}
currentRootって何だっけって言うと、
まずlet currentRoot = null;
と言う感じでnullで初期化されてる
代入はcommitRoot内でのみされてる
function commitRoot() {
deletions.forEach(commitWork);
commitWork(wipRoot.child);
currentRoot = wipRoot;
wipRoot = null;
}
つまりwipRootが入ってる。
じゃあwipRootは何かと言うと、
これもまずlet wipRoot = null;
とnullで初期化されてる。
で、代入はrender関数内とsetState関数内
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
};
deletions = [];
nextUnitOfWork = wipRoot;
}
const setState = (action) => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
};
ということで、以下も踏まえて、currentRootもwipRootもrootのfiberのことだと捉えて良さそう
代わりに、ファイバーツリーのルートを追跡します。 これをprogress rootまたはwipRootと呼びます。
というのを踏まえて再度読んでいく
次に、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]
}
あーhook.queue.push(action)
してる以外はまじでrenderとsetStateはやってること同じだな確かに
nextUnitOfWork = wipRoot
の必要性や意味がわからんけどまぁいいや
だからrender内でもその1行があるわけだが、当然それの意味もわかっていないことに気付いた
wipRootは常にルートなのか?それとも中断された可能性がある途中のfiberなのか?
ちょっとわからなくなってきている
ふむふむ
しかし、まだアクションを実行していません。
次回コンポーネントをレンダリングするときにこれを行い、古いフックキューからすべてのアクションを取得し、それらを1つずつ新しいフックのstateに適用して、stateを返すときに更新されます。
function useState(initial) {
// ...
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
hook.state = action(hook.state)
})
// ...
}
終わった
これで自作Reactは完成です! ここまでお疲れ様でした!
へぇ
しかし、今回作ったDidactにはReactの機能や最適化手法で実装していないものがたくさんありました。
- Didactでは、レンダリングフェーズ中にツリー全体を歩いています。 代わりに、Reactはいくつかのヒントと経験則に従って、何も変更されていないサブツリー全体をスキップします。
- また、コミットフェーズではツリー全体を歩いています。 Reactは、変更点のあるファイバーのみを含むリンクリストを保持し、それらのファイバーのみにアクセスします。
- wipな作業ツリーを構築するたびに、ファイバーごとに新しいオブジェクトを作成します。 Reactは前の木からファイバーをリサイクルします。
- Didactは、レンダリングフェーズ中に新しい更新を検知すると、構築中の作業ツリーを破棄し、ルートから再開します。 Reactは、各更新に有効期限のタイムスタンプでタグを付け、それを使用して、どの更新の優先度が高いかを判断します。
あ、じゃあやっぱwipRootは常にルートと考えて良さそうかも。ルートからいちいちやり直すのがsetStateという話かな
この部分からそう読み取った
Didactは、レンダリングフェーズ中に新しい更新を検知すると、構築中の作業ツリーを破棄し、ルートから再開します。