Vue3——第 17 篇:模板引用(ref)与组件实例——操作 DOM 的正确姿势

本篇定位 :在 Vue 的声明式渲染体系中,我们通常不需要直接操作 DOM。但在某些场景下(如获取元素尺寸、聚焦输入框、调用子组件方法等),我们仍然需要直接访问 DOM 元素或组件实例。本篇将系统讲解模板引用的各种用法,从基础的 ref 到 Vue 3.5+ 的 useTemplateRef,再到 nextTick 的原理与使用。读完本篇,你将能够安全、高效地在 Vue 中操作 DOM 和组件实例。


一、为什么需要模板引用?

1.1 声明式 vs 命令式

Vue 的核心是声明式渲染 ------你描述"界面应该长什么样",Vue 负责更新 DOM。但在某些情况下,命令式操作 DOM 是必要的:

场景 说明
聚焦控制 页面加载后自动聚焦到输入框
尺寸测量 获取元素的宽高、位置
第三方库集成 图表、地图、编辑器等需要 DOM 元素
动画控制 手动触发动画的开始/暂停
调用子组件方法 父组件需要触发子组件的特定行为

模板引用(Template Ref) 就是 Vue 提供的在声明式框架中安全地命令式操作 DOM/组件的机制。

1.2 与响应式 ref 的区别

维度 响应式 ref 模板 ref
用途 存储响应式数据 获取 DOM 元素/组件实例
值 任意 JavaScript 值 DOM 元素或组件实例
响应式 ✅ 是 ❌ 否(只是引用)
声明方式 const count = ref(0) const el = ref(null) + ref="el"

二、模板 ref 的基本用法

2.1 绑定 DOM 元素

使用 ref 属性标记元素,然后通过同名变量访问:

vue 复制代码
<script setup>
import { ref, onMounted } from 'vue'

// 声明一个 ref,名称与模板中的 ref 属性一致
const inputEl = ref<HTMLInputElement | null>(null)
const divEl = ref<HTMLElement | null>(null)

onMounted(() => {
  // 在 mounted 之后可以访问 DOM 元素
  console.log(inputEl.value)  // <input> 元素
  console.log(divEl.value)    // <div> 元素
  
  // 操作 DOM
  inputEl.value?.focus()
  console.log('元素宽度:', divEl.value?.offsetWidth)
})
</script>

<template>
  <!-- 使用 ref 属性绑定 -->
  <input ref="inputEl" placeholder="自动聚焦" />
  <div ref="divEl" class="box">内容</div>
</template>

关键规则:

  • ref 属性的值必须与变量名一致
  • 变量必须使用 ref(null) 初始化
  • DOM 元素在 onMounted 之后才可用(setup 执行时 DOM 尚未渲染)

2.2 在 v-for 中使用 ref

在 v-for 中,ref 会返回数组 或对象:

vue 复制代码
<script setup>
import { ref, onMounted } from 'vue'

const items = ref(['A', 'B', 'C', 'D'])
const itemRefs = ref<HTMLElement[]>([])

onMounted(() => {
  console.log(itemRefs.value)  // [div, div, div, div]
  itemRefs.value.forEach((el, i) => {
    console.log(`第 ${i} 个元素的文本:`, el.textContent)
  })
})
</script>

<template>
  <div
    v-for="(item, index) in items"
    :key="index"
    :ref="(el) => itemRefs[index] = el"
  >
    {{ item }}
  </div>
</template>

使用对象存储 ref(更清晰):

vue 复制代码
<script setup>
import { ref } from 'vue'

const items = ref([
  { id: 1, name: 'A' },
  { id: 2, name: 'B' },
  { id: 3, name: 'C' }
])

// 使用对象存储每个元素的引用
const itemRefs = ref<Record<number, HTMLElement>>({})

function setItemRef(el: HTMLElement | null, id: number) {
  if (el) {
    itemRefs.value[id] = el
  }
}
</script>

<template>
  <div
    v-for="item in items"
    :key="item.id"
    :ref="(el) => setItemRef(el, item.id)"
  >
    {{ item.name }}
  </div>
</template>

2.3 在条件渲染中使用 ref

条件渲染中的 ref 需要处理元素可能不存在的情况:

vue 复制代码
<script setup>
import { ref, watch } from 'vue'

const show = ref(false)
const contentEl = ref<HTMLElement | null>(null)

// 监听 show 变化,在元素出现后操作
watch(show, (newVal) => {
  if (newVal) {
    // 使用 nextTick 确保 DOM 已更新
    nextTick(() => {
      console.log(contentEl.value)  // 元素存在
    })
  }
})
</script>

<template>
  <button @click="show = !show">切换</button>
  <div v-if="show" ref="contentEl">条件内容</div>
</template>

三、Vue 3.5+ 新特性:useTemplateRef

3.1 基本用法

Vue 3.5 引入了 useTemplateRef ,提供类型安全的模板引用方式:

vue 复制代码
<script setup>
import { useTemplateRef, onMounted } from 'vue'

// 类型安全的模板引用
const inputRef = useTemplateRef<HTMLInputElement>('inputEl')
const divRef = useTemplateRef<HTMLElement>('divEl')

onMounted(() => {
  // 自动推导类型,无需手动断言
  inputRef.value?.focus()
  console.log(divRef.value?.offsetWidth)
})
</script>

<template>
  <input ref="inputEl" />
  <div ref="divEl">内容</div>
</template>

3.2 类型安全与智能提示

useTemplateRef 的核心优势是类型安全:

vue 复制代码
<script setup lang="ts">
import { useTemplateRef } from 'vue'

// ✅ 类型安全:明确指定元素类型
const inputRef = useTemplateRef<HTMLInputElement>('input')
const buttonRef = useTemplateRef<HTMLButtonElement>('button')
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvas')

function handleClick() {
  // IDE 会提供准确的类型提示
  inputRef.value?.focus()
  inputRef.value?.select()
  
  // ✅ 类型检查:inputRef 是 HTMLInputElement,有 value 属性
  console.log(inputRef.value?.value)
  
  // ❌ 类型错误:HTMLButtonElement 没有 value 属性
  // console.log(buttonRef.value?.value)
}
</script>

<template>
  <input ref="input" placeholder="输入框" />
  <button ref="button">按钮</button>
  <canvas ref="canvas"></canvas>
</template>

3.3 useTemplateRef vs 传统 ref

特性 传统 ref useTemplateRef
类型安全 需要手动断言 ✅ 自动推导
语法简洁 需要声明和匹配 ✅ 更直观
IDE 支持 一般 ✅ 更智能
Vue 版本 Vue 3.0+ Vue 3.5+
推荐度 可用 推荐

四、组件引用与 defineExpose

4.1 引用子组件实例

在父组件中可以通过 ref 获取子组件的实例:

vue 复制代码
<!-- 父组件 -->
<script setup>
import { useTemplateRef } from 'vue'
import ChildComponent from './ChildComponent.vue'

const childRef = useTemplateRef<InstanceType<typeof ChildComponent>>('child')

function callChildMethod() {
  // 调用子组件暴露的方法
  childRef.value?.sayHello()
  childRef.value?.reset()
}
</script>

<template>
  <ChildComponent ref="child" />
  <button @click="callChildMethod">调用子组件方法</button>
</template>

4.2 defineExpose:暴露组件 API

默认情况下,子组件的内部数据和方法是私有的 。使用 defineExpose 暴露需要对外公开的 API:

vue 复制代码
<!-- ChildComponent.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)
const message = ref('Hello')

function increment() {
  count.value++
}

function reset() {
  count.value = 0
  message.value = 'Hello'
}

function sayHello() {
  console.log('Hello from child!', message.value)
}

// 暴露给父组件的 API
defineExpose({
  count,          // 暴露响应式数据
  increment,      // 暴露方法
  reset,
  sayHello
})
</script>

<template>
  <div>
    <p>子组件计数:{{ count }}</p>
    <p>消息:{{ message }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

父组件调用:

vue 复制代码
<script setup>
import { useTemplateRef } from 'vue'
import ChildComponent from './ChildComponent.vue'

const childRef = useTemplateRef<InstanceType<typeof ChildComponent>>('child')

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

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

function logCount() {
  // 访问暴露的响应式数据
  console.log('子组件 count:', childRef.value?.count)
}
</script>

<template>
  <ChildComponent ref="child" />
  <button @click="handleIncrement">父组件调用 increment</button>
  <button @click="handleReset">父组件调用 reset</button>
  <button @click="logCount">查看 count</button>
</template>

4.3 类型安全的 defineExpose

使用 TypeScript 时,可以定义暴露的类型:

typescript 复制代码
// ChildComponent.vue
export interface ChildExpose {
  count: number
  increment: () => void
  reset: () => void
  sayHello: () => void
}

<script setup>
import type { ChildExpose } from './ChildComponent.vue'

// 暴露时自动匹配类型
defineExpose<ChildExpose>({
  count,
  increment,
  reset,
  sayHello
})
</script>

父组件使用类型:

vue 复制代码
<script setup lang="ts">
import ChildComponent, { type ChildExpose } from './ChildComponent.vue'

const childRef = useTemplateRef<ChildExpose>('child')
// 现在 childRef.value 的类型是 ChildExpose | null
</script>

五、nextTick:等待 DOM 更新

5.1 为什么需要 nextTick?

Vue 的 DOM 更新是异步的,数据变化后 DOM 不会立即更新:

vue 复制代码
<script setup>
import { ref, nextTick } from 'vue'

const count = ref(0)
const el = useTemplateRef<HTMLSpanElement>('countEl')

function increment() {
  count.value = 1
  
  // ❌ 此时 DOM 尚未更新
  console.log(el.value?.textContent)  // 仍然是 '0'
  
  // ✅ 使用 nextTick 等待 DOM 更新
  nextTick(() => {
    console.log(el.value?.textContent)  // 现在是 '1'
  })
}
</script>

<template>
  <span ref="countEl">{{ count }}</span>
  <button @click="increment">增加</button>
</template>

5.2 nextTick 的原理

Vue 的更新机制:

复制代码
数据变化 → 收集依赖 → 放入微任务队列 → 批量执行 → DOM 更新 → nextTick 回调

组件数据变化
    ↓
Vue 将更新放入微任务队列
    ↓
队列中的更新合并后执行(批量更新)
    ↓
DOM 更新完成
    ↓
nextTick 回调执行

5.3 nextTick 的用法

方式一:回调方式:

vue 复制代码
<script setup>
import { nextTick } from 'vue'

function updateAndMeasure() {
  // 更新数据
  data.value = 'new value'
  
  // 等待 DOM 更新后测量
  nextTick(() => {
    const height = containerRef.value?.offsetHeight
    console.log('容器高度:', height)
  })
}
</script>

方式二:Promise 方式(推荐):

vue 复制代码
<script setup>
import { nextTick } from 'vue'

async function updateAndMeasure() {
  // 更新数据
  data.value = 'new value'
  
  // 等待 DOM 更新
  await nextTick()
  
  // 现在可以安全地操作 DOM
  const height = containerRef.value?.offsetHeight
  console.log('容器高度:', height)
}
</script>

5.4 nextTick 的常见使用场景

① 获取更新后的 DOM 尺寸:

vue 复制代码
<script setup>
import { ref, nextTick } from 'vue'

const isExpanded = ref(false)
const contentRef = useTemplateRef<HTMLElement>('content')

async function toggleExpand() {
  isExpanded.value = !isExpanded.value
  
  // 等待 DOM 展开后获取高度
  await nextTick()
  const height = contentRef.value?.scrollHeight
  console.log('展开后高度:', height)
}
</script>

② 聚焦输入框:

vue 复制代码
<script setup>
import { ref, nextTick } from 'vue'

const showInput = ref(false)
const inputRef = useTemplateRef<HTMLInputElement>('input')

async function showAndFocus() {
  showInput.value = true
  await nextTick()
  inputRef.value?.focus()
}
</script>

③ 滚动到指定元素:

vue 复制代码
<script setup>
import { ref, nextTick } from 'vue'

const messages = ref(['msg1', 'msg2', 'msg3'])
const lastMsgRef = useTemplateRef<HTMLElement>('lastMsg')

async function addMessage(text: string) {
  messages.value.push(text)
  await nextTick()
  // 滚动到最新消息
  lastMsgRef.value?.scrollIntoView({ behavior: 'smooth' })
}
</script>

5.5 flush 选项:控制更新时机

nextTick 默认在 DOM 更新后执行。Vue 3.5+ 提供了更精细的控制:

vue 复制代码
<script setup>
import { watch, nextTick } from 'vue'

// 使用 flush: 'post' 的 watch 效果类似 nextTick
watch(count, async (newVal) => {
  // DOM 已更新
  await nextTick()
  // 相当于 flush: 'post'
}, { flush: 'post' })

// 或者在 watch 中直接使用 nextTick
watch(count, async () => {
  // 使用 nextTick 确保 DOM 更新
  await nextTick()
  // 操作 DOM
})
</script>

六、综合实战:可编辑列表组件

让我们综合运用模板 ref、组件引用和 nextTick,构建一个功能完整的可编辑列表:

vue 复制代码
<!-- EditableList.vue - 主组件 -->
<script setup lang="ts">
import { ref, nextTick, useTemplateRef, onMounted } from 'vue'
import EditableItem from './EditableItem.vue'

// 列表数据
const items = ref([
  { id: 1, text: '学习 Vue 模板引用', completed: false },
  { id: 2, text: '理解 nextTick 原理', completed: false },
  { id: 3, text: '掌握 defineExpose', completed: false }
])

// 引用
const listContainer = useTemplateRef<HTMLElement>('listContainer')
const newInputRef = useTemplateRef<HTMLInputElement>('newInput')
const itemRefs = ref<Record<number, InstanceType<typeof EditableItem>>>({})

// 新项输入
const newText = ref('')
const isAdding = ref(false)

// 添加新项
async function addItem() {
  const text = newText.value.trim()
  if (!text) return
  
  isAdding.value = true
  
  const newItem = {
    id: Date.now(),
    text,
    completed: false
  }
  items.value.unshift(newItem)
  newText.value = ''
  
  // 滚动到列表顶部
  await nextTick()
  listContainer.value?.scrollTo({ top: 0, behavior: 'smooth' })
  
  isAdding.value = false
}

// 删除项
function deleteItem(id: number) {
  items.value = items.value.filter(item => item.id !== id)
  delete itemRefs.value[id]
}

// 切换完成状态
function toggleItem(id: number) {
  const item = items.value.find(i => i.id === id)
  if (item) {
    item.completed = !item.completed
  }
}

// 编辑项
function editItem(id: number) {
  // 聚焦到该编辑项
  itemRefs.value[id]?.focus()
}

// 滚动到指定项
async function scrollToItem(id: number) {
  const ref = itemRefs.value[id]
  if (ref) {
    // 获取元素的 DOM 引用(需要子组件暴露)
    // 实现见下方 EditableItem
  }
}

// 统计信息
const stats = computed(() => ({
  total: items.value.length,
  completed: items.value.filter(i => i.completed).length,
  active: items.value.filter(i => !i.completed).length
}))

// 清空已完成
function clearCompleted() {
  items.value = items.value.filter(i => !i.completed)
}

// 自动聚焦新输入框
onMounted(() => {
  newInputRef.value?.focus()
})

// 暴露给父组件的方法
defineExpose({
  addItem,
  clearCompleted,
  getItems: () => items.value
})
</script>

<template>
  <div class="editable-list">
    <h2>📋 可编辑列表</h2>
    
    <!-- 统计 -->
    <div class="stats">
      <span>总计:{{ stats.total }}</span>
      <span>已完成:{{ stats.completed }}</span>
      <span>待完成:{{ stats.active }}</span>
      <button v-if="stats.completed > 0" @click="clearCompleted" class="btn-clear">
        清空已完成
      </button>
    </div>

    <!-- 添加表单 -->
    <div class="add-form">
      <input
        ref="newInput"
        v-model="newText"
        @keydown.enter="addItem"
        placeholder="输入新待办..."
        class="add-input"
        :disabled="isAdding"
      />
      <button @click="addItem" :disabled="isAdding" class="btn-add">
        {{ isAdding ? '⏳ 添加中...' : '➕ 添加' }}
      </button>
    </div>

    <!-- 列表 -->
    <div ref="listContainer" class="list-container">
      <EditableItem
        v-for="item in items"
        :key="item.id"
        :item="item"
        ref="itemRefs"
        @delete="deleteItem"
        @toggle="toggleItem"
        @edit="editItem"
      />
    </div>
  </div>
</template>

<style scoped>
.editable-list {
  max-width: 700px;
  margin: 20px auto;
  padding: 24px;
  background: white;
  border-radius: 16px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.06);
}
h2 { margin-top: 0; font-size: 22px; }

.stats {
  display: flex;
  gap: 20px;
  padding: 10px 16px;
  background: #f7fafc;
  border-radius: 8px;
  font-size: 14px;
  color: #4a5568;
}
.btn-clear {
  margin-left: auto;
  background: #fed7d7;
  color: #c53030;
  border: none;
  padding: 2px 14px;
  border-radius: 4px;
  cursor: pointer;
}

.add-form {
  display: flex;
  gap: 10px;
  margin: 16px 0;
}
.add-input {
  flex: 1;
  padding: 10px 14px;
  border: 2px solid #e2e8f0;
  border-radius: 8px;
  font-size: 15px;
}
.add-input:focus {
  outline: none;
  border-color: #4299e1;
}
.btn-add {
  padding: 10px 24px;
  background: #4299e1;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-weight: 600;
}
.btn-add:disabled { opacity: 0.5; cursor: not-allowed; }

.list-container {
  max-height: 500px;
  overflow-y: auto;
}
</style>
vue 复制代码
<!-- EditableItem.vue - 列表项子组件 -->
<script setup lang="ts">
import { ref, nextTick, useTemplateRef, computed } from 'vue'

interface Item {
  id: number
  text: string
  completed: boolean
}

const props = defineProps<{
  item: Item
}>()

const emit = defineEmits<{
  (e: 'delete', id: number): void
  (e: 'toggle', id: number): void
  (e: 'edit', id: number): void
}>()

// 编辑状态
const isEditing = ref(false)
const editText = ref(props.item.text)

// 引用
const textDisplayRef = useTemplateRef<HTMLSpanElement>('textDisplay')
const editInputRef = useTemplateRef<HTMLInputElement>('editInput')
const itemRef = useTemplateRef<HTMLElement>('item')

// 开始编辑
function startEdit() {
  isEditing.value = true
  editText.value = props.item.text
  
  // 聚焦到输入框
  nextTick(() => {
    editInputRef.value?.focus()
    editInputRef.value?.select()
  })
  
  emit('edit', props.item.id)
}

// 保存编辑
function saveEdit() {
  const text = editText.value.trim()
  if (text) {
    // 更新本地文本(通过父组件更新)
    emit('edit', props.item.id)
    // 实际项目中需要通过父组件更新
  }
  isEditing.value = false
}

// 取消编辑
function cancelEdit() {
  isEditing.value = false
  editText.value = props.item.text
}

// 键盘事件
function handleKeydown(event: KeyboardEvent) {
  if (event.key === 'Enter') {
    saveEdit()
  } else if (event.key === 'Escape') {
    cancelEdit()
  }
}

// 暴露给父组件的方法
defineExpose({
  focus: () => {
    if (isEditing.value) {
      editInputRef.value?.focus()
    } else {
      startEdit()
    }
  },
  getElement: () => itemRef.value,
  getText: () => props.item.text
})

// 样式类
const itemClass = computed(() => ({
  'item': true,
  'completed': props.item.completed,
  'editing': isEditing.value
}))
</script>

<template>
  <div ref="item" :class="itemClass">
    <!-- 复选框 -->
    <input
      type="checkbox"
      :checked="item.completed"
      @change="emit('toggle', item.id)"
      class="item-checkbox"
    />

    <!-- 显示模式 -->
    <template v-if="!isEditing">
      <span ref="textDisplay" class="item-text">{{ item.text }}</span>
      <div class="item-actions">
        <button @click="startEdit" class="btn-edit">✏️</button>
        <button @click="emit('delete', item.id)" class="btn-delete">🗑️</button>
      </div>
    </template>

    <!-- 编辑模式 -->
    <template v-else>
      <input
        ref="editInput"
        v-model="editText"
        @keydown="handleKeydown"
        @blur="saveEdit"
        class="edit-input"
      />
      <div class="edit-actions">
        <button @click="saveEdit" class="btn-save">✅</button>
        <button @click="cancelEdit" class="btn-cancel">❌</button>
      </div>
    </template>
  </div>
</template>

<style scoped>
.item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  background: #f7fafc;
  border-radius: 8px;
  margin-bottom: 8px;
  transition: all 0.2s;
}
.item:hover { background: #edf2f7; }
.item.completed { opacity: 0.6; }
.item.completed .item-text {
  text-decoration: line-through;
  color: #a0aec0;
}
.item.editing {
  background: #ebf8ff;
  border: 2px solid #4299e1;
}

.item-checkbox {
  width: 18px;
  height: 18px;
  cursor: pointer;
  accent-color: #4299e1;
  flex-shrink: 0;
}

.item-text {
  flex: 1;
  font-size: 15px;
  color: #2d3748;
}

.item-actions {
  display: flex;
  gap: 4px;
}
.item-actions button {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 16px;
  padding: 2px 6px;
  border-radius: 4px;
  transition: background 0.2s;
}
.item-actions .btn-edit:hover { background: #ebf8ff; }
.item-actions .btn-delete:hover { background: #fff5f5; }

.edit-input {
  flex: 1;
  padding: 4px 10px;
  border: 2px solid #4299e1;
  border-radius: 6px;
  font-size: 15px;
}
.edit-input:focus { outline: none; }

.edit-actions {
  display: flex;
  gap: 4px;
}
.edit-actions button {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 16px;
  padding: 2px 6px;
  border-radius: 4px;
}
.edit-actions .btn-save:hover { background: #f0fff4; }
.edit-actions .btn-cancel:hover { background: #fff5f5; }
</style>

七、模板引用的最佳实践

7.1 使用 useTemplateRef(Vue 3.5+)

typescript 复制代码
// ✅ 推荐:类型安全
const inputRef = useTemplateRef<HTMLInputElement>('input')

// ⚠️ 可用但类型不安全
const inputRef = ref<HTMLInputElement | null>(null)

7.2 在 nextTick 中访问 DOM

typescript 复制代码
// ✅ 推荐
async function updateAndAccess() {
  data.value = 'new'
  await nextTick()
  // 此时 DOM 已更新
  console.log(el.value?.textContent)
}

// ❌ 不推荐(可能获取到旧值)
function updateAndAccess() {
  data.value = 'new'
  console.log(el.value?.textContent)  // 可能是旧值
}

7.3 条件渲染中的 ref

typescript 复制代码
// ✅ 推荐:使用 watch + nextTick
watch(show, async (newVal) => {
  if (newVal) {
    await nextTick()
    el.value?.focus()
  }
})

// ✅ 推荐:在 onUpdated 中处理(注意避免循环)
onUpdated(() => {
  if (show.value && !hasFocused.value) {
    el.value?.focus()
    hasFocused.value = true
  }
})

7.4 暴露合理的 API

typescript 复制代码
// ✅ 推荐:只暴露必要的方法和数据
defineExpose({
  reset,
  validate,
  data: readonly(data)  // 使用 readonly 保护数据
})

// ❌ 不推荐:暴露过多内部实现
defineExpose({
  internalState,
  privateMethod,
  _tempData
})

7.5 清理 DOM 引用

typescript 复制代码
// ✅ 推荐:在组件卸载时清理
onUnmounted(() => {
  // 清除外部引用
  if (chartInstance) {
    chartInstance.destroy()
    chartInstance = null
  }
})

八、常见陷阱与解决方案

8.1 ref 在 setup 中访问为 null

vue 复制代码
<script setup>
import { ref, onMounted } from 'vue'

const el = ref<HTMLElement | null>(null)

// ❌ 错误:setup 时 DOM 尚未渲染
console.log(el.value)  // null

// ✅ 正确:在 onMounted 中访问
onMounted(() => {
  console.log(el.value)  // DOM 元素
})
</script>

8.2 v-for 中的 ref 顺序问题

vue 复制代码
<script setup>
const items = ref(['A', 'B', 'C'])
const itemRefs = ref<HTMLElement[]>([])

// ❌ 错误:数组顺序可能与渲染顺序不一致
// 使用函数式 ref 确保顺序正确
</script>

<template>
  <!-- ✅ 正确:使用函数式 ref -->
  <div
    v-for="(item, index) in items"
    :key="index"
    :ref="(el) => itemRefs[index] = el"
  >
    {{ item }}
  </div>
</template>

8.3 组件引用未暴露方法

vue 复制代码
<!-- ❌ 子组件未暴露方法 -->
<script setup>
// 没有 defineExpose
</script>

<!-- 父组件调用会报错 -->
<script setup>
const childRef = ref(null)
function callChild() {
  childRef.value?.someMethod()  // undefined
}
</script>

解决方案 :在子组件中使用 defineExpose。

8.4 ref 与响应式数据混淆

vue 复制代码
<script setup>
// ❌ 错误:模板 ref 和响应式 ref 概念混淆
const input = ref('')  // 这是响应式数据
// 模板中 <input ref="input" /> 会冲突
</script>

<template>
  <!-- 模板 ref 应该用不同的名字 -->
  <input ref="inputRef" v-model="input" />
</template>

九、本篇小结

知识点 核心内容 关键要点
模板 ref 获取 DOM 元素引用 在 onMounted 后访问
useTemplateRef Vue 3.5+ 类型安全引用 推荐使用
组件引用 获取子组件实例 需要 defineExpose 暴露 API
defineExpose 暴露组件内部 API 只暴露必要的
nextTick 等待 DOM 更新 用于数据变化后的 DOM 操作
v-for ref 使用函数式 ref 保证顺序正确
清理 在 onUnmounted 中清理 防止内存泄漏

十、思考与练习

  1. 基础练习 :创建一个 FocusableInput 组件,使用 useTemplateRef 获取输入框引用,暴露 focus 和 select 方法给父组件。

  2. 实践任务 :在可编辑列表组件中,添加"滚动到指定项"功能,点击按钮时列表滚动到对应项的位置(使用 scrollIntoView)。

  3. 陷阱排查:分析以下代码的问题并修正:

    vue 复制代码
    <script setup>
    import { ref } from 'vue'
    const el = ref<HTMLElement | null>(null)
    console.log(el.value?.offsetWidth)
    </script>
    <template>
      <div ref="el">内容</div>
    </template>
  4. 深度思考 :nextTick 和 setTimeout(fn, 0) 有什么区别?为什么 Vue 选择使用微任务(microtask)而不是宏任务(macrotask)?

  5. 扩展任务:为可编辑列表添加"批量操作"功能,使用模板 ref 获取所有项的 DOM 引用,实现全选、批量删除、批量完成等操作。

下篇预告

第 18 篇我们将学习Vue 2 到 Vue 3 迁移指南,系统讲解:

  • 破坏性变更清单(全局 API、v-model、$attrs 等)
  • 迁移构建工具使用
  • 选项式 API 到组合式 API 的迁移策略
  • Vue 2 项目迁移实操

准备好升级你的 Vue 项目到最新版本!🚀

相关推荐
头发还在的女程序员1 小时前
Java在线租房系统源码解析:Spring Boot+Vue 3+UniApp房源管理与预约看房方案
java·vue.js·spring boot·租房小程序·租房系统·租房软件
FYKJ_20101 小时前
springboot同人周边订单管理系统48955-计算机毕业设计、课程设计
java·javascript·vue.js·spring boot·django·php·课程设计
梯度下降者11 小时前
Nuxt框架
vue.js·服务端渲染·全栈开发·混合渲染·nuxt框架
yuyuxun118 小时前
协同过滤的校园二手物品智能推荐系统的设计与实现 毕业设计-附源码40082
javascript·vue.js·spring boot·python·数据分析·django·课程设计
FungLeo1 天前
成为全栈·Vue 管理后台篇·编辑器图片上传:从文件选择到文章附件关联
前端·vue.js·编辑器·markdown·文件上传·formdata·附件管理
码云社区1 天前
智慧物业系统选型指南:房屋档案、账单管理与报修协作
前端·javascript·vue.js·智慧物业
ruanno_top2 天前
表单重置后,验证码还显示已通过:Vue 受控组件的 3 种假同步
vue.js
ruanno_top2 天前
给标签页加 roving tabindex,整组反而键盘进不去了
vue.js
仨孩子的爹2 天前
CSS打印样式:字体族与行高在纸上的对照实测
vue.js