本連載では分散型マイクロブログ用ソフトウェアMisskeyの開発に関する紹介と、関連するWeb技術について解説を行っています。
今回は、MisskeyでUIフレームワークとして採用している、Vueの実験的な脱仮想DOM実装であるVaporモードの開発状況を紹介します。
仮想DOMとは
今日、一般的なWebのUIフレームワークでは仮想DOM
Webでは、JavaScriptからHTMLを操作するためのインターフェイスとしてDOMが用意されていますが、仮想DOMを採用するフレームワークではこのDOMを直接操作するのではなく、一旦独自に仮となるDOM
しかし、UIが複雑になってくると仮想DOMも大きくなり、メモリ消費量が多くなったりDOM更新時のパフォーマンスが低下することが欠点とされてきました。
Vaporモードとは
以前も少し触れましたが、最近のWebのUIフレームワークでは
そのようなフレームワークでは、仮想DOMを用いないかわりに、アプリケーションのコンパイル時にあらかじめ変更される可能性のある要素を特定しておき、仮想DOMを介さずに直接DOMをpatchするコードを生成します。
DOMを直接操作することでレンダリング時のオーバーヘッドが削減できるほか、仮想DOMに関する実装がランタイムに不要になることからバンドルサイズの削減も見込まれます。
Vueでは現行の3.
開発状況
Vaporモードを実装するVueは現在Vue本体とは別リポジトリ
それを見ると、本記事執筆時点
コンポーネントに関する議論はこのIssueで行われています。
筆者はVaporモードが楽しみすぎて当該リポジトリの動きをチェックするのが日課になっていますが、現在は主にVueのコアチームメンバーが主体となり粛々と開発されている印象です。
コミットの頻度もそう高くないため、気になる方は追ってみるのもおすすめです。
試してみる
テンプレート内でコンポーネントは使えませんが、基本的なカウンターやTODOアプリは作ることができます。
Vapor PlaygroundやVapor Template Explorerといったオンラインの実験環境が用意されていますので実際に試してみましょう。
Playgroundのほうでは、コンポーネントがVaporと非Vaporでどのようにコンパイルされるか比較したり、編集した内容をローカルでも動作可能な形のプロジェクトファイルとしてエクスポートすることができます。
例えば、公式のサンプルにある以下のコンポーネントを見てみます。
<script setup>
import { ref, getCurrentInstance } from 'vue'
const msg = ref('Hello World!')
const isVapor = getCurrentInstance().vapor
</script>
<template>
<h1>{{ msg }}</h1>
<input v-model="msg" />
<b>VAPOR {{ isVapor ? 'ON' : 'OFF' }}</b>
</template>
input要素があり、そこに入力された文字列がタイトルとしてレンダリングされるコンポーネントです。
このコンポーネントがコンパイルされると、非Vaporでは以下の結果になります
function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock(_Fragment, null, [
_createElementVNode("h1", null, _toDisplayString($setup.msg), 1 /* TEXT */),
_withDirectives(_createElementVNode("input", {
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => (($setup.msg) = $event))
}, null, 512 /* NEED_PATCH */), [
[_vModelText, $setup.msg]
]),
_createElementVNode("b", null, "VAPOR " + _toDisplayString($setup.isVapor ? 'ON' : 'OFF'), 1 /* TEXT */)
], 64 /* STABLE_FRAGMENT */))
}
一方、Vaporモードでコンパイルした場合は以下の結果になります
const t0 = _template("<h1></h1>")
const t1 = _template("<input>")
const t2 = _template("<b></b>")
function render(_ctx) {
const n0 = t0()
const n1 = t1()
const n2 = t2()
_withDirectives(n1, [[_vModelText, () => _ctx.msg]])
_delegate(n1, "update:modelValue", () => $event => (_ctx.msg = $event))
_renderEffect(() => _setText(n0, _ctx.msg))
_renderEffect(() => _setText(n2, "VAPOR ", _ctx.isVapor ? 'ON' : 'OFF'))
return [n0, n1, n2]
}
render関数の中身が変わっていることが分かります。
非Vaporでは、_createElementVNode
等の仮想DOMにおける要素であるVNodeを作成するコードが生成されている一方、Vaporではそのようなコードは無くなっていて、生成結果がシンプルになっています。
注意:
これらの結果はコミットdb140a1時点のものであり、今後開発が進むにつれて結果は変わることに留意してください。
詳細に見てみましょう。
Vaporのほうでは、render関数内に以下の一文が生成されています。
_renderEffect(() => _setText(n0, _ctx.msg))
これはmsg
変数が変更された場合n0
要素のテキストを変更するコードです。このn0
が何なのかということですが、
const t0 = _template("<h1></h1>")
としてrender関数外で定義されているt0
関数の返り値がn0
になっています。
const n0 = t0()
では今度は_template
関数は一体何なのか・
単純明快、単に実際のDOM要素を作って返しているだけです。つまりここでも仮想DOMは全く使われていません。
一応、_setText
の中身も見てみましょう。
ここでも単に実際のDOM要素のtextContentを書き換えているだけで、特に仮想DOMは関わっていません。
肝となるのは依存するリアクティブ変数を追跡する_renderEffect
関数で、実装はVaporモードというよりVueのリアクティビティの話になるので詳細は割愛しますが、これによって仮想DOM無しで効率的に必要な要素だけをpatchすることができています。
簡単に言うと、render関数に渡される_ctx
というのは単なるオブジェクトではなく、実はProxyです。
そのため、_renderEffect
関数内で_ctx.
へのアクセスを認知することができ、追跡すべき依存が判るという仕組みです。
ちなみにctxはcontextの略で、プログラミングでは頻出ワードです。
v-if
v-if
ディレクティブの実装についても見てみます。以下のコンポーネントを用意します。
<script setup>
import { ref } from 'vue'
const show = ref(false)
</script>
<template>
<div>
<input type="checkbox" v-model="show"/>
<h1 v-if="show">test</h1>
</div>
</template>
チェックボックスの状態によってタイトルの表示を切り替えます。このコンポーネントでも、Vaporか否かでコンパイル結果は大きく異なります。
Vaporでは以下の結果となります。
function render(_ctx) {
const n4 = t1()
const n0 = n4.firstChild
_withDirectives(n0, [[_vModelCheckbox, () => _ctx.show]])
_delegate(n0, "update:modelValue", () => $event => (_ctx.show = $event))
const n1 = _createIf(() => (_ctx.show), () => {
const n3 = t0()
return n3
})
_insert(n1, n4)
return n4
}
_createIf
なる関数が呼び出されています。実装は以下にあります。
packages/
少し長いですが、やってることは渡されたcondition
に応じて返す要素を変えているだけです。
なお、第二引数、第三引数はBlock
型を返す関数を要求していますが、このBlock
は何かというと以下の定義がされています。
export type Block = Node | Fragment | ComponentInternalInstance | Block[]
要するに単に実際のDOM要素Fragment
についても中身はこのBlock
の配列です。
そういうわけで、ここでもやはり仮想DOMはありません。
v-for
最後にv-for
の実装も見てみます。
今度は_createFor
が使われています。実装は以下にあります。
packages/
今まで見てきた実装と比べるとかなり複雑になっていて、ここで詳細を解説することは控えますがいずれにせよ素のDOMが操作されています。
流れとしては以下のとおりです。
- 更新前と更新後のアイテムの数を取得する
- 更新前が0なら初回レンダリングを行う
(mount) - 更新後が0ならすべての要素を消す
(unmount) - それ以外なら適切に再レンダリング
(ここが長い)
リストのレンダリングは、キーによるキャッシュだったり、特定のアイテムの更新だったりがあるので実装は簡単ではありませんが、パフォーマンスに直結する部分なので今後も改修が行われると思います。
まとめ
今回はVueにおける脱仮想DOM実装であるVaporモードの近況について紹介しました。
Playgroundでの検証では実際に、Vaporモードでコンパイルしたアプリケーションが仮想DOM無しでリアクティブなUIを実現していることを確認できました。
コンポーネントがどのようなランタイムのコードとしてコンパイルされるかというのは、通常フレームワークを利用する分には知る必要はありませんが、技術の理解を深めるのに役立ちます。
MisskeyのWebクライアントであるDeckUIではDOMノード数が5000近くなることもあり、Webアプリとしてはかなり複雑なほうだと思うので、Vaporモードが実装されれば大きくパフォーマンスを向上させられるのではないかと期待しています。
コンポーネントの実装はまだwipでしたので今回は触れていませんが、今後実装が終わり次第取り上げて遊んでみたいと思います。