🐡

Svelte と Vue Vapor Mode の出力を比較する

2024/10/31に公開

Vue Vapor Mode をやる可能性があるので、調べることにした。

Svelteの知識があるので、自分の為のキャッチアップとして、Vue Vapor と Svelte 出力の比較を行う。SSR時の処理などは追ってない。

試した場所

静的コンポーネント

まず何もしない Static なコンポーネントで比較する。

Svelte

<h1>Hello</h1>
import "svelte/internal/disclose-version";
import * as $ from "svelte/internal/client";

var root = $.template(`<h1>Hello</h1>`);

export default function App($$anchor) {
	var h1 = root();

	$.append($$anchor, h1);
}

静的なテンプレートがあり、これをそのまま注入する。
差分計算等は svelte/internal/* 側にある。誤解している人もいるが、Svelte もコンパイル時に全部を消し去るのではなく最低限のランタイムはある。

Vue Vapor

<template>
  <h1>Hello</h1>
</template>
const __sfc__ = {};
import { template as _template } from 'vue/vapor';
const t0 = _template("<h1>Hello</h1>")
function render(_ctx) {
  const n0 = t0()
  return n0
}
__sfc__.render = render
__sfc__.__file = "src/App.vue"
export default __sfc__

出力内容としては同等

部分的な動的更新

Svelte

<script lang=ts>
let name = 'world';
</script>
<h1>Hello {name}!</h1>

出力

import "svelte/internal/disclose-version";
import * as $ from "svelte/internal/client";

var root = $.template(`<h1></h1>`);

export default function App($$anchor) {
	let name = 'world';
	var h1 = root();

	h1.textContent = `Hello ${name ?? ""}!`;
	$.append($$anchor, h1);
}

テンプレート展開後に、 h1 の textContent を注入している。ここの組み立てが動的。

Vue Vapor

<script setup lang="ts">
import { ref } from 'vue'
const name = ref('World')
</script>

<template>
  <h1>Hello, {{ name }}</h1>
</template>

/* Analyzed bindings: {
  "ref": "setup-const",
  "name": "setup-ref"
} */
import { defineComponent as _defineComponent } from 'vue/vapor'
import { ref } from 'vue'

const __sfc__ = /*@__PURE__*/_defineComponent({
  vapor: true,
  __name: 'App',
  setup(__props, { expose: __expose }) {
  __expose();

const name = ref('World')

const __returned__ = { name }
Object.defineProperty(__returned__, '__isScriptSetup', { enumerable: false, value: true })
return __returned__
}

});
import { renderEffect as _renderEffect, setText as _setText, template as _template } from 'vue/vapor';
const t0 = _template("<h1></h1>")
function render(_ctx) {
  const n0 = t0()
  _renderEffect(() => _setText(n0, "Hello, ", _ctx.name))
  return n0
}
__sfc__.render = render
__sfc__.__file = "src/App.vue"
export default __sfc__

ほとんど同じ。 _renderEffect の部分に副作用が押し込まれている。

実装はこのあたり

https://github.com/vuejs/vue-vapor/blob/2ed0be80209d6b3148b257aac91358a8d8c057a0/packages/runtime-vapor/src/renderEffect.ts#L17

  const effect = new ReactiveEffect(() =>
    callWithAsyncErrorHandling(cb, instance, VaporErrorCodes.RENDER_FUNCTION),
  )
  // ...
  effect.run()

// https://github.com/vuejs/vue-vapor/blob/main/packages/reactivity/src/effect.ts#L150
  run(): T {
    // TODO cleanupEffect

    if (!(this.flags & EffectFlags.ACTIVE)) {
      // stopped during cleanup
      return this.fn()
    }

    this.flags |= EffectFlags.RUNNING
    cleanupEffect(this)
    prepareDeps(this)
    const prevEffect = activeSub
    const prevShouldTrack = shouldTrack
    activeSub = this
    shouldTrack = true

    try {
      return this.fn()
    } finally {
      if (__DEV__ && activeSub !== this) {
        warn(
          'Active effect was not restored correctly - ' +
            'this is likely a Vue internal bug.',
        )
      }
      cleanupDeps(this)
      activeSub = prevEffect
      shouldTrack = prevShouldTrack
      this.flags &= ~EffectFlags.RUNNING
    }
  }

バッチ化してそう。

というのが、ここに解説があった。

https://gihyo.jp/article/2024/04/misskey-12

DOMとの双方向通信

時分が試した時点では v-model が動かなかったので、簡単なバインディングを自分で書いた。

<script setup lang="ts">
import { ref, computed } from 'vue'
const input = ref('text')

const onInput = (ev: InputEvent) => {
  input.value = (ev.target as HTMLInputElement).value;
}
const mes = computed(() => `Hello, ${input.value}`)
</script>

<template>
  <input
    :value="input"
    @input="onInput"
  />
  <p>{{mes}}</p>
</template>
/* Analyzed bindings: {
  "ref": "setup-const",
  "computed": "setup-const",
  "input": "setup-ref",
  "onInput": "setup-const",
  "mes": "setup-ref"
} */
import { defineComponent as _defineComponent } from 'vue/vapor'
import { ref, computed } from 'vue'

const __sfc__ = /*@__PURE__*/_defineComponent({
  vapor: true,
  __name: 'App',
  setup(__props, { expose: __expose }) {
  __expose();

const input = ref('text')

const onInput = (ev) => {
  input.value = (ev.target ).value;
}
const mes = computed(() => `Hello, ${input.value}`)

const __returned__ = { input, onInput, mes }
Object.defineProperty(__returned__, '__isScriptSetup', { enumerable: false, value: true })
return __returned__
}

});
import { delegate as _delegate, renderEffect as _renderEffect, setDynamicProp as _setDynamicProp, setText as _setText, delegateEvents as _delegateEvents, template as _template } from 'vue/vapor';
const t0 = _template("<input>")
const t1 = _template("<p></p>")
_delegateEvents("input")
function render(_ctx) {
  const n0 = t0()
  const n1 = t1()
  _delegate(n0, "input", () => _ctx.onInput)
  _renderEffect(() => _setDynamicProp(n0, "value", _ctx.input))
  _renderEffect(() => _setText(n1, _ctx.mes))
  return [n0, n1]
}
__sfc__.render = render
__sfc__.__file = "src/App.vue"
export default __sfc__

_delegateEvents("input") このイベントが再レンダーをキックするという目印。

n0(=t0=template(...)) にハンドラ登録し、input と t1 に値の再計算処理がある。

感想

コンパイル時に値を確定させるまでのツリーを作って、再計算部分を払い出しておくというのはVue VaporもSvelteも同じ。内部イベントモデルは違いそうだが、ユーザーが気にする部分は多分はない。

Vue Vapor で細かいのを色々呼ぶと未実装で落ちた。ここの互換性を上げてる最中という認識。

Discussion