仮想DOMライブラリのdekuを読んだ話
英語の勉強をするぞと意気込み、字幕なしで動画を観始めたところフランスが舞台のドキュンタリーを観ていたことに気付いた小飼です。
React.js
でアプリケーションを構築するにあたり、その内部実装について理解して使っているケースは必ずしも多くないと思います。
特にReact.js
の情報があふれているような昨今では、内部実装をきちんと理解しようとすることが直接的にアプリケーション構築の生産性に寄与するとは限りません。
ただ、単なるライブラリのユーザーでいると思わぬ落とし穴に入り込まないとも限りませんし、API
の変更があった場合にも唯々諾々と開発側の方針に倣うことしかできません。
もちろんそれは内部実装を理解していたところで大きく事情は変わりませんが、少なくとも自分なりの納得のいく理解は作れるかも知れませんし、もし自分の状況にそぐわないのであれば別のライブラリを選定するような選択肢も選び得ると思います。
その意味で(React.js
に限った話ではありませんが)ライブラリの内部実装をきちんと理解しておくことは、とても大事で、かつ安心できる開発環境にもつながると考えています。
さて、React.js
の流行と共に、その本質である仮想DOMの実装がいくつも出てきました。
そこで本稿ではコードベースが大きく読み下すことが難しいReact.js
の前に、シンプルな仮想DOM実装を読んでみて、
React.js
が本質的にはどんな処理を担うライブラリであるかを理解する一助としてみたいと思います。
いくつか実装の有る中で、コードベースが小さく提供するAPIがシンプルであるように意図されたdeku
を選びました。
なお、本稿執筆時点の最新公開版である2.0.0-rc7
を対象としています。
非常に活発な開発が行われているライブラリですので、本稿公開時点で2.0.0-rc13
にまでアップデートされていることに留意してください。
また、こちらはメジャーアップデート版ですので、過去に公開されたdeku
の記事とはAPI
が異なっているケースが多々あると思います。
npmモジュールを読む前に
deku
やReact.js
を始めとするnpm
で配布されているモジュールは、require
されるとpackage.json
のmain
フィールドに記述されたパスを読み込むようになっています。
The main field is a module ID that is the primary entry point to your program
"main": "lib/index.js",
慣習的にこのエントリーポイントには、外部に公開するAPI
を持ったモジュールを読み込むだけにしている場合が多いです。
// deku/src/index.js
import element from './element'
import string from './string'
import dom from './dom'
export {
element,
string,
dom
}
※本来deku
がrequire
された時に読み込むのは、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.md
のGetting Started || Usage || Example
などの項を確認して、処理の開始点となっていそうな箇所から読んでいきます。
(express
のようにcreateApplication
のような、明らかに処理の開始点となる関数が記述されている場合もあります)
// 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
というファクトリらしき関数から処理が始まっているようです。
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としてブラウザに描画する関数を生成するようです。
実際のコードはこんな感じです。
// 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
にはどんなデータが入っているのでしょうか。
実際の描画
// 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ノードをキャッシュしているように読み取れます。
実際にはどうなっているのでしょうか。
// 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ノードを返していることが確認できました。
// 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にご相談下さいませ!