仮想DOMライブラリのdekuを読んだ話

英語の勉強をするぞと意気込み、字幕なしで動画を観始めたところフランスが舞台のドキュンタリーを観ていたことに気付いた小飼です。

React.jsでアプリケーションを構築するにあたり、その内部実装について理解して使っているケースは必ずしも多くないと思います。
特にReact.jsの情報があふれているような昨今では、内部実装をきちんと理解しようとすることが直接的にアプリケーション構築の生産性に寄与するとは限りません。

ただ、単なるライブラリのユーザーでいると思わぬ落とし穴に入り込まないとも限りませんし、APIの変更があった場合にも唯々諾々と開発側の方針に倣うことしかできません。
もちろんそれは内部実装を理解していたところで大きく事情は変わりませんが、少なくとも自分なりの納得のいく理解は作れるかも知れませんし、もし自分の状況にそぐわないのであれば別のライブラリを選定するような選択肢も選び得ると思います。
その意味で(React.jsに限った話ではありませんが)ライブラリの内部実装をきちんと理解しておくことは、とても大事で、かつ安心できる開発環境にもつながると考えています。

さて、React.jsの流行と共に、その本質である仮想DOMの実装がいくつも出てきました。
そこで本稿ではコードベースが大きく読み下すことが難しいReact.jsの前に、シンプルな仮想DOM実装を読んでみて、
React.jsが本質的にはどんな処理を担うライブラリであるかを理解する一助としてみたいと思います。

いくつか実装の有る中で、コードベースが小さく提供するAPIがシンプルであるように意図されたdekuを選びました。

dekuのgithubレポジトリ
dekuのドキュメント

なお、本稿執筆時点の最新公開版である2.0.0-rc7を対象としています。
非常に活発な開発が行われているライブラリですので、本稿公開時点で2.0.0-rc13にまでアップデートされていることに留意してください。
また、こちらはメジャーアップデート版ですので、過去に公開されたdekuの記事とはAPIが異なっているケースが多々あると思います。

npmモジュールを読む前に

dekuReact.jsを始めとするnpmで配布されているモジュールは、requireされるとpackage.jsonmainフィールドに記述されたパスを読み込むようになっています。
The main field is a module ID that is the primary entry point to your program

1
"main": "lib/index.js",

慣習的にこのエントリーポイントには、外部に公開するAPIを持ったモジュールを読み込むだけにしている場合が多いです。

1
2
3
4
5
6
7
8
9
10
// deku/src/index.js
import element from './element'
import string from './string'
import dom from './dom'

export {
element,
string,
dom
}

※本来dekurequireされた時に読み込むのは、src/ディレクトリ以下にあるファイルをbabelで変換したlib/ディレクトリ以下のファイルになります。
一般にbabel変換後のファイルは可読性が低く開発者の意図が見えづらいことから、以降はsrcディレクトリを読んでいくものとします。

また、余談ですがbabel-cliを始めとするCLIツールは、コマンドとして呼び出された時にbinフィールドに記述されたパスから処理が始まります。
supply a bin field in your package.json which is a map of command name to local file name

ビルド系ツールやテストランナーのコードを読む時は、この辺りから読み始める必要がありそうです。
数は少ないですが、CDNに対応するなどscriptタグとして読み込まれるような使い方を意図している場合はwindowオブジェクトへのひも付け処理が入る場合もあります。

上述の通り、エントリーポイントにはライブラリのユーザーに対して露出されるモジュールが書かれているだけの場合が多いので、どんな処理が始まるのかはわかりません。
その場合はREADME.mdGetting Started || Usage || Exampleなどの項を確認して、処理の開始点となっていそうな箇所から読んでいきます。
(expressのようにcreateApplicationのような、明らかに処理の開始点となる関数が記述されている場合もあります)

1
2
3
4
5
6
7
8
9
10
11
12
// express/lib/express.js
exports = module.exports = createApplication;

// 多分ここから処理がはじまっている...
function createApplication() {
var app = connect();
merge(app, proto);
app.request = { __proto__: req, app: app };
app.response = { __proto__: res, app: app };
app.init();
return app;
}

dekuのエントリーポイント

README.mdを見る限り、createRendererというファクトリらしき関数から処理が始まっているようです。

1
2
3
4
5
6
7
8
9
10
11
12
13
import {dom, element} from 'deku'
// (略)...
let {createRenderer} = dom

// (略)...
// Create a renderer that can turn vnodes into real DOM elements
let render = createRenderer(document.body, store.dispatch)

// Update the page and add redux state to the context
render(
<MyButton>Hello World!</MyButton>,
store.getState()
)

renderの生成

Create a renderer that can turn vnodes into real DOM elementsとコメントされているように、
引数に渡した仮想DOMを実際のDOMとしてブラウザに描画する関数を生成するようです。

実際のコードはこんな感じです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// deku/dom/createRenderer.js
import createElement from './createElement'
import {diffNode} from '../diff'
import patch from './patch'
import uid from 'uid'

/**
* Create a DOM renderer using a container element. Everything will be rendered
* inside of that container. Returns a function that accepts new state that can
* replace what is currently rendered.
*/

export default function createDOMRenderer (container, dispatch) {
// container: 実際の描画でルートとなる実DOMノード Exampleではdocument.body
// dispatch: Exampleではdispatcher

let oldVnode = null
let node = null
let path = uid()

if (container && container.childNodes.length > 0) {
container.innerHTML = ''
}

let update = (newVnode, context) => {
// debugger
let changes = diffNode(oldVnode, newVnode, path)
node = changes.reduce(patch(dispatch, context), node)
oldVnode = newVnode
return node
}

let create = (vnode, context) => {
node = createElement(vnode, path, dispatch, context)
if (container) container.appendChild(node)
oldVnode = vnode
return node
}

// createDOMRendererクロージャの中のnode変数にキャッシュされている`前回の描画状態`によって処理を振り分ける関数を返している
return (vnode, context = {}) => {
return node !== null
? update(vnode, context)
: create(vnode, context)
}
}

create(DOM)Rendererクロージャの中で保持されたnode変数にキャッシュされている何かを判断材料に、呼びだす関数と結果を振り分ける関数を返していることがわかります。
ではnodeにはどんなデータが入っているのでしょうか。

実際の描画

1
2
3
4
5
6
// update function
let changes = diffNode(oldVnode, newVnode, path)
node = changes.reduce(patch(dispatch, context), node)

// create function
node = createElement(vnode, path, dispatch, context)

となっていて、どうも何らかのDOMノード、それもvnodeというvが前についた変数が別にあることから、実DOMノードをキャッシュしているように読み取れます。
実際にはどうなっているのでしょうか。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/dom/createElement.js
export default function createElement (vnode, path, dispatch, context) {
// (略)...
let DOMElement = cached.cloneNode(false)
// (略)...

// vnodeを再帰的に探索して子ノードを順次DOMElementに追加していっている
vnode.children.forEach((node, index) => {
if (node === null || node === undefined) {
return
}
let child = createElement(
node,
createPath(path, node.key || index),
dispatch,
context
)
DOMElement.appendChild(child)
})

// 実DOMノードを返している
return DOMElement
}

実DOMノードを返していることが確認できました。

1
2
3
// update function
let changes = diffNode(oldVnode, newVnode, path)
node = changes.reduce(patch(dispatch, context), node)

初回にrender関数を呼び出した時は一からDOMElementを構築してキャッシュする、二回目以降(node変数に値が保存されているのでupdate関数が呼び出されることが読み取れます)は、これも初回にキャッシュされた古い仮想DOMノードと最新の仮想DOMノードの差分を算出(diffNode関数)、パッチを当てた実DOMノード(patch関数)を返すような仕組みになっているようです。

以上のように、render関数に最新の状態オブジェクト(store.getState())を渡して呼びだすことでアプリケーションを描画/更新するという処理の流れになっていることがわかりました。

まとめ

dekuの実装をザッと読んでみることで、仮想DOMライブラリが一体どういった処理をしているのかを追ってみましたがいかがだったでしょうか。
実際の仮想DOMライブラリの選定においては、仮想DOM同士の差分検出アルゴリズムの効率性に各々の性能が出てくるはずですが、今回は全体の流れを追うという目的で省略しました。
(ひとに説明できるほど理解していないとも言いますが…)

dekuの実装は非常に小さく、素直にコードを追っていくことで理解しやすいきれいなコードだと感じました。
仮想DOMライブラリとしては必要十分な機能を持っているように思います。

サイズが軽いのがモバイルアプリケーション構築の時などには魅力的ですが、一方で全ての仮想DOMツリーをクロージャにキャッシュする関係上、パフォーマンス面で不安が残ります。
(これは他の実装であっても同様ですが…)

いずれにしろ、全体の流れを把握することでプロジェクトに最適なライブラリの選定がしやすくなるのではないでしょうか。
本稿がその一助になれば幸いです。

Reactなどに代表される仮想DOMライブラリを活用したフロントエンドアプリケーション開発をご検討の企業様は、是非MMMにご相談下さいませ!

このエントリーをはてなブックマークに追加