2018/11/19

自作フレームワークをつくって学ぶ 仮想DOM実践入門

昨今の代表的なJavaScriptフレームワーク(React、Vue.jsなど)には「仮想DOM(Virtual DOM)」という概念が採用されているので、フロントエンド界隈の人は一度は耳にしたことがあるだろう。ただ、仮想DOMについて学ぼうと検索してもヒットするのは「フレームワークの使い方」ばかり。踏み込んでいても概念の説明どまりで、仮想DOMがどのように実装されているか解説した記事はすくない。

ということで、当記事では理解を深めるために仮想DOMを使ったフレームワークを自作し、仮想DOMに入門する!
そして、ReactやVue.jsを単なるブラックボックスのフレームワークではなく、中身を理解して使えるようになることを、当記事の目標とする。


フレームワークが完成すると以下のようなWebアプリケーションがつくれるようになる。

TOC


記事が長くなりそうなのでも目次
  • そもそもDOMとは?
    • DOMやDOMツリー、ノードについての解説をする
  • 仮想DOMとは?
    • 仮想DOMの概念の説明
    • 仮想DOMの実装
  • リアルDOMと仮想DOMの比較
    • リアルDOMで直接DOM操作するWebアプリケーション開発
    • 仮想DOMを採用したフレームワークを使ったWebアプリケーション開発
  • 仮想DOMを使ったフレームワークの開発
    • ここが本題
    • 仮想DOMの概念について理解している方はここからお読みください
  • フレームワーク開発: View編
  • フレームワーク開発: Action編
  • フレームワーク開発: Controller編
  • 自作フレームワークを使ったWebアプリケーション開発

完成版のリポジトリを作成したので、この記事でわからないことがあったら実際に動かしながら学んでみてほしい。


そもそもDOMとは?

DOM

仮想DOMを語る前に、基本となる「DOM」について説明する。
DOMとは「Document Object Model」の略で、ざっくり説明するとJavaScriptからHTMLを操作するためのAPIだ。
<div id="app">
  <h1 class="title">仮想DOM実践入門</h1>
</div>

<script>
const app = document.getElementById('app');

const p = document.createElement('p');
p.innerText = '仮想DOM完全に理解した';

app.appendChild(p);
</script>
上記サンプルコードでは、p要素にテキストを設定し、取得したdiv#app要素に追加している。このようにDOMを使うことで、動的なページを作成できる。

また、以降説明する仮想DOMと区別するために、HTMLを直接操作するDOMのことを「リアルDOM」と呼ぶこともある。

DOMツリーとノード

DOMはHTMLドキュメントを「オブジェクトのツリー」として扱っており、そのツリーを「DOMツリー」と呼ぶ。また、ツリーのオブジェクトひとつひとつのことを「ノード」と呼ぶ。より詳しい説明は、以下の記事を参照してほしい。


仮想DOMとは?


前述のDOMツリーはブラウザがHTMLドキュメントをレンダリングするために持っている。そのため無秩序にリアルDOMを操作すると、その都度HTMLの解析、DOMツリーの再構築などの処理が走りレンダリングコストがかかってしまう。

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

いままでブラウザが持っていたリアルDOMツリーを、JavaScriptのオブジェクトとして表現しようという考え。リアルDOMを操作するのではなく、オブジェクトを変更し差分があるところだけ更新するので一般的に速くなると言われている。

ここで仮想DOMについて誤解を生まないよう説明しておくと、「仮想DOM」というAPIや機能があるわけではない。ReactやVue.jsだって最終的にはリアルDOMを操作しているので、DOMツリー構造が大きくなればなるほど計算量が増えて遅くなる。

ここまでの説明では「仮想DOM=JSON」で、何がすごいかよくわからないと思う。そこで次章では仮想DOMを使ったフレームワークの実装について解説する。



仮想DOMの実装


仮想DOMを使ったフレームワークの動きについてざっくり説明する。

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


最初のサンプルコードを思い出してほしい。
<div id="app">
  <h1 class="title">仮想DOM実践入門</h1>
</div>

<script>
const app = document.getElementById('app');

const p = document.createElement('p');
p.innerText = '仮想DOM完全に理解した';

app.appendChild(p);
</script>

これを仮想DOMで表現すると以下のようなオブジェクトになる。
// 変更前
{
  nodeName: "div",
  attributes: { id: "app" },
  children: [
    {
      nodeName: "h1",
      attributes: { class: "title" },
      children: ["仮想DOM実践入門"]
    }
  ]
}

スクリプトが実行されたあとの仮想DOMは以下のように更新される。
// 変更後
{
  nodeName: "div",
  attributes: { id: "app" },
  children: [
    {
      nodeName: "h1",
      attributes: { class: "title" },
      children: ["仮想DOM実践入門"]
    },
+   {
+     nodeName: "p",
+     attributes: {},
+     children: ["完全に理解した"]
+   }
  ]
}

この差分だけリアルDOMに反映するので、変更が最小限に抑えられるという理屈だ。
また、jQueryのようなリアルDOMを操作するフレームワークを使うと、リアルDOMと仮想DOMの1対1の関係が崩れてしまう。これが「ReactやVue.js」と「jQuery」の相性が悪いとされる理由だ。



リアルDOMと仮想DOMの比較


この章では従来のリアルDOMを操作する実装と、仮想DOMのフレームワークを使った実装を比較しながら、「ボタンをクリックしたら数字が増えるアプリ」を例にとり具体的な解説をする。

リアルDOMを使った従来のWebアプリケーション開発

従来のWebアプリケーション開発ではjQueryやprototype.jsなどがよく使われていた。ただ、今回はシンプルにしたいのでバニラJSで実装する。

従来なら以下のような実装になる。
<div id="app">
  <p id="counter">0</p>
  <button type="button" id="increment">+1</button>    
</div>

<script>
const state = { count: 0 };
const btn = document.getElementById('increment');
btn.addEventListener('click', () => {
  const counter = document.getElementById('counter');
  counter.innerText = ++state.count;
})
</script>
このアプリケーションを開発しているフロントエンドエンジニアの頭の中は、たぶん以下のような感じ。

  1. stateというオブジェクトで現在のcountを管理しよう
  2. ボタンをクリックしたらインクリメント処理を行おう
  3. state.countをインクリメントしよう
  4. state.countを表示するために表示する要素(p#counter)を取得しよう
  5. 取得した要素の文字をstate.countで更新しよう
ここまで小さなアプリケーションであれば、フレームワークを使うより高速にリアルDOMを直接操作したほうが高速に動作する。

しかし、デメリットもいくつかある。

  • UIとロジックが分離できない(または難しい)
  • 状態(state)の管理が難しい(グローバル変数にするしか…)
  • UIとロジックをつなぐControllerが肥大化しがち
  • 規模が大きくなると「考えること」が増える

仮想DOMを採用したフレームワークを使用したWebアプリケーション開発

次に昨今のフレームワークを使った実装なら以下のようになる。
※雰囲気を伝えるためなのでこのままでは動作しないので注意
const view = (state, actions) = {
  return (
    <p id="counter">{ state.count }</p>
    <button
      type="button"
      id="increment"
      onclick={() => { actions.increment();}}>
      +1
    </button>
  );
}

const state = {
  count: 0
};

const actions = {
  increment: state => {
    state.count++;
  }
};

const app = new App({ el:"#app", view, state, actions });

若干コード量が増えた気もするが、フレームワークを使うメリットもいくつかある

  • UIとロジックが分離できる
  • 状態を一元管理できる
  • DOMの書き換えを最小限にできる
  • スコープが狭くなるので「考えること」が減る



仮想DOMをつかったフレームワークの開発


ここまでで仮想DOMの基本的な概念や、従来のWebアプリケーション開発とは違うことがわかっていただけたと思う。

ようやくここから本題の「仮想DOMをつかったフレームワークの開発」に入る。フレームワークを開発する上でのポイントをいくつか紹介し、最後に完成したフレームワークを使い先ほどの「ボタンをクリックしたら数字が増えるアプリ」や定番の「TODOアプリ」を開発する。

今回はコードリーディングしやすいようにTypeScriptで実装する。



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


まずはView部分。機能は以下のとおり。

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

仮想DOMツリーを作成する

JSXをトランスパイルするとh()メソッドに変換されるので、まずはh()メソッドを実装する。
// jsx
const view = <p class="message">virtual dom</p>;

// jsxをトランスパイルすると…
const view = h("p", { class: "message" }, "virtual dom");

このh()は仮想DOMツリーを構築するためのメソッドだ。
// view.ts
type NodeType = VNode | string | number;
type Attributes = { [key: string]: string | Function };

export interface View<State, Actions> {
  (state: State, actions: Actions): VNode;
}

/**
 * 仮想DOM
 */
export interface VNode {
  nodeName: keyof ElementTagNameMap;
  attributes: Attributes;
  children: NodeType[];
}

/**
 * 仮想DOMを作成する
 */
export function h(
  nodeName: keyof ElementTagNameMap,
  attributes: Attributes,
  ...children: NodeType[]
): VNode {
  return { nodeName, attributes, children };
}

このhメソッドにノード名(タグ名)、属性、子ノードを渡すと、仮想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" ]
  )
);

↑これが↓こうなる。
{
  "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をカジッたことがあれば理解しやすいだろう。
// view.ts

/**
 * リアルDOMを生成する
 */
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;
}

function isVNode(node: NodeType): node is VNode {
  return typeof node !== "string" && typeof node !== "number";
}

/**
 * targetに属性を設定する
 */
function setAttributes(target: HTMLElement, attrs: Attributes): void {
  for (let attr in attrs) {
    if (isEventAttr(attr)) {
      const eventName = attr.slice(2);
      target.addEventListener(eventName, attrs[attr] as EventListener);
    } else {
      target.setAttribute(attr, attrs[attr] as string);
    }
  }
}

function isEventAttr(attr: string): boolean {
  // onから始まる属性名はイベントとして扱う
  return /^on/.test(attr);
}

createElementメソッドに先ほど作成した仮想DOMツリーを渡すことで、リアルDOMを操作し、メイン要素(div#appなど)にappendChildで追加することでブラウザにレンダリングされる。

差分検知

仮想DOMの肝といっても良い差分検知機構を実装する。変更前後の仮想DOMツリーを比較し、差分がある部分だけをリアルDOMに反映する処理だ。
enum ChangedType {
  /** 差分なし */
  None,
  /** nodeの型が違う */
  Type,
  /** テキストノードが違う */
  Text,
  /** ノード名(タグ名)が違う */
  Node,
  /** inputのvalueが違う */
  Value,
  /** 属性が違う */
  Attr
}

/**
 * 受け取った2つの仮想DOMの差分を検知する
 */
function hasChanged(a: NodeType, b: NodeType): ChangedType {
  // different type
  if (typeof a !== typeof b) {
    return ChangedType.Type;
  }

  // different string
  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)) {
      return ChangedType.Attr;
    }
  }
  return ChangedType.None;
}

本当ならもっと複雑で効率的な差分検知アルゴリズムが必要なのだが、シンプルにしたいので列挙体ChangedTypeで定義した5種類の差分だけを見る。

  • ChangedType.Type: 要素とテキストのように型が異なる
  • ChangedType.Text: テキストノードが異なる
  • ChangedType.Node: ノード名(タグ名)が異なる
  • ChangedType.Value: input要素のvalueが異なる
  • ChangedType.Attr: 属性(styleやclass、イベントなど)が異なる


次に仮想DOMが変更されたときに、リアルDOMに反映する処理を実装する。
/**
 * 仮想DOMの差分を検知し、リアルDOMに反映する
 */
export function updateElement(
  parent: HTMLElement,
  oldNode: NodeType,
  newNode: NodeType,
  index = 0
): void {
  // oldNodeがない場合は新しいノード
  if (!oldNode) {
    parent.appendChild(createElement(newNode));
    return;
  }

  const target = parent.childNodes[index];

  // newNodeがない場合はそのノードを削除する
  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:
      // valueの変更時にNodeを置き換えてしまうとフォーカスが外れてしまうため
      updateValue(
        target as HTMLInputElement,
        (newNode as VNode).attributes.value as string
      );
      return;
    case ChangedType.Attr:
      // 属性の変更は、Nodeを再作成する必要がないので更新するだけ
      updateAttributes(
        target as HTMLElement,
        (oldNode as VNode).attributes,
        (newNode as VNode).attributes
      );
      return;
  }

  // 再帰的にupdateElementを呼び出し、childrenの更新処理を行う
  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
      );
    }
  }
}

// NodeをReplaceしてしまうとinputのフォーカスが外れてしまうため
function updateAttributes(
  target: HTMLElement,
  oldAttrs: Attributes,
  newAttrs: Attributes
): void {
  // remove attrs
  for (let attr in oldAttrs) {
    if (!isEventAttr(attr)) {
      target.removeAttribute(attr);
    }
  }
  // set attrs
  for (let attr in newAttrs) {
    if (!isEventAttr(attr)) {
      target.setAttribute(attr, newAttrs[attr] as string);
    }
  }
}

// updateAttributesでやりたかったけど、value属性としては動かないので別途作成
function updateValue(target: HTMLInputElement, newValue: string) {
  target.value = newValue;
}
updateElementメソッドに変更前後の仮想DOMツリーを渡すことで、差分がある箇所だけリアルDOMに反映する。



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

次にAction部分なのだが、Action本体はユーザが定義するものなので、フレームワーク側では型定義だけを提供する。
// action.ts
export type ActionType<State> = (state: State, ...data: any) => void | any;

export type ActionTree<State> = {
  [action: string]: ActionType<State>
};

このフレームワークではFluxアーキテクチャ風の単方向データフローを採用するので、Store(状態)を更新できるのはActionのみになる。そのため、引数としてstateを渡して、Action内で更新できるようにする。


  • View: Storeのデータを元に仮想DOMツリーを構築しレンダリングする
  • Action: Viewなどから呼ばれるイベントで唯一Storeを更新できる
  • Store: データ保管庫



アプリケーション開発時は以下のようにActionを実装することになる。
const actions: ActionTree<State> = {
  createTask: (state, title: string) => {
    if (!title) { return; }
    const task = { title };
    state.tasks.push(task);
  },
  removeTask: (state, index: number) => {
    state.tasks.splice(index, 1);
  }
}



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


最後にフレームワークの心臓部のControllerを実装する。
ControllerはDispatcher的な立ち位置で、ActionでStoreが更新されたらViewの更新処理を実行する部分だ。
// app.ts
import { ActionTree } from "./action";
import { View, VNode, createElement, updateElement } from "./view";

interface AppConstructor<State, Actions> {
  /** 親ノード */
  el: HTMLElement | string;
  /** Viewの定義 */
  view: View<State, ActionTree<State>>;
  /** 状態管理 */
  state: State;
  /** Actionの定義 */
  actions: ActionTree<State>;
}

export class App<State, Actions> {
  private readonly el: HTMLElement;
  private readonly view: View<State, ActionTree<State>>;
  private readonly state: State;
  private readonly actions: ActionTree<State>;
  private oldNode: VNode;
  private newNode: VNode;
  private skipRender: boolean;

  constructor(params: AppConstructor<State>) {
    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();
  }

  /**
   * ActionにStateを渡し、新しい仮想DOMを作る
   */
  private dispatchAction(actions: ActionTree<State>) {
    const dispatched = {} as ActionTree<State>;
    for (let key in actions) {
      const action = actions[key];
      dispatched[key] = (state: State, ...data: any) => {
        const ret = action(state, ...data);
        this.resolveNode();
        return ret;
      };
    }
    return dispatched;
  }

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

  /**
   * レンダリングのスケジューリングを行う
   * (連続でActionが実行されたときに、何度もDOMツリーを書き換えないため)
   */
  private scheduleRender() {
    if (!this.skipRender) {
      this.skipRender = true;
      setTimeout(this.render.bind(this));
    }
  }

  /**
   * 描画処理
   */
  private render(): void {
    if (this.oldNode) {
      updateElement(this.el, this.oldNode, this.newNode);
    } else {
      this.el.appendChild(createElement(this.newNode));
    }

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

dispatchActionではユーザが定義したactionsを書き換え、引数にStateを追加したり、処理が終わったら仮想DOMの再構築を実行したりする。

ひとつのイベント(1回のクリックや1回の入力など)で複数回actionを呼ぶことがある。そのため、actionが呼ばれるたびに仮想DOMを更新し、リアルDOMに反映するのは効率が悪い。というかレンダリングコストが跳ね上がる。
そのため、関連するすべてのaction処理が終わってから仮想DOMを更新するために、render処理の前にscheduleRenderを噛ましている。ここではsetTimeoutを使って若干処理を遅延させることですべてのaction処理が終わるのを待っている。


お疲れさま!
フレームワークの完成だ!!


ということで、次の章では自作フレームワークを用い、Webアプリケーションを開発していく。



Webアプリケーション開発


HTMLは以下のようなマウントポイント(div#app)を持っていることを前提とする。
<body>
  <div id="app"></div>
</body>

ボタンをクリックしたら数字が増えるアプリケーション

import { View, h } from "./view";
import { ActionTree } from "./action";
import { App } from "./app";

type State = typeof state;
type Actions = typeof actions;

const state = {
  count: 0
};

const actions: ActionTree<State> = {
  increment: (state: State) => {
    state.count++;
  }
};

const view: View<State, Actions> = (state, actions) => {
  return h(
    "div",
    null,
    h("p", null, state.count),
    h(
      "button",
      { type: "button", onclick: () => actions.increment(state) },
      "count up"
    )
  );
};

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



TODOアプリ



import { View, h } from "./view";
import { App } from "./app";
import { ActionTree } from "./action";

type State = typeof state;
type Actions = typeof actions;

const state = {
  tasks: ["virtual dom", "完全に理解する"],
  form: {
    input: "",
    hasError: false
  }
};

const actions: ActionTree<State> = {
  validate: (state, input: string) => {
    if (!input || input.length < 3 || input.length > 20) {
      state.form.hasError = true;
    } else {
      state.form.hasError = false;
    }

    return !state.form.hasError;
  },
  createTask: (state, title: string) => {
    state.tasks.push(title);
    state.form.input = "";
  },
  removeTask: (state, index: number) => {
    state.tasks.splice(index, 1);
  }
};

const view: View<State, Actions> = (state, actions) => {
  return h(
    "div",
    { style: "padding: 20px;" },
    h("h1", { class: "title" }, "仮想DOM完全に理解したTODOアプリ"),
    h(
      "div",
      { class: "field" },
      h("label", { class: "label" }, "Task Title"),
      h("input", {
        type: "text",
        class: "input",
        style: "width: 200px;",
        value: state.form.input,
        oninput: (ev: Event) => {
          const target = ev.target as HTMLInputElement;
          state.form.input = target.value;
          actions.validate(state, state.form.input);
        }
      }),
      h(
        "button",
        {
          type: "button",
          class: "button is-primary",
          style: "margin-left: 10px;",
          onclick: () => {
            if (actions.validate(state, state.form.input)) {
              actions.createTask(state, state.form.input);
            }
          }
        },
        "create"
      ),
      h(
        "p",
        {
          class: "notification",
          style: `display: ${state.form.hasError ? "display" : "none"}`
        },
        "3〜20文字で入力してください"
      )
    ),
    h(
      "ul",
      { class: "panel" },
      ...state.tasks.map((task, i) => {
        return h(
          "li",
          { class: "panel-block" },
          h(
            "button",
            {
              type: "button",
              class: "delete",
              style: "margin-right: 10px;",
              onclick: () => actions.removeTask(state, i)
            },
            "remove"
          ),
          task
        );
      })
    )
  );
};

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



参考サイト





以上

written by @bc_rikko

3 件のコメント :