
神隱的 DOM ref
大家好,我是鱈魚。(。・∀・)ノ゙
最近在開發 CodStack 時,遇到一個詭異的問題:明明 canvas 有綁 ref,Babylon.js 也正常初始化,但只要開啟右鍵選單,原本註冊在 canvas 的事件就失效了。
讓我們來研究研究。
重現問題
我把 3D 場景初始化的內容放在 use-babylon-simple composable 中,簡化後的結構如下:
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 元件中,讓我們可以開啟右鍵選單。
<template>
<u-context-menu :items="contextMenuItems">
<canvas ref="canvasRef" class="w-full h-full outline-none block" />
</u-context-menu>
</template>現在會發現鬼故事發生了,開啟右鍵選單前改變寬度一切正常,但開啟右鍵選單後改變寬度會發現方塊扁掉了!╭(°A ,°`)╮
所以說那個問題呢?
結果文章上線後,我發現此問題僅在 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 內容:
<!-- 沒有 as-child:Primitive 渲染成 <span> 包裹 slot -->
<span data-state="closed" @contextmenu="...">
<canvas />
</span>加上 as-child 後,元件不會產生自己的 wrapper 元素,而是將所有 attrs(data-state、事件處理、樣式等)直接合併到 slot 的第一個子元素上:
<!-- 有 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 自然就沒有對象可以觀察,最終造成:
useElementSize不再響應尺寸變化:使engine.resize()不再被觸發- 畫面比例失真:引擎持續以舊的畫布尺寸渲染,但 CSS 已經改變了 canvas 的顯示大小,方塊就被拉伸變形了
總解一下因果關係:
- Slot 的 cloneVNode 產生的新 VNode 遺失了 ref
- Vue unset 舊 ref 後無法 set 回來
- canvasRef 永久為 null
- useResizeObserver 失去觀察目標。
驗證元件原始碼
<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 永久遺失。
搜尋了一下,似乎還真的有人遇到類似的問題,還有相關討論:
- [Bug]: ref not working properly in custom-wrapped Tooltip with asChild prop
- [Bug]: DropdownMenuContent as-child > ref element is not updated when closed
- Another implementation of asChild
如果各位大大知道更多資訊,還請不吝分享。( ´∀`)~♥
如何除靈
所以要怎麼除靈呢?只能改變 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 指令。
<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 後,即可開啟選單且不管怎麼調整寬度,方塊都不會扁掉。(\*´∀`)~♥
所以說那個問題呢?
如同剛剛提到的現象,此問題僅在 dev 模式下發生,prod 模式不受影響,所以大家應該看不出差別。(›´ω`‹ )
如果大家知道到底發生了甚麼事,還請不吝分享。( ´∀`)~♥
什麼時候適合用 v-once
v-once 在以下情境非常實用:
- 第三方函式庫接管的 DOM 元素:canvas(WebGL)、地圖容器、影片播放器等
- 不需要 Vue 響應式更新的靜態內容:一次性渲染的大量列表項目
- 放在會頻繁更新的父元件 slot 裡的穩定元素:就像本文的案例
反過來說,如果元素本身的 attribute 或內容需要隨資料變化,就不適合用 v-once,因為加上去之後此 DOM 的內容就再也不會更新了。
如同那些被作者棄坑的作品,再也不會更新了。(╥ω╥`)
總結 🐟
- 使用
as-child模式的元件(如 Nuxt UI 的UContextMenu),底層的 reka-uiSlot元件會在每次 render 時對 slot 內容執行cloneVNode,導致ref永久遺失 - 這是 reka-ui 的已知問題,
cloneVNode沒有正確保留原始 VNode 的ref v-once讓 Vue 快取 VNode,使Slot每次拿到的都是同一個帶有ref的 VNode,避免 ref 被 unsetv-once適合用在由第三方函式庫(Babylon.js、Three.js 等)接管、不需要 Vue 響應式更新的 DOM 元素上- 注意
v-once會讓該元素上所有的動態綁定失效,只適合用在不需要 Vue 更新的元素