---
url: /blog-vue/vue-v-once-preserve-canvas-ref.md
description: 註冊在 DOM 的事件莫名失效？來看看如何解決這個問題。(´・ω・`)
---

![vue-v-once-preserve-canvas-ref](/vue-v-once-preserve-canvas-ref.webp){.cover}

# 神隱的 DOM ref

大家好，我是鱈魚。(｡･∀･)ﾉﾞ

最近在開發 [CodStack](/aquarium/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 ,°\`)╮%

::: warning 所以說那個問題呢？
結果文章上線後，我發現此問題**僅在 dev 模式下發生**，prod 模式不受影響，不確定具體原因。%(´ー\`)%

如果大家知道到底發生了甚麼事，還請不吝分享。%( ´∀\`)~♥%
:::

## 為什麼會壞掉？

其實我不知道到底發生甚麼事，以下是和 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>

      <USlider
        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}%` }"
    >
      <UContextMenu :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>
      </UContextMenu>
    </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` 永久遺失。

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

* [\[Bug\]: ref not working properly in custom-wrapped Tooltip with asChild prop](https://github.com/unovue/reka-ui/issues/1580)
* [\[Bug\]: DropdownMenuContent as-child > ref element is not updated when closed](https://github.com/unovue/reka-ui/issues/694)
* [Another implementation of asChild](https://github.com/unovue/reka-ui/discussions/1324)

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

## 如何除靈

所以要怎麼除靈呢？只能改變 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` 後，即可開啟選單且不管怎麼調整寬度，方塊都不會扁掉。%(\*´∀\`)~♥%

::: warning 所以說那個問題呢？
如同剛剛提到的現象，此問題**僅在 dev 模式下發生**，prod 模式不受影響，所以大家應該看不出差別。%(›´ω\`‹ )%

如果大家知道到底發生了甚麼事，還請不吝分享。%( ´∀\`)~♥%
:::

## 什麼時候適合用 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 的[已知問題](https://github.com/unovue/reka-ui/issues/1580)，`cloneVNode` 沒有正確保留原始 VNode 的 `ref`
* `v-once` 讓 Vue 快取 VNode，使 `Slot` 每次拿到的都是同一個帶有 `ref` 的 VNode，避免 ref 被 unset
* `v-once` 適合用在由第三方函式庫（Babylon.js、Three.js 等）接管、不需要 Vue 響應式更新的 DOM 元素上
* 注意 `v-once` 會讓該元素上所有的動態綁定失效，只適合用在不需要 Vue 更新的元素
