仮想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ツリーと呼びます。また、ツリーの要素/オブジェクトひとつひとつを、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

エンジニアHubに会員登録すると
続きをお読みいただけます(無料)。
登録のメリット
  • すべての過去記事を読める
  • 過去のウェビナー動画を
    視聴できる
  • 企業やエージェントから
    スカウトが届く