仮想DOMは本当に“速い”のか? DOM操作の新しい考え方を、フレームワークを実装して理解しよう

最近のJavaScriptフレームワークで利用される「仮想DOM」について、リアルDOMの違い、メリット・デメリット、仮想DOMを使ったフレームワーク開発などを、ダーシノ(bc_rikko)さんが解説します。

仮想DOMは本当に“速い”のか? DOM操作の新しい考え方を、フレームワークを実装して理解しよう

 

はじめまして、ダーシノ@bc_rikkoです。さくらインターネットでフロントエンドエンジニアをする傍ら、NES.cssというファミコン風CSSフレームワークを開発しています。

さっそくですが、皆さんは、ReactやVue.jsといったJavaScriptフレームワークを使ったことがありますか? そういったフレームワークで使われている、仮想DOMについて知っていますか?

「聞いたことない」「聞いたことはあるけど、どう実装されているかは知らない」「熟知している」。いろいろなレベルの方がいらっしゃると思います。

仮想DOMはJavaScriptフレームワークに隠蔽(いんぺい)されており、私たち利用者が意識することはほとんどありません。ですが、仮想DOMの仕組みを知り、フレームワークがどのように動いているかを知ることで、解決できる問題がグンと増えます。

当記事は、フロントエンドの初心者~中級者を対象に、仮想DOMとは何か? 従来のDOM操作との違いは? 仮想DOMを使うメリット・デメリットは? などを、実際にJavaScriptフレームワークを実装しながら解説していきます。

筆者はこれまで参考記事にあるように、仮想DOMについて複数の記事をブログで公開してきました。当記事では、これらを元に加筆修正するとともに、新たに仮想DOMのデメリットや、仮想DOMを使わない代替手段にも踏み込んで解説しています。

そもそもDOM(Document Object Model)とは何か?

仮想DOMを語る前に、まずは基本となるDOMについて説明します。DOMとはDocument Object Modelの略で、簡単に言うとJavaScriptからHTMLドキュメントを操作するためのAPI(インターフェイス)です(正確にはXMLドキュメントも扱えますが、本記事の趣旨を逸脱するため省略します)

以下のサンプルコードを例に説明します。

<h1 id="title">Hello エンジニアHub</h1>
const title = document.getElementById('title');
title.innerHTML = '仮想DOM完全に理解した';

ここではDOMのgetElementByIdメソッドを用いて、HTMLドキュメントから#titleのIDを持つ要素を取得します。さらにDOMのinnerHTMLプロパティにテキストを代入することで、HTMLドキュメントのh1#titleを書き換えることができます。

このように、DOMのおかげでJavaScriptからHTMLドキュメントを操作できます。この他にも、ボタンクリック時のイベント登録や、スタイル・属性の変更、要素のサイズを取得といった処理も、全てDOMのAPIを使います。

このDOMを、仮想DOMと区別するため「リアルDOM」と呼ぶこともあります。

DOMツリーでHTML要素を管理する

Webブラウザは、HTMLドキュメントの各要素をオブジェクトとして扱い、そのオブジェクトを下図のようにツリー状にして管理しています。

1

図1: WebブラウザはDOMを仮想DOMを語る前に、まずは基本となるDOMツリー状にして管理している

このツリーを、DOMツリーと呼びます。また、ツリーの要素/オブジェクトひとつひとつを、Nodeと呼びます。

詳細は後述しますが、仮想DOMもリアルDOM同様に、Nodeをツリー状にして管理しています。

混同しがちなDOMとNode、そしてElement

DOMの話を掘り下げていくと、DOM、Node、Elementという、なんとなく似た使われ方をする単語が頻出します。そのため、これらを混同してしまうことがあります。

現に私も、仮想DOMを勉強する前はDOM≒Node≒Elementだと思っており、雰囲気で使い分けていました。実際には、下図のような継承関係にあります。

2

図2: DOM、Node、Elementの関係を表したクラス図

Nodeとは

先ほど説明したDOMツリーにおけるひとつひとつの箱(オブジェクト)が、Nodeです。firstChildparentNodeなどのプロパティ、appendChildremoveChildなどのメソッドを提供しています。

Nodeはいくつもの DOM API オブジェクトタイプが継承しているインターフェイスで、それらのさまざまなタイプを同じように扱える(同じメソッドのセットを継承する、または同じ方法でテストできる)ようにします。
Node - Web API | MDN

Nodeには、いくつか種類があります。

  • Document
  • Element
  • Attr
  • CharacterData
  • など

Elementとは

Nodeにはいくつか種類があると説明しました。Elementは、Nodeの中のひとつです。classListinnerHTMLなどのプロパティ、getElementByIdsetAttributeなどのメソッドを提供しています。

ElementはDocumentの中にあるすべての要素が継承する、もっとも一般的な基底クラスです。このインターフェイスは、すべての種類の要素に共通するメソッドとプロパティを記述するだけのものです。多くの具体的なクラスがElementを継承します。例えばHTML要素にはHTMLElementインターフェイス、SVG要素にはSVGElementインターフェイスなど。ほとんどの機能は、クラスの階層を下りると具体化していきます。
Element - Web API | MDN

HTMLの要素は、Elementを継承しています。

3

図3: Elementの関係を表したクラス図

DOM、Node、Elementの関係

DOM、Node、Elementは継承関係にあり、下図のような構造になっています(厳密に言うと違いますが、説明を分かりやすくするためシンプルにしています)

4

図4: DOM、Node、Elementの継承関係

instanceof演算子を使うと、NodeとElementの関係が分かりやすくなります。

<div id="app">
  <p>エンジニアHub</p>
</div>
// Documentからdiv#appを取得する
const app = document.getElementById('app');
console.log('app instanceof Node:',           app instanceof Node);           // true
console.log('app instanceof Element:',        app instanceof Element);        // true
console.log('app instanceof HTMLDivElement:', app instanceof HTMLDivElement); // true

// div#appから子Nodeを取得する
const node = app.childNodes[0];
console.log('node instanceof Node:',    node instanceof Node);    // true
console.log('node instanceof Element:', node instanceof Element); // false

// div#appから子Elementを取得する
const element = app.children[0];
console.log('element instanceof Node:',           element instanceof Node);           // true
console.log('element instanceof Element:',        element instanceof Element);        // true
console.log('element instanceof HTMLElement:',    element instanceof HTMLElement);    // true
console.log('element instanceof HTMLParagraphElement:', element instanceof HTMLParagraphElement); // true
console.log('element instanceof HTMLDivElement:', element instanceof HTMLDivElement); // false

上記の「app.childNodes[0]」について、div#appchildNodesで取得できる子Nodeは、Elementの基底クラスです。そのため、instanceof Nodetrueになりますが、instanceof Elementfalseになります。

app.children[0]」については、div#appchildrenで取得できる子Elementは、Nodeの派生クラスです。そのため、instanceof Nodeinstanceof Elementtrueになります。また、p要素のためinstanceof HTMLParagraphElementtrueになりますが、instanceof HTMLDivElementfalseになります。

このサンプルから、Node←Elementと継承されていることがよく分かるでしょう。

仮想DOMとレンダリングのコスト

前述のとおり、WebブラウザがDOMツリーを持っているのは、HTMLドキュメントをレンダリング(コンテンツをブラウザの画面に表示する処理)するためです。リアルDOMを無秩序に操作すると、その都度、以下のような処理が実行されます。

  1. DOMツリーを再構築する
  2. DOMツリーとCSSOMツリーを組み合わせてレンダリングツリーを構築する
  3. レンダリングツリーでレイアウトを行い、各Nodeの位置やスタイルを計算する
  4. レイアウト結果をもとに描画する

※ CSSOM: CSS Object Modelの略で、CSS版DOMのようなものです。

こういったブラウザのレンダリングフローをもっと詳しく知りたい方には次の記事がおすすめです。

5

出典: ブラウザのしくみ - Webkitのメインフロー | HTML5 Rocks

レンダリングフローを見ていただいたとおり、レンダリングはブラウザにとってコストの高い処理です。レンダリングコストを減らす最も効果的な方法は、無駄なレンダリングをなくすことです。

そこで考えられたのが、仮想DOMという概念です。

仮想DOMは、ブラウザが持っていたDOMツリーを、JavaScriptのオブジェクト(仮想DOMツリー)として表現します。そして、メモリ上の仮想DOMツリーを用いて差分検知を行い、必要最低限の差分のみをリアルDOMに反映するため、一般的にパフォーマンスが向上すると言われています。

6

図5: 仮想DOMで差分検知をしてリアルDOMに反映する

なお、仮想DOMについて誤解を生まないよう説明しておくと、仮想DOMという新しいAPIや機能があるわけではありません。

仮想DOMが実装されているJavaScriptフレームワーク(ReactやVue.jsなど)でも、最終的にはリアルDOMを操作しています。そのため、DOMツリーの構造が大きくなればなるほど、差分検知の計算量が増え、結果的に遅くなってしまうこともあります。

フレームワークで仮想DOMはどのように実装されているか

仮想DOMの概要を説明したので、続いて仮想DOMが実装されているフレームワークの動きについて、ざっくり説明します。

  1. 仮想DOMツリーを2種類用意する(変更前後のツリー2種類)
  2. 何らかのアクションでstateが書き換わる
  3. 仮想DOMを再構築する
  4. 変更前後の仮想DOMツリーを用いて差分検知する
  5. 差分があった箇所だけリアルDOMに反映する

※ state: アプリケーションが保持しているデータ。例えば、TODOアプリのタスクの完了状態など

以下のHTMLドキュメントとJavaScriptを例に、仮想DOMがどのように変化するかを説明します。

<div id="app">
  <h1 class="title">Hello エンジニアHub</h1>
</div>

<script>
// ※このスクリプトは説明用のサンプルです。

// div#appを取得する
const app = document.getElementById('app');

// Paragraph要素を作成し、テキストを設定する
const p = document.createElement('p');
p.innerHTML = '仮想DOM完全に理解した';

// div#appにp要素を追加する
app.appendChild(p);
</script>

1. 仮想DOMツリーを2種類用意する

先ほどのHTMLドキュメントを仮想DOMで表現すると、以下のようなオブジェクトになります。

{
  "nodeName"  : "div",
  "attributes": { "id": "app" },
  "children"  : [
    {
      "nodeName"  : "h1",
      "attributes": { "class": "title" },
      "children"  : ["Hello エンジニアHub"]
    }
  ]
}

図解すると以下のようになります。このように、ひとつひとつの要素をツリー状に管理しています。

7

図6: 仮想DOMツリーの図解

この仮想DOMツリーを2つ用意しておきます。

2. 何らかのアクションでstateが書き換わる

この例ではstateを持っておらず、単純にDOM操作によってp要素が追加されます。

3. 仮想DOMを再構築する

スクリプトが実行されると、以下のようなHTMLドキュメントに更新されるはずです(仮想DOMのフレームワークはまだリアルDOMに反映しません)

<div id="app">
  <h1 class="title">Hello エンジニアHub</h1>
  <p>仮想DOM完全に理解した</p>
</div>

HTMLドキュメントが変わるということは、仮想DOMも以下のように更新されます。

{
  "nodeName"  : "div",
  "attributes": { "id": "app" },
  "children"  : [
    {
      "nodeName"  : "h1",
      "attributes": { "class": "title" },
      "children"  : ["Hello エンジニアHub"]
    },
    // 仮想DOMツリーに↓が追加される
    {
      "nodeName"  : "p",
      "attributes": {},
      "children"  : ["仮想DOM完全に理解した"]
    }
  ]
}

4. 変更前後の仮想DOMツリーを用いて差分検知する

「1. 仮想DOMツリーを2種類用意する」で用意した変更前の仮想DOMツリーと、「3. 仮想DOMを再構築する」で更新された変更後の仮想DOMツリーを比較します。

{
  "nodeName"  : "div",
  "attributes": { "id": "app" },
  "children"  : [
    {
      "nodeName"  : "h1",
      "attributes": { "class": "title" },
      "children"  : ["Hello エンジニアHub"]
-    }
+    },
+    {
+      "nodeName"  : "p",
+      "attributes": {},
+      "children"  : ["仮想DOM完全に理解した"]
+    }
  ]
}

比較すると、div要素のchildrenに新しくp要素のオブジェクトが追加されていることが分かります。

5. 差分があった箇所だけリアルDOMに反映する

差分があった箇所(p要素の部分)のみを、リアルDOMに反映します。このように差分があった箇所だけをリアルDOMに反映することで、変更を最小限に抑えることができます。

また、jQueryのようなリアルDOMを操作するライブラリを使うと、リアルDOMと仮想DOMの1対1の関係が崩れてしまいます。これが、JavaScriptフレームワークとjQueryの相性が悪いとされる理由です。

リアルDOM vs. 仮想DOM

リアルDOMの概要や、仮想DOMがどのように実装されているかを説明してきました。ただ、これだけの知識では、仮想DOMが実装されたフレームワークを使うメリットが分かりません。

そのため、ミニマムなWebアプリケーションを例に、従来のリアルDOM操作による開発と、仮想DOMが実装されているフレームワークによる開発を比較してみましょう。

サンプルは「ボタンを押すと数字が増える」というシンプルなWebアプリケーションです。

8

図7: ボタンを押すと数字が増えるサンプルアプリケーション

次のCodePenでアプリケーションをそのまま試すことができます。

従来のリアルDOM操作による開発

従来のWebアプリケーション開発では、jQueryやPrototype.jsといったライブラリがよく使われていました。ただ、当記事ではできるだけシンプルにしたのでバニラJS(ライブラリを使わない素のJavaScript)で実装します。

<div id="app">
  <p id="counter">0</p>
  <button type="button" id="increment">+1</button>
</div>
// ※Arrow function(アロー関数)を使っているため、このままではIE11で動きません

// 現在のカウントを管理する
const state = { count: 0 };

// ボタンにクリックイベントを登録する
const btn = document.getElementById('increment');
btn.addEventListener('click', () => {
  // state.countをインクリメントしてp要素のテキストを書き換える
  const counter = document.getElementById('counter');
  counter.innerHTML = ++state.count;
});

ここまで小さなWebアプリケーションであれば、バニラJSのままで問題がなく、むしろ仮想DOMを使うより高速に動作します。

しかし、デメリットもいくつか存在します。

  • UIとロジックが分離できない(または難しい)
    • UI:button#incrementをクリックしたらp#counterを書き換える
    • ロジック:カウントをインクリメントする
  • 最終的なViewの状態が想像しづらい
  • 状態(state)の管理が難しい
    • アプリケーション全体からアクセスするため、変数のスコープを広げる必要がある
    • スコープが広いと管理が煩雑になりバグの温床になりやすい
  • UIとロジックをつなぐControllerが肥大化しがち
    • addEventListenerが増えがちである
  • 規模が大きくなると「考えること」が増える

仮想DOMが実装されているフレームワークによる開発

Vue.jsを使う場合、以下のような実装になります。

<div id="app">
  <p id="counter">{{ count }}</p>
  <button
    type="button"
    id="increment"
    @click="increment"
  >+1</button>
</div>
new Vue({
  el: '#app',
  data () {
    return {
      // 現在のカウントを管理する
      count: 0
    };
  },
  methods: {
    // countをインクリメントする
    increment() {
      this.count++;
    }
  }
});

若干コード量が増えましたが、フレームワークを使うメリットがあります。

  • UIとロジックを分離できる
    • 「HTMLのどの要素を取得し、書き換えるか」をJavaScript側で把握しなくてよい
  • 最終的なViewの状態が想像しやすい
  • 状態を一元管理できる
  • DOMの書き換えが最小限になる
  • スコープが狭くなることで「考えること」が減る

このサンプルコードでは、ボタンをクリックしたときにincrement()メソッドが呼ばれ、countがインクリメントされます。countが更新された時点で仮想DOMの再構築処理が走り、変更前後の仮想DOMを比較し差分を検知します。

そして、差分だけがリアルDOMに反映されます。

仮想DOMを使ったフレームワークを開発して理解を深める

ここまで、仮想DOMの概念を解説し、従来のWebアプリケーション開発と比較してきました。

次に、より理解を深めるため、実際に仮想DOMを使ったフレームワークを開発していきます。このフレームワークは、以下の3つのパートから成り立っています。

View
仮想DOMツリーに関する処理
Action
FluxアーキテクチャのAction
Controller
ViewとActionをつなぐ処理

以降で詳しく解説していきますが、最終的なソースコードは、筆者のGitHubにアップロードしています。全体図が見たい方は次を参照してください。

9 virtual-dom-framework/src/framework at master · BcRikko/virtual-dom-framework · GitHub

なお、このフレームワークのソースコードには読み解きやすいようコメントを多数書いていますが、さらに読みやすくするためTypeScriptで記述しています。TypeScriptを知らない方でも、型の部分(typeやinterfaceなど)を無視して読むことで、バニラJSと同じ感覚で読むことができます。

フレームワーク開発:View編

まず、仮想DOMの肝となるViewから開発します。ここで実装する機能は、以下のとおりです。

  • 仮想DOMツリーを作成する
  • 仮想DOMからリアルDOMに反映する
  • 仮想DOMツリーの差分検知
仮想DOMツリーを作成する

仮想DOMツリーを作成するh()メソッドを実装します。

/** Nodeが取りうる3種の型 */
type NodeType = VNode | string | number
/** 属性の型 */
type AttributeType = string | EventListener
type Attributes = {
  [attr: string]: AttributeType
}

/**
 * 仮想DOMのひとつのオブジェクトを表す型
 */
export type VNode = {
  nodeName: keyof ElementTagNameMap
  attributes: Attributes
  children: NodeType[]
}

/**
 * 仮想DOMを作成する
 * @param nodeName Nodeの名前(HTMLのタグ名)
 * @param attributes Nodeの属性(width/heightやstyleなど)
 * @param children Nodeの子要素のリスト
 */
export function h(
  nodeName: VNode['nodeName'],
  attributes: VNode['attributes'],
  ...children: VNode['children']
): VNode {
  return {
    nodeName,
    attributes,
    children
  }
}

なぜh()かと言うと、JSX(JavaScriptの拡張構文)をトランスパイル(コードからコードへのコンパイル)するときに、慣習的にcreateElementのエイリアスにhを使うためです。

// JSX
const view = (
  <div>
    <h1>Virtual DOM</h1>
    <p>仮想DOM完全に理解した</p>
  </div>
);

JSXをトランスパイルして得られる、変換後のコード例を、以下に示しましょう。

// トランスパイル後
var h = React.createElement;
var view =
  h(
    'div',
    null,
    h('h1', null, 'Virtual DOM'),
    h('p', null, '仮想DOM完全に理解した')
  );

なぜhを使うか、もっと詳しく知りたい方はPreactのドキュメントを参照ください。

ここで実装したh()メソッドにNode名(要素のタグ名)、属性(styleやid、classなど)、子Nodeを渡すと、仮想DOMツリーが出来上がります。

const view = (state, actions) =>
  h(
    "div",
    { id: "app" },
    h("p", { id: "counter" }, children: [ state.count ]),
    h("button", {
      type: "button",
      id: "increment",
      onclick: () => { actions.increment(); }},
      children: [ "+1" ]
    )
  );

上記の処理を実行すると、以下のような仮想DOMツリーが出来ます。

{
  "nodeName": "div",
  "attributes": { "id": "app" },
  "children": [
    {
      "nodeName": "main",
      "attributes": null,
      "children": [
        {
          "nodeName": "p",
          "attributes": { "id": "counter" },
          "children": [ 0 ]
        },
        {
          "nodeName": "button",
          "attributes": { "id": "increment", "type": "button", "onclick": Function },
          "children": [ "+1" ]
        }
      ]
    }
  ]
}
仮想DOMからリアルDOMに反映する

先ほど作成した仮想DOMツリーをリアルDOMに反映するメソッドを実装します。このメソッドではdocument.createElement()などが出てくるので、JavaScriptを触ったことがある人なら理解しやすいでしょう。

/**
 * Nodeを受け取り、VNodeなのかTextなのかを判定する
 */
const isVNode = (node: NodeType): node is VNode => {
  return typeof node !== 'string' && typeof node !== 'number'
}

/**
 * リアルDOMを作成する
 * @param node 作成するNode
 */
export function createElement(node: NodeType): HTMLElement | Text {
  if (!isVNode(node)) {
    return document.createTextNode(node.toString())
  }

  const el = document.createElement(node.nodeName)
  setAttributes(el, node.attributes)
  node.children.forEach(child => el.appendChild(createElement(child)))

  return el
}

/**
 * 属性を設定する
 * @param target 操作対象のElement
 * @param attributes Elementに追加したい属性のリスト
 */
const setAttributes = (target: HTMLElement, attributes: Attributes): void => {
  for (const attr in attributes) {
    if (isEventAttr(attr)) {
      // onclickなどはイベントリスナーに登録する
      // onclickやoninput、onchangeなどのonを除いたイベント名を取得する
      const eventName = attr.slice(2)
      target.addEventListener(eventName, attributes[attr] as EventListener)
    } else {
      // イベントリスナ-以外はそのまま属性に設定する
      target.setAttribute(attr, attributes[attr] as string)
    }
  }
}

/**
 * 受け取った属性がイベントかどうか判定する
 * @param attribute 属性
 */
const isEventAttr = (attribute: string): boolean => {
  // onからはじまる属性名はイベントとして扱う
  return /^on/.test(attribute)
}

ここで実装したcreateElement()メソッドに、先ほど作成した仮想DOMツリーを渡すことで、リアルDOMを操作し、要素を作成します。そしてメイン要素(ここではdiv#appNode.appendChildを使って追加することで、ブラウザ上に表示されます。

仮想DOMツリーの差分検知

仮想DOMを使ったフレームワークの中で肝と言ってよいのが、この差分検知機構です。変更前後の仮想DOMツリーを比較し、差分がある部分だけをリアルDOMに反映します。

本来はもっと複雑で効率的な差分検知アルゴリズムが必要ですが、今回は「仮想DOMを理解すること」が目的なので、なるべくシンプルにします。検知する差分は、以下の5種類だけです。

差分のタイプ 説明
Type NodeTypeが異なる
Text テキストNodeが異なる
Node 要素名が異なる
Value value属性が異なる(input要素用)
Attr 属性(styleやclass、イベントなど)が異なる
/** 差分のタイプ */
enum ChangedType {
  /** 差分なし */
  None,
  /** NodeTypeが異なる */
  Type,
  /** テキストNodeが異なる */
  Text,
  /** 要素名が異なる */
  Node,
  /** value属性が異なる(input要素用) */
  Value,
  /** 属性が異なる */
  Attr
}
/**
 * 差分検知を行う
 * @param a
 * @param b
 */
const hasChanged = (a: NodeType, b: NodeType): ChangedType => {
  if (typeof a !== typeof b) {
    return ChangedType.Type
  }

  if (!isVNode(a) && a !== b) {
    return ChangedType.Text
  }

  if (isVNode(a) && isVNode(b)) {
    if (a.nodeName !== b.nodeName) {
      return ChangedType.Node
    }

    if (a.attributes.value !== b.attributes.value) {
      return ChangedType.Value
    }

    if (JSON.stringify(a.attributes) !== JSON.stringify(b.attributes)) {
      // 本来ならオブジェクトひとつひとつを比較すべきなのですが、シンプルな実装にするためにJSON.stringifyを使っています
      // JSON.stringifyを使ったオブジェクトの比較は罠が多いので、できるだけ使わない方がよいです
      return ChangedType.Attr
    }
  }

  return ChangedType.None
}

次に、差分があった箇所をリアルDOMに反映する処理です。

/**
 * 仮想DOMの差分を検知し、リアルDOMに反映する
 * @param parent 親要素
 * @param oldNode 古いNode情報
 * @param newNode 新しいNode情報
 * @param index 子要素の順番
 */
export function updateElement(parent: HTMLElement, oldNode: NodeType, newNode: NodeType, index = 0): void {
  // oldNodeがない場合は新しいNodeを作成する
  if (!oldNode) {
    parent.appendChild(createElement(newNode))
    return
  }

  // newNodeがない場合は削除されたと判断し、そのNodeを削除する
  const target = parent.childNodes[index]
  if (!newNode) {
    parent.removeChild(target)
    return
  }

  // 差分検知し、パッチ処理(変更箇所だけ反映)を行う
  const changeType = hasChanged(oldNode, newNode)
  switch (changeType) {
    case ChangedType.Type:
    case ChangedType.Text:
    case ChangedType.Node:
      parent.replaceChild(createElement(newNode), target)
      return
    case ChangedType.Value:
      updateValue(target as HTMLInputElement, (newNode as VNode).attributes.value as string)
      return
    case ChangedType.Attr:
      updateAttributes(target as HTMLInputElement, (oldNode as VNode).attributes, (newNode as VNode).attributes)
      return
  }

  // 子要素の差分検知・リアルDOM反映を再帰的に実行する
  if (isVNode(oldNode) && isVNode(newNode)) {
    for (let i = 0; i < newNode.children.length || i < oldNode.children.length; i++) {
      updateElement(target as HTMLElement, oldNode.children[i], newNode.children[i], i)
    }
  }
}

/**
 * 属性を更新する
 * @param target 操作対象のElement
 * @param oldAttrs 古い属性
 * @param newAttrs 新しい属性
 */
const updateAttributes = (target: HTMLElement, oldAttrs: Attributes, newAttrs: Attributes): void => {
  // 処理をシンプルにするためoldAttrsを削除後、newAttrsで再設定する
  for (const attr in oldAttrs) {
    if (!isEventAttr(attr)) {
      target.removeAttribute(attr)
    }
  }

  for (const attr in newAttrs) {
    if (!isEventAttr(attr)) {
      target.setAttribute(attr, newAttrs[attr] as string)
    }
  }
}

/**
 * input要素のvalueを更新する
 * @param target 操作対象のinput要素
 * @param newValue inputのvalueに設定する値
 */
const updateValue = (target: HTMLInputElement, newValue: string): void => {
  target.value = newValue
}

フレームワーク開発:Action編

次はAction部分ですが、Actionはユーザーが実装するものなので、ここではフレームワークが提供する型定義だけを作成します。

export type ActionType<State> = (state: State, ...data: any) => void | any

/**
 * Action層の型定義
 */
export interface ActionTree<State> {
  [action: string]: ActionType<State>
}

このフレームワークでは、Fluxアーキテクチャ風の単方向データフローを採用します。

10

図8: Fluxアーキテクチャ風の単方向データフロー

Fluxとは、Facebookが提唱するアーキテクチャであり、単方向データフローが特徴です。単方向データフローとは、読んで字のごとく、データの流れが一方通行ということです。

View
Stateのデータをもとに仮想DOMツリーを構築しレンダリングする
ボタンクリックなどのイベントでActionを呼び出す
Action
Viewから呼ばれるイベントで、唯一Stateを更新できる
State
データの保管庫

従来のリアルDOM操作による開発を思い出してください。Action(クリックイベント)の中でViewcounter.innerHTMLとStatestate.countを更新しています。このように、自由にViewやStateを更新できてしまうと、アプリケーションの規模が大きくなったときに意図せぬ動作(副作用)が発生しやすくなります。

その点、Fluxアーキテクチャは単方向データフローのため、ViewやStateを書き換える場所が限定されます。

レイヤー できること できないこと
View Actionを実行できる Stateは更新できない
Action Stateを更新できる Viewは更新できない

このように、あえて制限事項を加えることで、壊れにくく、影響範囲が分かりやすいアプリケーションを開発できます。

実際のWebアプリケーション開発では、以下のように実装することになります。

/**
 * Actions: 各種イベント処理
 */
interface Actions extends ActionTree<State> {
  /** 新しいタスクを作成する */
  createTask: (state: State, title: string) => void
}
const actions: Actions = {
  createTask(state, title = '') {
    state.tasks.push(title)
    state.form.title = ''
  }
}

フレームワーク開発:Controller編

最後に、フレームワークの心臓部分であるControllerを実装します。

ControllerはDispatcher的な立ち位置で、ActionでStateが更新されたらViewの更新処理を実行する部分です。

import { View, VNode, updateElement, createElement } from './view'
import { ActionTree } from './action'

interface AppConstructor<State, Actions extends ActionTree<State>> {
  /** メインNode */
  el: Element | string
  /** Viewの定義 */
  view: View<State, Actions>
  /** 状態管理 */
  state: State
  /** Actionの定義 */
  actions: Actions
}

export class App<State, Actions extends ActionTree<State>> {
  private readonly el: Element
  private readonly view: AppConstructor<State, Actions>['view']
  private readonly state: AppConstructor<State, Actions>['state']
  private readonly actions: AppConstructor<State, Actions>['actions']

  /** 仮想DOM(変更前用) */
  private oldNode: VNode
  /** 仮想DOM(変更後用) */
  private newNode: VNode

  /** 連続でリアルDOM操作が走らないためのフラグ */
  private skipRender: boolean

  constructor(params: AppConstructor<State, Actions>) {
    this.el = typeof params.el === 'string' ? document.querySelector(params.el) : params.el
    this.view = params.view
    this.state = params.state
    this.actions = this.dispatchAction(params.actions)
    this.resolveNode()
  }

  /**
   * ユーザーが定義したActionsに仮想DOM再構築用のフックを仕込む
   * @param actions
   */
  private dispatchAction(actions: Actions): Actions {
    const dispatched: ActionTree<State> = {}

    for (const key in actions) {
      const action = actions[key]
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      dispatched[key] = (state: State, ...data: any): any => {
        const ret = action(state, ...data)
        this.resolveNode()
        return ret
      }
    }

    return dispatched as Actions
  }

  /**
   * 仮想DOMを構築する
   */
  private resolveNode(): void {
    // 仮想DOMを再構築する
    this.newNode = this.view(this.state, this.actions)
    this.scheduleRender()
  }

  /**
   * renderのスケジューリングを行う
   */
  private scheduleRender(): void {
    if (!this.skipRender) {
      this.skipRender = true
      // setTimeoutを使うことで非同期になり、かつ実行を数ミリ秒遅延できる
      setTimeout(this.render.bind(this))
    }
  }

  /**
   * リアルDOMに反映する
   */
  private render(): void {
    if (this.oldNode) {
      updateElement(this.el as HTMLElement, this.oldNode, this.newNode)
    } else {
      this.el.appendChild(createElement(this.newNode))
    }

    this.oldNode = this.newNode
    this.skipRender = false
  }
}

dispatchAction()メソッドでは、ユーザーが定義したactionsに仮想DOM再構築用のフックを仕込みます。そうすることで、ユーザーが実装する部分にフレームワークの処理が漏れ出ないようにできます。

また、1回のユーザー入力に対して、複数回のActionを呼び出すことがあります。Actionが呼ばれるたびに仮想DOMの再構築処理が走り、リアルDOMに反映していては効率が悪く、レンダリングコストが跳ね上がります。

関連する全てのActionが終わってから仮想DOMを再構築するため、render()メソッドでレンダリング処理をする前に、scheduleRender()メソッドでスケジューリングしています。setTimeoutを使って処理を若干遅延させることで、レンダリングの効率化を図っています。

これで、仮想DOMを使ったフレームワークの開発は全て終了です。次の章では、この自作フレームワークを用いて、Webアプリケーションを開発します。

自作フレームワークを使ったWebアプリケーション開発

自作フレームワークを用いて、シンプルなTODOアプリを開発します。

11

図9: 仮想DOMフレームワークを使って実装したTodoアプリ

次のCodePenでアプリケーションをそのまま試すことができます。

仮想DOMフレームワークを使って実装したTodoアプリ - GitHub

状態管理(State)の実装

TODOアプリに必要なStateは、以下のとおりです。

  • tasks ... タスク一覧を表示するために使うタイトルのリスト
  • form ... フォーム入力のチェックに使う
    • title ... 現在入力しているタイトル
    • hasError ... エラーメッセージを表示するために使うフラグ
/**
 * State: 状態管理
 */
type Task = string
type Form = {
  /** タスクのタイトル */
  title: string
  /** バリデーション結果 */
  hasError: boolean
}
type State = {
  /** タスク一覧 */
  tasks: Task[]
  /** フォームの状態 */
  form: Form
}
const state: State = {
  tasks: ['Learn about Virtual DOM', 'Write a document'],
  form: {
    title: '',
    hasError: false
  }
}

イベント処理(Actions)の実装

次に、イベント処理部分を実装します。

TODOアプリに必要なイベント処理は、以下のとおりです。

  • validate ... state.form.titleの入力値チェックを行う
  • createTask ... 新しいタスクを作成し、state.tasksに追加する
  • removeTask ... state.tasksから対象のタスクを削除する
/**
 * Actions: 各種イベント処理
 */
interface Actions extends ActionTree<State> {
  /** タイトルの入力チェックを行う */
  validate: (state: State, title: string) => boolean
  /** 新しいタスクを作成する */
  createTask: (state: State, title: string) => void
  /** indexで指定したタスクを削除する */
  removeTask: (state: State, index: number) => void
}
const actions: Actions = {
  validate(state, title: string) {
    if (!title || title.length < 3 || title.length > 20) {
      state.form.hasError = true
    } else {
      state.form.hasError = false
    }

    return !state.form.hasError
  },

  createTask(state, title = '') {
    state.tasks.push(title)
    state.form.title = ''
  },

  removeTask(state, index: number) {
    state.tasks.splice(index, 1)
  }
}

描画処理(View)の実装

次に、描画処理部分を実装します。

※サンプルの都合上、インラインスタイルを使っていますが、実際の開発ではCSSで外出しすることをおすすめします。

/**
 * View: 描画関連
 */
const view: View<State, Actions> = (state, actions) => {
  // prettier-ignore
  return h(
    'div', 
    { 
      class: 'nes-container is-rounded',
      style: 'padding: 2rem;'
    },
    h(
      'h1',
      {
        class: 'title',
        style: 'margin-bottom: 2rem;'
      },
      h('i', { class: 'nes-icon heart is-medium' }),
      'Virtual DOM TODO App '
    ),
    h(
      'form',
      {
        class: 'nes-container',
        style: 'margin-bottom: 2rem;'
      },
      h(
        'div',
        {
          class: 'nes-field',
          style: 'margin-bottom: 1rem;',
        },
        h(
          'label',
          {
            class: 'label',
            for: 'task-title'
          },
          'Title'
        ),
        h('input', {
          type: 'text',
          id: 'task-title',
          class: 'nes-input',
          value: state.form.title,
          oninput: (ev: Event) => {
            const target = ev.target as HTMLInputElement
            state.form.title = target.value
            actions.validate(state, target.value)
          }
        }),
      ),
      h(
        'p',
        {
          class: 'nes-text is-error',
          style: `display: ${state.form.hasError ? 'display' : 'none'}`,
        },
        'Enter a value between 3 and 20 characters'
      ),
      h(
        'button',
        {
          type: 'button',
          class: 'nes-btn is-primary',
          onclick: () => {
            if (state.form.hasError) return
            actions.createTask(state, state.form.title)
          }
        },
        'Create'
      )
    ),
    h(
      'ul',
      { class: 'nes-list is-disc nes-container' },
      ...state.tasks.map((task, i) => {
        return h(
          'li',
          {
            class: 'item',
            style: 'margin-bottom: 1rem;'
          },
          task,
          h(
            'button',
            {
              type: 'button',
              class: 'nes-btn is-error',
              style: 'margin-left: 1rem;',
              onclick: () => actions.removeTask(state, i)
            },
            '×'
          )
        )
      })
    )
  )
}

オブジェクトでHTMLドキュメントを表現しているため、少々読みづらいですが、JSXで表現すると以下のとおりです。

// 分かりやすくするため、一部簡略化しています
const view = (
  <div class="nes-container is-rounded">
    <h1 class="title">Virtual DOM TODO App</h1>

    <form class="nes-container">
      <div class="nes-field">
        <label for="task-title">Title</label>

        <input
          type="text"
          class="nes-input"
          value={state.form.title}
          onInput={() => /** タイトルのチェック */)}
          />

        <p
          class="nes-text is-error"
          style={{ display: state.form.hasError ? 'block' : 'none' }}>
          Enter a value between 3 and 20 characters
        </p>

        <button
          type="button"
          class="nes-btn is-primary"
          onClick={() => /* 登録処理 */}
          >
          Create
        </button>
      </div>
    </form>
    
    <ul class="nes-list is-disc nes-container">
      {
        state.tasks.map((task) => {
          return <li class="item">
            {task}
            <button
              type="button"
              class="nes-btn is-error"
              onClick={() => /** 削除処理 */}>
              ×
            </button>
          </li>
        })
      }
    </ul>
  </div>
);

フレームワークに渡す

最後に、ここまで作成してきたState、Actions、Viewを、自作フレームワークに渡して完成です。

new App<State, Actions>({
  el: '#app',
  state,
  view,
  actions
})

仮想DOMは銀の弾丸ではない

ここまで、仮想DOMのメリットについて紹介してきました。しかし、仮想DOMにもデメリットがあります。

仮想DOMはオーバーヘッドになる

仮想DOMフレームワークを実際に作ってみて、動かしてみて、いかがだったでしょうか? 「仮想DOMは速い」という触れ込みだったのに、何か違うと思いませんでしたか?

例えば、ボタンを押したらインクリメントするWebアプリケーションがあったとします。

<div id="app">
  <p id="counter">0</p>
  <button type="button">+1</button>
</div>

仮想DOMのフレームワークで実装すると、以下のようになります。

const state = {
  count: 0
};
const actions = {
  increment(state) {
    state.count++;
  }
};
const view = (state, actions) => {
  return h(
    'div',
    null,
    h('p', { id: 'counter' }, state.count),
    h('button', { type: 'button', onclick: actions.increment() })
  );
};

new App({ el: '#app', state, view, actions});

この場合、以下の手順を踏みます。

  1. +1ボタンをクリック
  2. Actionのincrement()メソッドを実行
  3. Stateのcountをインクリメント
  4. 仮想DOMツリーの再構築
  5. 変更前後の仮想DOMツリーで差分検知
  6. 差分をリアルDOMに反映

リアルDOMを直接操作した場合は、以下のようになります。

let count = 0;
document.querySelector('button').addEventListener('click', () => {
  document.querySelector('p#counter').innerHTML = ++count;
});

見比べてみていかがでしょうか? リアルDOMを直接操作した方が速くなるのは自明です。

特に仮想DOMの差分検知にはコストがかかります。ReactやVue.jsなど代表的なJavaScriptフレームワークには優れた差分検知アルゴリズムが採用されていますが、コストはゼロではありません。

小さなページに仮想DOMは大き過ぎる

前述のとおり、仮想DOMはオーバーヘッドになります。そのため、小さなWebアプリケーションの場合は、仮想DOMフレームワークを使わない方が高いパフォーマンスを得られます(フレームワークを使うなという意味ではありません)

「では、どうすればいいの?」と問われたときのため、JavaScriptフレームワークを使わない代替手段を紹介します。

  1. リアルDOMでがんばる
  2. innerHTMLとテンプレートリテラルでがんばる
  3. template要素でがんばる
  4. 仮想DOMを使わないフレームワークでがんばる

1.についてはすでに紹介したので、ここでは2.以降を紹介します。

2. innerHTMLとテンプレートリテラルでがんばる

テンプレートリテラルを使うと、複数行の文字列を定義したり、文字列に値を挿入できたりします。

※ IE11はテンプレートリテラルをサポートしていません。下記コードをそのまま実行できません。ご注意ください。
※ innerHTMLは直接HTMLを設定する機能ですので、XSS(クロスサイトスクリプト)の対策が必要です。XSS対策については当記事の内容から逸れてしまうので省略します。

<form>
  <label for="title">Title</label>
  <input type="text" id="title">
  <button type="button" id="createTask">Create</button>
</form>

<ul id="list">
</ul>
const createTaskElement = (title, deadline) => {
  const li = document.createElement('li');
  li.innerHTML = `<div class="item">
    <i class="icon"></i>
    <span class="title">${ title }</span>
    <time class="time" datetime="${ deadline }">${ deadline }</time>
  </div>`;

  return li;
};

const list = document.getElementById('list');
document.getElementById('createTask').addEventListener('click', () => {
  const title = document.getElementById('title').value;
  list.appendChild(createTaskElement(title, '2019/12/31'));
});

3. template要素でがんばる

template要素とは、JavaScriptからアクセスできる「再利用可能な要素の塊」を作成できる機能です。template要素を使うことで、JavaScriptによるHTMLの組み立てを減らし、最終的なHTMLドキュメントを想像しやすくできます。

※ IE11はtemplate要素をサポートしていません。下記コードをそのまま実行できません。ご注意ください。

<form>
  <label for="title">Title</label>
  <input type="text" id="title">
  <button type="button" id="createTask">Create</button>
</form>

<ul id="list">
</ul>

<template id="task-item">
  <div class="item">
    <i class="icon"></i>
    <span class="title">placeholder</span>
    <time class="time" datetime="">placeholder</time>
  </div>
</template>
const template = document.getElementById('task-item');

const createTaskElement = (title, deadline) => {
  const li = document.createElement('li');
  const item = document.importNode(template.content, true);
  
  item.querySelector('.title').innerHTML = title;
  const time = item.querySelector('.time')
  time.innerHTML = deadline;
  time.setAttribute('datetime', deadline);

  li.appendChild(item);

  return li;
};

const list = document.getElementById('list');
document.getElementById('createTask').addEventListener('click', () => {
  const title = document.getElementById('title').value;
  list.appendChild(createTaskElement(title, '2019/12/31'));
});

4. 仮想DOMを使わないフレームワークでがんばる

jQueryはまだ戦える

フロントエンド界隈で「jQueryは古い、時代遅れだ」という風潮があるのは事実です。ECMAScript 2015以降、トランスパイルを前提としたフロントエンド開発が主流となり、クロスブラウザ対応としてのjQueryの出番は少なくなっています。

しかし、用法用量を守って使えば、jQueryでも戦えます。多数のプラグインが公開され、ノウハウも共有されています。DOMツリーの変化が少ないLP(ランディングページ)などでは、まだまだ現役です。

仮想DOMを使わないフレームワークの登場

2019年4月に、仮想DOMを使わないSvelteというJavaScriptフレームワークが登場し、話題になりました。Svelte自体は2016年から開発されていましたが、2019年4月に大幅アップデートされたSvelte 3.0がリリースされました。

本筋から逸れてしまうため紹介だけにとどめますが、このような仮想DOMを使わないフレームワークも登場しています。

それでもやっぱり仮想DOMのフレームワークは便利

ここまで仮想DOMのデメリットについて述べてきました。それでも仮想DOMのフレームワークが選ばれているのには理由があります。

「仮想DOMで速くなる」は、正確ではありません。正しくは「仮想DOMを使うと遅くなりづらい」です。

例えば、リアルDOMを直接操作する実装方法は、ジェンガのようなものです。最初はサクサク開発が進みます。パフォーマンスも十分です。

しかし、段数が高くなるにつれアプリケーションの状態、イベントハンドリング、Viewの操作など、次第に考えることが増えていきます。そして、今にも崩れそうなジェンガのようなWebアプリケーションが出来上がります。ここまでくるとパフォーマンスがどうとは言っていられません。副作用なく変更するだけで精一杯でしょう。

反面、仮想DOMを使ったフレームワークでは、イベントハンドリング、Viewの操作などはフレームワークが隠蔽してくれます。View操作が増えても仮想DOMのおかげで必要最低限のパッチ処理で済みます。仮想DOMがボトルネックになることもありますが、それ以上にメリットの方が大きいです。

リアルDOMを直接操作する方がよいか、仮想DOMのフレームワークがよいかは、時と場合によるとしか言えません。ですが、ちゃんとメリットとデメリットを理解した上で使えば、それほど酷い状況にはなりません。

まとめ ── DOMと仮想DOM

この記事では、リアルDOMの基礎、仮想DOMの基礎、仮想DOMを使ったフレームワーク開発、仮想DOMのメリット・デメリットなど説明してきました。

ここでもう一度、それぞれをざっくりおさらいしましょう。

DOM(リアルDOM)
  • JavaScriptからHTMLドキュメントを操作するためのAPI
  • Nodeをツリー状にして管理している
  • ユーザーインタラクション(クリック時のイベントなど)が増えると管理が大変になる
  • 本気でパフォーマンスチューニングすれば、仮想DOMのフレームワークより速くなる。ただし、メンテナンスがつらくなる
仮想DOM
  • NodeをJavaScriptのオブジェクトでツリー(仮想DOMツリー)として表現し、変更前後の仮想DOMツリーを比較し、変更がある箇所だけをリアルDOMに反映する
  • 不要なDOM操作が発生せず、レンダリングコストが少なくなることから、一般的に速くなると言われている
  • 仮想DOMのフレームワークを使うことで、メンテナンスしやすいWebアプリケーションを開発できる
  • 仮想DOMの差分検知にもコストがあるため、数msのパフォーマンスチューニングが必要なアプリケーションの場合は、ボトルネックになることがある

最後に、この記事が仮想DOMを理解する手助けになることを願っています。

参考記事

ダーシノ 12 bc_rikko 13 BcRikko

14
速弾きができるフロントエンドエンジニア。ブラックSIerから脱出し、さくらインターネット株式会社に入社。「さくらのINFRA WARS(ブラウザゲーム)」などの企画・開発を手掛ける。NES.cssを開発し、GitHubの#css-frameworkトピックで、世界ランク4位を獲得(2019年3月時点)。好きな言葉は「おかげさま」、人生の目標は「笑って死ぬ」。
ブログ: Black Everyday Company

編集:横田恵(リブロワークス

若手ハイキャリアのスカウト転職