🦍

仮想DOMの差分検知を実装してみた

2022/10/10に公開

仮想DOM

DOM構造の状態をJSで保持することによって、実際のDOMにアクセスする回数を減らし、DOM更新の際に高パフォーマンスを発揮できる。

<article>
  <cite> ユーザー </cite>
  <p>コメント</p>
  <img src="" />
</article>

このようなHTMLがあったとしたら、SPAを使う際、JSで

const ui = {
  element: "article",
  children: [
    {
      element: "cite",
      innerText: "ユーザー",
    },
    {
      element: "p",
      innerText: "コメント",
    },
    {
      element: "img",
      src: "https://...jpg",
    },
  ],
};

のようにDOMを保持します。仮想DOMの一部しか更新していないのに、差分を含まないElementごとすべて実際のDOMに反映させるのは効率が悪いので、新旧の仮想DOMを見比べて、再反映の影響範囲を最小限にします。

差分検知

JSにおいて、オブジェクト同士の等価チェックは参照が同一か見ています。

const obj1 = {
  element: "div",
  innerText: "hello",
};

const obj2 = {
  element: "div",
  innerText: "hello",
};

obj1 === obj2
//false

これを比較するためにshallowEqualという比較方法でプロパティが同じかチェックします。

仮想DOMの差分検知

htmlはこれだけです。参考程度に。

<head>
// 省略
  <script src="src/index.js" type="module"></script> ---(1.1)
</head>
<body>
  <div id="app">
    <button id="renderButton">button</button> ---(2.1)
  </div>
</body>

こちらがhtmlが読み込んでいるJS(TS)ファイルです

import { h, getElement, shallowEqual } from "./vertualDom.js";

const getCard = ({ comment }: { comment: string }) => {
  return h("article", comment);
};

const render = (vEl: ReturnType<typeof h>) => {  ---(1.3)
  const appEl = document.querySelector("#app");

  if (!appEl) {
    return;
  }

  const el = getElement(vEl); ---(1.4)

  appEl.appendChild(el);
};

const app = () => {
  const card = getCard({ comment: "hello" });
  const card1 = getCard({ comment: "hello" });

  const renderButton = document.querySelector("#renderButton");

  if (!(renderButton instanceof HTMLButtonElement)) {
    return;
  }

  renderButton.onclick = () => { ---(2.1)
    if (!shallowEqual(card, card1)) { ---(2.2)
      render(card);
    }
  };

  return card;
};

const appEl = app(); ---(1.2)

if (appEl) {
  render(appEl); ---(1.3)
}

そして、最後に上のJSファイルで読み込まれているファイルです。

export const h = (tagName: keyof HTMLElementTagNameMap, innerText: string) => {
  return {
    tagName,
    innerText,
  };
};

// オブジェクトを実際のDOMに変換する関数
export const getElement = ({ tagName, innerText }: ReturnType<typeof h>) => { ---(1.4)
  const element = document.createElement(tagName);
  element.innerText = innerText;

  return element;
};

export const shallowEqual = <T extends ReturnType<typeof h>>( ---(2.2)
  currentEl: T,
  nextEl: T
) => { ---(2.3)
  if (currentEl === nextEl) {
    return true;
  }
  if (currentEl.tagName !== nextEl.tagName) {
    return false;
  }
  if (currentEl.innerText !== nextEl.innerText) {
    return false;
  }
  return true;
};

HTML読み込み時

  • (1.1) htmlがJSを読み込みます。
  • (1.2) app関数が実行されます。関数内部を無事下まで実行しcardを返却します。この時の戻り値は以下です。
{
  tagName:'article',
  innerText:'hello'
}
  • (1.3) render関数を実行します。
  • (1.4) getElement関数でエレメントを生成します。生成したエレメントをHTMLにレンダリングします。helloと表示されます。

クリック時

いよいよ、差分検知します。

  • (2.1) クリックイベントが実行されます。
  • (2.2) shallowEqual関数が実行されます。引数は2つとも以下のオブジェクトを渡します。
{
  tagName:'article',
  innerText:'hello'
}
  • (2.3) shallowEqual関数の内部の条件を判定します。
    • if(currentEl === nextEl)
      前述したようにオブジェクトの参照先を比較しています。今回はオブジェクトの参照は違うので通りません。
    • if (currentEl.tagName !== nextEl.tagName)
      同じtagNameなので通りません。
    • if (currentEl.innerText !== nextEl.innerText)
      同じinnerTextなので通りません。

結果的にどこも通らず、trueが返却されます。
よってrenderは実行されず、終了です。

以上

簡単な仮想DOMの差分検知を実装してみました。
記事の執筆を通して、JSだけでHTMLがどのようにレンダリングされているのか、多少理解が深まりました。

参考
(https://www.youtube.com/watch?v=xVNDJjsaW-0&t=134s)

Discussion