VirtualDOM Advent Calendar 2014 9日目の記事。

segmentio/deku は最近出たばかりのVirtualDOMの実装ライブラリです。

小さくて読みやすく拡張性がある実装を目指していて、既にあるvirtual-domとは別に作り始めたのも微妙にComponent周りの考え方が違うからのようです。

  • 読んだもの: deku 0.0.2
  • 読んだ日付: 2014年12月9日

絶賛実装中な感じなので、これを見たからといってもどうという感じではないと思います。

基本的なVirtual DOMの構造はあるので、そういう意味では読みやすいです。

基本的な使い方

var component = require('segmentio/deku');

// Buttonのcomponentを作る
// <button>とonClickした時のイベントがある
var ButtonComponent = component({
  onClick() {
    this.setState({ clicked: true });
  }
  render(dom, state, props) {
    return dom('button', { onClick: this.onClick }, [props.text]);
  }
});

// mainとなるcomponent - componentはcomponentを含められる
// <div> <button /> </div>
var App = component({
  render(dom, state, props) {
    return dom('div', { class: 'App' }, [
      ButtonComponent({ text: props.buttonText })
    ]);
  }
});

// `use`で拡張を追加出来る
App.use(styleHelper());

// bodyに `App` componentを追加
var scene = App.mount(document.body, {
  buttonText: 'Click Me!'
});

// 更新処理 => 描画も更新される
scene.setProps({
  buttonText: 'Do it...'
});

// bodyから取り除く
scene.remove();

lib/component

  • ユーザが触る感じのAPI定義をしてる場所
  • useでprototype拡張してpluginを定義できるように
  • コンストラクタが受け取るspecオブジェクト
var ButtonComponent = component({ // specオブジェクト
  onClick() {
    this.setState({ clicked: true });
  }
  render(dom, state, props) {
    return dom('button', { onClick: this.onClick }, [props.text]);
  }
});

specはcomponentの定義を書く場所。

  • renderはデフォルトだと空なので、必ず上書きする
  • APIはsetStateとかrenderStringとかReactのComponent APIっぽいのが多い

このspecで面白いのはdisplayNameというプロパティがあると、 作った component.displayName にそれを設定してくれる。(デバッグ用)

lib/node

Virtual DOM trees(長いの次からvTreeと書きます)を作る場所

component({
      render: function(dom, state, props){
        return dom('span', null, ['Hello World']);
      }
});

renderで渡ってくるdomという関数がvTreeを作る関数 = renderメソッドの返り値はvTree。 (本物のDOM Nodeではない)

lib/node/index.js

/**
 * Create virtual DOM trees.
 *
 * This creates the nicer API for the user.
 * It translates that friendly API into an actual tree of nodes.
 *
 * @param {String|Function} type
 * @param {Object} props
 * @param {Array} children
 * @return {Node}
 * @api public
 */

function dom(type, props, children) {}

dom関数でNodeを作る際にNodeにつくidはi++していったものを使ってる。 (全体で共通でインクリメントしていくid)

dom関数では

dom('span', null, ['Hello World']);

という感じで、DSLでElementNodeを作る事ができる。

ElementNode はタグの名前とかプロパティを持ったデータのオブジェクト (操作するメソッドがないDOM Nodeみたいなものっぽい)

/**
 * Initialize a new `ElementNode`.
 *
 * @param {String} tagName
 * @param {Object} attributes
 * @param {String} key Used for sorting/replacing during diffing.
 * @param {Array} children Child virtual dom nodes.
 * @api public
 */

function ElementNode(tagName, attributes, key, children) {
  this.type = 'element';
  this.tagName = tagName || 'div';
  this.attributes = parseAttributes(attributes);
  if (key) this.attributes['data-key'] = key;
  this.children = children || [];
  this.key = key;
}

もう一つのパターンが、domがComponentを受け取った場合はComponentNodeを作る事が出来る。

  it('should render an element with component root', function () {
    var Span = component({
      render: function(dom, state, props){
        return dom('span', null, 'foo');
      }
    });
    var Component = component({
      render: function(dom, state, props){
        return dom(Span);// Componentを渡してる
      }
    });
    var result = Component.renderString();
    assert(result === '<span>foo</span>');
  });

ComponentNodeとElementNodeの違い

componentのrenderはこのlib/nodeを使って、 vTreeを返す関数を定義していく。

そのrender関数が返したvTreeを実際のHTMLにしていくのが lib/rendererで、Virtual DOMの要であるdiff/patchもここにある。

lib/renderer

レンダリングを担当する場所

ComponentRendererMountで componentを受け取ってそれをレンダリングする(HTMLへ)

lib/renderer/component.js

レンダリングのmainとなる場所

それぞれのcomponentはrenderメソッドを実装しているので this.current = this.render()でvTreeを取り出して使う。

実際にvTreeからElementNodeを取り出してDOMに変換していくのは、以下のtoElementで行う。

ComponentRenderer.prototype.toElement

toElement は以下の処理を行う

ElementNode => DOM Node

toElementElementNodeを受け取り、 ElementNodeにはタグ名や属性の情報がプロパティとしてあるので、 これを使ってdocument.createElement(node.tagName)という感じで 普通にDOM APIで組み立てていく。

ElementNodeはchildrenを持っていることもあるので再帰的に行ってDOM Nodeを作る

Component/vTreeの関係

Component({
        // @return vTree
    render() => {
        return vTree(ComponentNode | ElementNodes)
    }
})

diff/patch

Virtual DOMの本領であるdiff/patchもRendererにある。

初期化する時にtoElementでDOM Nodeを作るのと同時に、 インスタンスに変更が起きたらComponentRenderer.prototype.queueを呼ぶようにイベントを設定する。

ComponentRenderer.prototype.queue

dekuはVue.jsのようにキューにためて一定のタイミングでViewのアップデートの処理を行う。

ComponentRenderer.prototype.queueは他が処理中なら待ったり、 連続して呼ばれたりしたらキャンセルしてから実際のupdate処理を呼ぶようにしてる。

いわゆるBatchingの処理です。

実際のdiff/patchでのDOM Nodeの更新をするのはComponentRenderer.prototype.updateで行われる

ComponentRenderer.prototype.update

  this.previous = this.current;
  this.current = this.render();
  // update the element to match.
  this.diff();

という感じで、現在のvTreeをthis.previousにいれて、 this.diff();を呼び出してdiff/patchをする。

diffという名前だけど置換処理もそのまま行ってるので、patchの処理も含まれている。 (今のところdiffの中間表現とかなく直接書き換えしてる、ここは実装変えそうな気がする)

lib/renderer/diff.js

diff はvTreeであるthis.previousthis.currentの プロパティ(属性とかtextContentとか)を比較して、 異なってるなら置換していくpatch処理を行う。

2014年12月4日: 絶賛実装中な感じ

diff/patchについて詳しくは AdventCalendar - segmentio/dekuのコード読んで自分でも仮想DOMのdiffアルゴリズムを書いてみた - Qiita を読みましょう。

lib/renderer/interactions.js

dekuではtoElementで実際のDOM Nodeにしたものへイベントを設定出来る

var ButtonComponent = component({
  onClick() {
    this.setState({ clicked: true });
  }
  render(dom, state, props) {
    return dom('button', { onClick: this.onClick }, [props.text]);
  }
});

これはシンプルな仕組みで、toElementで作ったDOM Nodeへのパス (単純にプロパティにいれて保持してると考える)を保持しています。

Componentにはひとつのrenderしかないので、その結果できるDOM Nodeも一つ(Treeではあるかもしれないが)となり、そのDOM Nodeを取り出して普通にspecオブジェクトのイベント定義を要素につけているだけのようです。


感想

dekuのコードは読みやすいというよりは流れが見やすい感じがします。 diff以外のところは結構素直に実装されていて、普通にコードを追っていくだけでどういう処理がされているか分かる感じです(実際実行しないで読めたので)

コードリーディングのツール

WebStormを使って読みました。 よくモジュール化されているので、定義元へのジャンプ等を使うとかなり読みやすいです。

メモは自分で作ったfloating-memo.appというのを使いました。

どこから読むか

Nodeモジュールを書き慣れている人は大体index.jsを作ってるケースが多いです。 dekuの場合もdeku/index.jsというファイルがあり、以下のような1行があるだけです。

単なるエイリアスみたいな使い方ですが、package.jsonのmainフィールドの指定のデフォルト値がindex.jsであったり、名前的にも分かりやすいのでよく見かける気がします。 (テストディレクトリ等からrequire("../")で読み込めると書いてましたが、package.json のあるディレクトリのパスを対象に require すると、自動的に main ファイルを読み込むようになっているとのことなのであくまで習慣的なものな気がします。)

module.exports = require('./lib/component');

つまり、lib/component/index.jsから読み始めればいいと分かります。

dekuの場合は上から読んでいけるコードなので、結構コードリーディングするのが楽だと思います。(イベント等があるとコードから追うのが難しくなるので、実行して必要がある)

後は興味がある所から見ていくか、順番に見ていくかを決めるだけですね。

lib
├── component
│   ├── index.js
│   ├── protos.js
│   └── statics.js
├── node
│   ├── component.js
│   ├── element.js
│   ├── index.js
│   └── text.js
└── renderer
    ├── component.js
    ├── diff.js
    ├── interactions.js
    ├── mount.js
    ├── string.js
    └── tree.js

よくわからない時はテストを見る

例えば、lib/node/index.jsを見ると、dom(type, props, children)関数で以下のようなif分岐があります。

// if you pass in a function, it's a `Component` constructor.
// otherwise it's an element.
if ('function' == typeof type) {
    var node = new ComponentNode(type, props, key, children);
} else {
    var node = new ElementNode(type, props, key, children);
}

typeに渡されるものが関数がそうでないかで分岐しています。

こういう時にまず見るのがテストだと思います(今どきのなら普通はある)。 test/node/index.jsがあるので、このテストを見ると

dom('div', { key: 'foo' })

という文字列を受け取るパターンと

var Span = component({
 render: function(dom, state, props){
   return dom('span', null, 'foo');
 }
});
var Component = component({
 render: function(dom, state, props){
   return dom(Span);
 }
});

dom(component)というようにcomponentを受け取るパターン(=type function)があると分かると思います。

更によくわからない時はIssueを検索してみましょう。

他のVirtual DOM

dekuはまだ0.0.2というレベルで、diff.jsもまだ実装が変わりそうな気配があります。

他にもvirtual-domReact等の情報の方が多いので、Virtual DOMの仕組み自体について知りたい場合は以下を見ましょう