本文 AI 產出,尚未審核

Vue3 課程 – 響應式系統(Reactivity System)

主題:ref 與 DOM 互動


簡介

在 Vue 3 中,ref 是最基礎的響應式 API,負責將普通的 JavaScript 值包裝成「可追蹤」的資料。當 ref 的值改變時,所有依賴它的 template、computed 或 watch 都會自動重新計算,進而更新畫面。

然而,許多新手在把 ref 用於 DOM 互動時,會碰到「值已變但畫面沒更新」或「取得的 DOM 為 null」等問題。了解 ref 與實際 DOM 元素的映射方式,才能寫出既簡潔又可靠的互動程式碼。

本篇文章將從 概念、實作範例、常見陷阱,到 最佳實踐 與 真實案例,一步步帶你掌握 ref 在 Vue 3 中與 DOM 互動的正確姿勢。


核心概念

1. ref 的兩種角色

角色 目的 使用情境
資料 ref 包裝原始資料(number、string、object 等) 表單輸入、計數器、API 回傳結果
DOM ref 取得模板中的實體 DOM 元素或子組件實例 手動聚焦、測量尺寸、第三方套件掛載點

重點:Vue 會自動在模板中把 ref 名稱對應到 setup() 中同名的變數,無需額外的 this.$refs。

2. 建立資料 ref

import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)          // 包裝數字
    const message = ref('Hello')  // 包裝字串

    function inc() {
      count.value++               // 必須透過 .value 存取
    }

    return { count, message, inc }
  }
}

ref 只是一個帶有 .value 屬性的容器,Vue 會在讀取或寫入 .value 時進行依賴追蹤。

3. 建立 DOM ref

<template>
  <input type="text" ref="nameInput" @keyup.enter="focusNext" />
  <button @click="focusInput">聚焦輸入框</button>
</template>
import { ref, onMounted } from 'vue'

export default {
  setup() {
    const nameInput = ref(null)   // 初始為 null,等掛載完成後會被 Vue 填入

    onMounted(() => {
      // 此時 DOM 已經掛載,可直接使用
      nameInput.value.focus()
    })

    function focusInput() {
      if (nameInput.value) {
        nameInput.value.focus()
      }
    }

    function focusNext() {
      // 這裡示範如何在同一個 component 中切換焦點
      // 假設還有另一個 ref: lastNameInput
    }

    return { nameInput, focusInput, focusNext }
  }
}

提示:在 setup() 中宣告的 ref 會在 onMounted 之後自動指向真實的 DOM,若在 mounted 前就存取會得到 null。

4. ref 與 v-model 的配合

<template>
  <input v-model="search" placeholder="搜尋關鍵字" />
  <button @click="clear">清除</button>
</template>
import { ref } from 'vue'

export default {
  setup() {
    const search = ref('')   // 直接綁定於 v-model

    function clear() {
      search.value = ''      // 變更會立即反映到 input
    }

    return { search, clear }
  }
}

此例展示 v-model 內部其實是自動把 ref 的 .value 讀寫,開發者不需要額外寫 event.target.value。

5. 取得子組件實例(組件 ref)

<!-- Parent.vue -->
<template>
  <ChildComponent ref="child" />
  <button @click="callChildMethod">呼叫子組件方法</button>
</template>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

export default {
  components: { ChildComponent },
  setup() {
    const child = ref(null)

    function callChildMethod() {
      // 子組件必須 expose 方法才能被呼叫
      child.value?.doSomething()
    }

    return { child, callChildMethod }
  }
}

在子組件內:

export default {
  setup(_, { expose }) {
    function doSomething() {
      console.log('子組件方法被呼叫')
    }
    expose({ doSomething })   // 必須使用 expose 讓父層可存取
  }
}

程式碼範例

範例 1:自動聚焦並偵測尺寸變化

<template>
  <div ref="box" class="box" @click="toggleSize">
    點我改變大小
  </div>
</template>

<script setup>
import { ref, onMounted, watchEffect } from 'vue'

const box = ref(null)
const large = ref(false)

function toggleSize() {
  large.value = !large.value
}

// 監測元素尺寸,尺寸變化時印出寬高
watchEffect(() => {
  if (box.value) {
    const { offsetWidth, offsetHeight } = box.value
    console.log(`寬度: ${offsetWidth}px, 高度: ${offsetHeight}px`)
  }
})

onMounted(() => {
  // 初始聚焦
  box.value?.focus()
})
</script>

<style scoped>
.box {
  width: 120px;
  height: 120px;
  background: #42b983;
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.box.large {
  width: 240px;
  height: 240px;
}
</style>

說明:box 為 DOM ref,在 watchEffect 中直接使用 box.value 取得尺寸;large 為資料 ref,切換時會觸發 CSS class 變化。

範例 2:使用 ref 操作第三方插件(如 Chart.js)

<template>
  <canvas ref="chartCanvas" width="400" height="200"></canvas>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { Chart, registerables } from 'chart.js'

Chart.register(...registerables)

const chartCanvas = ref(null)
let chartInstance = null

onMounted(() => {
  if (chartCanvas.value) {
    chartInstance = new Chart(chartCanvas.value, {
      type: 'bar',
      data: {
        labels: ['A', 'B', 'C'],
        datasets: [{ label: '數量', data: [12, 19, 3] }]
      }
    })
  }
})

onBeforeUnmount(() => {
  chartInstance?.destroy()
})
</script>

說明:第三方套件需要真實的 <canvas> 元素,透過 chartCanvas 取得後即可 instantiate。記得在元件卸載前釋放資源。

範例 3:表單驗證 – 取得錯誤訊息的 DOM 節點

<template>
  <form @submit.prevent="submit">
    <div>
      <input v-model="email" ref="emailInput" placeholder="Email" />
      <p v-if="emailError" class="error" ref="emailErrorMsg">{{ emailError }}</p>
    </div>
    <button type="submit">送出</button>
  </form>
</template>

<script setup>
import { ref } from 'vue'

const email = ref('')
const emailError = ref('')
const emailInput = ref(null)
const emailErrorMsg = ref(null)

function validate() {
  const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (!pattern.test(email.value)) {
    emailError.value = '請輸入有效的 Email'
    // 把焦點跳到錯誤訊息上,讓螢幕閱讀器能即時讀出
    emailErrorMsg.value?.focus()
    return false
  }
  emailError.value = ''
  return true
}

function submit() {
  if (validate()) {
    alert('表單送出成功!')
    // 清空表單
    email.value = ''
    emailInput.value?.focus()
  }
}
</script>

<style scoped>
.error {
  color: red;
  margin-top: 4px;
}
</style>

說明:此範例同時使用 資料 ref(email、emailError)與 DOM ref(emailInput、emailErrorMsg),示範如何在驗證失敗時自動聚焦錯誤訊息,提高可存取性。


常見陷阱與最佳實踐

陷阱 說明 解決方案
忘記 .value 在 setup() 中直接使用 ref 變數(如 count = 0)會失去響應性。 必須 透過 refVariable.value 讀寫。
在 setup 立即存取 DOM ref ref 在掛載前仍是 null,導致 TypeError。 使用 onMounted、nextTick 或 watch 等生命週期鉤子。
同名衝突 template 中的 ref 名稱與 setup() 內的資料 ref 同名,會被模板覆寫。 若需要同時保存資料與 DOM,使用不同名稱(如 countRef、countEl)。
子組件未 expose 父層透過 ref 取得子組件實例卻無法呼叫方法。 在子組件 setup 中使用 expose({ method })。
大量 ref 造成記憶體洩漏 未在 onBeforeUnmount 釋放第三方插件或事件監聽。 確保在組件卸載時 destroy、removeEventListener。

最佳實踐

  1. 命名規則:

    • 資料 ref → xxxRef(如 countRef)
    • DOM ref → xxxEl(如 inputEl)
  2. 聚焦與可存取性:

    • 使用 tabindex="-1" 讓錯誤訊息或非可聚焦元素可被程式聚焦。
    • 在聚焦前先檢查 ref.value 是否存在。
  3. 避免過度使用 ref:

    • 若只有單純的物件屬性變更,考慮使用 reactive。
    • ref 適合 原始值、單一物件 或 DOM。
  4. 與 watch 配合:

    • 想在 ref 變更時執行副作用(如 API 請求、DOM 操作)時,使用 watch 或 watchEffect。

實際應用場景

場景 為什麼需要 ref + DOM 實作要點
自訂彈窗 / Modal 需要在開啟時自動聚焦第一個輸入框,關閉時回到觸發元素。 使用 modalEl 取得彈窗容器,triggerEl 取得觸發按鈕,配合 onMounted / onBeforeUnmount 控制焦點。
無限滾動 / 監測可視區 需要取得滾動容器的 scrollTop、clientHeight 以判斷是否觸發載入。 scrollContainer 為 DOM ref,在 scroll 事件中讀取 scrollContainer.value.scrollTop。
圖表、地圖、3D 渲染 第三方套件只能接受真實的 DOM 元素作為掛載點。 在 onMounted 內建立圖表實例,並在 onBeforeUnmount 銷毀。
表單動態驗證 驗證失敗時需把焦點移至錯誤訊息或特定欄位。 透過 errorMsgEl、inputEl 取得 DOM,使用 .focus()。
動畫與過渡 需要直接操作元素的 style、classList 或使用 requestAnimationFrame。 animEl 為 DOM ref,在 watchEffect 中根據狀態變更添加/移除 class。

總結

  • ref 是 Vue 3 中最直觀的響應式 API,分為 資料 ref 與 DOM ref 兩大類。
  • 使用 ref 時必須透過 .value 讀寫,並在 掛載完成 後才操作 DOM 元素。
  • 透過 onMounted、watchEffect、watch 等生命週期與副作用 API,可把資料變化與 DOM 操作緊密結合。
  • 常見的陷阱包括忘記 .value、提前存取 DOM、子組件未 expose 等,遵守命名規則與釋放資源的最佳實踐,可讓程式碼更安全、可維護。

掌握了 ref 與 DOM 的互動方式,你就能在 Vue 3 中輕鬆實作 聚焦、尺寸測量、第三方插件掛載、表單驗證 等常見需求,為使用者提供流暢且具可存取性的介面體驗。祝開發順利!