Misskey & Webテクノロジー最前線

Vue Vaporモード近況

本連載では分散型マイクロブログ用ソフトウェアMisskeyの開発に関する紹介と、関連するWeb技術について解説を行っています。

今回は、MisskeyでUIフレームワークとして採用している、Vueの実験的な脱仮想DOM実装であるVaporモードの開発状況を紹介します。

仮想DOMとは

今日、一般的なWebのUIフレームワークでは仮想DOM(Virtual DOM, VDOM)と呼ばれる技術を採用していることが多いです。

Webでは、JavaScriptからHTMLを操作するためのインターフェイスとしてDOMが用意されていますが、仮想DOMを採用するフレームワークではこのDOMを直接操作するのではなく、一旦独自に仮となるDOM(V-tree)をメモリ上に構築し、操作する必要のあるHTML要素を特定して効率的にDOMを変更(patch)します。

しかし、UIが複雑になってくると仮想DOMも大きくなり、メモリ消費量が多くなったりDOM更新時のパフォーマンスが低下することが欠点とされてきました。

Vaporモードとは

以前も少し触れましたが、最近のWebのUIフレームワークでは「脱仮想DOM」のムーブメントが徐々に高まっています。

そのようなフレームワークでは、仮想DOMを用いないかわりに、アプリケーションのコンパイル時にあらかじめ変更される可能性のある要素を特定しておき、仮想DOMを介さずに直接DOMをpatchするコードを生成します。

DOMを直接操作することでレンダリング時のオーバーヘッドが削減できるほか、仮想DOMに関する実装がランタイムに不要になることからバンドルサイズの削減も見込まれます。

Vueでは現行の3.xも含めて長らく仮想DOMを採用してきましたが、近年SvelteSolidJSなどの仮想DOMを用いないフレームワークが注目されるようになったこともあり、それらに触発される形でVaporモードと呼ばれる仮想DOMを用いないコンパイルストラテジが計画され、現在鋭意開発中となっています。

開発状況

Vaporモードを実装するVueは現在Vue本体とは別リポジトリvuejs/core-vaporで開発されており、READMEのTODOで大まかな実装状況が示されています。

それを見ると、本記事執筆時点(4月上旬)では基本的なコンパイルとディレクティブの実装は終わっていますが、組み込みコンポーネントやユーザー実装コンポーネントはまだ実装中ということが分かります。

コンポーネントに関する議論はこのIssueで行われています。

筆者はVaporモードが楽しみすぎて当該リポジトリの動きをチェックするのが日課になっていますが、現在は主にVueのコアチームメンバーが主体となり粛々と開発されている印象です。

コミットの頻度もそう高くないため、気になる方は追ってみるのもおすすめです。

試してみる

テンプレート内でコンポーネントは使えませんが、基本的なカウンターやTODOアプリは作ることができます。

Vapor PlaygroundVapor Template Explorerといったオンラインの実験環境が用意されていますので実際に試してみましょう。

Playgroundのほうでは、コンポーネントがVaporと非Vaporでどのようにコンパイルされるか比較したり、編集した内容をローカルでも動作可能な形のプロジェクトファイルとしてエクスポートすることができます。

Playground。右上のトグルでVapor/非Vapor、Dev/Prodを切り替えられる
図

例えば、公式のサンプルにある以下のコンポーネントを見てみます。

<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関数は一体何なのか・何を返しているのかですが、実装を見ると以下となっています。

packages/runtime-vapor/src/dom/template.ts
export function template(html: string) {
  let node: ChildNode
  const create = () => {
    // eslint-disable-next-line no-restricted-globals
    const t = document.createElement('template')
    t.innerHTML = html
    return t.content.firstChild!
  }
  return () => (node || (node = create())).cloneNode(true)
}

単純明快、単に実際のDOM要素を作って返しているだけです。つまりここでも仮想DOMは全く使われていません。

一応、_setTextの中身も見てみましょう。

packages/runtime-vapor/src/dom/prop.ts#L188
export function setText(el: Node, ...values: any[]) {
  const text = values.map(v => toDisplayString(v)).join('')
  const oldVal = recordPropMetadata(el, 'textContent', text)
  if (text !== oldVal) {
    el.textContent = text
  }
}

ここでも単に実際のDOM要素のtextContentを書き換えているだけで、特に仮想DOMは関わっていません。

肝となるのは依存するリアクティブ変数を追跡する_renderEffect関数で、実装はVaporモードというよりVueのリアクティビティの話になるので詳細は割愛しますが、これによって仮想DOM無しで効率的に必要な要素だけをpatchすることができています。

簡単に言うと、render関数に渡される_ctxというのは単なるオブジェクトではなく、実はProxyです。

そのため、_renderEffect関数内で_ctx.msgへのアクセスを認知することができ、追跡すべき依存が判るという仕組みです。

ちなみに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/runtime-vapor/src/apiCreateIf.ts

少し長いですが、やってることは渡されたconditionに応じて返す要素を変えているだけです。

なお、第二引数、第三引数はBlock型を返す関数を要求していますが、このBlockは何かというと以下の定義がされています。

export type Block = Node | Fragment | ComponentInternalInstance | Block[]

要するに単に実際のDOM要素(Node)やコンポーネントのことを指しています。Fragmentについても中身はこのBlockの配列です。

そういうわけで、ここでもやはり仮想DOMはありません。

v-for

最後にv-forの実装も見てみます。

コンポーネント
<script setup>
import { ref } from 'vue'

const fruits = ref(['apple', 'orange', 'banana'])
</script>

<template>
<div>
  <h1 v-for="fruit of fruits">{{ fruit }}</h1>
</div>
</template>
Vaporでのコンパイル結果
function render(_ctx) {
  const n3 = t1()
  const n0 = _createFor(() => (_ctx.fruits), (_block) => {
    const n2 = t0()
    const _updateEffect = () => {
      const [fruit] = _block.s
      _setText(n2, fruit)
    }
    _renderEffect(_updateEffect)
    return [n2, _updateEffect]
  })
  _insert(n0, n3)
  return n3
}

今度は_createForが使われています。実装は以下にあります。

packages/runtime-vapor/src/apiCreateFor.ts

今まで見てきた実装と比べるとかなり複雑になっていて、ここで詳細を解説することは控えますがいずれにせよ素のDOMが操作されています。

流れとしては以下のとおりです。

  1. 更新前と更新後のアイテムの数を取得する
  2. 更新前が0なら初回レンダリングを行う(mount)
  3. 更新後が0ならすべての要素を消す(unmount)
  4. それ以外なら適切に再レンダリング(ここが長い)

リストのレンダリングは、キーによるキャッシュだったり、特定のアイテムの更新だったりがあるので実装は簡単ではありませんが、パフォーマンスに直結する部分なので今後も改修が行われると思います。

まとめ

今回はVueにおける脱仮想DOM実装であるVaporモードの近況について紹介しました。

Playgroundでの検証では実際に、Vaporモードでコンパイルしたアプリケーションが仮想DOM無しでリアクティブなUIを実現していることを確認できました。

コンポーネントがどのようなランタイムのコードとしてコンパイルされるかというのは、通常フレームワークを利用する分には知る必要はありませんが、技術の理解を深めるのに役立ちます。

MisskeyのWebクライアントであるDeckUIではDOMノード数が5000近くなることもあり、Webアプリとしてはかなり複雑なほうだと思うので、Vaporモードが実装されれば大きくパフォーマンスを向上させられるのではないかと期待しています。

コンポーネントの実装はまだwipでしたので今回は触れていませんが、今後実装が終わり次第取り上げて遊んでみたいと思います。

おすすめ記事

記事・ニュース一覧