仮想DOMの差分更新を簡単に実装する
作成日: 2022-10-28
ReactやVueで使われている仮想DOMの差分更新を実装しました。Stepを押すと仮想DOMが更新されます。動作デモは↓から。
仮想DOMは処理の純粋性を獲得する手段
JQueryの時代は、ブラウザの状態のDOMを動的に変更していました。状態というのは健康に悪いことが知られています。
コードが扱えるデータ構造ではなく、DOMのインターフェースから構造を触るのは、純粋な関数ではなく、複雑なIOの処理になってしまいます。そして、DOMはどこからでも触れるので、複雑度が上がる一方になります。
JQuery(Query) -> Void
DOMを操作するインターフェースを制限して、処理の純粋性を取り戻すにはどうすればいいでしょうか。ブラウザの状態であるDOMを直接操作すると、純粋ではなくなります。
単純な結論として、毎回ドキュメント全てのDOMを破棄して作り直すというのがあります。実のところ、作り直す実装も軽いアプリケーションだとそれほど悪くないです。
View(Data) -> DOM
Render(DOM) -> Void
ただ、変更のたびに全てのDOMを作り直すのは流石にコストが無視できないことが多いです。コストを減らすには、DOMの変更部分だけをアクションとして抽出し、アクションをDOMに反映させると良いでしょう。しかし、アクションが複雑になるため、いまいちです。
View(Data, Action) -> Query
JQuery(Query) -> Void
DOMは一から全部構築したいが、実際のDOMは部分的に変更したいというのが、仮想DOMのモチベーションです。
JSが扱えるデータ構造としてDOMを模した仮想DOMを操作するようにします。仮想DOMを実際のDOMに反映します。2回目以降の仮想DOMの反映では、前回の仮想DOMと要素を比較し、差分だけを実際のDOMに反映します。
View(Data) -> VirtualDOM
Render(VirtualDOM, VirtualDOM) -> Void
仮想DOMによって、DOMの変更が最小限になり、DOMを操作するインターフェースも1つになりました。
Reactの差分更新
Reactでどのように仮想DOMを実装しているかのドキュメントがあります。なぜkeyが必要なのか、などに答えるドキュメントになっています。
仮想DOMを実装する
仮想DOMの構造
仮想DOMはタグ、属性、子DOMの情報を持ちます。domには反映したときの実際のDOMを持っておきます。
class VDOM {
constructor(tag, attrs, children) {
this.tag = tag;
this.attrs = attrs;
this.children = children;
this.dom = null;
}
}
仮想DOMを作るためのユーティリティを定義しておきます。jsxだとうまい感じにHTMLタグをコンパイルしてくれるやつですね。
テキスト要素はtextタグとして、この実装の仮想DOMでは処理します。
const d = (tag, attrs, children) => new VDOM(tag, attrs, children);
const t = (text) => new VDOM("text", {text}, [])
const dom = d("span", {}, [t("Hello! ")]);
仮想DOMの描画
仮想DOMを実際のDOMに反映するのは、仮想DOMの木構造をたどるだけで完了します。
class VDOM {
render(parent) {
// this.domを作る
this.initElement();
this.updateAttrs();
// 親要素にdomを挿入する
parent.appendChild(this.dom);
// 子要素を反映する
for (let child of this.children) {
child.render(this.dom);
}
}
}
const parent = document.getElementById("main");
dom.render(parent);
仮想DOMの更新
差分更新が仮想DOMの真髄です。
タグが変わっていたらDOMを1から作り直します。タグが同じであれば、属性だけ更新します。
子要素については、変更があるところは変更し、削除と追加を行います。
class VDOM {
update(parent, after) {
// タグが違うので作り直し
if (this.tag !== after.tag || this.tag === "text") {
after.initElement();
this.dom.replaceWith(after.dom);
this.dom = after.dom;
this.tag = after.tag;
this.children = [];
}
// 属性の更新
this.attrs = after.attrs;
this.updateAttrs();
if (after.tag === "text") return;
// 子要素のupdate
const [befores, afters] = [this.children.length, after.children.length];
const updates = Math.min(befores, afters);
// 新旧で同じ数までは更新処理
for (let i = 0; i < updates; i++) {
this.children[i].update(this.dom, after.children[i]);
}
// 減ったら削除
for (let i = updates; i < befores; i++) {
this.children[i].dom.remove();
this.children.splice(i, 1);
}
// 増えたら挿入
for (let i = updates; i < afters; i++) {
after.children[i].render(this.dom);
this.children.push(after.children[i]);
};
}
}
let new_dom = d("span", {style:"color: crimson;"}, [t("How are you? ")]);
dom.update(parent, new_dom);
まとめ
仮想DOMの簡単な実装を行いました。実用的にはkeyを用いたDOMの同一判定などがあると、なお効率的でしょう。仮想DOMと前回のミニVueを組み合わせれば、そこそこ実用的でしょう。次回はReactの関数コンポーネント編です。