寫 jQuery 的年代,要對某個元素做事,第一步通常是 $('#xxx') 把它抓出來。來到 Vue 之後,大部分時間我們改資料、畫面自己跟著變,已經很少需要親手碰 DOM。
但「很少」不等於「不用」。讓 input 自動聚焦、捲動到某個位置、量元素的寬高,或把元素交給 Bootstrap、Chart.js 這類第三方套件,這些事情 Vue 的響應式幫不上忙,最後還是得拿到那個真實的 DOM 節點。
這篇就來聊 Vue 裡拿 DOM 的正確方式:模板 ref,以及 Vue 3.5 新增的 useTemplateRef。後半段會拿我自己以前寫的電商後台當例子,回頭檢查當時的 Bootstrap Modal 寫法漏了什麼。
在元件裡寫 document.querySelector('#myInput') 其實跑得動,但會遇到幾個問題:
#myInput 就有三個,querySelector 只會抓到第一個,而且不一定是你這個元件的那一個。v-if 裡,條件為 false 時根本不在 DOM 上;切換回來後又是一個新的節點。模板 ref 解決的就是這些:它讓「這個元件實例」拿到「自己模板裡的那個元素」,而且會跟著元素的掛載、卸載自動更新。
<script setup>
import { ref, onMounted } from 'vue'
// 變數名稱必須跟模板上 ref="searchInput" 一模一樣
const searchInput = ref(null)
onMounted(() => {
searchInput.value.focus()
})
</script>
<template>
<input ref="searchInput" placeholder="搜尋商品" />
</template>
Vue 編譯時會看模板上的 ref="searchInput",然後去 <script setup> 裡找同名的 ref 變數,把 DOM 元素塞進去。
這個寫法的連結是靠「名字剛好一樣」。哪天有人把變數改名成 inputEl,卻忘了改模板,不會有任何錯誤訊息,只會在執行到 .focus() 時噴出 Cannot read properties of null。
<script setup>
import { useTemplateRef, onMounted } from 'vue'
// 用字串明確指定要拿哪一個 ref,變數名稱可以自己取
const inputEl = useTemplateRef('search-input')
onMounted(() => {
inputEl.value.focus()
})
</script>
<template>
<input ref="search-input" placeholder="搜尋商品" />
</template>
差別在於連結方式從「變數名稱對得上」變成「用字串指名」。變數叫什麼都可以,意圖也寫得更清楚:這個變數就是拿來接模板 ref 的,不是一般的響應式資料。
更大的好處是它可以寫在 composable 裡,後面的 Modal 例子就會用到。
不管哪種寫法,在 <script setup> 最上層直接印出來都是 null:
const inputEl = useTemplateRef('search-input')
console.log(inputEl.value) // null
<script setup> 的程式碼在元件「建立」時執行,這時候模板還沒渲染,DOM 自然不存在。要等到 onMounted 之後,元素掛上畫面,ref 才會有值。
如果元素包在 v-if 裡,條件變成 false 時 ref 會回到 null,所以在事件處理或 watch 裡使用時,記得先確認它存在。
ref 不只可以放在 HTML 元素上,也可以放在子元件上。這時候拿到的不是 DOM,而是子元件的實例。
不過 <script setup> 的元件預設是封閉的,父層拿到實例也碰不到裡面的變數和方法。子元件要用 defineExpose 主動決定開放什麼:
// 子元件
<script setup>
const open = () => { /* ... */ }
const close = () => { /* ... */ }
defineExpose({ open, close })
</script>
// 父元件
<script setup>
import { useTemplateRef } from 'vue'
import BaseModal from './BaseModal.vue'
const dialog = useTemplateRef('dialog')
</script>
<template>
<button @click="dialog.open()">開啟</button>
<BaseModal ref="dialog" />
</template>
這個「預設封閉、主動開放」的設計很重要。子元件內部怎麼實作是它自己的事,父層只能用它願意給的介面,之後子元件要重構也不會影響到外面。
Bootstrap 的 Modal 是 ref 最典型的使用情境。它不看 Vue 的響應式資料,它要的是一個真實的 DOM 節點,拿到後自己去加 class、塞 backdrop、綁鍵盤事件。這些事 Vue 管不到,只能把元素交出去。
以前做 HelmentShop 這個安全帽電商時,後台有商品、訂單、優惠券、文章好幾個 Modal,所以我把 Bootstrap Modal 的初始化抽成了一個 composable。當時專案用的是 Vue 3.4,還沒有 useTemplateRef:
// composables/useModal.js
import { onMounted, ref } from 'vue'
import BsModal from 'bootstrap/js/dist/modal'
export function useModal(modalRef) {
const modal = ref(null)
function openModal() {
if (modal.value) {
modal.value.show()
}
}
function hideModal() {
if (modal.value) {
modal.value.hide()
}
}
onMounted(() => {
const modalElement = modalRef.value
if (modalElement) {
modal.value = new BsModal(modalElement)
}
})
return { modal, openModal, hideModal }
}
刪除商品的 Modal 元件這樣用:
// DelModal.vue
<script setup>
import { ref } from 'vue'
import { useModal } from '@/composables/useModal'
const modalRef = ref(null)
const { openModal, hideModal } = useModal(modalRef)
defineExpose({ openModal, hideModal })
</script>
<template>
<div ref="modalRef" class="modal fade" tabindex="-1">
<!-- 省略 modal 內容 -->
</div>
</template>
商品管理頁再透過元件 ref 呼叫:
// AdminProduct.vue
const delModalRef = ref(null)
function openDelModal(item) {
Object.assign(tempProduct, { ...item })
delModalRef.value.openModal()
}
一個例子裡就用到了三件事:ref 拿 DOM 交給 Bootstrap、ref 拿子元件實例、defineExpose 開放方法給父層。當時寫完覺得很乾淨,現在回頭看,有幾個地方可以更好。
元件被卸載時,Vue 會把自己渲染的 DOM 移除,但 Bootstrap 那邊不知道。
翻 Bootstrap 5 的原始碼會看到,它內部用一個 Map 記錄「哪個元素對應哪個實體」。元素從畫面上移除了,Map 還抓著它,這個元素就不會被垃圾回收。後台頁面切來切去,這些殘留會一直累積。
另一個比較容易被看見的狀況:Modal 開著的時候按瀏覽器上一頁,元件被卸載了,Bootstrap 塞在 <body> 上的 backdrop 還留在畫面上,整頁變成半透明黑色、什麼都點不到。
Bootstrap 有提供 dispose() 來清掉實體、事件和 backdrop,我們要做的是在元件卸載前呼叫它:
onBeforeUnmount(() => {
modal?.dispose()
})
這其實是使用第三方套件時的通則:把 DOM 交給 Vue 管不到的東西,也要記得在元件離開時收回來。 計時器、window 上的事件監聽、各種圖表套件都是同樣的道理。
補充:
dispose()會移除 backdrop,但不會還原<body>上的modal-openclass 和overflow: hidden。如果你的 Modal 有可能在開著的狀態下被卸載,卸載時要另外把 body 的狀態清掉,不然頁面會卡在無法捲動。
const modal = ref(null)
modal.value = new BsModal(modalElement)
回想 Day 3 講的 Proxy:放進 ref() 的物件會被 Vue 包成響應式代理,讀寫屬性都會經過追蹤。
但這裡有任何畫面依賴 modal.value 的變化嗎?沒有。它只是一個讓我們呼叫 show()、hide() 的工具物件,Vue 完全不需要追蹤它。用一般變數存就好:
let modal = null
如果真的有需要放進響應式結構(例如要 return 出去給外面判斷有沒有初始化完成),可以用 shallowRef 或 markRaw,告訴 Vue 不要往物件內部深入代理。
DelModal.vue 裡的 const modalRef = ref(null) 和模板上的 ref="modalRef" 必須同名,composable 還要靠元件把 ref 傳進來。
五個 Modal 元件,每個都要重複「建 ref → 傳進 composable」這兩步,而且任何一個名字打錯都不會報錯。
// composables/useModal.js(Vue 3.5+)
import { onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import BsModal from 'bootstrap/js/dist/modal'
export function useModal(refKey = 'modalRef') {
// composable 自己去拿模板上的 ref
const modalEl = useTemplateRef(refKey)
// 外部實體,不需要響應式
let modal = null
onMounted(() => {
modal = new BsModal(modalEl.value)
})
onBeforeUnmount(() => {
modal?.dispose()
})
return {
openModal: () => modal?.show(),
hideModal: () => modal?.hide(),
}
}
元件裡的 script 只剩這樣:
// DelModal.vue
<script setup>
import { useModal } from '@/composables/useModal'
const { openModal, hideModal } = useModal('modalRef')
defineExpose({ openModal, hideModal })
</script>
模板上的 ref="modalRef" 和父層的呼叫方式都不用動。
對照一下改了什麼:
| 舊寫法(3.4) | 新寫法(3.5+) | |
|---|---|---|
| 拿 DOM | 元件建 ref,再傳進 composable | composable 用字串自己拿 |
| Bootstrap 實體 | 放在 ref() 裡,被 Proxy 包起來 |
一般變數 |
| 卸載 | 沒處理 | onBeforeUnmount 裡 dispose() |
useTemplateRef 能寫在 composable 裡這點很關鍵。它只要在 setup 執行期間被呼叫就好,所以 composable 可以把「拿 DOM → 初始化 → 清理」整段生命週期包起來,元件完全不用知道細節。
v-for 裡的 ref 會拿到陣列:
<script setup>
import { useTemplateRef, onMounted } from 'vue'
const list = ['安全帽', '護目鏡', '手套']
const itemEls = useTemplateRef('items')
onMounted(() => {
console.log(itemEls.value) // [li, li, li]
})
</script>
<template>
<ul>
<li v-for="item in list" :key="item" ref="items">{{ item }}</li>
</ul>
</template>
要注意的是,官方文件有提醒這個陣列的順序不保證跟原始資料一樣。如果需要對應到某筆資料,最好用資料本身的 id 去找,不要依賴索引。
函式 ref: :ref 也可以綁一個函式,元素掛載時會傳入元素,卸載時傳入 null。適合需要自己決定怎麼存的情況,例如存進以 id 為 key 的物件:
<li
v-for="item in products"
:key="item.id"
:ref="(el) => { itemMap[item.id] = el }"
>
{{ item.title }}
</li>
有了 ref 之後,很容易又回到 jQuery 的習慣:想改文字就拿元素改 textContent,想隱藏就改 style.display,想切換樣式就 classList.add。
這些在 Vue 裡都不該用 ref 做。因為 Vue 會根據資料重新渲染,你手動改的 DOM 下一次更新時可能被蓋掉,而且資料和畫面從此對不起來。
我自己的判斷方式是問一句:這件事能不能用「資料」描述?
以 Modal 來說,如果你改用 Vue 自己寫 Modal(v-if 搭配 <Teleport>),開關就只是一個 isOpen 的布林值,根本不需要 ref。會需要 ref,是因為我們選擇把這件事交給 Bootstrap。
v-if 搞混。onMounted 之後才拿得到值,v-if 為 false 時會變回 null。useTemplateRef 用字串明確指定 ref,而且能寫在 composable 裡。defineExpose 決定開放什麼。onBeforeUnmount 清理;套件實體不需要響應式。