Skip to content

仮想 DOM(vnode)

Snabbdom 流の、軽い仮想 DOM です。画面を素の JavaScript オブジェクト(VNode)で表し、古い木と新しい木の差分を取り、そのちがいだけを本物の DOM に適用します。

  • init() は差分適用の仕組みを組み立て、patch 関数を返します。同梱のモジュール(class / props / attrs / style / events)は自動で登録されます。
  • patch(oldVnode, newVnode) は、oldVnode が本物の DOM 要素なら木をマウントし、そうでなければ二つの vnode の木の差分を取って DOM をその場で更新します。
  • h(sel, dataOrChildren?, children?)VNode を組み立てるハイパースクリプトのヘルパーです。

読み込み

js
import { init, h, classModule, propsModule, styleModule, eventListenersModule } from 'ranuts/vnode';

注意:この実装の init()引数を取りません。モジュールの一式は決まっていて内部で登録されるので、個々の *Module の export を init に渡す必要はありません。export しているのは参照と確認のためです。

使用例

はじめの一歩

js
import { init, h } from 'ranuts/vnode';

// init() は `patch` 関数を返します。
// 同梱のモジュール(class、props、attrs、style、events)は自動で登録されます。
const patch = init();

const container = document.getElementById('app');

// vnode の木を組み立てる
let vnode = h('div#app.container', { style: { color: 'red' } }, [
  h('h1', 'Hello vnode'),
  h('button', { on: { click: () => console.log('clicked') } }, 'Click me'),
]);

// 最初の描画。古い vnode の位置に本物の DOM 要素を渡すと、その中にマウントされます
patch(container, vnode);

// そのあと。更新後の木を組み立て、直前の vnode をそれへ patch します。
// DOM に適用されるのはちがいの部分(テキスト、スタイル、リスナー)だけです。
const newVnode = h('div#app.container', { style: { color: 'green' } }, [
  h('h1', 'Hello again'),
  h('button', { on: { click: () => console.log('clicked') } }, 'Updated'),
]);

patch(vnode, newVnode);
vnode = newVnode; // 次の patch のために最新の木を持っておく

h でノードを組み立てる

js
// タグだけ
h('div');

// タグ + data
h('div', { class: { active: true } });

// タグ + 子のテキストひとつ
h('span', 'hello');

// タグ + 子の配列
h('ul', [h('li', 'one'), h('li', 'two')]);

// タグ + data + 子
h('a', { attrs: { href: '/home' } }, 'Home');

// CSS 風のセレクターで id とクラスが決まります
h('div#main.card.large', 'content'); // <div id="main" class="card large">content</div>

// セレクターが "svg" で始まると、SVG の名前空間が自動で適用されます
h('svg', { attrs: { width: 100, height: 100 } }, [h('circle', { attrs: { cx: 50, cy: 50, r: 40 } })]);

API

init()

差分適用の仕組みを作り、patch 関数を返します。同梱のモジュールは内部で登録されるので、引数は取りません。

戻り値

説明
patchvnode の木を本物の DOM に対してマウント・差分適用します(oldVnode: VNode | Element, vnode: VNode) => VNode

patch(oldVnode, vnode)

init() が返す関数です。最初の呼び出しでは oldVnode に本物の DOM の Element を渡すと、その中に木がマウントされます。二回目以降は直前の VNode を渡すと、差分を取ってその場で更新します。返るのは新しい VNode で、それを次の呼び出しの「古いほう」として持っておきます。

パラメーター

パラメーター説明
oldVnode直前の vnode。最初のマウントでは DOM 要素VNode | Element
vnode描きたい新しい vnode の木VNode

h(sel, dataOrChildren?, children?)

VNode を組み立てるハイパースクリプトのヘルパーです。次のように多重定義されています。

シグネチャ説明
h(sel)セレクターだけから要素を作る
h(sel, data)VNodeData つきの要素(datanull でもよい)
h(sel, children)子つきの要素。文字列や数値、VNode ひとつ、あるいは配列
h(sel, data, children)data と子の両方をもつ要素

パラメーター

パラメーター説明
selCSS 風のセレクター。tagtag#idtag.class、それらの組み合わせ(div#id.a.b)。svg… なら SVG の名前空間が自動で付きますstring
dataノードのデータ。class / props / attrs / style / リスナー / key / hook。null でもかまいませんVNodeData | null
children文字列か数値(テキストノードになります)、VNode ひとつ、あるいはそれらの配列VNodeChildren

VNodeData のフィールド

フィールド説明適用するモジュール
propselm[key] = value の形で設定する DOM のプロパティRecord<string, any>propsModule
attrssetAttribute で設定する HTML 属性(true/false で付け外し)Record<string, string | number | boolean>attributesModule
class条件つきのクラス。name → boolean の対応Record<string, boolean>classModule
styleインラインスタイル。name → value の対応(--var のキーは CSS 変数になります)Record<string, any>styleModule
onイベントリスナー。event → handler(ハンドラーの配列も可)Record<string, Function | Function[]>eventListenersModule
key差分アルゴリズムが子を突き合わせ・並べ替えるために使う、変わらない目印string | number(差分の中核)
ns名前空間の URI(SVG の部分木では自動で設定されます)string(差分の中核)
hookvnode ごとのライフサイクルフック(HooksHooks(型としてのみ。注意書きを参照)

注意:hookHooks 型は公開されている型の一部です。ただしこの絞り込んだ実装は、モジュールのライフサイクル(create / update / destroy)を通して DOM を動かします。vnode ごとの data.hook のコールバックは、今の patch ループからは呼ばれません。

モジュール

モジュールはそれぞれ VNodeData の一部分を受け持ちます。init() がそのすべてを登録しますが、個別にも export されています。

export担当する部分説明
classModuledata.classname → boolean の対応をもとにクラスを付け外しします
propsModuledata.propsDOM のプロパティを直接代入します(elm[key] = value
attributesModuledata.attrssetAttribute で HTML 属性を付け外しします(xml/xlink も含む)
styleModuledata.styleインラインスタイルと CSS カスタムプロパティを設定します
eventListenersModuledata.onイベントリスナーを付け外しします
modulesモジュール名からモジュール本体への対応をもつ、既定のレジストリオブジェクト

より低い層の export

export説明
vnode(sel, data, children, text, elm) => VNodeh が内部で使う、低い層の VNode ファクトリー。アプリのコードでは h を使ってください。
addNS(data, children, sel) => voidSVG の名前空間を部分木へ再帰的に適用します。svg… のセレクターでは h が自動で呼びます。
htmlDomApiDOMAPIpatch が内部で使う、既定のブラウザー DOM アダプター(ノードの生成・挿入・削除・テキストなど)。
is{ array, isStr, primitive, isVnode }vnode の内部で広く使われている、小さな型ガードのヘルパー。
Chainclass Chainメソッドチェーンできる命令的な DOM ビルダー(setAttributeappendsetTextContent など)。vnode の差分とは無関係です。
create(tagName, options?) => Chain新しい Chain を返す、便利のためのファクトリー。

形と意味
VNode{ sel, data, children, elm, text, key, listener? }。仮想ノードそのもの
VNodeData{ props?, attrs?, class?, style?, on?, key?, ns?, hook? }。フィールドは上を参照
VNodesVNode[]
VNodeChildElementVNode | string | number
VNodeChildrenVNodeChildElement | VNodeChildElement[]
ArrayOrElement<T>T | T[]
Keystring | number
Hooks{ pre?, init?, create?, insert?, prepatch?, update?, postpatch?, destroy?, remove?, post? }
DOMAPIpatch が使う DOM の操作を表すインターフェース(htmlDomApi を参照)
Fragmentフラグメントを扱うための DocumentFragment の拡張
ModulesRecord<string, Record<string, ModuleHook>>。モジュールレジストリの形
ModuleHookモジュールのライフサイクルのコールバックひとつぶん

補足

  1. ブラウザー専用です。 ranuts/vnodedocument と DOM の API に触ります。Node ではなくブラウザー側のコードで import してください。
  2. 最後の vnode を取っておくこと。 patch は新しい VNode を返します。それを保存して次の更新で oldVnode として渡せば、いまの木に対して差分が取られます。
  3. VNode では textchildren はどちらか一方だけです。ノードはテキストノードか、子をもつ要素かのどちらかです。
  4. リストには key を。 動的なリストを描くときは、兄弟に安定した key を与えてください。そうすれば差分アルゴリズムがノードを作り直さず、突き合わせて並べ替えられます。

MIT ライセンスのもとで公開されています。