iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 14 篇

Vue 走過路過不要錯過 Day14 -想直接操作 DOM 的時候:模板 ref 與 useTemplateRef

  • 分享至 

  • xImage
  •  

寫 jQuery 的年代,要對某個元素做事,第一步通常是 $('#xxx') 把它抓出來。來到 Vue 之後,大部分時間我們改資料、畫面自己跟著變,已經很少需要親手碰 DOM。

但「很少」不等於「不用」。讓 input 自動聚焦、捲動到某個位置、量元素的寬高,或把元素交給 Bootstrap、Chart.js 這類第三方套件,這些事情 Vue 的響應式幫不上忙,最後還是得拿到那個真實的 DOM 節點。

這篇就來聊 Vue 裡拿 DOM 的正確方式:模板 ref,以及 Vue 3.5 新增的 useTemplateRef。後半段會拿我自己以前寫的電商後台當例子,回頭檢查當時的 Bootstrap Modal 寫法漏了什麼。


為什麼不直接 document.querySelector?

在元件裡寫 document.querySelector('#myInput') 其實跑得動,但會遇到幾個問題:

  1. 元件會被重複使用。 同一個元件在頁面上出現三次,#myInput 就有三個,querySelector 只會抓到第一個,而且不一定是你這個元件的那一個。
  2. 它不知道元素什麼時候存在。 元素如果包在 v-if 裡,條件為 false 時根本不在 DOM 上;切換回來後又是一個新的節點。
  3. 它是往整份 document 找。 元件應該只管自己的範圍,往外找容易抓到別人的東西。

模板 ref 解決的就是這些:它讓「這個元件實例」拿到「自己模板裡的那個元素」,而且會跟著元素的掛載、卸載自動更新。


基本用法

Vue 3.5 以前: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。

Vue 3.5 之後:useTemplateRef

<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 掛在元件上:拿到的是元件實例

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

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 開放方法給父層。當時寫完覺得很乾淨,現在回頭看,有幾個地方可以更好。


回頭檢查:漏掉的三件事

1. 沒有 dispose

元件被卸載時,Vue 會把自己渲染的 DOM 移除,但 Bootstrap 那邊不知道。

翻 Bootstrap 5 的原始碼會看到,它內部用一個 Map 記錄「哪個元素對應哪個實體」。元素從畫面上移除了,Map 還抓著它,這個元素就不會被垃圾回收。後台頁面切來切去,這些殘留會一直累積。

另一個比較容易被看見的狀況:Modal 開著的時候按瀏覽器上一頁,元件被卸載了,Bootstrap 塞在 <body> 上的 backdrop 還留在畫面上,整頁變成半透明黑色、什麼都點不到。

Bootstrap 有提供 dispose() 來清掉實體、事件和 backdrop,我們要做的是在元件卸載前呼叫它:

onBeforeUnmount(() => {
  modal?.dispose()
})

這其實是使用第三方套件時的通則:把 DOM 交給 Vue 管不到的東西,也要記得在元件離開時收回來。 計時器、window 上的事件監聽、各種圖表套件都是同樣的道理。

補充:dispose() 會移除 backdrop,但不會還原 <body> 上的 modal-open class 和 overflow: hidden。如果你的 Modal 有可能在開著的狀態下被卸載,卸載時要另外把 body 的狀態清掉,不然頁面會卡在無法捲動。

2. Bootstrap 實體放進了 ref()

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 不要往物件內部深入代理。

3. 靠變數名稱連結模板

DelModal.vue 裡的 const modalRef = ref(null) 和模板上的 ref="modalRef" 必須同名,composable 還要靠元件把 ref 傳進來。

五個 Modal 元件,每個都要重複「建 ref → 傳進 composable」這兩步,而且任何一個名字打錯都不會報錯。


用 useTemplateRef 改寫

// 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 與函式 ref

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?

有了 ref 之後,很容易又回到 jQuery 的習慣:想改文字就拿元素改 textContent,想隱藏就改 style.display,想切換樣式就 classList.add。

這些在 Vue 裡都不該用 ref 做。因為 Vue 會根據資料重新渲染,你手動改的 DOM 下一次更新時可能被蓋掉,而且資料和畫面從此對不起來。

我自己的判斷方式是問一句:這件事能不能用「資料」描述?

  • 文字內容、顯示或隱藏、class、style、input 的值 → 可以,交給響應式資料和模板。
  • 聚焦、捲動、量尺寸、播放影片、交給第三方套件 → 不行,這些是「動作」或「Vue 以外的世界」,才用 ref。

以 Modal 來說,如果你改用 Vue 自己寫 Modal(v-if 搭配 <Teleport>),開關就只是一個 isOpen 的布林值,根本不需要 ref。會需要 ref,是因為我們選擇把這件事交給 Bootstrap。


小結

  • 模板 ref 讓元件拿到「自己模板裡」的元素,不會被重複使用的元件或 v-if 搞混。
  • onMounted 之後才拿得到值,v-if 為 false 時會變回 null。
  • Vue 3.5 的 useTemplateRef 用字串明確指定 ref,而且能寫在 composable 裡。
  • ref 放在元件上拿到的是實例,子元件要用 defineExpose 決定開放什麼。
  • 把 DOM 交給第三方套件時,記得在 onBeforeUnmount 清理;套件實體不需要響應式。
  • 能用資料描述的事就交給響應式,ref 留給 Vue 管不到的部分。


上一篇
Vue 走過路過不要錯過 Day13 -v-model 表單綁定:input、checkbox、select 與修飾符
下一篇
Vue 走過路過不要錯過 Day15 - 組件拆分與生命週期:setup、onMounted、onUnmounted
系列文
重新認識Vue 走過路過不要錯過 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言