Skip to content

vue-v-once-preserve-canvas-ref

神隱的 ref

大家好,我是鱈魚。(。・∀・)ノ゙

最近在開發 CodStack 時,遇到一個詭異的問題:明明 canvas 有綁 ref,Babylon.js 也正常初始化,但只要開啟右鍵選單,原本註冊在 canvas 的事件就失效了。

讓我們來研究研究。

重現問題

我把 3D 場景初始化的內容放在 use-babylon-simple composable 中,簡化後的結構如下:

typescript
export function useBabylonSimple(
  canvasRef: MaybeRefOrGetter<HTMLCanvasElement | null>,
) {
  let engine: Engine | null = null;
  let scene: Scene | null = null;

  // 省略初始化過程

  const canvasSize = reactive(useElementSize(canvasRef));
  watch(canvasSize, () => {
    engine?.resize();
  });
}

其中 useElementSize 偵測 canvas 的尺寸變化,並在尺寸變化時調用 engine.resize() 方法。

可以注意到不管你怎麼調整寬度,場景會自動調整尺寸,方塊不會扁掉。◝( •ω• )◟

接下來讓我們把 canvas 放進 Nuxt UI 的 UContextMenu 元件中,讓我們可以開啟右鍵選單。

vue
<template>
  <u-context-menu :items="contextMenuItems">
    <canvas ref="canvasRef" class="w-full h-full outline-none block" />
  </u-context-menu>
</template>
點擊滑鼠右鍵或手機長按開啟右鍵選單

現在會發現鬼故事發生了,開啟右鍵選單前改變寬度一切正常,但開啟右鍵選單後改變寬度會發現方塊扁掉了!╭(°A ,°`)╮

為什麼會壞掉?

其實我不知道到底發生甚麼事,以下是和 AI 一起抽絲剝繭、嘗試驗證的心得。ヽ(́◕◞౪◟◕‵)ノ

根本原因是 Nuxt UI 與 reka-ui 的 as-child 機制導致 ref 永久遺失。

Nuxt UI 的 UContextMenu 在內部對 trigger 使用了 as-child 模式。

as-child 是 reka-ui(Nuxt UI 底層的 headless 元件庫)提供的一種渲染模式。一般情況下,元件會用自己的標籤(例如 <span>)包裹 slot 內容:

html
<!-- 沒有 as-child:Primitive 渲染成 <span> 包裹 slot -->
<span data-state="closed" @contextmenu="...">
  <canvas />
</span>

加上 as-child 後,元件不會產生自己的 wrapper 元素,而是將所有 attrs(data-state、事件處理、樣式等)直接合併到 slot 的第一個子元素上

html
<!-- 有 as-child:attrs 直接合併到 canvas -->
<canvas data-state="closed" @contextmenu="..." />

這樣的好處是不會產生多餘的 DOM 層級,讓開發者能完全控制渲染結構。而在實作上,reka-ui 的 Slot 元件透過 Vue 的 cloneVNode 來實現這個合併,每次 render 都會產生一個全新的 cloned VNode

當選單開啟時,data-state"closed" 變為 "open",觸發 ContextMenuTrigger 重新渲染,Slot 隨之重新執行 cloneVNode

透過實測我們發現了一個關鍵事實:canvas 的 DOM 元素完全沒被替換。用原生 ResizeObserver 直接綁定在初始元素上,開啟選單後持續改變寬度,observer 依然正常觸發,證明 DOM 元素從頭到尾都是同一個。

真正的問題是 ref 永久遺失:Vue 在 patch 時 unset 了舊 VNode 的 ref(canvasRef = null),但 cloneVNode 產生的新 VNode 沒有攜帶 ref,所以 Vue 無法將 ref 設定回來。canvasRef 從此永遠停留在 null

由於 useResizeObserver 監聽的是 canvasRef(而非 DOM 元素本身),ref 變成 null 後 observer 自然就沒有對象可以觀察,最終造成:

  1. useElementSize 不再響應尺寸變化:使 engine.resize() 不再被觸發
  2. 畫面比例失真:引擎持續以舊的畫布尺寸渲染,但 CSS 已經改變了 canvas 的顯示大小,方塊就被拉伸變形了

總解一下因果關係:

  1. Slot 的 cloneVNode 產生的新 VNode 遺失了 ref
  2. Vue unset 舊 ref 後無法 set 回來
  3. canvasRef 永久為 null
  4. useResizeObserver 失去觀察目標。
驗證元件原始碼
vue
<template>
  <div class="flex flex-col gap-4">
    <div class="text-sm font-medium">
      Debug:追蹤 ref 與 ResizeObserver 行為
    </div>

    <div class="flex flex-wrap items-center gap-3">
      <label class="shrink-0 text-sm">
        調整寬度
      </label>

      <u-slider
        v-model="resizePercent"
        :min="50"
        :max="100"
        class="flex-1 accent-primary"
      />
    </div>

    <div class="text-xs opacity-50">
      步驟:1. 拖曳寬度 → 2. 右鍵開啟選單 → 3. 再拖曳寬度,觀察哪些 observer 停止觸發
    </div>

    <div
      class="transition-all"
      :style="{ width: `${resizePercent}%` }"
    >
      <u-context-menu :items="menuItems">
        <div
          ref="targetRef"
          class="w-full h-20 rounded-lg border border-black/5 dark:border-white/5 bg-primary/10 flex items-center justify-center text-sm"
        >
          沒有 v-once
        </div>
      </u-context-menu>
    </div>

    <!-- log 輸出 -->
    <div class="flex flex-col gap-1 max-h-80 overflow-y-auto rounded-lg bg-black/5 dark:bg-white/5 p-3">
      <div class="text-xs font-medium opacity-50 mb-1">
        事件 Log(新的在上面)
      </div>
      <div
        v-for="(log, index) in logs"
        :key="index"
        class="text-xs font-mono"
        :class="{
          'text-red-500': log.type === 'error',
          'text-yellow-500': log.type === 'warn',
          'text-green-500': log.type === 'success',
          'text-blue-500': log.type === 'native',
          'opacity-60': log.type === 'info',
        }"
      >
        {{ log.message }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { ContextMenuItem } from '@nuxt/ui/.'
import { useElementSize, useResizeObserver } from '@vueuse/core'
import { computed, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'

const targetRef = useTemplateRef('targetRef')

interface LogEntry {
  type: 'info' | 'warn' | 'error' | 'success' | 'native';
  message: string;
}

const logs = ref<LogEntry[]>([])

function addLog(type: LogEntry['type'], message: string) {
  logs.value.unshift({ type, message })
  if (logs.value.length > 80) {
    logs.value.length = 80
  }
}

// ===== 1. 追蹤 ref 變化(sync watcher 捕捉中間狀態) =====
let previousElement: HTMLElement | null = null
watch(targetRef, (newElement) => {
  const isSameDOM = newElement === previousElement
  if (newElement === null) {
    addLog('warn', `ref → null`)
  }
  else {
    addLog(
      isSameDOM ? 'info' : 'error',
      `ref → element(${isSameDOM ? '同一個 DOM ✓' : '不同的 DOM ✗'})`,
    )
  }
  if (newElement) {
    previousElement = newElement
  }
}, { flush: 'sync' })

// ===== 2. VueUse useResizeObserver =====
let vueUseCount = 0
useResizeObserver(targetRef, () => {
  vueUseCount++
  addLog('info', `[VueUse useResizeObserver] 觸發 #${vueUseCount}`)
})

// ===== 3. VueUse useElementSize =====
const elementSize = useElementSize(targetRef)
watch(() => elementSize.width.value, (width) => {
  if (width > 0) {
    addLog('success', `[VueUse useElementSize] width = ${Math.round(width)}`)
  }
})

// ===== 4. 原生 ResizeObserver(完全繞過 VueUse) =====
let nativeObserver: ResizeObserver | null = null
let nativeCount = 0

watch(targetRef, (element) => {
  if (!element)
    return

  // 只在第一次綁定,之後不再重建
  if (nativeObserver)
    return

  nativeObserver = new ResizeObserver(() => {
    nativeCount++
    addLog('native', `[原生 ResizeObserver] 觸發 #${nativeCount}`)
  })
  nativeObserver.observe(element)
}, { immediate: true })

onBeforeUnmount(() => {
  nativeObserver?.disconnect()
})

// ===== 控制 =====
const resizePercent = ref(100)

const menuItems = computed<ContextMenuItem[][]>(() => [
  [
    { label: 'Action A', icon: 'material-symbols:select-all-rounded' },
    { label: 'Action B', icon: 'material-symbols:flip-camera-ios-outline-rounded' },
  ],
])
</script>

就結論而言,這似乎是 reka-ui 的 Slot 元件實作上的 bug,導致 ref 永久遺失。

搜尋了一下,似乎還真的有人遇到類似的問題,還有相關討論:

如果各位大大知道更多資訊,還請不吝分享。( ´∀`)~♥

如何除靈

所以要怎麼除靈呢?只能改變 template 結構嗎?的確也可以,只是有一個更簡單的解決方案。( •̀ ω •́ )✧

那就是出場次數堪比日本製壓縮機,聊天紀錄永遠輪迴在「下次約」,我們那個極度少見的朋友:v-onceヽ(•̀∀•̀ヽ )


路人:「你這樣是歧視 v-once。ლ(╹ε╹ლ)

鱈魚:「不然你問各位讀者,有用過的舉手,我數數看 (´・ω・`)


v-once 的作用是告訴 Vue:「這個節點只渲染一次,之後的更新都跳過」。

前面提到問題的根源在於 Slot 元件每次 render 都會對 slot 內容執行 cloneVNode,而 clone 出來的新 VNode 遺失了 ref,導致 Vue unset 舊 ref 後無法 set 回來。

加上 v-once 後,Vue 會在首次渲染時快取這個 VNode。後續 Slot 呼叫 slots.default() 取得的是快取的 VNode,而非重新建立的新 VNode。因此即使 cloneVNode 再次執行,clone 的來源始終是同一個帶有 ref 的 VNode,ref 不會被 unset,自然也就不會遺失。

用法很簡單,就是加上一個 v-once 指令。

vue
<template>
  <context-menu :items="contextMenuItems">
    <canvas v-once ref="canvasRef" class="w-full h-full outline-none block" />
  </context-menu>
</template>

以下是使用 v-once 前後的對比,可以看到使用 v-once 後,即可開啟選單且不管怎麼調整寬度,方塊都不會扁掉。(\*´∀`)~♥

沒有 v-once
Element Size:(尚未收到事件)
有 v-once
Element Size:(尚未收到事件)

什麼時候適合用 v-once

v-once 在以下情境非常實用:

  • 第三方函式庫接管的 DOM 元素:canvas(WebGL)、地圖容器、影片播放器等
  • 不需要 Vue 響應式更新的靜態內容:一次性渲染的大量列表項目
  • 放在會頻繁更新的父元件 slot 裡的穩定元素:就像本文的案例

反過來說,如果元素本身的 attribute 或內容需要隨資料變化,就不適合用 v-once,因為加上去之後此 DOM 的內容就再也不會更新了。

如同那些被作者棄坑的作品,再也不會更新了。(╥ω╥`)

總結 🐟

  • 使用 as-child 模式的元件(如 Nuxt UI 的 UContextMenu),底層的 reka-ui Slot 元件會在每次 render 時對 slot 內容執行 cloneVNode,導致 ref 永久遺失
  • 這是 reka-ui 的已知問題cloneVNode 沒有正確保留原始 VNode 的 ref
  • v-once 讓 Vue 快取 VNode,使 Slot 每次拿到的都是同一個帶有 ref 的 VNode,避免 ref 被 unset
  • v-once 適合用在由第三方函式庫(Babylon.js、Three.js 等)接管、不需要 Vue 響應式更新的 DOM 元素上
  • 注意 v-once 會讓該元素上所有的動態綁定失效,只適合用在不需要 Vue 更新的元素