segmentio/dekuのコードリーディング
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
レンダリングを担当する場所
ComponentRenderer
やMount
で
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
toElement
はElementNode
を受け取り、
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.previous
とthis.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-domやReact等の情報の方が多いので、Virtual DOMの仕組み自体について知りたい場合は以下を見ましょう
お知らせ欄
JavaScript Primerの書籍版がAmazonで購入できます。
JavaScriptに関する最新情報は週一でJSer.infoを更新しています。
GitHub Sponsorsでの支援を募集しています。