Vue Template Refs

在 Vue 中,雖然大多數情況下我們透過資料驅動的方式來操作 DOM,但有時候還是需要直接存取 DOM 元素。Template Refs(模板引用)提供了一種方式,讓你可以在元件中取得 DOM 元素或子元件的實例。

基本用法

使用 ref 屬性來標記元素,然後在 <script setup> 中用同名的 ref 變數來存取:

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

// 宣告一個 ref 來存放元素引用
const inputRef = ref(null)

onMounted(() => {
  // 元件掛載後,inputRef.value 會是 DOM 元素
  inputRef.value.focus()
})
</script>

<template>
  <!-- ref 屬性的值對應到變數名稱 -->
  <input ref="inputRef" type="text">
</template>
只有在元件掛載後(onMounted),ref 才會被賦值。在 onMounted 之前,ref 的值是 null

存取子元件

ref 也可以用來存取子元件的實例:

<script setup>
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'

const childRef = ref(null)

onMounted(() => {
  // 存取子元件的方法或屬性
  childRef.value.someMethod()
})
</script>

<template>
  <ChildComponent ref="childRef" />
</template>

子元件需要 expose

<script setup> 中,元件預設是「封閉」的,父元件無法存取內部的屬性和方法。需要使用 defineExpose 明確暴露:

<!-- ChildComponent.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

function reset() {
  count.value = 0
}

// 暴露給父元件
defineExpose({
  count,
  increment,
  reset
})
</script>

更多 defineExpose 的用法請參考 defineExpose

v-for 中的 Refs

當 ref 用在 v-for 中時,ref 會是一個陣列:

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

const items = ref([1, 2, 3])
const itemRefs = ref([])

onMounted(() => {
  // itemRefs.value 是一個 DOM 元素陣列
  console.log(itemRefs.value)
})
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item" ref="itemRefs">
      {{ item }}
    </li>
  </ul>
</template>
ref 陣列的順序不保證與原始陣列一致。

函式 Refs

ref 屬性也可以綁定一個函式,每次元件更新時都會呼叫:

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

const inputEl = ref(null)

function setInputRef(el) {
  // el 是 DOM 元素,或在卸載時是 null
  inputEl.value = el
}
</script>

<template>
  <input :ref="setInputRef">
</template>

這在需要更複雜的 ref 邏輯時很有用,例如根據條件選擇性地儲存 ref。

常見使用情境

聚焦輸入框

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

const inputRef = ref(null)

onMounted(() => {
  inputRef.value?.focus()
})

// 也可以在方法中使用
function focusInput() {
  inputRef.value?.focus()
}
</script>

<template>
  <input ref="inputRef" type="text">
  <button @click="focusInput">聚焦</button>
</template>

取得元素尺寸

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

const boxRef = ref(null)
const dimensions = ref({ width: 0, height: 0 })

onMounted(() => {
  const rect = boxRef.value.getBoundingClientRect()
  dimensions.value = {
    width: rect.width,
    height: rect.height
  }
})
</script>

<template>
  <div ref="boxRef" class="box">
    尺寸:{{ dimensions.width }} x {{ dimensions.height }}
  </div>
</template>

滾動到元素

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

const sectionRef = ref(null)

function scrollToSection() {
  sectionRef.value?.scrollIntoView({
    behavior: 'smooth',
    block: 'start'
  })
}
</script>

<template>
  <button @click="scrollToSection">滾動到區塊</button>

  <div style="height: 1000px;">長內容...</div>

  <section ref="sectionRef">
    <h2>目標區塊</h2>
  </section>
</template>

整合第三方函式庫

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

const canvasRef = ref(null)
let chartInstance = null

const props = defineProps({
  data: Array
})

onMounted(() => {
  // 初始化圖表
  chartInstance = new Chart(canvasRef.value, {
    type: 'bar',
    data: {
      datasets: [{ data: props.data }]
    }
  })
})

// 資料更新時更新圖表
watch(() => props.data, (newData) => {
  if (chartInstance) {
    chartInstance.data.datasets[0].data = newData
    chartInstance.update()
  }
})

onUnmounted(() => {
  // 清理
  if (chartInstance) {
    chartInstance.destroy()
  }
})
</script>

<template>
  <canvas ref="canvasRef"></canvas>
</template>

表單驗證

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

const formRef = ref(null)
const emailRef = ref(null)

function validateForm() {
  // 使用原生 HTML5 驗證
  if (!formRef.value.checkValidity()) {
    formRef.value.reportValidity()
    return false
  }
  return true
}

function focusEmail() {
  emailRef.value?.focus()
}
</script>

<template>
  <form ref="formRef" @submit.prevent="validateForm">
    <input ref="emailRef" type="email" required placeholder="Email">
    <input type="password" required minlength="6" placeholder="密碼">
    <button type="submit">送出</button>
  </form>
</template>

動態取得多個 refs

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

const items = ref(['A', 'B', 'C', 'D'])
const itemRefs = ref(new Map())

function setItemRef(el, item) {
  if (el) {
    itemRefs.value.set(item, el)
  } else {
    itemRefs.value.delete(item)
  }
}

function scrollToItem(item) {
  const el = itemRefs.value.get(item)
  el?.scrollIntoView({ behavior: 'smooth' })
}

onMounted(() => {
  console.log('所有 refs:', itemRefs.value)
})
</script>

<template>
  <nav>
    <button v-for="item in items" :key="item" @click="scrollToItem(item)">
      跳到 {{ item }}
    </button>
  </nav>

  <div
    v-for="item in items"
    :key="item"
    :ref="(el) => setItemRef(el, item)"
    class="section"
  >
    區塊 {{ item }}
  </div>
</template>

呼叫子元件方法

<!-- CounterChild.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

function decrement() {
  count.value--
}

function reset() {
  count.value = 0
}

defineExpose({
  count,
  increment,
  decrement,
  reset
})
</script>

<template>
  <div class="counter">
    計數:{{ count }}
  </div>
</template>
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import CounterChild from './CounterChild.vue'

const counterRef = ref(null)

function handleIncrement() {
  counterRef.value?.increment()
}

function handleReset() {
  counterRef.value?.reset()
}

function getCount() {
  return counterRef.value?.count
}
</script>

<template>
  <CounterChild ref="counterRef" />
  <button @click="handleIncrement">+1</button>
  <button @click="handleReset">重置</button>
  <button @click="() => console.log(getCount())">取得計數</button>
</template>

搭配 TypeScript

<script setup lang="ts">
import { ref, onMounted } from 'vue'

// DOM 元素
const inputRef = ref<HTMLInputElement | null>(null)

// 子元件(假設有型別定義)
import ChildComponent from './ChildComponent.vue'
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)

onMounted(() => {
  inputRef.value?.focus()
  childRef.value?.someMethod()
})
</script>

<template>
  <input ref="inputRef" type="text">
  <ChildComponent ref="childRef" />
</template>

最佳實踐

  1. 優先使用資料驅動:大多數情況下,應該透過響應式資料來控制 DOM,而不是直接操作

  2. 在 onMounted 後存取:確保 DOM 已經存在

  3. 使用可選鏈ref.value?.method() 避免 null 錯誤

  4. 子元件使用 defineExpose:明確暴露需要被存取的內容

  5. 清理第三方函式庫:在 onUnmounted 中銷毀實例

  6. 避免過度使用:如果發現大量使用 refs 來操作 DOM,可能需要重新思考設計