読む:仮想DOMの作りかた
これじっくり見て思ったこと書いていく
うーん?なんか実DOMと結構違うな
仮想DOMでは以下のようなオブジェクトに変換されて表現されています。
{
name: "div",
props: {id: "app"},
nodeType: null,
key: null,
realNode: ... //実際のDOMへの参照
children: [
{
name: "h1",
props: {},
nodeType: null,
key: null,
realNode: ... //実際のDOMへの参照
children: [
{
name: "Hello World",
props: {},
nodeType: TEXT_NODE,
key: null,
realNode: ... //実際のDOMへの参照
children: []
}
]
}
]
}
実DOMでいうインタフェースに似てるな。あれがそのままオブジェクトになったみたいな。
実DOMでいうNodeオブジェクトって、インタフェースの具現化としての実体で、HTML要素そのものの見た目だったけど、仮想DOMのNode(VNode)オブジェクトはインタフェースがそのままの見た目でオブジェクトとして具現化したみたいな感じなのか
例として1つのdiv要素で比較
- 実DOMでいうNodeオブジェクト
<div id="example">aaa</div>
- 仮想DOMでいうVNodeオブジェクト
{
name: "div",
props: {id: "example"},
nodeType: null,
key: null,
realNode: ... //実際のDOMへの参照
children: [...]
}
いや、でも実DOMのNodeオブジェクトはconsole.logで出したからそう見えるだけであって、console.dirでDOM要素として見るとこんな感じなので、プロパティの種類以外は同じような形と言ってもいいかも。
この大量のプロパティは、このNodeオブジェクトの実装元であるHTMLDivElementやHTMLElementなどから引き継いだもの。
なのでどちらも結構似てて、インターフェース的な見た目で、プロップスやメソッドが定義されてるという共通点はあるか。
変な勘違いしかけたけどとりあえずOK
まぁプロパティは全然違うけど
大事:
要素のテキスト部分(ここでいうHello world)はテキストノードであり、子ノードである。
つまりh1ノードの兄弟ノードではなく子ノード。これはDOMも仮想DOMも同じ。
<h1>Hello world</h1>
これは実DOMと同じ
VNodeが階層構造になって一個のウェブページを表せるようになったのが仮想DOMです。
これも同じ。名前がtextNodeなのも同じ
更に仮想DOMではただの文字列もVNodeとして表現します。例えば下のようなものです。
{
name: "Hello World",
props: {},
nodeType: TEXT_NODE,
key: null,
realNode: ... //実際のDOMへの参照
children: []
}
んん?どゆこと?
実際にReactなどが要素を更新する時は、前の仮想DOMを表現したオブジェクトと新しい仮想を表現したDOMオブジェクトを比較して違いがあったところだけ更新
↓こうでは?
実際にReactなどが要素を更新する時は、前の実DOMを表現したオブジェクト(=仮想DOM①)と、新しい実DOMを表現したオブジェクト(=仮想DOM②)を比較して違いがあったところだけ更新
「前の仮想DOMを表現したオブジェクト」というのがよくわからない。
仮想DOMを作ろうとしてる段階で前の仮想DOMを参考にするって変では?
前回の仮想DOM②を、今回の(最新の)仮想DOM①とする、みたいな意味?なら理解できるが
これ短いので全部読んでみたけどやっぱ仮想DOM①は実DOMをコピーしているっぽい。
「DOMのコピー」である仮想DOMを作成(仮想DOM 1)
んで仮想DOM②の方は
コンポーネントの状態が変更されると、新しい仮想DOMが作り出される(仮想DOM 2)
とのことなので、つまりレンダーの最後にsetterの更新処理が一気に走る瞬間にやっと作られるのだろう。
ただ、どのように作られるんだろう?が気になる。
実DOMをコピーするのか仮想DOM①をコピーするのか、多分この2択だと思うんだけど、どっちなのかは気になるところ
とりあえず読み進めていく
追記(12/3):多分これ間違ってた。
ここで気付いた
1つのHTML要素に1つのVNodeが対応しているイメージ
だがVNodeはそれに加えて子ノードの情報も持ってるぽい。(親は無いのね)
まずこの各プロパティが何なのか理解することでVNodeの解像度を上げようという感じ
interface VirtualNodeType {
name: HTMLElementTagNameMap | string; // divやh1等の場合はHTMLElementTagNameMap、文字を表すVNodeの場合はstring型
props: ...; // HTML要素の属性
children: VirtualNodeType[];
realNode: ...; // 実際のDOMへの参照
nodeType: ...; // このVNodeのタイプ(文字を表すノードなのか要素を表すノードなのか)
key: ...; // keyを表す
}
このVirtualNodeTypeがすべての基本。いろんなところで使われる。重要
んーでも厳密に言うとちがうのかも?
つまりVNodeはHTML要素に対応してるというより、HTML要素に対応しているDOM Nodeに対応しているのではないだろうか、その方が直感的ではある
と思いきやVNodeにあるpropsというプロパティはこんな感じでHTML要素の属性が入ってるらしい
props: {
id: "app",
class: "example"
},
あーでも属性はDOMでも保持してるか。
例えばid属性は普通にidという名前で保持されてたりするし、
class属性もclassNameという名前で保持されてる
じゃあますますどっちか分からんぞ
んでkey属性という、HTML要素にもDOM Nodeにも無い属性があるなぁ
// propにはkeyとoninputやclass、id等のHTMLElementの属性の名前が入ります
interface DOMAttributes {
key?: KeyAttribute;
[prop: string] : any;
}
んん?
realNodeはそのVNodeに対応した実際のHTML要素への参照を入れてあります。
document.getElementById("app");
HTML要素の参照ではなく、HTML要素からパース(解析)されたDOM要素の参照ではないかという気がするが...
だってそもそもJSってHTMLに直接アクセスできる訳じゃなくてDOMを介してHTMLと間接的に繋がってるみたいなところあるしなぁ(多分)
まぁでも「HTML要素への参照とはすなわちDOM要素への参照である」みたいな論理なのかなぁ、あまりイメージできないけどまぁそう捉えるとするか。
であればrealNodeというのは名前の通り、対応する実DOMのNodeを表すと考えられそう
(NodeってHTMLではないもんね)
VirtualNodeTypeのrealNodeプロパティの中に間接的にvdom?: VirtualNodeType;
が入ってんのがよくわからない。
「自分のプロパティの中に自分を入れる」必要性が感じられない
// eventNameはinputやsubmit,click等のoninput等のon以降の文字の小文字が入る
interface HandlersType {
[eventName: string]: (event: Event) => void;
}
type ElementAttachedNeedAttr = HTMLElement & {
vdom?: VirtualNodeType;
eventHandlers?: HandlersType; //handlersにイベントを入れておいてoninput等のイベントを管理する
};
type TextAttachedVDom = Text & {
vdom?: VirtualNodeType;
};
type ExpandElement = ElementAttachedNeedAttr | TextAttachedVDom;
interface VirtualNodeType {
...,
realNode: ExpandElement | null; //実際の要素への参照
...,
}
と思いきや、なるほど
ElementAttachedNeedAttrやTextAttachedVDomに付与されているvdomというプロパティにはその要素に対応した仮想DOMを代入します。このプロパティは仮想DOMの更新処理の際に前の要素と新しい要素の差分処理のために使います。(※詳しくは後で解説します)
多分、vdomの目線において「自分自身」というより「自分が変更される前の自分」「1個前の自分」が入っているみたいなイメージっぽい
なのでおそらく、自分が変更されなかったら自分とまったく同じものがvdomに入っていることになるし、自分が変更されたら過去の自分(変更前の自分)がvdomに入っていることになるのだろう。
それで差分を検出する、ということ。理解。
これどういうことだ?
仮想DOMは実DOMをコピーすることで作られるはずなので、実DOMの要素を手に入れられないなんてことは絶対ない気がするのだが
ちなみにrealNodeがnullを受け取れるようにしているのは最初にあるHTMLへの要素を追加する(つまりReactでいう最初のrender)際にはどうやったってそのVNodeに対応する実際の要素など手に入らないからです
GPTに聞いてみたら衝撃だったんだが、もしかして...仮想DOMって実DOMのコピーではないのか!?
確かにjsxから直接仮想DOM作るというのは可能かも!んでその仮想DOMを参考に実DOMが作られるのか!?
(ClaudeもGPTと同じこと言ってる...)
でも確かに、そうであれば矛盾は解消される。最初にレンダーされる際は初めて仮想DOMが作られるから、その瞬間には実DOMは作られていないので。
ただそうなるとこの
「DOMのコピー」である仮想DOMを作成(仮想DOM 1)
という表現が適切ではないということになる...?
いや、でもDOM「の」コピーであるというだけで、DOM「を」コピーした訳ではない、ということかも?
コピー = 複製。複製の意味はこれ
美術品などを原作どおりに再現すること。また、そうしたもの。
なので、「そうしたもの」つまり原作どおりに再現したもの、という意味なだけであって、再現するために原作を参照したわけではない、という論理は成り立つ。
そういうことなのかもしれない。それなら矛盾はなくなる。
- 自分が今まで思ってたこと
最初は実DOMから仮想DOMが作られる
それ以降は仮想DOMから実DOMへのコミット
つまり流れが実DOM→仮想DOMから仮想DOM→実DOMへと変わる
- あり得る可能性
最初は仮想DOMから実DOMが作られる
それ以降も仮想DOMから実DOMへのコミット
つまり流れはずっと仮想DOM→実DOM
どっちなんだろう、これはめちゃくちゃ重要だぞ、要確認だ...!!!
これ、textNode以外の残り11種類のNodeTypeはどう表現するんだ?textNode以外は全部nullにするっていうのがよくわからん
type TEXT_NODE = 3;
interface VirtualNodeType {
...,
nodeType: TEXT_NODE | null,
}
やはり判別はしないらしい。textNodeかそれ以外のNodeか、という情報だけ分かればいいってこと?なぜtextNodeだけ区別するんだろう、、
普通のHTMLElement要素の場合はnodeTypeをnullとします。
GPTに聞いてみたけど、なるほどねぇという感じ
「textNodeなら文字列比較するだけで良いから他のNodeとちょっと比較方法違うよね、だからそれだけ区別するよ」
て感じか
じゃあVirtualNodeType.name
とVirtualNodeType.nodeType
って、「textNode(文字列)か否か」を分けることができるという意味でどっちか1つだけで良い気がするんだが...なんで分けてるんだろ
あーでもあれか、nodeTypeの方がnameより雑というか大雑把だから、ほんとに「textNodeか否か」だけ調べたいときのような抽象的な操作(ノードの種類を意識しない汎用処理など)で使いやすいのかも。
nameには「それそのもの = div
やh1
や"hello world"
など」が具体的に入っているのに対して、nodeTypeには「textNodeか否か」というだけの情報が抽象的に入っている、というイメージか。
確かにどっちも使い道はありそう
とりあえずVirtualNodeTypeの各プロパティは理解した
(realNodeがけっこう複雑で、reconcileに関わる重要なプロパティな気がしている)
interface VirtualNodeType {
name: HTMLElementTagNameMap | string; // divやh1等の場合はHTMLElementTagNameMap、文字を表すVNodeの場合はstring型
props: DOMAttributes; //HTML要素の属性
children: VirtualNodeType[]; //子要素のVNodeのリスト
realNode: ExpandElement | null; //実際の要素への参照
nodeType: TEXT_NODE | null; // このVNodeのタイプ(文字を表すノードなのか要素を表すノードなのか)
key: KeyAttribute | null; //keyを表す
}
次はh関数
へぇ、今でいうjsx関数か
次にh関数を定義します。h関数とはVNodeを作成するための関数でReactではcreateElmentが対応します。
(jsx関数の説明)
あー、ていうか、これってすなわちcreateElement関数やjsx関数で仮想DOMを作成しているということだよね
であれば、やはり普段書いているReact、jsx関数によって仮想DOMが直接作られているのか...!
仮想DOMは実DOMのコピーではなくjsx関数から直接作られるというのは正しそうだぞ...!
ちなみにh関数はこの10年前の記事にも載ってて、同じように「Virtual DOMの生成に使う」と言われてる
(メモ:h関数内で使われてる、引数として渡されるchildrenという配列にはいつどのように値が格納されているのかが気になる)
createVNode関数は引数をそのまま返してるだけやん感があるが、そういう訳でもなく、undefinedをnullに統一するとか、再利用性向上のために作られた、かましておくための関数なのではないかという予想はできる。
ということでh関数理解。次はrender関数
さて次は、render関数というものを作ります。ReactではReactDOM.render関数が対応します
このrender関数にVNodeを渡して実際に要素を追加したり更新したりします。
んでそのReactDOM.renderは今でいうcreateRootらしい
render は React 18 で createRoot に置き換わりました。
createRoot
んん?でも例えばviteでreactプロジェクト作ってmain.tsx見てみるとこうなってるから、createRootとrenderは別なんじゃないのか?
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.js'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
謎だが...今回の趣旨とずれるので一旦飛ばす
そういえば、これに関してだけど
やっぱり仮想DOM①は実DOMのコピーではないっぽい...!!!ただjsxから直接作られているという訳でもなくて...
ざっくりこういう流れっぽい
- createRootで「FiberRootNode」と「HostRoot」が作られる
- jsxつまり_jsxs, _jsxでReact要素が作られる
- render(root.render)でReact要素がFiberに変換されるつまり仮想DOMが作られる
なので仮想DOM①は実DOMからコピーされてるわけではなく、Reactのjsxやその他関数で作られてる!!
んでさらにさらに...
仮想DOM②も実DOMのコピーではなく、仮想DOM①のシャローコピーで作られていく。んでこのシャローコピーの技法をダブルバッファリングとか呼ぶ
なるほどなぁ、これWIPが仮想DOM②でCurrentが仮想DOM①だったのか...
(始めて読んだときは全然わかってなかった)
ただ一気に全部仮想DOM①をシャローコピーする訳でもなくて、なんかApp()直下のdiv?とかまでとりあえずコピーしておくだけっぽい。
じゃないとのちのち変更するはめになるが、変更してしまうと当然シャローコピー元に影響及ぼしてしまうため。
なので変更ではなく追加していくイメージ。
つまりmemoizedStateに保存してある循環リストを実行したりする段階で、Fiberを新規作成していったりただコピーでいいFiberはコピーしたり、ていうのをやっていって最終的に仮想DOM②が完成する。
あとdeletion配列っていう削除すべきFiberの情報も親Fiberに持たせておく。
(追加だけした結果として完成版を作っても、どこを削除したのか見つけるのが不可能もしくは可能とは言え面倒になってしまうから丁寧に親Fiberにdeletion持たせておいてるのだろう、という雑な予想)
知見すぎる
ということでrender関数の説明に戻る
renderという名前なんだから仮想DOM①と仮想DOM②の差分を計算してるはず
...と思ったのにこれだと実DOMと仮想DOMの比較では?
nodeは実DOMのノードなので
render関数は最終的にこんな感じで使います。
const node = document.getElementById("app");
render(
node,
h("div", {}, [
h("h1", {}, ["Hello World"]), //タダの文字を表したい場合はh関数のchildrenに文字のみ渡す
);
続きを読んでみる
中身
// 本物のElementからVNodeを作成するための関数
const createVNodeFromRealElement = (
realElement: HTMLElement
): VirtualNodeType => {
if (realElement.nodeType === TEXT_NODE) {
return createTextVNode(realElement.nodeName, realElement);
} else {
const VNodeChildren: VirtualNodeType[] = [];
const childrenLength = realElement.childNodes.length;
for (let i = 0; i < childrenLength; i++) {
const child = realElement.children.item(i);
if (child !== null) {
const childVNode = createVNodeFromRealElement(child as HTMLElement);
VNodeChildren.push(childVNode);
}
}
const props: VirtualNodeType["props"] = {};
if (realElement.hasAttributes()) {
const attributes = realElement.attributes;
const attrLength = attributes.length;
for (let i = 0; i < attrLength; i++) {
const { name, value } = attributes[i];
props[name] = value;
}
}
const VNode = createVNode(
realElement.nodeName.toLowerCase(),
props,
VNodeChildren,
realElement,
null
);
return VNode;
}
};
const renderNode = (
parentNode: HTMLElement,
realNode: VirtualNodeType["realNode"],
oldVNode: VirtualNodeType | null,
newVNode: VirtualNodeType
) => {
//...色んな処理
}
export const render = (
realNode: ElementAttachedNeedAttr,
newVNode: VirtualNodeType
) => {
if (realNode.parentElement !== null) {
let oldVNode: VirtualNodeType | null;
// realNodeごと追加更新削除処理につっこむ!
const vnodeFromRealElement = createVNodeFromRealElement(realNode);
if (realNode.vdom === undefined) {
oldVNode = { ...vnodeFromRealElement };
} else {
oldVNode = realNode.vdom;
}
vnodeFromRealElement.children = [newVNode];
newVNode = vnodeFromRealElement;
renderNode(realNode.parentElement, realNode, oldVNode, newVNode);
} else {
console.error(
"Error! render func does not work, because the realNode does not have parentNode attribute."
);
}
};
まず上から、createVNodeFromRealElement関数から見てみるか
// 本物のElementからVNodeを作成するための関数
const createVNodeFromRealElement = (
realElement: HTMLElement
): VirtualNodeType => {
if (realElement.nodeType === TEXT_NODE) {
return createTextVNode(realElement.nodeName, realElement);
} else {
const VNodeChildren: VirtualNodeType[] = [];
const childrenLength = realElement.childNodes.length;
for (let i = 0; i < childrenLength; i++) {
const child = realElement.children.item(i);
if (child !== null) {
const childVNode = createVNodeFromRealElement(child as HTMLElement);
VNodeChildren.push(childVNode);
}
}
const props: VirtualNodeType["props"] = {};
if (realElement.hasAttributes()) {
const attributes = realElement.attributes;
const attrLength = attributes.length;
for (let i = 0; i < attrLength; i++) {
const { name, value } = attributes[i];
props[name] = value;
}
}
const VNode = createVNode(
realElement.nodeName.toLowerCase(),
props,
VNodeChildren,
realElement,
null
);
return VNode;
}
};
引数のrealElementはHTMLElement型か。
このchildNodesというプロパティは、HTMLElementの祖先であるNodeインタフェースで定義されてる
const childrenLength = realElement.childNodes.length;
定義元
interface Node extends EventTarget {
...
readonly childNodes: NodeListOf<ChildNode>;
...
NodeListってのはHTMLCollectionと違って「DOMの変更に対して静的」という特徴があるやつ
つまりNodeListという配列に似てるやつの中にChildNodeが複数入ってる、という型がchildNodesと言える
このchildrenというプロパティは、Elementインタフェースの継承元であるParentNodeインタフェースが定義している
const child = realElement.children.item(i);
定義元
interface ParentNode extends Node {
...
readonly children: HTMLCollection;
つまりchildrenは、HTMLCollectionという配列っぽいもの。
ちなみにさっきのNodeListとは違いHTMLCollectionはDOMの変更に対して動的に追跡できる。
んでそのchildrenのitem()は、まぁ大体予想できるけど配列のインデックスみたいなやつ。
つまり要素を返すメソッド。
interface HTMLCollectionBase {
...
item(index: number): Element | null;
そこで生まれる疑問:
なぜchildren.lengthではなくchildNodes.lengthの数だけループを回すのか
const childrenLength = realElement.childNodes.length;
for (let i = 0; i < childrenLength; i++) {
const child = realElement.children.item(i);
つまりなぜNodeListの数だけfor文を回すのに、実際にchiildとして取得するのはNodeListではなくHTMLElementであるchildrenなのか
なんで別々でやってるんだ?統一すべきでは?
その答えになりそうな情報は、そもそもchildNodesに入っているものとchildrenに入っているものは全然違うので当然lengthも違うということ。
例えばこういうHTMLがあったとして、
<div id="example">
<p>あ</p>
<p>い</p>
<p>う</p>
</div>
jsでこう書いたとする。
const myElement = document.getElementById("example");
const nodelist = myElement.childNodes;
const htmlcollection = myElement.children;
console.log('nodelistは', nodelist);
console.log('nodelistの長さは', nodelist.length);
console.log('htmlcollectionは', htmlcollection);
console.log('htmlcollectionの長さは', htmlcollection.length);
すると結果はこうなる
NodeListのtextというのは\n
つまり改行であって、textNodeである
pに関しては、NodeListもHTMLCollectionもまったく同じでelementNodeである
つまりNodeListの方が、改行(textNode)の分、要素数(length)が長くなる。
ただ、勿論HTMLをこういう風に改行無しで書いているのであれば、
<div id="example"><p>あ</p><p>い</p><p>う</p></div>
NodeListもHTMLCollectionもlengthは同じになる
以上を踏まえて、なぜNodeListの分だけループしているのにchildはHTMLCollectionとして取得しているのかを考えてみる
const childrenLength = realElement.childNodes.length;
for (let i = 0; i < childrenLength; i++) {
const child = realElement.children.item(i);
if (child !== null) {
const childVNode = createVNodeFromRealElement(child as HTMLElement);
VNodeChildren.push(childVNode);
}
}
ん-、全然わからん。nodelistの長さじゃなくてhtmlelementの長さ分回して、その中でhtmlelementを取得する、で良い気がするが...
一旦無視。
とりあえずこのスコープでやってることをまとめる↓
- 実DOM要素のnodelistの長さだけforを回して
- その数だけhtmlcollectionを取得しようとして
- そのhtmlcollectionからvnodeを作って
- createVNodeFromRealElement関数が再帰的に呼び出されている
- つまり親要素のVNodeを作り出そうとする過程で、その子要素のVNodeも作り出す必要がある、だからVNode作成関数であるcreateVNodeFromRealElementを再帰的に呼び出している、ということ
- createVNodeFromRealElement関数が再帰的に呼び出されている
- VNodeChildrenという最初に空の配列として初期化したやつにpushしていく
次のスコープ
const props: VirtualNodeType["props"] = {};
if (realElement.hasAttributes()) {
const attributes = realElement.attributes;
const attrLength = attributes.length;
for (let i = 0; i < attrLength; i++) {
const { name, value } = attributes[i];
props[name] = value;
}
}
やってることを言語化してみる
- まずpropsという空のオブジェクトで初期化した変数を用意
- 実DOM要素が属性を1つ以上持っているかチェックし、持っていたら処理開始
- まずその属性自体をすべてattributesという名前で取得
(このattributesはNamedNodeMapという型で定義されてる)
interface Element extends Node, ARIAMixin, Animatable, ChildNode, NonDocumentTypeChildNode, ParentNode, Slottable {
readonly attributes: NamedNodeMap;
- んで属性の数をattrLengthとして取得
- 属性の数だけforを回す
- 各属性の属性名をname、属性の値をvalueとしてattributesから取得できるので、それぞれ取得
- 最初に初期化したprops変数の中にname: valueの形でpushしていく
んで最後のスコープはこれ
const VNode = createVNode(
realElement.nodeName.toLowerCase(),
props,
VNodeChildren,
realElement,
null
);
return VNode;
処理を言語化していく
createVNodeが再登場か。中身はこれ
const createVNode = (
name: VirtualNodeType["name"],
props: VirtualNodeType["props"],
children: VirtualNodeType["children"],
realNode?: VirtualNodeType["realNode"],
nodeType?: VirtualNodeType["nodeType"],
key?: KeyAttribute
): VirtualNodeType => {
return {
name,
props,
children,
realNode: realNode === undefined ? null : realNode,
nodeType: nodeType === undefined ? null : nodeType,
key: key === undefined ? null : key,
};
};
ほぼそのまま返すけど、再利用性向上 & 書式統一 のためにかます関数なんだろうなぁという予想をしたやつ。
これを使ってる。
- まず1行目
realElement.nodeName.toLowerCase(),
これは、DOMノードのnodeNameは絶対大文字で返ってくるのだが、
それを小文字にしておきたいという思いがあるのでそうしている、という感じだろう。
(なぜ小文字にしておきたいのかはしらんけど...)
- んで2行目以降はそのまま渡してる感じ。
- でその結果作られたVNodeを返してる。
これでcreateVNodeFromRealElement関数の中身は終わり。
説明も見てみる
また実際の要素からVNodeを作成するためのcreateVNodeFromRealElement関数も作成しています。
createVNodeFromRealElement関数は実際の要素からchildrenとpropsを取得してその値をcreateVNode関数もしくはcreateTextVNode関数に渡してVNodeを作成します。
うん、そやね。おけ
次はrenderNode関数を見ていく
軽くしか書かれてないので軽く見ていく
あとで細かくやるらしい
const renderNode = (
parentNode: HTMLElement,
realNode: VirtualNodeType["realNode"],
oldVNode: VirtualNodeType | null,
newVNode: VirtualNodeType
) => {
//...色んな処理
}
親ノード、実DOMノード、古いVNode、新しいVNodeを受け取って、いろいろやる感じか。
VNodeが2つあるってことはreconcileつまり差分計算してそうだな
てか子ノードの情報を持っておくとかはあったけど親ノードの情報を持つのは初かも。なぜ持つのか理由は結構気になる。
説明も見てみる
render関数はrenderNode関数の呼び出しをするための関数です。renderNode関数は再帰的に呼び出しをするので呼びだすための関数としてrender関数を定義しています。
へぇ、再帰的に呼び出す処理が入ってるらしい。
とりあえずOK。最後にrender関数を見ていく。
追記:親の情報を持つ理由がわかった
render関数はこれ
(あーもしかしてreconcileしてるのって実はrenderじゃなくてrenderNode関数なのかな)
export const render = (
realNode: ElementAttachedNeedAttr,
newVNode: VirtualNodeType
) => {
if (realNode.parentElement !== null) {
let oldVNode: VirtualNodeType | null;
// realNodeごと追加更新削除処理につっこむ!
const vnodeFromRealElement = createVNodeFromRealElement(realNode);
if (realNode.vdom === undefined) {
oldVNode = { ...vnodeFromRealElement };
} else {
oldVNode = realNode.vdom;
}
vnodeFromRealElement.children = [newVNode];
newVNode = vnodeFromRealElement;
renderNode(realNode.parentElement, realNode, oldVNode, newVNode);
} else {
console.error(
"Error! render func does not work, because the realNode does not have parentNode attribute."
);
}
};
render関数はrenderNode関数の呼び出しをするための関数です。renderNode関数は再帰的に呼び出しをするので呼びだすための関数としてrender関数を定義しています。
これ先に見てみたけどよく意味がわからないのでとりあえずコードを先に読んでから考えるか
※このrender関数には一点混乱しやすいポイントがあります。
最初に要素を追加する際には以下コードのような元からHTMLファイルに書かれている要素を基準として追加されると思います。ただこの際にrenderNode関数にrealNodeとして<div id="app"></div>を渡し、parentNodeとしてbody要素を渡します。これは<div id="app"></div>をparentNodeとして渡してしまうとrealNodeとして渡す要素がなくなってしまう為です。そしてrenderNode関数ではrealNodeも追加更新削除処理の対象に含めて処理します。
まず引数として受け取るrealNodeにparentElementが無かったらエラーで終了するようになってる。
parentElementっていうのはこういう風に定義されてるやつ。親ノードを取れる。
interface Node extends EventTarget {
readonly parentElement: HTMLElement | null;
逆に親が存在しないノードってあるか?というと、bodyの親であるhtmlには親が存在しない。nullになる。
なので多分body以下のnodeならすべてOKってことだろう。
- if文の中に入ったら、まず引数として受け取ったrealNodeからVNodeを作る
const vnodeFromRealElement = createVNodeFromRealElement(realNode);
- んでrealNodeにvdomプロパティがあるかどうか判定しているが...
if (realNode.vdom === undefined) {
実DOMにはvdomなんてプロパティ無くね?て思ったけど、そういえばvdomは最初に作ってたの忘れてた。
これ
// eventNameはinputやsubmit,click等のoninput等のon以降の文字の小文字が入る
interface HandlersType {
[eventName: string]: (event: Event) => void;
}
type ElementAttachedNeedAttr = HTMLElement & {
vdom?: VirtualNodeType;
eventHandlers?: HandlersType; //handlersにイベントを入れておいてoninput等のイベントを管理する
};
type TextAttachedVDom = Text & {
vdom?: VirtualNodeType;
};
type ExpandElement = ElementAttachedNeedAttr | TextAttachedVDom;
interface VirtualNodeType {
...,
realNode: ExpandElement | null; //実際の要素への参照
...,
}
なのでrealNodeは実DOMノードという名前から想像されるHTMLElementそのものではなく、それを拡張したものでvdomというプロパティも持っていることがあるよ、というのは忘れないようにしよう。
名前に惑わされないようにせねば
なのでこれはどういうことかというと、
if (realNode.vdom === undefined) {
oldVNode = { ...vnodeFromRealElement };
} else {
oldVNode = realNode.vdom;
}
- まずif文の中(realNodeにvdomがなかった場合)
さっきただ宣言だけしておいたoldVNodeに{ ...vnodeFromRealElement }
を代入する。
vnodeFromRealElementはさっきの、realNodeから作ったばかりのほやほやVNode。
これをスプレッド演算子で展開している、つまりシャローコピーしている。オブジェクト自体の参照自体を共有している訳ではない。
- 次にelseの中(realNodeにvdomがあった場合)
oldVNodeにrealNode.vdomを直接代入。「ん?参照渡ししちゃっていいのか?それだとrealNode.vdomも変更の影響受けちゃうぞ?」と思ったが、
// realNodeごと追加更新削除処理につっこむ!
というコメントがあるので、あえてやっているんだろう。
(...なんでシャローコピーと参照渡しで分けてるんだろう、統一しない理由が気になる)
んで最後これで終わり
vnodeFromRealElement.children = [newVNode];
newVNode = vnodeFromRealElement;
renderNode(realNode.parentElement, realNode, oldVNode, newVNode);
- ほやほやvnodeのchildrenプロパティに、
[newVNode]
を代入 - その変更後のほやほやvnodeをnewVNodeに代入
- つまり
newVNode -> ほやほやvnode -> newVNode
という奇妙な流れができてる
newVNodeは引数で受け取るVirtualNodeType型のやつ。
なぜ直接代入せずにわざわざ配列で囲むんだろう?と思ったが、もともとchildrenプロパティは配列の中にVNodeがたくさん入っているという型だったので、それか。
interface VirtualNodeType {
children: VirtualNodeType[]; //子要素のVNodeのリスト
しかしなぜnewVNodeをほやほやvnodeのchildrenに入れるの?
うーん、わからん。そもそも引数のrealNodeとnewVNodeが何を指してるのかわからないので当然わからん。
だけどoldVNode = realNode.vdom;
とあるように、realNodeは変更前の実DOMノードみたいな感じだと思われる。
じゃあnewVNodeは、変更が適用された後のvnodeみたいな?
(変更されなかった可能性もあるので「reconcileの結果を反映しているvnode」と言った方が良いか)
だから多分仮想DOM②の一部かな。
vnodeFromRealElement.children = [newVNode];
がわからん。
newVNodeって単数形だから、vnodeが1個だけ入ってるはず。
でもchildrenプロパティって配列の中に複数のvnodeが入るものなので、そうなると謎。
= [newVNodes]
とかだったら理解できるんだけれども。
つまり、本来配列の中に複数のvnode(オブジェクト)が入ってるものの中に、配列の中に1つのvnode(オブジェクト)が入ってるものを代入してるのが奇妙。理由がわからない。
それに加えて、そもそもなんでchildrenなのかもまったくわからない。そのnewVNodeがほやほやvnodeの子ノードであるかどうかなんてまったく関係ない話のように感じるのだが...
考えられる仮説としては、realNode(引数①)の子ノードの仮想ノードがnewVNode(引数②)として渡されるという前提がある、とか?
つまり親から子にわたっていく段階でrender関数は呼ばれるようになってて、引数にも親と子を渡すようになってて、みたいな前提があるとか、ならギリありえるかなぁぐらい
次の(2行目の)これも、当然意味が分からない。
newVNode = vnodeFromRealElement;
vnodeFromRealElementのchildrenとしてnewVNodeを代入したのに、今度はvnodeFromRealElementつまり親をnewVNodeに代入する、という謎。
うーん、まぁ起きていることを一応言語化だけしておくと、
newVNodeを子として代入しておくが、newVNodeはすぐに親を代入され、実質親となる。
newVNodeの値があっちいったりこっちいったりしてるし、そもそも何なのかわかんないし、で謎すぎて混乱するな。まぁ一旦飛ばそう。
- 最後にrenderNodeを呼び出す
renderNode(realNode.parentElement, realNode, oldVNode, newVNode);
引数は
ということでやはり実際のreconcileはrender関数ではなくrenderNode関数がやってるのか...
(わかりづらい命名だな...名前逆にすべきでは?)
以上、わかったことはrealNodeは変更がまだ反映されてない古いやつっぽいよなぁということぐらいしかない。
だがこのあとrenderNode関数の詳しい解説があるのでそっちを見ていく
うーん、やはりnewVNode(現在のVNode)はすでに更新処理が終わった後のものっぽい
// 1. 以前と変わっていない場合何もしない処理
if (newVNode === oldVNode) {}
その更新処理そのものを見たかったなぁ。newVNodeがいかにしてnewVNodeになったのかがまったくわからなくてイメージしづらい。
まぁ、この記事の立ち位置としては、そこまではカバーしていないということなんだな。
あくまで更新が起きたあと何をするのかなどが書かれているのであって、更新の内容・過程とかは書かれてないっぽいな。
(setterのバッチ処理などをどう実際に仮想DOMへの更新に落とし込むのか、など)
そうか、確かに「仮想DOMの作り方」だから「仮想DOMにどのように更新を適用するのか」などは違う話になるのか。
数学で言うと「公式とそれを使った問題の解き方」は書かれてるが、「その公式をどのように導出したのか」は書かれてない、みたいな感じか。
なるほど。じゃあそこは別で取り組んでいこう。
ということで読み進めていく
renderNode関数の処理を6つに分けていくそう
1. 以前と変わっていない場合何もしない処理
これだけだとなんか味気ないので、ここでrenderNode関数の引数の説明もしておきます。
要素とは?追加先とは?
- parentNode: 要素の追加先(これは更新しない)
実DOMノードへの変更はrenderではなくcommitフェーズなので更新しちゃダメじゃない?
- realNode: 更新する実際の要素
すでに更新しているのがnewVNodeなのであれば、もうrealNodeを更新する必要は無くない?
- newVNode: realNodeに対応している更新後のVNode、これと更新前のVNodeを比較して差分を取る
あーでもあれかも、renderフェーズは「あとは実DOMにこの更新内容を適用するだけですよ」ていうcommitフェーズの直前まではやるので、それをやるためにrealNodeが必要なのかも。
だから別にrealNodeを実際に更新する訳ではないが、「のちに更新されるはずの実際の要素」という意味で、持っておく必要があるのかもしれない
2. Text要素の更新、消去処理
renderTextNode関数のこれは、
if (typeof newVNode.name === "string") {
文字を表すvnodeなら必ずnameがstringになるからだな
interface VirtualNodeType {
name: HTMLElementTagNameMap | string; // divやh1等の場合はHTMLElementTagNameMap、文字を表すVNodeの場合はstring型
んで、DOMのこのnodeValueプロパティというのが何なのかというと、
realNode.nodeValue = newVNode.name;
定義はこれ
interface Node extends EventTarget {
nodeValue: string | null;
公式はこれ
なるほど、ただの要素だとnullになる。テキストとかコメントとかだとその中身の文字列が入ってるのか
試してみたら確かに要素はnullになるので、要素の中にテキストとかコメントとか入れてそれを見てみるとちゃんとnodeValueがあった
テキスト
コメント
改行とスペースも入る感じだな
それを踏まえて、改めてこれを考える
realNode.nodeValue = newVNode.name;
まず、すでにこのif文は通過済みなので、
if (typeof newVNode.name === "string") {
newVNodeがテキストノードかコメントノードであることはほぼ確定。
(さっき見た通りCDATASectionノードとProcessingInstructionノードってやつもnodeValueがnullにならないっぽいけど、聞いたことないんでまぁ多分この2つは例外だろう。知らんけど)
んでそのnewVNodeのnameプロパティをrealNode.nodeValueに代入してる。
型定義のところでは
文字を表すVNodeの場合はstring型
としか書かれていなかったが、nodeValueに対応するということなのでnewVNode.nameはコメントやテキストの中身の文字列そのものが入っているのだろうと推測できる。
んでrenderTextNode関数の最後
return realNode;
でrealNodeを返す。
だがそれ以外にも例外的な場合のエラー処理とかも書いてあるので一応見るか
if (realNode !== null) {
if (typeof newVNode.name === "string") {
realNode.nodeValue = newVNode.name;
return realNode;
} else {
console.error(
"Error! renderTextNode does not work, because rendering nodeType is TEXT_NODE, but newNode.name is not string."
);
return realNode;
}
} else {
console.error(
"Error! renderTextNode does not work, because redering nodeType is TEXT_NODE, but realNode is null. can't add text to node"
);
return realNode;
}
なるほど、
- realNodeがなぜかnullのとき
- newVNode.nameがなぜかstring型じゃないとき
にエラーをはいてからそのままrealNodeを返す、て感じ
どういう場合にその例外が起きるのかはちょっと気になるが
んでそのrenderTextNode関数は、renderNode関数内で以下のように使われるので、
if (newVNode === oldVNode) {
} else if (
oldVNode !== null &&
newVNode.nodeType === TEXT_NODE &&
oldVNode.nodeType === TEXT_NODE
) {
realNode = renderTextNode(realNode, newVNode);
}
まぁ、けっこう限られたシーンで使われるな、という感じ。
いや、そうでもないか。
つまりVNodeに変更が見られたが、どちらもtextNodeであったということは共通している、というときに呼び出されるのがrenderTextNode。
つまりただ、文字を違う文字に変えた、みたいなときに呼び出される。
この処理で更新前の文字をnewVNodeで指定された文字に変更します。
しかし、ということはrenderTextNode関数は実DOMに変更を加えてしまうというcommitフェーズの動きをしているな・・・
この1行で。(自分も試したけど画面の文字が実際に変更された)
realNode.nodeValue = newVNode.name;
renderという名前がついてるのにcommitもしてるのはすごく違和感あるが、まぁこの記事では明確にそこらへんは区別していないのだろう。
ということでとりあえず次に行く
あとでこの行の前後にalertとかつけて、実際にこの行で画面が変更されるのか確認してみよう
3. 要素の追加、削除、<div>から<span>のような要素の種類自体を変えた時の入れ替え処理
この追加削除入れ替え処理ですが、これはとても単純でcreateRealNodeFromVNode関数で作成した要素をinsertBeforeしたり前からあった要素をremoveChildをしてるだけです。
const renderNode = (...) => {
if (newVNode === oldVNode) {
} else if (
oldVNode !== null &&
newVNode.nodeType === TEXT_NODE &&
oldVNode.nodeType === TEXT_NODE
) {
realNode = renderTextNode(realNode, newVNode);
}
// 要素の追加、削除、もしくは<div>から<span>等、要素の種類自体を変えた時の入れ替え処理
else if (oldVNode === null || oldVNode.name !== newVNode.name) {
const newRealNode = createRealNodeFromVNode(newVNode);
if (newRealNode !== null) {
parentNode.insertBefore(newRealNode, realNode);
}
if (oldVNode !== null && oldVNode.realNode !== null) {
parentNode.removeChild(oldVNode.realNode);
}
...
}
}
あーなるほど!parentNodeをわざわざ引数から受け取る理由がわかった。
insertBeforeやparentNodeによって実DOMを変更する際に必要だからか!
それぞれの実DOMノードの参照を常に持っておくなんてのは不可能で(削除されたりするので)、だから毎回insertBeforeとかすることになるが、その際にparentNodeを基準にしてinsertしていく、という感じか。
んで変更前のrealNodeの直前に新しいノードをinsertしておいて、
parentNode.insertBefore(newRealNode, realNode);
それが終わったらもうrealNodeは古いから消しちゃう、という流れか。
...と思ったが消すのはrealNodeではなくoldVNode.realNodeなのか...
parentNode.removeChild(oldVNode.realNode);
realNodeの隣にnewRealNode作ったので、realNodeが二つできちゃうから前者のrealNodeは消しちゃった方が良いと思ったんだが。
oldVNode.realNodeとrealNodeは参照を共有してるからどっちか消せばどっちも消される、みたいなことになってるのか?...わからん
次に、じゃあそのnewRealNodeはどうやって作ったのかという話だが、そこでcreateRealNodeFromVNode関数というのが登場する。
この部分
const newRealNode = createRealNodeFromVNode(newVNode);
関数の定義はこれ
const createRealNodeFromVNode = (VNode: VirtualNodeType) => {
let realNode: ElementAttachedNeedAttr | TextAttachedVDom;
if (VNode.nodeType === TEXT_NODE) {
if (typeof VNode.name === "string") {
realNode = document.createTextNode(VNode.name);
// NOTE 要素を新しく作成する場合はchildrenに対してcreateRealNodeFromVNodeを再帰的に
// 呼んでいる関係でここでVNodeとrealNodeの相互参照を作成する
VNode.realNode = realNode;
realNode.vdom = VNode;
} else {
console.error(
"Error! createRealNodeFromVNode does not work, because rendering nodeType is TEXT_NODE, but VNode.name is not string"
);
return null;
}
} else {
realNode = document.createElement(VNode.name as string);
for (const propName in VNode.props) {
patchProperty(realNode, propName, null, VNode.props[propName]);
}
// NOTE 要素を新しく作成する場合はchildrenに対してcreateRealNodeFromVNodeを再帰的に
// 呼んでいる関係でここでVNodeとrealNodeの相互参照を作成する
VNode.realNode = realNode;
realNode.vdom = VNode;
for (const child of VNode.children) {
const realChildNode = createRealNodeFromVNode(child);
if (realChildNode !== null) {
realNode.append(realChildNode);
}
}
}
return realNode;
};
(ここ2つもif必要?最初のtextNode確定だけで良いと思うんだが。textNodeの中でも複数の種類があるとかそんなことはないはずだし)
if (VNode.nodeType === TEXT_NODE) {
if (typeof VNode.name === "string") {
ほう
このcreateRealNodeFromVNode関数はText要素を作る部分とHTMLElement要素を作る部分の二つがあります。
これは、
Text要素を作る部分は単純でただdocument.createTextNodeを呼んでいるだけです。
ここやね
realNode = document.createTextNode(VNode.name);
ちなみに今更だがvdomのnameプロパティの型であるHTMLElementTagNameMapの定義はこれ
interface HTMLElementTagNameMap {
"a": HTMLAnchorElement;
"abbr": HTMLElement;
"address": HTMLElement;
"area": HTMLAreaElement;
"article": HTMLElement;
"aside": HTMLElement;
"audio": HTMLAudioElement;
"b": HTMLElement;
"base": HTMLBaseElement;
"bdi": HTMLElement;
"bdo": HTMLElement;
"blockquote": HTMLQuoteElement;
"body": HTMLBodyElement;
"br": HTMLBRElement;
"button": HTMLButtonElement;
"canvas": HTMLCanvasElement;
"caption": HTMLTableCaptionElement;
"cite": HTMLElement;
"code": HTMLElement;
"col": HTMLTableColElement;
"colgroup": HTMLTableColElement;
"data": HTMLDataElement;
"datalist": HTMLDataListElement;
"dd": HTMLElement;
"del": HTMLModElement;
"details": HTMLDetailsElement;
"dfn": HTMLElement;
"dialog": HTMLDialogElement;
"div": HTMLDivElement;
"dl": HTMLDListElement;
"dt": HTMLElement;
"em": HTMLElement;
"embed": HTMLEmbedElement;
"fieldset": HTMLFieldSetElement;
"figcaption": HTMLElement;
"figure": HTMLElement;
"footer": HTMLElement;
"form": HTMLFormElement;
"h1": HTMLHeadingElement;
"h2": HTMLHeadingElement;
"h3": HTMLHeadingElement;
"h4": HTMLHeadingElement;
"h5": HTMLHeadingElement;
"h6": HTMLHeadingElement;
"head": HTMLHeadElement;
"header": HTMLElement;
"hgroup": HTMLElement;
"hr": HTMLHRElement;
"html": HTMLHtmlElement;
"i": HTMLElement;
"iframe": HTMLIFrameElement;
"img": HTMLImageElement;
"input": HTMLInputElement;
"ins": HTMLModElement;
"kbd": HTMLElement;
"label": HTMLLabelElement;
"legend": HTMLLegendElement;
"li": HTMLLIElement;
"link": HTMLLinkElement;
"main": HTMLElement;
"map": HTMLMapElement;
"mark": HTMLElement;
"menu": HTMLMenuElement;
"meta": HTMLMetaElement;
"meter": HTMLMeterElement;
"nav": HTMLElement;
"noscript": HTMLElement;
"object": HTMLObjectElement;
"ol": HTMLOListElement;
"optgroup": HTMLOptGroupElement;
"option": HTMLOptionElement;
"output": HTMLOutputElement;
"p": HTMLParagraphElement;
"picture": HTMLPictureElement;
"pre": HTMLPreElement;
"progress": HTMLProgressElement;
"q": HTMLQuoteElement;
"rp": HTMLElement;
"rt": HTMLElement;
"ruby": HTMLElement;
"s": HTMLElement;
"samp": HTMLElement;
"script": HTMLScriptElement;
"search": HTMLElement;
"section": HTMLElement;
"select": HTMLSelectElement;
"slot": HTMLSlotElement;
"small": HTMLElement;
"source": HTMLSourceElement;
"span": HTMLSpanElement;
"strong": HTMLElement;
"style": HTMLStyleElement;
"sub": HTMLElement;
"summary": HTMLElement;
"sup": HTMLElement;
"table": HTMLTableElement;
"tbody": HTMLTableSectionElement;
"td": HTMLTableCellElement;
"template": HTMLTemplateElement;
"textarea": HTMLTextAreaElement;
"tfoot": HTMLTableSectionElement;
"th": HTMLTableCellElement;
"thead": HTMLTableSectionElement;
"time": HTMLTimeElement;
"title": HTMLTitleElement;
"tr": HTMLTableRowElement;
"track": HTMLTrackElement;
"u": HTMLElement;
"ul": HTMLUListElement;
"var": HTMLElement;
"video": HTMLVideoElement;
"wbr": HTMLElement;
}
なのでタグ名が文字列で入っている感じ
なのでelse内のこれは、
realNode = document.createElement(VNode.name as string);
そのタグ(要素)のノードを普通に作ってるだけ。
でfor inでpropsの中をループしていく。
なのでpropNameにはid
とかclass
とかname
とかそういう文字列が入る。
for (const propName in VNode.props) {
patchProperty(realNode, propName, null, VNode.props[propName]);
}
ほう。とりあえずなんとなく。
patchProperty関数でVirtualNodeType型で定義していたpropsプロパティから実際の属性を作成した要素に付与していきます。
patchProperty関数は後に細かくやるらしいのでいったんok
んで次に、またこの2行があるがコードの必要性がわからない。
この変更によって助かってるところが見当たらないような...
なので「今は全然使わないけど、最後の最後に使うときに更新されてないと困るよね、だから今のうち更新しておくよ」的な感じなのかなぁと妄想
// NOTE 要素を新しく作成する場合はchildrenに対してcreateRealNodeFromVNodeを再帰的に
// 呼んでいる関係でここでVNodeとrealNodeの相互参照を作成する
VNode.realNode = realNode;
realNode.vdom = VNode;
あーなんとなくわかったかも
この処理はとても大事でこれを使って次の更新の際のoldVNodeを取ったりします。本来はrenderNode関数の最後に書いて全て処理してしまうのですがこのcreateRealNodeFromVNode関数に関しては子要素に対して再帰的に呼び出す処理があるためこちらで書く必要がありました。
まず1行目の
VNode.realNode = realNode;
の必要性から。
そもそもcreateRealNodeFromVNode関数が引数として受け取ってるVNodeってなんだったけ?から考えてみると、renderNode関数で受け取るnewVNode。
つまり更新後の新しいVNode。
で、次回更新の際にまたrenderNode関数が呼ばれた時、そこで渡されるoldVNodeつまり古いVNodeは、前回のnewVNodeつまり新しいVNodeということになる。(はず)
んでoldVNodeはrenderNode関数で使っている。createRealNodeFromVNode関数を呼び出す前も後も。
else if (oldVNode === null || oldVNode.name !== newVNode.name) {
const newRealNode = createRealNodeFromVNode(newVNode);
if (newRealNode !== null) {
parentNode.insertBefore(newRealNode, realNode);
}
if (oldVNode !== null && oldVNode.realNode !== null) {
parentNode.removeChild(oldVNode.realNode);
}
...
}
というかそもそも引数で受け取っている。
const renderNode = (
parentNode: HTMLElement,
realNode: VirtualNodeType["realNode"],
oldVNode: VirtualNodeType | null,
newVNode: VirtualNodeType
) => {}
だからその際にoldVNode.realNodeがそれまでの最新情報を反映してくれてないと困るよね、だから必要だよね、ということか。
んで2行目の
realNode.vdom = VNode;
も必要な理由があって、単純にrealNodeもrenderNode関数の引数として呼ばれるから。
const renderNode = (
parentNode: HTMLElement,
realNode: VirtualNodeType["realNode"],
oldVNode: VirtualNodeType | null,
newVNode: VirtualNodeType
) => {}
だからちゃんと更新しとかないと、次回更新時にrenderNode関数が呼ばれた時にそれまでの(前回までの)最新情報が保存されてないと不都合が生じるよね、ということ。
だと思われる。なるほど。
ん、?いやでも、それならrenderNode関数の最後に書けばいいのになぁ。
で、なんでそうしないのかというと
本来はrenderNode関数の最後に書いて全て処理してしまうのですがこのcreateRealNodeFromVNode関数に関しては子要素に対して再帰的に呼び出す処理があるためこちらで書く必要がありました。
ということだが、よく理解できてない。
これってつまり「子要素を再帰的に呼び出す際に、それまでの最新情報が保存できてないと困る」ってことだと思うが、そうは思えないんだよな...。
ん、いやわかったかも。
単純に「親だけじゃなくて子も全部最新情報を保存しとかなあかん」ってだけの話か。
renderNode関数の最後に書いちゃうと、createRealNodeFromVNode関数が実行された際に再帰処理も発生していろいろ処理がめぐっていく過程において最新情報を保存しておくという処理が抜け落ちてしまうのか。
つまり親の更新しかできなくなっちゃう、ていう気がする。
なるほど。
んでcreateRealNodeFromVNode関数の最後、これ
for (const child of VNode.children) {
const realChildNode = createRealNodeFromVNode(child);
if (realChildNode !== null) {
realNode.append(realChildNode);
}
}
patchProperty関数でVirtualNodeType型で定義していたpropsプロパティから実際の属性を作成した要素に付与していきます。その後、その要素のchildrenに対してもcreateRealNodeFromVNode関数を適応させていきappendを呼びだして子要素として実際の要素に追加していきます。
また再帰的に呼び出してるな。やっぱ親の処理中に子も処理したいみたいな場合は再帰なんだな
const realChildNode = createRealNodeFromVNode(child);
まずこれで、vnodeの子を実ノードに変換
const realChildNode = createRealNodeFromVNode(child);
んでそれをほやほやrealNodeに追加する。
realNode.append(realChildNode);
appendを使ってるので自動でvnodeのchildrenプロパティに追加される。
で最後にrealNodeを返してcreateRealNodeFromVNode関数は終了。
return realNode;
ただふと思ったんだけど、この部分ってvnodeから関数とか使わずただ実ノードに変換してるわけだが、
realNode = document.createElement(VNode.name as string);
...それできるなら全部それでよくない?vnode->実ノード への変換をするためにわざわざcreateRealNodeFromVNode関数とか作る必要が無い気がしてきたんだが。
vnode.nameを見てそのタグ名を使ってcreateElementするだけで良いよなぁ、なんでそうしないんだろう
まぁ、ただ単に「それ以外にも代入処理したりpatchProperty関数使ったり、いろいろやりたいことあるから関数としてまとめてる」っていう感じかな、たぶん
4. patchProperty関数とイベント処理
おあずけされていたpatchProperty関数の中身を見ていく
忘れないように、呼び出され方はこれ(createRealNodeFromVNode関数内)
realNode = document.createElement(VNode.name as string);
for (const propName in VNode.props) {
patchProperty(realNode, propName, null, VNode.props[propName]);
}
ほう
patchProperty関数では主に以下のように処理を分けられます。
- プロパティ名がkeyだった場合
- 最初の2文字が"on"から始まるイベント系
- 以前あった属性を削除する場合
- 属性を追加もしくは更新する場合
あと、どうやら何もreturnせずに、ただ処理するだけの関数っぽい
・プロパティ名がkeyだった場合
なるほど
まずプロパティ名がkeyだった場合の解説です。keyだった場合は何も変えてはいけません。keyは更新時に要素を特定するためのプロパティなので変えてしまうと差分検出処理の効率が悪くなってしまいます。
これやね
const patchProperty = (
realNode: ElementAttachedNeedAttr,
propName: DOMAttributeName,
oldPropValue: any,
newPropValue: any
) => {
// NOTE key属性は一つのrealNodeに対して固有でないといけないから変更しない
if (propName === "key") {
}
・最初の2文字が"on"から始まるイベント系
次は最初の二文字が"on"から始まるイベント系です。
まずこれでon○○かどうかチェック
else if (propName[0] === "o" && propName[1] === "n") {
次の行
const eventName = propName.slice(2).toLowerCase();
例えばonClickが入ってるとすると、onより後の文字を小文字にするのでclickになる、て感じ
次
if (realNode.eventHandlers === undefined) {
realNode.eventHandlers = {};
}
新しくプロパティを作り、空で初期化してる感じ
実際の要素にeventHandlersというプロパティを追加し、それにオブジェクトを代入します。
このrealNodeはElementAttachedNeedAttr型だが、それはこうなっていたことを忘れずに
type ElementAttachedNeedAttr = HTMLElement & {
vdom?: VirtualNodeType;
eventHandlers?: HandlersType; //handlersにイベントを入れておいてoninput等のイベントを管理する
};
なのでeventHandlersというプロパティは最初から想定されていたことがわかる。
ちなみにeventHandlersの型であるHandlersTypeはこれ
// eventNameはinputやsubmit,click等のoninput等のon以降の文字の小文字が入る
interface HandlersType {
[eventName: string]: (event: Event) => void;
}
次
realNode.eventHandlers[eventName] = newPropValue;
newPropValueが関数ってことか。そうだったけ?
更にそのオブジェクトのイベント名のプロパティにイベントが発火した時に発動する関数を代入します。
確認したら確かにそうだった。createRealNodeFromVNode関数内でpatchProperty関数が呼ばれる時を見なおすとこれ
patchProperty(realNode, propName, null, VNode.props[propName]);
第四引数つまりnewPropValueとして設定されているものはVNode.props[propName]
。
VNode.props[propName]
はpropName(onClickなど。つまりkey)に対応する値である。
んでもうonで始まるpropNameであることはifの条件分岐により確定しているので、ということはそれに対応する値は関数である。
onclick: function handleClick() {}
みたいになってるはず。
なのでその関数をrealNode.eventHandlers[eventName]
に代入している。
realNode.eventHandlers[eventName]
とは何かだけど、
まずrealNode.eventHandlers
がさっきのこれ
// eventNameはinputやsubmit,click等のoninput等のon以降の文字の小文字が入る
interface HandlersType {
[eventName: string]: (event: Event) => void;
}
んでその[eventName]
というキーを指定しているので、そのキーに対応する値は関数であることが確認できる。
理解。
次
if (
newPropValue === null ||
newPropValue === undefined ||
newPropValue === false
) {
realNode.removeEventListener(eventName, listenerFunc);
} else if (!oldPropValue) {
realNode.addEventListener(eventName, listenerFunc);
}
そしてeventHandlersプロパティに代入された関数を呼び出すlistenerFuncという関数に対してaddEventListenerをします。
まずifを通過するのはどういう場合かと言うと、
propnameがon○○だったのにも関わらずその値としての関数は入っていなかった、という場合。
その場合、realNodeのそのeventに登録されているリスナはremoveしている。
realNode.removeEventListener(eventName, listenerFunc);
で次にelse if (!oldPropValue)が通る場合だが、そもそも呼び出されるときに
patchProperty(realNode, propName, null, VNode.props[propName]);
のようにoldPropValueはnullで設定されているので絶対に通るのでは?という感じはする。
つまり、普通にちゃんとon○○の値も関数として存在していた場合、このelse ifは通過するはず。
その場合はrealNodeのそのeventに関数をイベントリスナとして登録する。
realNode.addEventListener(eventName, listenerFunc);
んで、肝心のlistenerFunc関数はこれ
// NOTE ElementAttachedNeedAttr.handlersに存在する関数を呼びだすだけの関数
// イベント追加時にこれをaddEventListenerする事でイベント変更時にElementAttachedNeedAttr.handlersの関数を変えるだけで良い
const listenerFunc = (event: Event) => {
const realNode = event.currentTarget as ElementAttachedNeedAttr;
if (realNode.eventHandlers !== undefined) {
realNode.eventHandlers[event.type](event);
}
};
ただイベントハンドラつまりイベントリスナ?を実行するだけみたいな
よくわかってないので読み進める
realNode.eventHandlers: { click: (event) => {console.log(`clickEvent: ${event.currentTarget.value}`)}, //onclick select: (event) => {console.log(`selectEvent: ${event.currentTarget.id}`)}, //onselect ... }
このようにすることで発火する関数を変えたい時にaddEventListenerやremoveEventListenerをする必要がなくなり、eventHandlersのそのイベント名のプロパティの関数を変えるだけで済むようになります。
ん-と、切り替えるだけで良いというのはそうだろうけど
切り替えるだけで機能するためにはそもそも事前に関数を設定しておかないといけないと思うが、それはどうやってるんだ?
あーそれがさっきのこれか
realNode.eventHandlers[eventName] = newPropValue;
てか、これの意味がよくわからない
このようにすることで発火する関数を変えたい時にaddEventListenerやremoveEventListenerをする必要がなくなり、eventHandlersのそのイベント名のプロパティの関数を変えるだけで済むようになります。
普通に使ってるやないか...と思ってしまう
realNode.addEventListener(eventName, listenerFunc);
これは「関数を変えたい時」ではないってこと?
じゃあどの「時」だ?これは
さかのぼって考えると、
そもそもこれを実行してる関数はpatchProperty関数で、その関数を呼び出すのはcreateRealNodeFromVNode関数。つまりレンダー中とかに呼び出されるはず。
つまりバッチ処理として更新が一気に行われてる最中の話。
で、それは「関数を変えたい時」ではない、というのはまぁイメージできるっちゃあできるか。
もうそのハンドラを持ってた要素は消されたよ、とかそういう話もあり得るので、その場合は「変更」ではなく「削除」だし、
新しいハンドラを持った要素が作られたよ、という場合なら「追加」だし。
なのでレンダー中にイベントハンドラが変更されるということはない、なので、さすがにこの場合はaddEventlistenerとか使ってokです、という感じか。
「じゃあいつ変更すんねん」「関数を変えるときはaddEventListenerとかしないで済むんですってそれいつやねん」て話だけども、
多分それはレンダー中ではなくユーザーのイベントを検知した時ってことなのかな?多分。
なので今のところまでのpatchProperty関数の役割をまとめると、
- propNameが"key"だったらスキップするよ
- イベントハンドラの関数を、イベントごとに事前に設定しておくよ
- これによって、ユーザーのイベントなどでハンドラの関数が変更されるときはaddEventlistenerとかをわざわざせずともプロパティを切り替えるだけで済むようになるよ
- とはいえ今はレンダー中だから、addEventlistenerとかremoveEventListenerとかを普通に使うよ(使わざるを得ない)
って感じか。
てかだったら、「もはやaddEventListenerとかもしないでええやん、なんでそこはaddEventListenerちゃんとやんねん」て思ったけれども、
「さすがに初めて(?)登録する際はaddEventListenerっていうv8エンジンとかそっち系(?)の低レイヤーに繋がってるメソッド使わないと何もできなくなっちゃうからそれは使わせてや」
「1回それ使っちゃえば、その使った後にどんな関数を実行したいのか使いたい関数を変更する方法とかそこらへんは自由・柔軟に設定すればいいからさ」
みたいな感じじゃないかなぁと妄想
次、やっとpatchPropertyの最後
・以前あった属性を削除する場合
・属性を追加もしくは更新する場合
一気に2つやる。
else if (newPropValue === null || newPropValue === undefined) {
realNode.removeAttribute(propName);
} else {
realNode.setAttribute(propName, newPropValue);
}
次に以前あった属性を削除する場合についてです。
これは変更後の属性がnullまたはundefinedだった場合に処理します。
この処理にはremoveAttributeを使用します。最後に属性を追加更新する場合についてです。
この処理はsetAttributeを使用して処理します。
newPropValueは元々VNode.props[propName]
として呼び出される。つまりその属性の値。
propNameはその属性のキー。
newPropValueがなかった場合にこれ
realNode.removeAttribute(propName)
あった場合にこれ
realNode.setAttribute(propName, newPropValue)
作りたてほやほやのrealNodeに、vnodeのpropsの状態をただ同期するだけって感じかな
ただ、もともとrealNodeは
realNode = document.createElement(VNode.name as string);
という風に作られているので、そもそもprops作られてないからremoveする必要無いと思うんだが...
このように言っているので、「以前はその属性があった」ことが確定らしいが、なんでそう言えるかがわからない
以前あった属性を削除する
のでなぜremoveAttributesする必要があるのかを、前にどんどん戻りながら考えてみる
まず改めて、removeAttributeはこうなってる
else if (newPropValue === null || newPropValue === undefined) {
realNode.removeAttribute(propName);
それはpatchProperty関数内。引数はこれ
const patchProperty = (
realNode: ElementAttachedNeedAttr,
propName: DOMAttributeName,
oldPropValue: any,
newPropValue: any
) => {
patchProperty関数の呼び出し元はこれ。
const createRealNodeFromVNode = (VNode: VirtualNodeType) => {
...
realNode = document.createElement(VNode.name as string);
for (const propName in VNode.props) {
patchProperty(realNode, propName, null, VNode.props[propName]);
}
createRealNodeFromVNode関数の呼び出し元はこれ
const renderNode = (
parentNode: HTMLElement,
realNode: VirtualNodeType["realNode"],
oldVNode: VirtualNodeType | null,
newVNode: VirtualNodeType
) => {
...
else if (oldVNode === null || oldVNode.name !== newVNode.name) {
const newRealNode = createRealNodeFromVNode(newVNode);
renderNode関数の呼び出し元はこれ
export const render = (
realNode: ElementAttachedNeedAttr,
newVNode: VirtualNodeType
) => {
...
renderNode(realNode.parentElement, realNode, oldVNode, newVNode);
やっぱ謎だなぁ...
結局これで、
realNode = document.createElement(VNode.name as string);
新しく作られたrealNodeに属性なんてあるはずがないのに、それに対してremoveAttritubuteしてる理由がわからない。
realNode.removeAttribute(propName);
一旦飛ばす
「createRealNodeFromVNode関数からだけではなく他のところでもpatchProperty関数は呼ばれる可能性があるよ、だから一応removeの処理は書いておく必要があるよ」みたいなそういう話なのかなぁ
5. 要素の更新処理(子要素に関する処理は含まず)
こうあるんだが、削除はしてないように思えるぞ...?
以下のコードのようにmergePropertiesという関数を使って古いVNodeのプロパティと新しいVNodeのプロパティを合併しています。これには古いVNodeにはあったプロパティが新しいVNodeでは削除されてた場合しっかりとそのプロパティが実際の要素(realNode)から削除されるようにするという目的があります。
コードはこれ
const mergeProperties = (oldProps: DOMAttributes, newProp: DOMAttributes) => {
const mergedProperties: DOMAttributes = {};
for (const propName in oldProps) {
mergedProperties[propName] = oldProps[propName];
}
for (const propName in newProp) {
mergedProperties[propName] = newProp[propName];
}
return mergedProperties;
};
これって更新してるだけで削除はできないと思う。
(あとnewPropはnewPropsの間違いではないだろうか)
newPropの中のキーをループしていき、それに対して更新をかけている訳だが、そもそも削除されたものはnewPropの中に存在しないので、削除することはできないはず。
なので削除すべきものはmergedPropertiesオブジェクトの中に残り続けるのでは?
これ、絶対if文通っちゃうと思う。newVNode.props[propName]
の間違いでは?
if (compareValue !== newVNode.props) {
それで読み替えていくか
input要素の入力欄が変わるたびにinputElement.valueが動的に変わるみたいなことかな?理解
また以下のコードのようにinputやselect等の入力系の処理は要素自体が自動でその要素が持つ値をを変えてくれます。
もし、もう既に要素の値が変わっているのに更新処理をしてしまったらその処理は無駄になってしまいます。なのでこういった入力系の属性はif文で場合分けしています。
updateOnlyThisNodeはこのように使われてる
const renderNode = (...) => {
...
// 要素の追加、削除、もしくは<div>から<span>等、要素の種類自体を変えた時の入れ替え処理
else if (oldVNode === null || oldVNode.name !== newVNode.name) {
...
}
// 要素の更新
else {
// 要素の更新処理本体
realNode = updateOnlyThisNode(realNode, oldVNode, newVNode);
...
}
なのでこのrealNodeしか更新できないのがupdateOnlyThisNode関数なので、realNodeの子要素にはどうやって変更を適用するのかを次章で確認していきたい。
(単純にrealNodeのchildrenに対して再帰的にまたupdateOnlyThisNode関数を実行するだけかな?)
6. 子要素の追加、更新、削除処理
ほうほう
この章では以下のように処理ごとに分けて実装していきます。
- 準備
- 子要素の更新処理の基本
- 既に更新したkeyを持つoldVNodeのスキップ処理
- keyを持っていない子要素の削除処理
- keyを持っていない子要素の更新処理
- keyを持っている子要素の更新処理
- keyを持っている子要素の更新処理
- 必要のない子要素の削除処理
- 仕上げ
へぇ。要素を消す&新しく追加する のではなくて要素の「位置」だけ変える、みたいなイメージ?
そしたら、次にkey属性について考えなければいけません。このkey属性を持っている要素はchildrenリスト内での順番が入れ替わっていたりしても削除したり追加したりしてはいけません。仮想DOMの効率をよくするために変更前の要素を更新するという形で処理しなければならないのです。
keyだけでいいんだ
keyだけでも、そのkeyはnewVNodeと対応づけることができてるってこと?へぇ
// 同じく子要素の追加や削除処理の為に必要な為作成
// NOTE keyを持つnewVNodeで既に更新されたものはこちらのオブジェクトで記録されている。
const renderedNewChildren: { [key in KeyAttribute]: "isRendered" } = {};
これよくわからない。keyを持ってる変更前の要素は削除せず更新すると言ってたはずだが。
それを実現するために最初に以下のコードを書いてhasKeyOldChildrenとrenderedNewChildrenというオブジェクトを作成します。
この二つのオブジェクトを使って子要素の処理の最後にまとめてkeyを持った要素の削除処理を行います。
子要素の更新処理の基本
へぇ
更新処理は以下のコードのようにnewVNodeのchildrenすべてを比較するまで行います。
この際にoldVNodeのchildrenがどこまで比較されたかを気にする必要はありません。削除するべきoldVNodeが削除されているかは最後にしっかりとチェックされるからです。以下のコードのwhileループの中ではnewVNodeのchildrenをすべて比較すれば全部オーケーです。
ほーなるほど、だからforループじゃないんだ。スキップという発想は無かった。
また補足ですが、この更新処理でfor文等でchildrenを直接ループさせずindexでループさせているのはoldVNodeの子要素をスキップしたりといったようにfor文でループさせる方式だと難しい処理があるからです。
なぜnewの方はoldと違ってundefinedかどうかをチェックしないんだろう
const newChildVNode = newVNode.children[newChildNowIndex];
const newKey = newChildVNode.key;
newVNodeの子ノードは絶対keyが存在するということ?なぜ?
あるノードにキーをつけた状態でその親を新しいノードとして作るから、みたいことを他でしているのか?でもそのあるノードってなんやねん、になるしなぁ
いや、そもそもReactの仕様として、
「keyが設定されていない子を持つ親として新しいノードを作成しようとするとWarningが出るとかエラーが出るとかそういうのがあるから、newVNodeの子ノードはすべてキーを持つことは確定している」
みたいな論理があるのかもしれない
でもそれだったらoldVNodeもキーは持ってるはずだなぁ
わからん
既に更新したkeyを持つoldVNodeのスキップ処理
if (oldKey !== null && renderedNewChildren[oldKey] === "isRendered") {
oldChildNowIndex++;
continue;
}
キーはnullではない(=存在する)のに、レンダーは既にされている、そしてoldVNode === newVNode
ではない
↓
ということはキーを持つ要素の順番が入れ替わっている(などの状態だ)からだ
という論理が成り立つらしい。
こんな感じ
変更前
<div key="a"></div>
<div key="b"></div>
<div key="c"></div>
変更後
<div key="a"></div>
<div key="c"></div>
<div key="b"></div>
うーんと、全然わからん
そもそもrenderedNewChildren[oldKey]
を"isRendered"にするタイミングがいつかもまだ見てないのでわかってないのでこれはしょうがないとして、
この条件分岐でなぜ「キーを持つ要素がただ入れ替わっただけ(など)」という場合を指すことができるのかわからない。
if (oldKey !== null && renderedNewChildren[oldKey] === "isRendered") {
あと、キーを持つ要素はなんで更新しないの?位置はさすがに更新しないといけないはずでは?
この処理をすることでkeyを持たない要素の更新処理を効率よくできる確率が高まります。
とりあえず読み進めてみる
keyを持っていない子要素の削除処理
この処理ではnewVNodeでは確実に存在しない、keyなしの要素を削除します。
その判定方法は次に比較をする予定のoldVNodeのkeyが現在比較をしているnewKeyの値と同じだった時です。
// NOTE keyを持っていない削除するべき要素を削除する処理
// ※keyを持っている削除するべき要素は最後にまとめて削除する
if (
newKey !== null &&
oldChildVNode !== null &&
oldChildVNode.children[oldChildNowIndex + 1] !== undefined &&
newKey === oldChildVNode.children[oldChildNowIndex + 1].key
) {
// keyのない要素は以前のrenderの時と同じ位置になかったら削除する
if (oldKey === null) {
realNode.removeChild(
oldChildVNode.realNode as ElementAttachedNeedAttr
);
}
oldChildNowIndex++;
continue;
}
あー、この例を見ながらだと理解できた
更新前
<h1>Will delete</h1>
<div key="Hello">Hello</div>
<div key="World">World</div>
<div key="!!!">!!!</div>
//更新後
<div key="Hello">Hello</div>
<div key="World">World</div>
<div key="!!!">!!!</div>
「次のoldが今のnewと同じってことは、今のoldは消されてると言える」という発想は凄い。確かに
ただ、ここでまたkeyが出てきたけど、これはどういうことだ?
// keyのない要素は以前のrenderの時と同じ位置になかったら削除する
if (oldKey === null) {
realNode.removeChild(
oldChildVNode.realNode as ElementAttachedNeedAttr
);
}
keyが無い要素は、位置が変わった瞬間ゲームオーバーつまり消されるってことか。
じゃあkeyがあったら一変わっても消さないよ、てことのはず。
うーん、やっぱり謎の特別扱いかぁ。
keyってそんな特別扱いされるもんだっけ?keyをよくわかっていない気がしてきた。
keyって、例えば<ul>の内部で連続する<li>のような要素をそれぞれ一意に識別するための識別子、ていう感じよね?
その上でなぜ特別扱いされるのか考えると...
あー、確かにそうか。
「前と同じ位置に同じ要素が無いぞ!」と差分を検知しても、その要素にキーがあれば「あぁ前回あそこにいたやつか。てことはあいつがここに移ったってことね」と断定できたり、「一旦保留しといて、前回どこにいたやつなのか探しに行くか」とかいろいろやれるから、消す必要がないのか。
それがさっきのこれに繋がるのか、なるほど
そしたら、次にkey属性について考えなければいけません。このkey属性を持っている要素はchildrenリスト内での順番が入れ替わっていたりしても削除したり追加したりしてはいけません。仮想DOMの効率をよくするために変更前の要素を更新するという形で処理しなければならないのです。
ただoldの子ノードにキーが無い場合はrealnodeの子ノードを削除するってのはどういうことだ?
// keyのない要素は以前のrenderの時と同じ位置になかったら削除する
if (oldKey === null) {
realNode.removeChild(
oldChildVNode.realNode as ElementAttachedNeedAttr
);
}
位置が変わってるだけかもしれないのに問答無用で消しちゃってるのがよくわからんのだが。
以下2点を整理して考えてみるか
- 今どういう条件分岐を通過してきているのか
- 何を消しているのか
- 今どういう条件分岐を通過してきているのか
- oldKeyがnullだったら
if (oldKey === null) {
(oldKeyがnullになっているということはさっきのここを通過していることになる)
2. oldVNode.children[oldChildNowIndex]
がundefinedだったら
if (oldVNode.children[oldChildNowIndex] === undefined) {
oldChildVNode = null;
oldKey = null;
}
- newKeyがnullじゃなかったら
- oldChildVNodeがnullじゃなかったら
-
oldChildVNode.children[oldChildNowIndex + 1]
がundefinedじゃなかったら - newKeyが
oldChildVNode.children[oldChildNowIndex + 1].key
と同じだったら
if (
newKey !== null &&
oldChildVNode !== null &&
oldChildVNode.children[oldChildNowIndex + 1] !== undefined &&
newKey === oldChildVNode.children[oldChildNowIndex + 1].key
)
んん??2でoldChildVNode = null;
されているはずなのに4の「oldChildVNodeがnullじゃなかったら」という分岐を通過してきていることになる。
矛盾だなぁ、なぜだろう
であれば、さっきのこれはifではなくelseに通過したということか
if (oldVNode.children[oldChildNowIndex] === undefined) {
oldChildVNode = null;
oldKey = null;
} else {
oldChildVNode = oldVNode.children[oldChildNowIndex];
oldKey = oldChildVNode.key;
}
つまりoldVNode.children[oldChildNowIndex]
はundefinedではなく、存在したことになる。
んで、次にこれを通る
if (
newKey !== null &&
oldChildVNode !== null &&
oldChildVNode.children[oldChildNowIndex + 1] !== undefined &&
newKey === oldChildVNode.children[oldChildNowIndex + 1].key
)
さっきoldVNode.children[oldChildNowIndex] === undefined
が成立しない(elseに入った)状態でそれをoldChildVNodeに代入したことに加えて、oldChildVNode !== null
であることになる。
つまりoldChildVNodeはundefinedでもnullでもない、つまり何かしら実体があるということがわかる。
んで最終的にこれに到達する。
if (oldKey === null) {
oldKeyの代入元は、さきほどのこれ
oldKey = oldChildVNode.key;
つまり、oldChildVNodeは実体があるのだが、oldChildVNode.keyはnullである、という状態だと結論づけることができる。
とりあえず条件分岐の流れは追えた
- 何を消しているのか
これ
// keyのない要素は以前のrenderの時と同じ位置になかったら削除する
if (oldKey === null) {
realNode.removeChild(
oldChildVNode.realNode as ElementAttachedNeedAttr
);
}
realNodeの子ノードであるrealNodeを消してる。
んでその子realNodeは、oldChildVNode.realNodeとして保持されていたもの。
「oldChildVNode.key(=oldKey)がnullだったのでそのoldChildVNodeが保持しているrealNodeとまったくおなじものを、realNodeの子ノードから消す」ということをしている。
うーん..?
あ、そもそもここを通過しているのは重要かも
if (
newKey !== null &&
...
)
つまりnewKeyはnullじゃないということ。
なのにoldKeyはnullというのが今。
ここからわかることは、「keyが無い要素が置かれていたはずの場所に、keyがある要素が置かれている」という更新が見られたということ。
んで、それに加えて1個先のoldKeyは今のnewKeyと同じだと判明している。
newKey === oldChildVNode.children[oldChildNowIndex + 1].key
そういう状況で、子realNodeを最終的にremoveしている。
realNode.removeChild(
oldChildVNode.realNode as ElementAttachedNeedAttr
);
あー、なんとなくわかったかもしれない。
keyがnullのoldChildVNodeが消されてると判明
↓
「keyがnullじゃなかったらこの後で対応するkeyを持つノードを見つけられたら対応付けることができるからまだ保留しておいてもよかったけど、今回はkeyがnullの時点でそれ不可能だし、現状この位置では消されてると言っても良いので、消すわ」
↓
「ただ移動してるだけだったとしても、それはまたその位置に処理が到達したときに、新規作成という形で更新することにするわ」
多分雰囲気としてはこんな感じか
その仮説が正しければ、こうまとめることができそう
「絶対にそのrealNodeを消していいとは断定できないが、消されてるっぽい感じではあるので消しちゃおう。もし移動しただけだとしても、keyが無いからそれは判別できないので、削除/新規作成で何とか対応するしかないよね。keyみたいに更新っていう形は不可能だよね。」
だってこれって、
更新前
<h1>Will delete</h1>
<div key="Hello">Hello</div>
<div key="World">World</div>
<div key="!!!">!!!</div>
//更新後
<div key="Hello">Hello</div>
<div key="World">World</div>
<div key="!!!">!!!</div>
こうなってただけの可能性もあるもんな
更新前
<h1>Will delete</h1>
<div key="Hello">Hello</div>
<div key="World">World</div>
<div key="!!!">!!!</div>
//更新後
<div key="Hello">Hello</div>
<div key="World">World</div>
<div key="!!!">!!!</div>
<h1>Will delete</h1>
うん、多分自分の理解は正しいはず。
よし、とりあえずok
なるほど、key有りの削除は最後か
// NOTE keyを持っていない削除するべき要素を削除する処理
// ※keyを持っている削除するべき要素は最後にまとめて削除する
んで最後にoldのindexだけプラスして、次のwhile文に飛ぶのか
oldChildNowIndex++;
continue;
なるほど、確かにそうすればoldとnewのindexが次からまた同じように対応づいて、比較を続けていくことができるからか
理解
ふと思ったこと:
次のoldと今のnewの子同士のキーが同じであることはさっき言ったように確定しているので、oldIndexを1足して終わりにするんじゃなくて、oldIndexを2足して、newIndexを1足すまでやって良いのでは?
だって次のif比較で絶対
oldChildVNode.children[oldChildNowIndex] === newChildVNode.children[newChildNowIndex]
がtrueになることはわかり切ってるので、わざわざ比較するのが無駄な気がする
あーでも、それはそうだとしても、その比較するスコープ以降の処理でその子の更に子を再帰処理で同じように見ていくみたいなことがありそう!それをやるためにも必要なのか。(知らんけど)
なるほどー、理解
keyを持っていない要素の更新処理
多分「keyを持っていない子要素の更新処理」のことっぽい
// keyを持っていない子要素の更新処理
if (newKey === null) {
if (oldKey === null) {
renderNode(
realNode as ElementAttachedNeedAttr,
oldChildVNode === null ? null : oldChildVNode.realNode,
oldChildVNode,
newChildVNode
);
newChildNowIndex++;
}
oldChildNowIndex++;
}
さっきはoldKeyがnullだがnewKeyはnullじゃないという場合だったが、
今回はoldKeyもnewKeyもnullの場合。
しかし何かしらの更新がされていることは確定している状態。
その場合renderNode関数を再帰呼び出しするだけでokらしい。
どういうことか考えてみよう
なるほど
これの判定方法は結構大雑把です。もしかしたらoldVNodeは<div>要素でnewVNodeは<span>要素かもしれません。けれどこの処理で呼びだしているrenderNode関数には要素の種類が入れ替わった時用の処理があります。なので効率は少し落ちるかもしれませんが最終的にはしっかりと画面に反映されます。
この処理の際に効率よく要素を更新できるようにrenderNode関数内ではいろいろと工夫しています。
そういえば要素の更新処理、renderNode関数内にあったな、どこだっけ
あった、ここらへんか
確かにupdateOnlyThisNodeとかpatchPropertyとかいろいろ使ってrealNodeの更新をやってくれてるので、そこに渡すだけでokて感じか。
さっきはoldKeyが無くてnewKeyはある状態かつoldが消されてるっぽい状況だったので、その場合は削除/新規作すべきだったが、
今回の様にkeyが無いノード同士なら削除/新規作成ではなく更新で済むってことなんだな。
なぜ今回は更新だけで良いんだろう
「実は更新ではなく位置が変わっているだけ」の可能性もあるのは同じだよなぁ
「位置は変わっていない」という前提をとりあえずおいて、「だが更新はされている。ということは中身が変わったと捉える」という流れを踏んでいるのかもしれない
でもそんな根拠もない前提を置くか?普通
あー、「そもそも位置変わるってなんやねん、そんなこと起こらないだろ」と言う感じかもしれない。
だからさっきも「次のoldと今のnewのkeyが同じなら削除確定やろ、移動なんかしないし」と言えたのかもしれない
たしかに移動ってなんやねんって思ってきた...自分で言っといて
なので今回も、keyがnull同士かつ更新はされている、という状態なので、シンプルに位置が同じまま中身が変わったんだろうなと推測できるため更新で済ませる、ということかも
とりあえずそれで理解
keyを持っている子要素の更新処理
keyを持ってる要素の更新処理は二通りの条件分岐があります
1通り目
現在のoldKey===newKeyだった場合です。こちらの方で更新処理ができるとkeyを持っていない要素の更新処理をする際に効率よくできる確率が高まります。
ここ
// 以前のrender時とkeyが変わっていなかった場合、更新
if (oldChildVNode !== null && oldKey === newKey) {
const childRealNode = oldChildVNode.realNode;
renderNode(
realNode as ElementAttachedNeedAttr,
childRealNode,
oldChildVNode,
newChildVNode
);
renderedNewChildren[newKey] = "isRendered";
oldChildNowIndex++;
}
てか忘れかけてたけど、realNodeって、realNodeというより「親としての」realNodeなんだよな
更新なので、また取り合えずrenderNodeを再帰呼び出しして更新してる感じ
んで最後にこれやるだけ
renderedNewChildren[newKey] = "isRendered";
oldChildNowIndex++;
newChildNowIndex++;
は最後にちゃんとやるので、今はoldだけ足してる
renderedNewChildrenってどんな形のやつだっけ
あーこれか
// 同じく子要素の追加や削除処理の為に必要な為作成
// NOTE keyを持つnewVNodeで既に更新されたものはこちらのオブジェクトで記録されている
const renderedNewChildren: { [key in KeyAttribute]: "isRendered" } = {};
なるほど、「keyを持つnewVNodeで既に更新されたものはこちらのオブジェクトで記録されている」という説明の通り、すでにrenderNodeの実行によってnewVNodeも更新が終わっているのでそのキー(とそれに対応する"isRendered")をrenderedNewChildrenに保存しておくわけか
そうすればさっきのここで役立つ
// 既にrenderされているoldChildVNodeをスキップする処理
if (oldKey !== null && renderedNewChildren[oldKey] === "isRendered") {
oldChildNowIndex++;
continue;
}
理解
2通り目
oldKey===newKeyではありませんでしたがoldVNode.childrenの中にそのkeyに対応する要素があった時に場合です。hasKeyOldChildrenオブジェクト内にnewKeyプロパティの値が存在するかを確認して判定します。どちらの方法のrenderNode関数を呼び出して要素を更新しています。
また、この際にkeyを持っている子要素のnewVNodeの更新が完了したらrenderedNewChildrenオブジェクトのkeyの名前のプロパティに「既にこのキーの要素は更新したよ」フラグを代入します
ここ
if () {
// 以前のrender時とkeyが変わっていなかった場合、更新
} else {
const previousRenderValue = hasKeyOldChildren[newKey];
// 以前のrender時には既にこのkeyを持つ要素が存在していた場合
if (
previousRenderValue !== null &&
previousRenderValue !== undefined
) {
renderNode(
realNode as ElementAttachedNeedAttr,
previousRenderValue.realNode,
previousRenderValue,
newChildVNode
);
renderedNewChildren[newKey] = "isRendered";
}
//keyを持つ要素の追加処理
...
...
renderedNewChildren[newKey] = "isRendered";
}
hasKeyOldChildrenってなんだっけ
これか
// 子要素の追加や削除処理の為にoldVNodeでkeyがある要素の連想配列が必要な為作成
// NOTE keyを持つoldVNodeをすべて保存している
let hasKeyOldChildren: { [key in KeyAttribute]: VirtualNodeType } = {};
for (const child of oldVNode.children) {
const childKey = child.key;
if (childKey !== null) {
hasKeyOldChildren[childKey] = child;
}
}
なるほど、キーとそれに対応する古いVNodeが格納されてる
だからこのキーのどれかと一致するnewKeyを今持っているのであれば、そのnewKeyのVNodeは前回レンダー時に存在していたことになる
それがここに該当する
const previousRenderValue = hasKeyOldChildren[newKey];
// 以前のrender時には既にこのkeyを持つ要素が存在していた場合
if (
previousRenderValue !== null &&
previousRenderValue !== undefined
)
んで、その場合の肝心の処理内容はこれ
renderNode(
realNode as ElementAttachedNeedAttr,
previousRenderValue.realNode,
previousRenderValue,
newChildVNode
);
renderedNewChildren[newKey] = "isRendered";
さっきと似てるな
今回はpreviousRenderValue.realNode
を子realNodeとして引数に渡している。
やはりoldVNodeに対応するrealNodeがchildrenとして渡されるというのは同じようだ
...ていうか今更なんだけど、なぜそれが子になるのかわからないなぁ、調べ直すか
ふむふむ、確かにparentNodeは前にinsertBefore/removeChildとかしてた以外全然使われてなくて、realNodeばかり使われている感覚はあるな
これだけだとなんか味気ないので、ここでrenderNode関数の引数の説明もしておきます。
- parentNode: 要素の追加先(これは更新しない)
- realNode: 更新する実際の要素
- oldVNode: realNodeに対応している更新前のVNode、これと更新後のVNodeを比較して差分を取る
- newVNode: realNodeに対応している更新後のVNode、これと更新前のVNodeを比較して差分を取る
const renderNode = (
parentNode: HTMLElement,
realNode: VirtualNodeType["realNode"],
oldVNode: VirtualNodeType | null,
newVNode: VirtualNodeType
)
なるほどわかってきた、つまり、
「これが更新前の古いrealNodeや。あと更新後の新しいrealNodeとかも渡すから、それらを見て更新作業頼んだで」
と言うだけでやってくれるのがrenderNodeなので、第二引数にはその「更新前の古いrealNode」を突っ込む必要があって、今回はそれがpreviousRenderValue.realNode
なので突っ込んだって感じだな
なぜpreviousRenderValue.realNode
が古いかつ更新すべきものであると言えるのかというと、条件分岐をさかのぼってみるとわかる。
oldKey === newKeyではない、だがoldKeyの歴史(render済み)のうち1つが今のnewKeyと一致した
↓
ということはそのoldKeyのノードが今のnewKeyのノードに変身したことがわかる
↓
そのoldKeyのノードを更新すべき
↓
だからそれを子(更新すべきもの)として渡す
という流れだと思う
んでその後、これはさっきと同じなんだけど、
renderedNewChildren[newKey] = "isRendered";
さっきは最後にこれがあったが、今回は無い。
oldChildNowIndex++;
なんでだろう
見てみたけど後でプラスしてるってわけでもないんだよなぁ
まじでわからん。一旦飛ばす
keyを持っている子要素の追加処理
前回存在しなかったkeyをnewKeyとして持っている
↓
つまり完全に新しい要素(キー有)だ
↓
だから追加しよう
ということね
これ
// keyを持つ要素の追加処理
else {
renderNode(
realNode as ElementAttachedNeedAttr,
null,
null,
newChildVNode
);
}
renderedNewChildren[newKey] = "isRendered";
renderedNewChildren[newKey] = "isRendered";
はelse内に入れても変わらないし、入れた方が他と同じで統一感があって良い気がするが...まぁいいや
ただrenderNodeを読んでるだけって感じか。
しかもrenderNodeの引数である、
- parentNode: 要素の追加先(これは更新しない)
- realNode: 更新する実際の要素
- oldVNode: realNodeに対応している更新前のVNode、これと更新後のVNodeを比較して差分を取る
- newVNode: realNodeに対応している更新後のVNode、これと更新前のVNodeを比較して差分を取る
の4つのうち1と4しか渡してない。
差分なんて取る必要無くて、ただnewVNodeをcreateRealNodeFromVNodeによってrealNodeに変換してからそれをparentNodeにinsertBeforeするだけで良いからだな
これでやっとif (newKey === null) {} else {
のスコープが終了しようとしているわけだが、終了する直前にこれをやっている
newChildNowIndex++;
if (oldChildVNode !== null && oldKey === newKey) {} else {
のスコープの終了直後とも言える
あと、これがwhile (newChildNowIndex < newChildrenlength) {
内の最後の処理でもある
一区切りついたからインデックス足して次の比較へ向かう準備を済ませる、という感じか
必要のない子要素の削除処理
巨大な1個目のwhileは終了したので、2個目の小さいwhileに入る
ほう
この必要のない子要素を削除する処理は二つの処理があります。keyを持たない子要素を削除する処理とkeyを持つ子要素を削除する処理です。
keyを持たない子要素の削除処理
これ
// 前のwhile処理で利用されなかった到達しなかった子要素のindexのうちkeyを持っていないモノを削除
while (oldChildNowIndex < oldChildrenLength) {
const unreachOldVNode = oldVNode.children[oldChildNowIndex];
if (unreachOldVNode.key === null || unreachOldVNode.key === undefined) {
if (unreachOldVNode.realNode !== null) {
realNode.removeChild(unreachOldVNode.realNode);
}
}
oldChildNowIndex++;
}
あー確かに。なるほどなぁ
上で記述した子要素の追加更新処理はnewVNode.childrenの要素をすべて比較できたらそれで終了でした。ただその方式だとnewNodeの要素の数を減らした際に比較ができてない物が残り、newVNodeでは存在していないはずなのに実際の要素では存在しているkeyを持っていない子要素がある可能性があります。
なのでこの処理で未だに比較できてなくてkeyを持たない子要素をremoveChildで削除します。
あくまでループはwhile (newChildNowIndex < newChildrenlength) {
でnewの方だったもんな、なるほど
keyもっててももってなくても、newに無いなら消して良いのでは?と思ったが、多分それはそう。
実際、この後でも同じようにremoveしてる。
ただ、処理がちょっと違うから分けてるよ、ということなのだと思う。
とりあえず、キーが無い古いやつを消すわーていうのやってるのがこれ
keyを持つ子要素の中で更新後に削除されたものを実際に削除する処理
これ
// keyをもつoldVNodeの子要素の中で新しいVNodeでは削除されているものを削除
for (const oldKey in hasKeyOldChildren) {
if (
renderedNewChildren[oldKey] === null ||
renderedNewChildren[oldKey] === undefined
) {
const willRemoveNode = hasKeyOldChildren[oldKey].realNode;
if (willRemoveNode !== null) {
realNode.removeChild(willRemoveNode);
}
}
}
お、またhasKeyOldChildrenとrenderedNewChildrenが出てきた。
一応のせとく
// 子要素の追加や削除処理の為にoldVNodeでkeyがある要素の連想配列が必要な為作成
let hasKeyOldChildren: { [key in KeyAttribute]: VirtualNodeType } = {};
for (const child of oldVNode.children) {
const childKey = child.key;
if (childKey !== null) {
hasKeyOldChildren[childKey] = child;
}
}
// 同じく子要素の追加や削除処理の為に必要な為作成
const renderedNewChildren: { [key in KeyAttribute]: "isRendered" } = {};
古いやつで、キーを持っててまだレンダーされてないやつをどんどん消してく、という感じ
じゃあなんでさっきはrenderedNewChildrenをチェックしなかった?レンダーされてるかどうかをチェックしなかった?
キーを持ってないなら問答無用で消しちゃって良いけど、キーを持ってるならレンダーされてない場合のみ消そう、レンダーされてるなら消さないで残しとこう、という感じだと思うが、それはなぜ?
レンダーされてるなら、前回のレンダーで「これは必要だ」と判断したということになり、だから残す必要があると判断できる、みたいな感じかな?
このコードに到達する段階では、キーを持っててレンダーされてないみたいな取りこぼしがまだrealNodeに残ってる可能性があるんだろうな。それを拾う最後の砦なのだろう。
newには含まれていない = レンダーされてない ということのはずなので、だから消すべきという論理、のはず
仕上げ
これ
if (realNode !== null) {
// NOTE newVNodeに対応する実際の要素を代入する。これを次の更新の際に使う
newVNode.realNode = realNode;
// NOTE 今後更新する際に差分を検出する為実際のHTML要素に対してvdomプロパティを加える
// このvdomプロパティが次の更新の際のoldVNodeになる
realNode.vdom = newVNode;
}
return realNode;
最後に実際の要素とVNodeの相互参照を作成してrealNodeをreturnしたら仮想DOMの完成です。
この最後に追加した相互参照を使って次回の更新時にoldVNodeやrealNodeを取得します。
相互参照を作って次回の更新時の比較の土台を作る、という感じ。
newVNode.realNodeつまりvdomからdomへの参照は、次回の更新の際にrealNodeとして使う。
この型を見るに、parentNodeではなくrealNodeとして使われると思われる。
const renderNode = (
parentNode: HTMLElement,
realNode: VirtualNodeType["realNode"],
oldVNode: VirtualNodeType | null,
newVNode: VirtualNodeType
)
いやでもnewVNodeは次回oldになっちゃうので、それでいうとこれはoldVNode.realNodeとして扱われるはずか
realNode.vdomつまりdomからvdomへの参照は、次回の更新の際にoldVNodeとして使う
ということはこれ、次回の更新では、前者はoldVNode.realNodeで後者はoldVNodeとして扱われるといっていいかも
newVNode.realNode = realNode;
realNode.vdom = newVNode;
ていうか今更気付いたんだけど、renderNodeの第一引数以外は全部子として考えて良さそうだな
こんな感じで再帰呼び出しされてたりするし
renderNode(
realNode as ElementAttachedNeedAttr,
oldChildVNode === null ? null : oldChildVNode.realNode,
oldChildVNode,
newChildVNode
);
たとえ最初の呼び出しだったとしてもinsert先は親にせねばならないから第一引数以外は常に何かのchildrenになるって感じかな、多分
動かしてみよう
yarn startでエラーになるなぁ
gpt曰く
エラーの原因は、Node.js v20以降のOpenSSLライブラリの変更と、Webpack v4がそれに対応していないことです。
gptの通りコマンド打ったけど全然だめだったので記事を探しに行こう
(やっぱ生成aiってターミナルでのエラー解決に弱い気が)
これの通りexport NODE_OPTIONS=--openssl-legacy-provider
実行したらyarn startで画面にRun!がちゃんと出るようになった
一時的に環境変数を設定してるっぽい。
gpt曰く
はい、その方法も適切です!export NODE_OPTIONS=--openssl-legacy-provider を設定することで、OpenSSLの互換性モードを有効化し、Webpack v4とNode.js v20の互換性の問題を回避することができます。
(なんかconsoleにはすごいエラー出てるけど)
あーこれ
じゃなくて、完成系の の方をcloneすれば良かったなまぁいいや
index.tsとvirtualDom.tsをコピペ
わぉ、すごい、ちゃんと動いた
index.tsのコードを見ていくか
ちなみにh関数って何の略だ?とふと思ったので調べたら、hypescriptとやらの略らしい。知見だ
h() 関数は hyperscript の略で、「HTML(hypertext markup language)を生成する JavaScript」という意味です。
Hyper Text Markup Lauguageを生成することができるJavaScript
なるほど、なんとなくindex.tsもわかった。
-
input欄が変更されるたびにsetStateが呼び出され、画面への変更が適用される。
-
半角スペースを入れるたびに新しいdivが作成される。なぜならsetState内のcreateKeyList関数の処理内容で
split(' ')
してその結果としての各要素をmapで巡り、毎回divを作っているから。 -
createKeyList見て改めて思ったけど、子要素にしかkeyをつける必要は無い。だから親にはkeyはついてないはず、とわかる。
文字を消していくとちゃんとそのdivも消えるのが面白い。renderNode関数のremoveChildのおかげか。
どのremoveChildだろう?
keyがあるdivを消してる訳なので、oldKeyはnullでないけどnewKeyはnullの場合、みたいな感じのところがあるはず、探してみる
あった、多分これだと思う
// keyをもつoldVNodeの子要素の中で新しいVNodeでは削除されているものを削除
for (const oldKey in hasKeyOldChildren) {
if (
renderedNewChildren[oldKey] === null ||
renderedNewChildren[oldKey] === undefined
) {
const willRemoveNode = hasKeyOldChildren[oldKey].realNode;
if (willRemoveNode !== null) {
realNode.removeChild(willRemoveNode);
}
}
}
終了。丸6日かかった