🐤

フロントエンド苦手意識を克服すべく、お手軽仮想DOMを自作した

2022/01/10に公開

はじめに

私はバックエンド(PHP)とクラウド(AWS)を主軸にエンジニア稼業をやっていこうと思っており、フロントエンド[1]についてはかなり苦手意識がありました。
できればフロントエンドとは距離をおいて生きていこうと思っていたのですが、現実はそう甘くありません。
仕事でフロントエンド開発を行う機会が少なからずあり、消極的な気持ちで取り組むよりは前向きに取り組めるようになれたらいいなあということで、そろそろ腹を括ってちゃんと勉強しようと思いました。この記事はその記録です。

早速、その「フロントエンドへの苦手意識」というのがどこから由来しているかを考えると、
「自分が何をしているのかわからないのに、ドキュメントの通りに記述するとなんか動く」という状態が気持ち悪いからだと思いました。
そのため、フレームワークを活用して何かアプリを作ってみるのではなく、そのフレームワークの基盤となる概念を学ぼうと思いました。

作ってみてどうだったか

かなり良かったです。フロントエンドに対する苦手意識も大部分払拭できたような気がします。
フロントエンドに関してはうっすら嫌いだと思っていたのですが、実は知識が足りていなかっただけであったことに気づきました。
この気付きは、今後新しい技術に触れる際にも心に留めておきたいです。

作ったもの

input要素に入力された値を、仮想DOMを通じてリアルタイムに同期する仕組みを作りました。
コード量、機能は極限まで最小限とすることを目指しました。
input要素入力値をリアルタイムに表示するGIF

コード行数は100行程度なので、なかなかお手軽にできたのではないでしょうか。

$ cloc . --exclude-dir=node_modules --by-file --not-match-f=^.+\.json$
       9 text files.
       9 unique files.                              
       3 files ignored.

github.com/AlDanial/cloc v 1.90  T=0.01 s (659.1 files/s, 14594.9 lines/s)
---------------------------------------------------------------------------------------
File                                     blank        comment           code
---------------------------------------------------------------------------------------
./src/index.js                               4              0             38
./src/pvdom/diff.js                          2              1             31
./src/pvdom/patch.js                         1              0             19
./src/pvdom/pvdom.js                         4              1             19
./src/pvdom/renderNode.js                    3              0             15
./README.md                                  4              0              9
./index.html                                 0              0              4
---------------------------------------------------------------------------------------
SUM:                                        18              2            135
---------------------------------------------------------------------------------------

ソースコードはすべてGithubにあります。

https://github.com/khmory/plain-virtual-dom

仮想DOMとは

仮想DOMについて学ぶ前に、DOMとは何かを学ぶ必要があります。
MDNのドキュメントを参照してみましょう。

— ウェブページを表す HTML のように — 文書の構造をメモリー内に表現することで、ウェブページとスクリプトやプログラミング言語を接続するものです。

https://developer.mozilla.org/ja/docs/Web/API/Document_Object_Model

もう少し具体化すると、HTML文書をブラウザのメモリ上に展開し、操作可能にしたもの、ということができると思います。

仮想DOMというのは、このDOMの仮想的な姿をJavaScriptのオブジェクトとして保時し、それを適時DOMに適用する仕組みを示した概念です。
では、従来のJavaScript開発と、仮想DOMを使用した開発での違いを下記に示します。

従来のJavaScript実行

  1. JavaScriptがAPIを通してリアルDOM[2]を直接操作
  2. ブラウザの表示が更新される

仮想DOMを使用したJavaScript実行

  1. 仮想DOMを変更する
  2. フレームワークが仮想DOMとリアルDOMの差分を検知(diff)し、差分をリアルDOMに適用(patch)
  3. ブラウザの表示が更新される

仮想DOMを使用したほうが1ステップが増えていますが、これには大きなメリットがあります。
それは、DOMを宣言的に記述できる(エンジニアが表示されてほしいDOMを示すと、フレームワークが実際のブラウザ表示をそれに合わせてくれる)ということです。

このアプローチにより React の宣言型 API が可能になっています。あなたは UI をどのような状態にしたいのか React に伝え、React は必ず DOM をその状態と一致させます。これにより、React なしではアプリケーションを構築するために避けて通れない属性の操作やイベントハンドリング、および手動での DOM 更新が抽象化されます。

https://ja.reactjs.org/docs/faq-internals.html

参考

https://qiita.com/mizchi/items/4d25bc26def1719d52e6

次に、仮想DOMの表現についてです。
HTMLのJSON版といったところでしょうか。

仮想DOM

{
  tagName: "div",
  attributes: { id: "div-id", content: "" },
  children: [
    {
      tagName: "h3",
      attributes: { id: "p-id", content: "" },
      children: [
        {
          tagName: "text",
          attributes: { id: "text-id", content: "入力内容: " },
        },
      ],
    },
  ],
};

そして、この仮想DOMに対応するHTMLはこのようになります。

HTML

<div id="div-id" content="">
  <h3 id="p-id" content="">入力内容: </h3>
</div>

実際に作っていく

下記の4つが実装されていれば、仮想DOMの最小限の実装と言えると思います。

  • 仮想DOMで宣言した内容をリアルDOMに描画する(render)
  • リアルDOMと仮想DOMの差分を検出する(diff)
  • 検出した差分をリアルDOMに反映する(patch)
  • 適切なタイミングでdiff/patchを作動させる

この内、「適切なタイミングでdiff/patchを作動させる」については、本来であれば状態変更を検知する仕組みが必要ですが、今回は簡単のため、 setInterval() を使用して◯ミリ秒ごとにdiff/patchを呼び出すことにしました。
@izumi_yoshiki さんからPRを頂き、requestAnimationFrame() に変更しました。ありがとうございます。

このあと、それぞれの実装を紹介していきます。

※ 実装は記事用に簡易化しています。

仮想DOMで宣言した内容をリアルDOMに描画する(render)

function render(vDOM) {
  const realElement = renderNode(vDOM);
  document.body.appendChild(realElement);
}

function renderNode(node) {
  const element = document.createElement(node.tagName);
  for (const key in node.attributes) {
    element.setAttribute(key, node.attributes[key]);
  }

  node.children.forEach((child) => {
    if (child.tagName == "text") {
      element.innerText = child.attributes.content;
      return;
    }
    element.appendChild(renderNode(child));
  });
  return element;
}

render() 関数は仮想DOMオブジェトを受け取り、 renderNode() 関数を呼び出します。renderNode() 関数は終端に達するまで自身を再帰的に呼び出し、Nodeを返却します。返却されたNodeを render() 関数によってリアルDOMのNodeに対して付与することで、ブラウザ上に表示されます。

リアルDOMと仮想DOMの差分を検出する(diff)

function diff(oldVdom, newVdom) {
  let patchArray = [];
  diffNode(oldVdom, newVdom, patchArray);
  return patchArray;
}

function diffNode(oldNode, newNode, patchArray) {
  if (oldNode.tagName != newNode.tagName) {
    patchArray.push({
      id: oldNode.attributes.id,
      type: "tagName",
      value: newNode,
    });
  }
  if (oldNode.children && newNode.children) {
    // contentの更新対象は親ノードのidとなるため、先読みする
    for (let i = 0; i < oldNode.children.length; i++) {
      if (
        oldNode.children[i].attributes.content !=
        newNode.children[i].attributes.content
      ) {
        patchArray.push({
          id: oldNode.attributes.id,
          type: "content",
          value: newNode.children[i].attributes.content,
        });
      }
      diffNode(oldNode.children[i], newNode.children[i], patchArray);
    }
  }
  return;
}

diff() 関数はdiffNode()関数を再帰的に呼び出し、現在ブラウザに表示中の仮想DOMとこれから反映させようとしている仮想DOMを受け取り差分を計算します。差分のリストは patchArray という配列に格納しています。

ここの実装はかなり簡易化しましたが、今回目指すものを作るには十分です。

検出した差分をリアルDOMに反映する(patch)

function patch(patchArray) {
  let targetElement;
  let newElement;
  switch (patchArray.type) {
    case "tagName":
      targetElement = window.document.getElementById(patchArray.id);
      newElement = document.createElement(patchArray.value);
      targetElement.replaceWith(newElement);
      break;
    case "content":
      targetElement = window.document.getElementById(patchArray.id);
      targetElement.innerText = patchArray.value;
      break;
    default:
      console.error("invalid patch type");
      break;
  }
}

patch() 関数は、先程 diff() で生成した patchArray をもとにリアルDOMに反映を行います。すべてのノードにはid属性をつけるようにしたので、patchArray に含まれるidを使用して、DOMを更新していきます。

ここまでくると、冒頭で示したような処理が動くようになります🎉

input要素入力値をリアルタイムに表示するGIF

自作仮想DOMのTypeScript化

上記の実装はJavaScriptで行いましたが、せっかくなのでTypeScript化してみました。下記の流れで段階的に導入していくことで、サクッと型をつけることができました。

  1. .js ファイルをすべて .ts ファイルに変更し、.tsconfig.jsonを作成する
  2. この状態で動作することを確認する
  3. .tsconfig.json に少しずつ足していき、表示されたエラーを潰していく

最終的には strict をtrueにした状態でエラーが出ないところまで持っていきました。

実装はこちらです。
https://github.com/khmory/plain-virtual-dom-ts

本格的な仮想DOM実装について学ぶ

せっかく仮想DOMについて学んだので、最後のお楽しみとしてVue.jsの仮想DOMの実装について調べてみました。
下記のブログがわかりやすくまとまっていて参考になりました。

vNodeの構造体が、今回自作したものと基本的な構成が同じなのが感慨深いですし、vNodeの内部にElementを保持しているのも興味深いですね。
また、diff/patchのデータ受け渡しなど私が簡易化してしまった部分がしっかりと実装されていて素晴らしいです。

https://blog.engineer.adways.net/entry/2018/01/19/200000

https://blog.engineer.adways.net/entry/2018/02/02/183000

https://blog.engineer.adways.net/entry/2018/02/16/190500

おわりに

今回はフロントエンドの苦手克服のため、仮想DOMをお手軽に実装してみました。
もし興味が湧いた方がいれば、実際に作ってみてはいかがでしょうか。

参考

実装にあたり、下記のブログを参考にしました。

https://medium.com/@maheswaranapk/js-create-your-own-virtual-dom-diy-70b278999acc

https://kuroeveryday.blogspot.com/2018/11/how-to-create-virtual-dom-framework.html

https://qiita.com/umashiba/items/e2a9776e6c44a40d2d8f

注釈

脚注
  1. 本記事におけるフロントエンドとは、WEBフロントエンド、ひいては Vue.js、Angular、React.js などを使用したフロントエンド開発を指します。 ↩︎

  2. 仮想DOMと対比して、本来のDOMをリアルDOMと呼ぶこととします。 ↩︎

GitHubで編集を提案

Discussion