Skip to content

vue-template-compilation-magic

Vue template 編譯魔法

和同事討論到 Vue template、h() 的使用情境剛好最近使用 Nuxt UI 也常使用 h() 來渲染內容。

在高度動態的場景下,使用 h() 會更加方便,先前也有文章提到相關應用。

甚麼是 h()

h() 是 Vue 的渲染函式,詳細說明可以看看官方文件

但是 h() 不會有 template 的編譯最佳化,所以沒有特別需求的情境下,還是建議使用 template。

官方文件的說法如下:

If you are familiar with Virtual DOM concepts and prefer the raw power of JavaScript, you can also directly write render functions instead of templates, with optional JSX support. However, do note that they do not enjoy the same level of compile-time optimizations as templates.

聽說歸聽說,忍者龜忍者,從來沒有仔細研究過到底差在哪,趁這次機會來看看吧。( ´ ▽ ` )ノ

速度真的有差?

口說無憑,直接寫個範例比較看看吧。( ´ ▽ ` )ノ

由於 JSX 與 h() 概念相近,所以這裡只比較 template 與 h() 的差異。

分別建立 template 與 h() 的範例:

vue
<script setup>
defineProps(['items'])
</script>

<template>
  <div class="root-container">
    <div v-for="item in items" :key="item.id" class="card-wrapper" data-test="static-attr">
      <div class="card-header" style="background: #eee; padding: 10px; border-bottom: 1px solid #ddd;">
        <h4 class="title" style="margin: 0; color: #333;">
          靜態標題 - Level 1
        </h4>
        <span class="badge" style="background: red; color: white; padding: 2px 5px;">Static Badge</span>
      </div>

      <div class="level-2-wrapper" style="display: flex; gap: 10px; padding: 10px;">
        <div class="static-col-left" style="width: 50px; background: #ccc;">
          Left
        </div>

        <div class="level-3-wrapper" style="flex: 1; border: 1px dashed blue;">
          <ul class="static-list" style="list-style: none; margin: 0; padding: 0;">
            <li style="padding: 5px;">
              Static List Item A
            </li>
            <li style="padding: 5px;">
              Static List Item B
            </li>

            <li class="level-4-target" style="padding: 5px; background: #eef;">
              <span class="label" style="font-weight: bold; margin-right: 10px;">Value:</span>

              <span class="dynamic-val" style="color: blue; font-size: 1.2em;">
                {{ item.text }}
              </span>
            </li>
          </ul>
        </div>
      </div>

      <div class="card-footer" style="padding: 5px; text-align: center; color: #999;">
        Copyright &copy; 2024 Static Inc.
      </div>
    </div>
  </div>
</template>
ts
import { h } from 'vue'

export default {
  props: ['items'],
  setup(props: { items: any[] }) {
    return () => h('div', { class: 'root-container' }, props.items.map((item) =>
      h('div', { 'key': item.id, 'class': 'card-wrapper', 'data-test': 'static-attr' }, [
        h('div', { class: 'card-header', style: 'background: #eee; padding: 10px; border-bottom: 1px solid #ddd;' }, [
          h('h4', { class: 'title', style: 'margin: 0; color: #333;' }, '靜態標題 - Level 1'),
          h('span', { class: 'badge', style: 'background: red; color: white; padding: 2px 5px;' }, 'Static Badge'),
        ]),

        h('div', { class: 'level-2-wrapper', style: 'display: flex; gap: 10px; padding: 10px;' }, [
          h('div', { class: 'static-col-left', style: 'width: 50px; background: #ccc;' }, 'Left'),

          h('div', { class: 'level-3-wrapper', style: 'flex: 1; border: 1px dashed blue;' }, [
            h('ul', { class: 'static-list', style: 'list-style: none; margin: 0; padding: 0;' }, [
              h('li', { style: 'padding: 5px;' }, 'Static List Item A'),
              h('li', { style: 'padding: 5px;' }, 'Static List Item B'),

              h('li', { class: 'level-4-target', style: 'padding: 5px; background: #eef;' }, [
                h('span', { class: 'label', style: 'font-weight: bold; margin-right: 10px;' }, 'Value:'),

                h('span', { class: 'dynamic-val', style: 'color: blue; font-size: 1.2em;' }, item.text),
              ]),
            ]),
          ]),
        ]),

        h('div', { class: 'card-footer', style: 'padding: 5px; text-align: center; color: #999;' }, 'Copyright © 2024 Static Inc.'),
      ]),
    ))
  },
}

接著引用以上元件並計時更新耗時,為了讓差距明顯一點,讓內容重複 2000 次。

perf-comparison.vue

vue
<template>
  <div class=" flex flex-col gap-2 border border-gray-400/80 p-4 rounded">
    <div class="flex gap-2 text-base">
      <div
        :class="{ 'bg-primary': currentMode === 'template' }"
        class="  bg-gray-200/50 p-3 rounded cursor-pointer duration-300 flex-1"
        @click="currentMode = 'template'"
      >
        使用 Template
      </div>

      <div
        :class="{ 'bg-primary': currentMode === 'h' }"
        class="  bg-gray-200/50 p-3 rounded cursor-pointer duration-300 flex-1"
        @click="currentMode = 'h'"
      >
        使用 h()
      </div>
    </div>

    <div
      class="flex justify-center cursor-pointer border p-2 rounded duration-300"
      :class="{ ' border-dashed opacity-60': isStarted }"
      @click="isStarted = !isStarted"
    >
      {{ isStarted ? '停止' : '開始' }}
    </div>

    <div class="mt-2">
      更新耗時:<span>{{ timeCost }} ms</span>
    </div>

    <div class="h-0 overflow-hidden">
      <list-template
        v-if="currentMode === 'template'"
        :items="items"
      />
      <list-h
        v-else
        :items="items"
      />
    </div>
  </div>
</template>

<script setup>
import { useRafFn } from '@vueuse/core'
import { nextTick, ref, watch } from 'vue'
import ListH from './list-h.ts'
import ListTemplate from './list-template.vue'

const COUNT = 2000
const items = ref(Array.from({ length: COUNT }).fill(0).map((_, i) => ({ id: i, text: `Item ${i}` })))
const currentMode = ref('template')
const timeCost = ref(0)
const isStarted = ref(false)

async function runTest() {
  const newVal = `Updated ${Math.floor(Math.random() * 1000)}`
  const start = performance.now()

  items.value[COUNT - 1].text = newVal

  // 等待 DOM 更新完成
  await nextTick()

  const end = performance.now()
  timeCost.value = (end - start).toFixed(2)
}

const ticker = useRafFn(() => {
  runTest()
}, {
  fpsLimit: 10,
  immediate: false,
})

watch(isStarted, (value) => {
  value ? ticker.resume() : ticker.pause()
})
</script>

<style></style>

現在大家可以按下開始按鈕並切換模式,看看耗時有甚麼差異。

使用 Template
使用 h()
開始
更新耗時:0 ms

在我的電腦上,template 大約在 5 ms,h() 大約在 20 ms。

不同裝置結果會不一樣,不過很明顯可以看出 template 的速度會比 h() 快得多。

不過通常不會產生大量元素,所以平時完全看不出差別,只是當你的網頁量級增加,積少成多,性能負擔自然會增加。

所以實務上來說除非有明確的需求或元件 API 限制,否則比較推薦使用 template。(・∀・)9

編譯結果

template 為甚麼會比較快呢?文件的說法是因為 Vue 的編譯器會自動最佳化(Compile-time Optimization),快取靜態節點、加速 Patch Flags 加速比較過程等等。

實際上不管是 template 還是 h(),最終 Vue 都會編譯成 JS 程式碼,用以產生 Virtual DOM (VDOM)。

讓我們來看看編譯後的產物有甚麼差別吧。੭ ˙ᗜ˙ )੭

這裡使用 Vue SFC Playground 的 PROD 模式進行編譯,來比較 template、h() 與 JSX 的編譯內容。

分別建立 3 種版本的簡單範例:

vue
<script setup>
import { ref } from 'vue'

const msg = ref('Hello Vue!')
</script>

<template>
  <h1>{{ msg }}</h1>
  <input v-model="msg">

  <h2>Hi</h2>
</template>
ts
import { defineComponent, h, ref } from 'vue'

export default defineComponent({
  setup() {
    const msg = ref('Hello Vue!')

    return () => h('div', null, [
      h('h1', null, msg.value),
      h('input', {
        value: msg.value,
        onInput: (e: Event) => {
          msg.value = (e.target as HTMLInputElement).value
        },
      }),
      h('h2', null, 'Hi'),
    ])
  },
})
tsx
import { defineComponent, ref } from 'vue'

export default defineComponent({
  setup() {
    const msg = ref('Hello Vue!')

    return () => (
      <div>
        <h1>{msg.value}</h1>
        <input
          value={msg.value}
          onInput={(e) => (msg.value = (e.target as HTMLInputElement).value)}
        />
        <h2>Hi</h2>
      </div>
    )
  },
})

編譯結果:

js
/* Analyzed bindings: {
  "ref": "setup-const",
  "msg": "setup-ref"
} */
import { createElementBlock as _createElementBlock, createElementVNode as _createElementVNode, Fragment as _Fragment, openBlock as _openBlock, toDisplayString as _toDisplayString, vModelText as _vModelText, withDirectives as _withDirectives, ref } from 'vue'

const __sfc__ = {
  __name: 'App',
  setup(__props) {
    const msg = ref('Hello Vue!')

    return (_ctx, _cache) => {
      return (_openBlock(), _createElementBlock(_Fragment, null, [
        _createElementVNode('h1', null, _toDisplayString(msg.value), 1 /* TEXT */),
        _withDirectives(_createElementVNode('input', {
          'onUpdate:modelValue': _cache[0] || (_cache[0] = ($event) => ((msg).value = $event))
        }, null, 512 /* NEED_PATCH */), [
          [_vModelText, msg.value]
        ]),
        _cache[1] || (_cache[1] = _createElementVNode('h2', null, 'Hi', -1 /* CACHED */))
      ], 64 /* STABLE_FRAGMENT */))
    }
  }
}
__sfc__.__file = 'src/App.vue'
export default __sfc__
js
/* Analyzed bindings: {} */

import { defineComponent, h, ref } from 'vue'

const __sfc__ = defineComponent({
  setup() {
    const msg = ref('Hello Vue!')

    return () => h('div', null, [
      h('h1', null, msg.value),
      h('input', {
        value: msg.value,
        onInput: (e) => {
          msg.value = (e.target).value
        },
      }),
      h('h2', null, 'Hi'),
    ])
  },
})

__sfc__.__file = 'src/App.vue'
export default __sfc__
js
/* Analyzed bindings: {} */

import { createTextVNode as _createTextVNode, createVNode as _createVNode, defineComponent, ref } from 'vue'
const __sfc__ = defineComponent({
  setup() {
    const msg = ref('Hello Vue!')
    return () => _createVNode('div', null, [_createVNode('h1', null, [msg.value]), _createVNode('input', {
      value: msg.value,
      onInput: (e) => msg.value = e.target.value
    }, null), _createVNode('h2', null, [_createTextVNode('Hi')])])
  }
})
__sfc__.__file = 'src/App.vue'
export default __sfc__

從結果中可以發現 h() 與 JSX 結果基本上相似,template 則是加了不少東西,讓我們來看看是甚麼吧。ヾ(◍'౪`◍)ノ゙

Vue Runtime Helpers

template 編譯結果中的 openBlockcreateElementBlockwithDirectivestoDisplayString_cache 等等都是 Vue Runtime Helpers 的一部分,用於處理 Virtual DOM 的操作。

其中 Cache 與 Patch Flags 是相當重要的一環,可以來看看 Vue 的 Rendering Mechanism 文件,裡面說明了 Vue 如何改進渲染效能。

不過 h() 也可以寫出同 Cache 的效果,只要將希望快取的內容提升到變數中即可,例如剛剛的範例就可以改寫成這樣:

ts
import { defineComponent, h, ref } from 'vue'

export default defineComponent({
  setup() {
    const msg = ref('Hello Vue!')

    const onInput = (e: Event) => { 
      msg.value = (e.target as HTMLInputElement).value 
    } 

    const staticNode = h('h2', null, 'Hi') 

    return () => h('div', null, [
      h('h1', null, msg.value),
      h('input', {
        value: msg.value,
        onInput: (e) => { 
          msg.value = (e.target).value 
        }, 
        onInput, 
      }),
      h('h2', null, 'Hi'), 
      staticNode, 
    ])
  },
})

只是這樣寫起來會比較麻煩,也不支援 Patch Flags。◝( •ω• )◟

同場加映:Vapor Mode

啟發於 SolidJS,Vue v3.6 引入了 Vapor 模式(連名字都有致敬 XD),完全捨棄 VDOM,改為直接操作 DOM。

可以相容原有的寫法,只要在 <script setup> 中加入 vapor 即可:

vue
<script setup vapor> 
import { ref } from 'vue'

const msg = ref('Hello Vue!')
</script>

<template>
  <h1>{{ msg }}</h1>
  <input v-model="msg">

  <h2>Hi</h2>
</template>

Vue SFC Playground 中將版本改為 v3.6,可以看到編譯結果已經沒有 VDOM 的身影了。

js
/* Analyzed bindings: {
  "ref": "setup-const",
  "msg": "setup-ref"
} */
import { applyTextModel as _applyTextModel, renderEffect as _renderEffect, setText as _setText, template as _template, toDisplayString as _toDisplayString, txt as _txt, ref } from 'vue'

const __sfc__ = {
  __name: 'App',
  __vapor: true,
  setup(__props, { expose: __expose }) {
    __expose()

    const msg = ref('Hello Vue!')

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

const t0 = _template('<h1> ')
const t1 = _template('<input>')
const t2 = _template('<h2>Hi')
function render(_ctx, $props, $emit, $attrs, $slots) {
  const n0 = t0()
  const n1 = t1()
  const n2 = t2()
  const x0 = _txt(n0)
  _applyTextModel(n1, () => (_ctx.msg), (_value) => (_ctx.msg = _value))
  _renderEffect(() => _setText(x0, _toDisplayString(_ctx.msg)))
  return [n0, n1, n2]
}
__sfc__.render = render
__sfc__.__file = 'src/App.vue'
export default __sfc__

其中 _template 用來建立真實 DOM,仔細比對可以發現:

  • n1input 元素,_applyTextModel 處理 v-model 指令,同步 msg 資料
  • x0h1 元素,_renderEffect 追蹤 msg 響應,使用 _setText 更新 text 資料

可以注意到 Vapor 模式的更新關注點是 DOM,透過響應式系統精準更新,而原本 VDOM 則是整個元件,每次更新都需要比較整個元件的 VDOM。

相較之下 Vapor 省掉許多不必要的計算,配合 alien-signals 更是如虎添翼,性能大幅提升。

不過目前 Vapor 模式還在實驗階段,期待 v3.6 的正式推出!╰(*´︶`*)╯

總結 🐟

  • template 比 h()
  • 高度動態情境使用 h() 更加方便
  • Vue Compiler 會自動最佳化 template,更高效的操作 VDOM
  • Vapor 模式則無 VDOM,可以節省許多不必要的計算,藉此提高性能