どうもこんにちは、ウマシバ(@UMASHIBA)といいます!
先日、仮想DOMを個人的に実装してみまして、その際に得た知見を記事という形で共有したいと思います。
仮想DOMを作成するという内容上とても長い記事になってしまっていました。時間がある時や何日かに分けたりして読んでくださるとうれしいです。
それでは記事本文です。どうぞ、
はじめに
この記事はReactやVue, Angular等のモダンなフロントエンドフレームワークの基礎部分となっている仮想DOMを作ってみようという記事です。
形式は、先日私が作成した仮想DOMと同じモノを順を追って実装してみるという形でやります。
完成形はここ(https://github.com/UMASHIBA1/UMASHIBAVirtualDOM) にあります。
※もしよければスターください。すごくよろこびます。
この記事を読むには
- TypeScriptを読み書きできる能力
- HTMLの基本的な知識
- ReactやVue等の仮想DOMを用いたフレームワークを使用した経験
があれば大丈夫です。
仮想DOMの概要
まず最初に仮想DOMの概要について説明します。
仮想DOMの本体は基本的にただのオブジェクトです。
例えば、このようなhtml要素は
<div id="app">
<h1>Hello world</h1>
</div>
仮想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: []
}
]
}
]
}
また、下のように一個の要素を表すオブジェクトをVNodeと呼んでいます。
VNodeが階層構造になって一個のウェブページを表せるようになったのが仮想DOMです。
{
name: "div",
props: {id: "app"},
nodeType: null,
key: null,
realNode: ... //実際のDOMへの参照
children: [...]
}
更に仮想DOMではただの文字列もVNodeとして表現します。例えば下のようなものです。
{
name: "Hello World",
props: {},
nodeType: TEXT_NODE,
key: null,
realNode: ... //実際のDOMへの参照
children: []
}
ReactやVueなどそれぞれのフレームワークによってこの形式には多少違いがあるとは思いますがおおむねこのような形で仮想DOMは表現されているはずです。
実際にReactなどが要素を更新する時は、前の仮想DOMを表現したオブジェクトと新しい仮想を表現したDOMオブジェクトを比較して違いがあったところだけ更新しています。
これから作成するプログラムはこのようなオブジェクトをいじってうまく画面に変化を反映するという処理がほとんどです。
それでは頑張っていきましょう!
実際に作ってみる
出鼻をくじくようで申し訳ないんですが一点注意点がありまして、今回は簡略化のためにSVGには対応しません。本当はisSVGみたいなフラグを使って対応させないといけないんですがその点はご勘弁を...😔
さてそれでは気を取り直して実際に作っていきましょう!
開発環境を整える
この仮想DOMを作成するには開発環境を整える必要があります。
これに手間取るのは時間がもったいないので仮想DOMを作成するのに必要な開発環境を設定したリポジトリをGitHubに作りました。今回はこれをクローンして使いたいと思います。
リポジトリ: https://github.com/UMASHIBA1/VirtualDOM-tutorial-starter
まず以下のコマンドをターミナルで順に打ってもらえば開発環境の作成は完了するはずです。
git clone https://github.com/UMASHIBA1/VirtualDOM-tutorial-starter.git
cd .\VirtualDOM-tutorial-starter\
yarn install
yarn start
ここまでやったらブラウザが開いてRun!という文字が表示されると思います。そうしたら開発環境の設定は成功です!
実際に仮想DOMを作成するのに必要なファイルは
- virtualDom.ts: 実際に仮想DOMのコードを書き込むファイル
- index.ts: 作成した仮想DOMを読み込んで使うファイル
-
index.html: 仮想DOMのroot要素があるHTMLファイル
他のファイルは基本的にただの設定用ファイルです。
ほとんどのコードはVirtualDom.tsファイルに書き込む事になります。
それでは次の章から実際にコードを書いていきましょう!
型を定義する
まずは仮想DOMの型を定義します。
型の全体像
最初に仮想DOMで使う型の全体像置いておきます。このコードを見てもう分かった!という方はh関数の定義の章まで飛ばしてしまってください。
type TEXT_NODE = 3;
type KeyAttribute = string | number;
type DOMAttributeName = "key" | string;
// propにはkeyやonclick、class、id等のHTMLElementの属性の名前が入ります
interface DOMAttributes {
key?: KeyAttribute;
\[prop: string]: any; // ※QiitaのMarkDownの仕様なのかわかりませんが[]だけだと文字が消えてしまった為\を最初に入れてます。
}
// 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 {
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を表す
}
型の解説スタート
それでは型の解説を始めます。
以下のVirtualNodeType型を埋める形式で解説していきます。
このVirtualNodeTypeが一つのVNodeを表す型です。
interface VirtualNodeType {
name: HTMLElementTagNameMap | string; // divやh1等の場合はHTMLElementTagNameMap、文字を表すVNodeの場合はstring型
props: ...; // HTML要素の属性
children: VirtualNodeType[];
realNode: ...; // 実際のDOMへの参照
nodeType: ...; // このVNodeのタイプ(文字を表すノードなのか要素を表すノードなのか)
key: ...; // keyを表す
}
propsの定義
まず、propsを定義します。
propsは以下のようにidやclass、onclick等の属性を表すオブジェクトの型です。
props: {
id: "app",
class: "example"
},
下のコードのDOMAttributesがそれにあたります。
// keyとして許すのはstringとnumber型のみ
type KeyAttribute = string | number;
// 仮想DOMの処理に使うのでDOMAttribute型のkeyのUnion型もついでに定義しておく
type DOMAttributeName = "key" | string;
// propにはkeyとoninputやclass、id等のHTMLElementの属性の名前が入ります
interface DOMAttributes {
key?: KeyAttribute;
[prop: string] : any;
}
interface VirtualNodeType {
...
props: DOMAttributes; // HTML要素の属性
...
}
KeyAttribute
という型は<div key="key-example">
といったようなkey属性に対して代入することを許可する型を決めています。
また、DOMAttributesについて、本当は[props: string]: any;
のみでも大丈夫なんですが通常のHTML要素にはないkeyという属性があるということを明示するためにkey?: KeyAttribute;
というプロパティを追加しています。
realNodeの定義
次はrealNode
を定義していきます。
realNodeはそのVNodeに対応した実際のHTML要素への参照を入れてあります。
document.getElementById("app");
みたいなのを代入してあるということです。
以下がrealNodeを定義するのに関連する型です。
// 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型やText型などを拡張しています。
※ちなみにText型はHTMLの中にある"Hello World"といったような文字列をあらわしている型です。(詳しくはこちら)
ElementAttachedNeedAttr
やTextAttachedVDom
に付与されているvdom
というプロパティにはその要素に対応した仮想DOMを代入します。このプロパティは仮想DOMの更新処理の際に前の要素と新しい要素の差分処理のために使います。(※詳しくは後で解説します)
またElementAttachedNeedAttr
にはeventHandlers
というプロパティにHandlersType
という型が追加されていると思います。この**eventhandlers
は仮想DOMでDOMイベントを管理する処理をする際に使うプロパティ**です。これも後で詳しく解説します。とりあえず今はこういう型を使うんだなーとだけ思っていてください。
ちなみにrealNode
がnull
を受け取れるようにしているのは最初にあるHTMLへの要素を追加する(つまりReactでいう最初のrender)際にはどうやったってそのVNodeに対応する実際の要素など手に入らないからです。
nodeTypeの定義
そしたら次はnodeType
の定義をします。
型の定義は以下のようになっています。
type TEXT_NODE = 3;
interface VirtualNodeType {
...,
nodeType: TEXT_NODE | null,
}
TEXT_NODE
型はこのVNodeが"Hello World"
といった様なText要素を表すものであるということを明示する型です。
TEXT_NODE
型の値が3なのはNodeというインターフェースのプロパティであるNode.nodeTypeを参考にしています。
普通のHTMLElement要素の場合はnodeTypeをnullとします。
keyの定義
最後にkey
を定義します。
keyの型は以下のようになっています。
// type KeyAttribute = string | number; (propsを定義した際に定義済み)
interface VirtualNodeType {
key: KeyAttribute | null; //keyを表す
}
key
は同じような形式の要素を効率的に更新するためのプロパティです。key
は必須というわけではない為、key
のないVNodeにはnull
を渡します
h関数の作成
h関数の概要
次にh
関数を定義します。h
関数とはVNodeを作成するための関数でReactではcreateElmentが対応します。
最終的にこのように引数を渡したら
h("div", {id: "app"}, [
h("h1", {}, ["Hello World"])
])
下のような感じのVNodeオブジェクトを返してくれる関数を作りたいです。
{
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: []
}
]
}
]
}
h関数の実装
実際のコードはこんな感じです。
const TEXT_NODE = 3;
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,
};
};
const createTextVNode = (
name: string,
realNode?: VirtualNodeType["realNode"]
) => {
return createVNode(name, {}, [], realNode, TEXT_NODE);
};
export const h = (
name: VirtualNodeType["name"],
props: VirtualNodeType["props"],
children: (VirtualNodeType | string)[],
realNode?: VirtualNodeType["realNode"]
) => {
const VNodeChildren: VirtualNodeType[] = [];
for (const child of children) {
if (typeof child === "string") {
const textVNode = createTextVNode(child);
VNodeChildren.push(textVNode);
} else {
VNodeChildren.push(child);
}
}
const thisVNode = createVNode(
name,
props,
VNodeChildren,
realNode,
null,
props.key
);
return thisVNode;
};
それではコードの説明をします。
- createVNode関数: 実際にVNodeを作成します。
-
createTextVNode関数:
Text
用のVNodeを作成します。この関数で作成されたVNodeはnodeType
プロパティがTEXT_NODE
になっています。 -
h関数:
createVNode
関数とcreateTextVNode
関数を呼び出して最終的にVNodeを返す関数です。
これでh
関数は完成です。
ひとまず、h
関数の実装お疲れ様でした。
render関数の作成
render関数の概要
さて次は、render
関数というものを作ります。ReactではReactDOM.render関数が対応します
このrender
関数にVNodeを渡して実際に要素を追加したり更新したりします。
render
関数は最終的にこんな感じで使います。
const node = document.getElementById("app");
render(
node,
h("div", {}, [
h("h1", {}, ["Hello World"]), //タダの文字を表したい場合はh関数のchildrenに文字のみ渡す
);
引数を渡すと要素の追加や更新をしてくれるようにしたいです。
render関数の実装
それではrender
関数を実装していきます。
render関数は以下のようになっています。
// 本物の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."
);
}
};
render
関数はrenderNode
関数の呼び出しをするための関数です。renderNode
関数は再帰的に呼び出しをするので呼びだすための関数としてrender
関数を定義しています。
また実際の要素からVNodeを作成するためのcreateVNodeFromRealElement
関数も作成しています。
createVNodeFromRealElement関数は実際の要素からchildrenとpropsを取得してその値をcreateVNode
関数もしくはcreateTextVNode
関数に渡してVNodeを作成します。
※このrender関数には一点混乱しやすいポイントがあります。
最初に要素を追加する際には以下コードのような元からHTMLファイルに書かれている要素を基準として追加されると思います。ただこの際にrenderNode関数にrealNodeとして<div id="app"></div>
を渡し、parentNodeとしてbody
要素を渡します。これは<div id="app"></div>
をparentNodeとして渡してしまうとrealNodeとして渡す要素がなくなってしまう為です。そしてrenderNode関数ではrealNodeも追加更新削除処理の対象に含めて処理します。
<body>
<div id="app"></div>
</body>
renderNode関数
renderNode関数の概要
さてここからが本番です。renderNode
関数を作成します。頑張りましょう!
renderNode
関数は実際に要素を追加、更新、削除する関数です。
この関数は色んな処理をするのでとても行数が長くなってしまいます。なので処理ごとに章で区切って解説していきます。
処理の順序は以下のような形です。
- 以前と変わっていない場合何もしない
- Text要素、文字の更新、消去処理
- 要素の追加、削除、
<div>
から<span>
のような要素の種類自体を変えた時の入れ替え処理 - patchProperty関数とイベント処理
- 要素の更新処理(子要素に関する処理は含まず)
- 子要素の追加、更新、削除処理
1. 以前と変わっていない場合何もしない処理
const renderNode = (
parentNode: HTMLElement,
realNode: VirtualNodeType["realNode"],
oldVNode: VirtualNodeType | null,
newVNode: VirtualNodeType
) => {
// 1. 以前と変わっていない場合何もしない処理
if (newVNode === oldVNode) {}
}
文字通り以前と変わっていない場合何もしない処理です。
oldVNode
(以前のVNode)とnewVNode
(現在のVNode)が同じだった場合何もしません。
これだけだとなんか味気ないので、ここでrenderNode
関数の引数の説明もしておきます。
- parentNode: 要素の追加先(これは更新しない)
- realNode: 更新する実際の要素
- oldVNode: realNodeに対応している更新前のVNode、これと更新後のVNodeを比較して差分を取る
- newVNode: realNodeに対応している更新後のVNode、これと更新前のVNodeを比較して差分を取る
以上です。
2. Text要素の更新、消去処理
const renderTextNode = (
realNode: VirtualNodeType["realNode"],
newVNode: VirtualNodeType
) => {
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;
}
};
const renderNode = (
...
)=>{
if (newVNode === oldVNode) {
} else if (
oldVNode !== null &&
newVNode.nodeType === TEXT_NODE &&
oldVNode.nodeType === TEXT_NODE
) {
realNode = renderTextNode(realNode, newVNode);
}
...
次に、Text要素の更新、消去処理について解説します。
まず、Text要素であるかどうかの判定はnodeTypeプロパティがTEXT_NODEであるか否かで判定します。
もしTEXT_NODEであったらrenderTextNode
という関数を呼び出します。
さてそれではrenderTextNode
関数の解説に入ります。
renderTextNodeはいろいろif else条件分岐がありますが基本的に下記の処理をするだけの関数です。
realNode.nodeValue = newVNode.name;
return realNode;
この処理で更新前の文字をnewVNodeで指定された文字に変更します。
3. 要素の追加、削除、<div>
から<span>
のような要素の種類自体を変えた時の入れ替え処理
const patchProperty = (
realNode: ElementAttachedNeedAttr,
propName: DOMAttributeName,
oldPropValue: any,
newPropValue: any
) => {
...
}
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;
};
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);
}
...
}
}
そしたら次は要素の追加、削除、<div>
から<span>
のような要素の種類自体を変えた時の入れ替え処理について解説します。
この章では
- 追加削除入れ替え処理
- createRealNodeFromVNode関数
- の二つを作成しています。
patchProperty関数の詳細については次の章で解説します。
この追加削除入れ替え処理ですが、これはとても単純でcreateRealNodeFromVNode関数で作成した要素をinsertBeforeしたり前からあった要素をremoveChildをしてるだけです。
しかしここで定義しているcreateRealNodeFromVNode
関数は少し面倒くさいです。
このcreateRealNodeFromVNode関数はText要素を作る部分とHTMLElement要素を作る部分の二つがあります。
Text要素を作る部分は単純でただdocument.createTextNodeを呼んでいるだけです。
ただHTMLElementの場合はdocument.createElmentで要素を作成した後、patchProperty
関数でVirtualNodeType
型で定義していたpropsプロパティから実際の属性を作成した要素に付与していきます。その後、その要素のchildrenに対してもcreateRealNodeFromVNode
関数を適応させていきappendを呼びだして子要素として実際の要素に追加していきます。
そしてText要素、HTMLElment要素どちらを作る際にも下のような処理を入れて実際の要素とVNodeの相互参照を作成しています。
VNode.realNode = realNode;
realNode.vdom = VNode;
この処理はとても大事でこれを使って次の更新の際のoldVNodeを取ったりします。本来はrenderNode関数の最後に書いて全て処理してしまうのですがこのcreateRealNodeFromVNode
関数に関しては子要素に対して再帰的に呼び出す処理があるためこちらで書く必要がありました。
4. patchProperty関数とイベント処理
// 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);
}
};
const patchProperty = (
realNode: ElementAttachedNeedAttr,
propName: DOMAttributeName,
oldPropValue: any,
newPropValue: any
) => {
// NOTE key属性は一つのrealNodeに対して固有でないといけないから変更しない
if (propName === "key") {
}
// イベントリスナー属性について
else if (propName[0] === "o" && propName[1] === "n") {
const eventName = propName.slice(2).toLowerCase();
if (realNode.eventHandlers === undefined) {
realNode.eventHandlers = {};
}
realNode.eventHandlers[eventName] = newPropValue;
if (
newPropValue === null ||
newPropValue === undefined ||
newPropValue === false
) {
realNode.removeEventListener(eventName, listenerFunc);
} else if (!oldPropValue) {
realNode.addEventListener(eventName, listenerFunc);
}
}
// 属性を削除する場合
else if (newPropValue === null || newPropValue === undefined) {
realNode.removeAttribute(propName);
} else {
realNode.setAttribute(propName, newPropValue);
}
};
次はpatchProperty関数とその際に行うイベント系の処理について解説します。
patchProperty関数では主に以下のように処理を分けられます。
- プロパティ名がkeyだった場合
- 最初の2文字が"on"から始まるイベント系
- 以前あった属性を削除する場合
- 属性を追加もしくは更新する場合
**まずプロパティ名がkeyだった場合の解説です。**keyだった場合は何も変えてはいけません。keyは更新時に要素を特定するためのプロパティなので変えてしまうと差分検出処理の効率が悪くなってしまいます。
次は最初の二文字が"on"から始まるイベント系です。
イベント系の処理は多少特殊になっていて実際の要素にeventHandlersというプロパティを追加し、それにオブジェクトを代入します。更にそのオブジェクトのイベント名のプロパティにイベントが発火した時に発動する関数を代入します。
そしてeventHandlersプロパティに代入された関数を呼び出すlistenerFuncという関数に対してaddEventListenerをします。
分かりやすいように実例を挙げると以下のようなオブジェクトがeventHandlersプロパティの中に入っています。
realNode.eventHandlers: {
click: (event) => {console.log(`clickEvent: ${event.currentTarget.value}`)}, //onclick
select: (event) => {console.log(`selectEvent: ${event.currentTarget.id}`)}, //onselect
...
}
このようにすることで発火する関数を変えたい時にaddEventListenerやremoveEventListenerをする必要がなくなり、eventHandlersのそのイベント名のプロパティの関数を変えるだけで済むようになります。
ちなみに最初に定義したHandlersType型はこの為に定義しました。
// eventNameはinputやsubmit,click等のoninput等のon以降の文字の小文字が入る
interface HandlersType {
[eventName: string]: (event: Event) => void;
}
type ElementAttachedNeedAttr = HTMLElement & {
vdom?: VirtualNodeType;
eventHandlers?: HandlersType; //handlersにイベントを入れておいてoninput等のイベントを管理する
};
次に以前あった属性を削除する場合についてです。
これは変更後の属性がnull
またはundefined
だった場合に処理します。
この処理にはremoveAttributeを使用します。
最後に属性を追加更新する場合についてです。
この処理はsetAttributeを使用して処理します。
5. 要素の更新処理(子要素に関する処理は含まず)
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;
};
// 渡された要素は更新するがそのchildrenは更新しない
const updateOnlyThisNode = (
realNode: VirtualNodeType["realNode"],
oldVNode: VirtualNodeType,
newVNode: VirtualNodeType
) => {
if (realNode !== null) {
for (const propName in mergeProperties(oldVNode.props, newVNode.props)) {
let compareValue;
// inputやcheckbox等の入力系
if (propName === "value" || propName === "checked") {
compareValue = (realNode as HTMLInputElement)[propName];
} else if (propName === "selected") {
//型の関係でselectedだけvalue,checkedと別で比較
compareValue = (realNode as HTMLOptionElement)[propName];
} else {
compareValue = oldVNode.props[propName];
}
if (compareValue !== newVNode.props) {
patchProperty(
realNode as ElementAttachedNeedAttr,
propName,
oldVNode.props[propName],
newVNode.props[propName]
);
}
}
} else {
console.error(
`Error! updateOnlyThisNode does not work, because realNode is null.\n
[info]: oldVNode.name: ${oldVNode.name}, newVNode.name: ${newVNode.name}
`
);
}
return realNode;
};
const renderNode = (...) => {
...
// 要素の追加、削除、もしくは<div>から<span>等、要素の種類自体を変えた時の入れ替え処理
else if (oldVNode === null || oldVNode.name !== newVNode.name) {
...
}
// 要素の更新
else {
// 要素の更新処理本体
realNode = updateOnlyThisNode(realNode, oldVNode, newVNode);
...
}
「要素の更新処理(子要素に関する処理は含まず)」の解説です。
子要素を含めない要素の更新処理はupdateOnlyThisNode
関数で行っています。コードの可読性を高めるために関数として処理をまとめてあります。
ここではupdateOnlyThisNode
関数の解説をします。
updateOnlyThisNode関数は主に要素の属性の更新処理をしています。
以下のコードのようにmergeProperties
という関数を使って古いVNodeのプロパティと新しいVNodeのプロパティを合併しています。これには古いVNodeにはあったプロパティが新しいVNodeでは削除されてた場合しっかりとそのプロパティが実際の要素(realNode)から削除されるようにするという目的があります。
for (const propName in mergeProperties(oldVNode.props, newVNode.props)) {
...
}
また以下のコードのようにinput
やselect
等の入力系の処理は要素自体が自動でその要素が持つ値をを変えてくれます。
もし、もう既に要素の値が変わっているのに更新処理をしてしまったらその処理は無駄になってしまいます。なのでこういった入力系の属性はif文で場合分けしています。
if (propName === "value" || propName === "checked") {
compareValue = (realNode as HTMLInputElement)[propName];
} else if (propName === "selected") {
//型の関係でselectedだけvalue,checkedと別で比較
compareValue = (realNode as HTMLOptionElement)[propName];
}
あとはもし変更があったらpatchProperty
関数で属性を更新して、その後にrealNode
を返すだけです。
6. 子要素の追加、更新、削除処理
const renderNode = (...) => {
...
// 要素の追加、削除、もしくは<div>から<span>等、要素の種類自体を変えた時の入れ替え処理
else if (oldVNode === null || oldVNode.name !== newVNode.name) {
...
}
else {
// 要素の更新処理本体
realNode = updateOnlyThisNode(realNode, oldVNode, newVNode);
if (realNode !== null) {
// 子要素の作成削除更新処理
let oldChildNowIndex = 0;
let newChildNowIndex = 0;
const oldChildrenLength = oldVNode.children.length;
const newChildrenlength = newVNode.children.length;
// 子要素の追加や削除処理の為に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" } = {};
while (newChildNowIndex < newChildrenlength) {
let oldChildVNode: VirtualNodeType | null;
let oldKey: string | number | null;
if (oldVNode.children[oldChildNowIndex] === undefined) {
oldChildVNode = null;
oldKey = null;
} else {
oldChildVNode = oldVNode.children[oldChildNowIndex];
oldKey = oldChildVNode.key;
}
const newChildVNode = newVNode.children[newChildNowIndex];
const newKey = newChildVNode.key;
// 既にrenderされているoldChildVNodeをスキップする処理
if (oldKey !== null && renderedNewChildren[oldKey] === "isRendered") {
oldChildNowIndex++;
continue;
}
// 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;
}
// keyを持っていない子要素の更新処理
if (newKey === null) {
if (oldKey === null) {
renderNode(
realNode as ElementAttachedNeedAttr,
oldChildVNode === null ? null : oldChildVNode.realNode,
oldChildVNode,
newChildVNode
);
newChildNowIndex++;
}
oldChildNowIndex++;
} else {
// 以前のrender時とkeyが変わっていなかった場合、更新
if (oldChildVNode !== null && oldKey === newKey) {
const childRealNode = oldChildVNode.realNode;
renderNode(
realNode as ElementAttachedNeedAttr,
childRealNode,
oldChildVNode,
newChildVNode
);
renderedNewChildren[newKey] = "isRendered";
oldChildNowIndex++;
} else {
const previousRenderValue = hasKeyOldChildren[newKey];
// 以前のrender時には既にこのkeyを持つ要素が存在していた場合
if (
previousRenderValue !== null &&
previousRenderValue !== undefined
) {
renderNode(
realNode as ElementAttachedNeedAttr,
previousRenderValue.realNode,
previousRenderValue,
newChildVNode
);
renderedNewChildren[newKey] = "isRendered";
}
// keyを持つ要素の追加処理
else {
renderNode(
realNode as ElementAttachedNeedAttr,
null,
null,
newChildVNode
);
}
renderedNewChildren[newKey] = "isRendered";
}
newChildNowIndex++;
}
}
// 前のwhile処理で利用されなかった到達しなかったoldVNodeの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++;
}
// 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);
}
}
}
} else {
console.error("renderNode does not work well, because realNode is null.");
}
}
if (realNode !== null) {
// NOTE newVNodeに対応する実際の要素を代入する。これを次の更新の際に使う
newVNode.realNode = realNode;
// NOTE 今後更新する際に差分を検出する為実際のHTML要素に対してvdomプロパティを加える
// このvdomプロパティが次の更新の際のoldVNodeになる
realNode.vdom = newVNode;
}
return realNode;
};
さて次は子要素の追加、更新、削除処理です。
この処理が仮想DOMを作成するうえでの一番の鬼門だと思います。休憩を入れながら頑張りましょう!
この章では以下のように処理ごとに分けて実装していきます。
- 準備
- 子要素の更新処理の基本
- 既に更新したkeyを持つoldVNodeのスキップ処理
- keyを持っていない子要素の削除処理
- keyを持っていない子要素の更新処理
- keyを持っている子要素の更新処理
- keyを持っている子要素の更新処理
- 必要のない子要素の削除処理
- 仕上げ
####準備
実際に子要素を追加、更新、削除する前に少し準備をします。
子要素の比較ループのためのindex定義
まず古いVNodeのchildrenと新しいVNodeのchildrenの比較をループさせるための変数を定義します。
以下のコードのoldChildNowIndexとnewChildNowIndexがそれぞれoldVNode.childrenとnewVNode.childrenの現在比較しているindexを表します。これをうまいこといじくりながら古いVNodeと新しいVNodeを比較していきます。
// 子要素の作成削除更新処理
let oldChildNowIndex = 0;
let newChildNowIndex = 0;
const oldChildrenLength = oldVNode.children.length;
const newChildrenlength = newVNode.children.length;
key属性を扱う為に必要なオブジェクトの準備
そしたら、次にkey属性について考えなければいけません。このkey属性を持っている要素はchildrenリスト内での順番が入れ替わっていたりしても削除したり追加したりしてはいけません。仮想DOMの効率をよくするために変更前の要素を更新するという形で処理しなければならないのです。
それを実現するために最初に以下のコードを書いてhasKeyOldChildren
とrenderedNewChildren
というオブジェクトを作成します。
この二つのオブジェクトを使って子要素の処理の最後にまとめてkeyを持った要素の削除処理を行います。
またhasKeyOldChildren
はkeyを持った要素の追加処理の際にも使います。
// 子要素の追加や削除処理の為に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;
}
}
// 同じく子要素の追加や削除処理の為に必要な為作成
// NOTE keyを持つnewVNodeで既に更新されたものはこちらのオブジェクトで記録されている。
const renderedNewChildren: { [key in KeyAttribute]: "isRendered" } = {};
子要素の更新処理の基本
それでは次に更新処理がどのように行われるかの基本的な部分を解説します。
更新処理は以下のコードのようにnewVNodeのchildrenすべてを比較するまで行います。
この際にoldVNodeのchildrenがどこまで比較されたかを気にする必要はありません。削除するべきoldVNodeが削除されているかは最後にしっかりとチェックされるからです。以下のコードのwhileループの中ではnewVNodeのchildrenをすべて比較すれば全部オーケーです。
また補足ですが、この更新処理でfor文等でchildrenを直接ループさせずindexでループさせているのはoldVNodeの子要素をスキップしたりといったようにfor文でループさせる方式だと難しい処理があるからです。
while (newChildNowIndex < newChildrenlength) {
let oldChildVNode: VirtualNodeType | null;
let oldKey: string | number | null;
if (oldVNode.children[oldChildNowIndex] === undefined) {
oldChildVNode = null;
oldKey = null;
} else {
oldChildVNode = oldVNode.children[oldChildNowIndex];
oldKey = oldChildVNode.key;
}
const newChildVNode = newVNode.children[newChildNowIndex];
const newKey = newChildVNode.key;
...
...
}
既に更新したkeyを持つoldVNodeのスキップ処理
次は既に更新したkeyを持つoldVNodeのスキップ処理の解説です。
if (oldKey !== null && renderedNewChildren[oldKey] === "isRendered") {
oldChildNowIndex++;
continue;
}
この更新処理の際のoldVNodeが既に更新されていた場合はoldChildNowIndex
を+1してまたwhile文の先頭に行きます。
この処理は以下のコードのようにkeyを持つ要素のchildren内での順番が入れ替わっていた時などに実行されます。
変更前
<div key="a"></div>
<div key="b"></div>
<div key="c"></div>
変更後
<div key="a"></div>
<div key="c"></div>
<div key="b"></div>
この処理をすることでkeyを持たない要素の更新処理を効率よくできる確率が高まります。
keyを持っていない子要素の削除処理
keyを持っていない要素の削除処理について解説します。
// 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;
}
この処理ではnewVNodeでは確実に存在しない、keyなしの要素を削除します。
その判定方法は次に比較をする予定のoldVNodeのkeyが現在比較をしているnewKeyの値と同じだった時です。
この処理により以下のように要素を削除した際の削除処理が効率よくできます。
更新前
<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>
keyを持っていない要素の更新処理
次はkeyを持っていない要素の更新処理です。
// keyを持っていない子要素の更新処理
if (newKey === null) {
if (oldKey === null) {
renderNode(
realNode as ElementAttachedNeedAttr,
oldChildVNode === null ? null : oldChildVNode.realNode,
oldChildVNode,
newChildVNode
);
newChildNowIndex++;
}
oldChildNowIndex++;
この処理はnewKeyがnullでoldKeyもnullだった時に実行されます。
これの判定方法は結構大雑把です。もしかしたらoldVNodeは<div>
要素でnewVNodeは<span>
要素かもしれません。けれどこの処理で呼びだしているrenderNode
関数には要素の種類が入れ替わった時用の処理があります。なので効率は少し落ちるかもしれませんが最終的にはしっかりと画面に反映されます。
この処理の際に効率よく要素を更新できるようにrenderNode関数内ではいろいろと工夫しています。
keyを持っている子要素の更新処理
次はkeyを持っている子要素の更新処理の解説です。
// keyを持っていない子要素の更新処理
if (newKey === null) {
if (oldKey === null) {
...
}
oldChildNowIndex++;
} else {
// 以前のrender時とkeyが変わっていなかった場合、更新
if (oldChildVNode !== null && oldKey === newKey) {
const childRealNode = oldChildVNode.realNode;
renderNode(
realNode as ElementAttachedNeedAttr,
childRealNode,
oldChildVNode,
newChildVNode
);
renderedNewChildren[newKey] = "isRendered";
oldChildNowIndex++;
} 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";
}
newChildNowIndex++;
}
keyを持ってる要素の更新処理は二通りの条件分岐があります
1通り目
現在のoldKey===newKeyだった場合です。こちらの方で更新処理ができるとkeyを持っていない要素の更新処理をする際に効率よくできる確率が高まります。
2通り目
oldKey===newKeyではありませんでしたがoldVNode.childrenの中にそのkeyに対応する要素があった時に場合です。hasKeyOldChildren
オブジェクト内にnewKeyプロパティの値が存在するかを確認して判定します。
どちらの方法のrenderNode関数を呼び出して要素を更新しています。
また、この際にkeyを持っている子要素のnewVNodeの更新が完了したらrenderedNewChildrenオブジェクトのkeyの名前のプロパティに「既にこのキーの要素は更新したよ」フラグを代入します
keyを持っている子要素の追加処理
// 以前のrender時には既にこのkeyを持つ要素が存在していた場合
if (
previousRenderValue !== null &&
previousRenderValue !== undefined
) {
...
}
// keyを持つ要素の追加処理
else {
renderNode(
realNode as ElementAttachedNeedAttr,
null,
null,
newChildVNode
);
}
renderedNewChildren[newKey] = "isRendered";
}
newChildNowIndex++;
}
このkeyを持つ要素を追加する処理は前章のどのやり方でも更新するVNodeであると判定が出なかったものに対してします。(つまりelseの場合です。)
これもrenderNode関数を呼んでいます。
必要のない子要素の削除処理
// 前の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++;
}
// 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);
}
}
}
この必要のない子要素を削除する処理は二つの処理があります。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で削除します。
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に保存されているすべての要素を検証します。
これより前の章でやった更新処理ではkeyを持つ要素を更新もしくは追加する際にrenderedNewChildrenにそのkeyのNodeが更新済みであるというフラグを保存していました。この処理ではrenderedNewChildreを使って削除するべき子要素を判定しています。
この子要素の削除処理によって計算量が少なく、かつ確実にkeyを持つ要素を削除できるようになります。
仕上げ
if (realNode !== null) {
// NOTE newVNodeに対応する実際の要素を代入する。これを次の更新の際に使う
newVNode.realNode = realNode;
// NOTE 今後更新する際に差分を検出する為実際のHTML要素に対してvdomプロパティを加える
// このvdomプロパティが次の更新の際のoldVNodeになる
realNode.vdom = newVNode;
}
return realNode;
最後に実際の要素とVNodeの相互参照を作成してrealNodeをreturnしたら仮想DOMの完成です。
この最後に追加した相互参照を使って次回の更新時にoldVNodeやrealNodeを取得します。
動かしてみよう
実際に動かしてみましょう。
それではindex.tsに以下のコードを書いてください。(最初にGitHubからクローンした方はコメントアウトを解除して環境の動作確認用コードを消せば大丈夫です。)
import { h, render } from "./virtualDom";
interface HTMLElementEvent<T extends HTMLElement> extends Event {
target: T;
}
const setState = (state: string) => {
const node = document.getElementById("app-container");
const createKeyList = () => {
return state.split(" ").map((value: string) => {
return h("div", { key: value }, [`key: ${value}`]);
});
};
if (node !== null) {
render(
node,
h("div", {}, [
h("h1", {}, [state]), //タダの文字を表したい場合はh関数のchildrenに文字のみ渡す
h(
"input",
{
type: "text",
value: state,
oninput: (e: HTMLElementEvent<HTMLInputElement>) =>
setState(e.target.value),
autofocus: true,
},
[]
),
h("div", { id: "container" }, createKeyList()),
])
);
}
};
setState("Hey");
そしたらターミナルで以下のコマンドを打ってください
yarn start
そうしたら以下のように仮想DOMが動かせるようになっているはずです。
自作仮想DOM動いた!うれしい。 pic.twitter.com/HomnKvUJTR
— UMASHIBA (@UMASHIBA) May 6, 2020
それではお疲れさまでした。これで仮想DOMの作成は終了です。
最後に
私自身、仮想DOMを実装したのは初めてだったのでまだまだ知識が足りていません。もしこの記事での間違いや改善すべき点などがありましたらコメント等でアドバイスお願いします。
それでは最後までお読みいただきありがとうございました。