お手製VirtualDOMをつくる 基礎編

いわゆる仮想DOMをつくります。

1. 目標物

1.1 生成されるHTML

<ul class=”list”>
  <li>item 1</li>
  <li>item 2</li>
</ul>

1.2 必要なオブジェクト

{ type: ‘ul’, props: {class’: ‘list’ }, children: [
  { type: ‘li’, props: {}, children: [‘item 1’] },
  { type: ‘li’, props: {}, children: [‘item 2’] }
] }

1.3 オブジェクトの説明

{ type: ‘…’, props: {}, children: [] }
  • typeに要素
  • propsにクラス等の属性
  • childrenに子ノード

2. 実際のコード

2.1 h関数(DOMツリーを受け取る)

上記のオブジェクトを処理するh関数をまず作る。

function h(type, props, …children) {
  return { type, props, children };
}

というわけで、以下のような形でDOMツリーを書ける。

h(‘ul’, {class’: ‘list’ },
  h(‘li’, {}, ‘item 1’),
  h(‘li’, {}, ‘item 2’),
);

2.2 createElemet(子要素の処理)

子ノードにあたるchildrenの処理は2パターンある。要素文字列だ。

function createElement(node) {
  if (typeof node === ‘string’) {
    return document.createTextNode(node);
  }
  return document.createElement(node.type);
}

子ノードを引数nodeで受け取り、String型かどうかで処理を分岐する。要素ならcreateElementで要素を生成し、文字列の場合はcreateTextNodeで文字列をDOMツリーに追加する。

function createElement(node) {
  if (typeof node === ‘string’) {
    return document.createTextNode(node);
  }
  const $el = document.createElement(node.type);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));
  return $el;
}

$マークは深い意味はない。普通の変数名と同様の処理がされる。なんか付いてる、目立ってるくらいに考えておいてほしい。

node.childrenmapで回してcreateElementに放り込んでいる。つまり再帰的にcreateElementを呼び出してこれは一番最後の子ノードまで続く。子ノードを最後まで呼び出すと、順番にforEachで子ノードとしてDOMに追加していく。

2.3 updateElement(要素の更新)

2.3.1 便利なDOM操作一覧

以下のメソッドはDOM操作をする際に多用される。 - appendChild() 子ノードの追加 - removeChild() 子ノードの削除 - replaceChild() 子ノードの置換

次はこれらのメソッドを使いながらDOMの更新について見ていく。

2.3.2 古いnode(要素)が無い場合

function updateElement($parent, newNode, oldNode) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  }
}

更新対象となる古いノードがないばあい、新規作成するので普通に先程の関数でノードを追加する。

2.3.3 新しいnodeが無い場合

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parent.childNodes[index]
    );
  }
}

新しい要素が無いばあいは、子要素を削除する。

2.3.4 nodeを更新する場合

ノードを更新するばあい、if文の条件が複雑になるので特別に関数を作ります。

function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
         typeof node1 === ‘string’ && node1 !== node2 ||
         node1.type !== node2.type
}

以下のことを確認しています - 要素・文字列の一致していない - 文字列でなおかつ内容が一致していない - ノードの属性が一致していない これらの条件のうちどれか一つでも満たせば(一致していなければ)trueが返るという仕組み。

というわけで、updateElementに以下の処理を追加する。

else if (changed(newNode, oldNode)) {
    $parent.replaceChild(
      createElement(newNode),
      $parent.childNodes[index]
    );
  }

解説していない箇所は$parent.childNodes[index]の箇所のみなのでほかは飛ばします。この箇所は$parentの子ノードのインデックス番号を指定しています。replaceで置換するために必要です。

2.3.5 子ノードの比較

子ノードを比較するうえでいくつかの問題がまだ未解決で残ってる。

  • 要素の場合のみ比較すべき(文字列は子要素を持てない)
  • 現状、直近のノードを親要素 として参照して処理している
  • 子ノードは一つずつ比較するべき。たとえundefinedが返ってくる可能性があったとしても

というわけで、updateElementに以下の分岐を追加すべきだ。

else if (newNode.type) {
    const newLength = newNode.children.length;
    const oldLength = oldNode.children.length;
    for (let i = 0; i < newLength || i < oldLength; i++) {
      updateElement(
        $parent.childNodes[index],
        newNode.children[i],
        oldNode.children[i],
        i
      );
    }
  }

新旧問わずに子ノードが存在するばあい、updateElement再帰的に呼び出す。引数は以下の通り。

  • $parentとしてn番(インデックス番号)目子要素
  • newNodeのn番目
  • oldNodeのn番目

以上の工程を経ることによって、子ノードがそれぞれ一つづつ比較されて処理される。

まとめ

ここまでのコードをあわせたものがこちら。

/** @jsx h */

function h(type, props, ...children) {
  return { type, props, children };
}

function createElement(node) {
  if (typeof node === 'string') {
    return document.createTextNode(node);
  }
  const $el = document.createElement(node.type);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));
  return $el;
}

function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
         typeof node1 === 'string' && node1 !== node2 ||
         node1.type !== node2.type
}

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parent.childNodes[index]
    );
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(
      createElement(newNode),
      $parent.childNodes[index]
    );
  } else if (newNode.type) {
    const newLength = newNode.children.length;
    const oldLength = oldNode.children.length;
    for (let i = 0; i < newLength || i < oldLength; i++) {
      updateElement(
        $parent.childNodes[index],
        newNode.children[i],
        oldNode.children[i],
        i
      );
    }
  }
}

const a = (
  <ul>
    <li>item 1</li>
    <li>item 2</li>
  </ul>
);

const b = (
  <ul>
    <li>item 1</li>
    <li>hello!</li>
  </ul>
);

const $root = document.getElementById('root');
const $reload = document.getElementById('reload');

updateElement($root, a);
$reload.addEventListener('click', () => {
  updateElement($root, b, a);
});
<button id="reload">RELOAD</button>
<div id="root"></div>

気になったら(CodePen.io)https://codepen.io/で確かめてみてね〜〜♪

今回は基礎てきなところのみで、いくつかの機能は未実装のまま。以下の機能が本来は実装されるべき。

  • 属性の追加・更新・削除
  • イベント操作
  • コンポーネント
  • 実際のDOMのノードを参照する
  • ライブラリと一緒に使ってjQueryみたいに実際のDOMを直接いじる
  • その他いろいろ

しかし、これでVirtual DOMの基本のきは抑えたんだぜ、やったな🔥🔥

P.S.

JSXのところを大幅に飛ばしてしまってるけど、そんなに難しくないので大丈夫!それにこのあとJSXの記事書くし!

参考文献

medium.com