仮想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の関数コンポーネント編です。