08|模板引用:请 DOM 帮忙可以,别让它管理状态
第一章概览 / 08。目标:在声明式模板之外,安全地访问 DOM 或组件的少量命令式能力,并严格控制时机与暴露面。
主例保存为 Vue 3.5.x + Vite TypeScript 项目的 src/App.vue。它刻意使用 3.5 新增的 useTemplateRef();若项目低于 3.5,只能使用后文明确标出的同名 ref(null) 兼容写法。
什么时候该用,什么时候不该用
| 需求 | 优先方案 | 模板 ref 是否合适 |
|---|---|---|
| 根据状态显示/隐藏、改 class、渲染列表 | 响应式状态 + 模板 | 不合适;让 Vue 管 DOM |
| 聚焦输入框、测量尺寸、调用原生媒体 API | 模板 ref | 合适,属于命令式浏览器能力 |
| 父组件让子组件提交表单 | props / emits 表达请求 | 通常不合适;避免强耦合 |
父组件调用子组件的 focus() / reset() | 小而明确的 defineExpose 接口 | 可以,但接口要克制 |
DOM ref:先等它真的出生
模板 ref 在首次渲染前是 null,因为 DOM 还没挂载。Vue 3.5+ 推荐 useTemplateRef();较早的 Vue 3 可以用同名 ref(null)。TypeScript 不要对 null 假装没看见,它会在切换 v-if 时回来提醒你。
<!-- src/App.vue -->
<script setup lang="ts">
import { nextTick, onMounted, ref, useTemplateRef } from 'vue'
const searchInput = useTemplateRef<HTMLInputElement>('searchInput')
const showSearch = ref(false)
onMounted(() => {
searchInput.value?.focus()
})
async function openSearch() {
// 当本次状态修改会让目标元素出现时,先等 Vue 提交 DOM 更新。
showSearch.value = true
await nextTick()
searchInput.value?.focus()
}
</script>
<template>
<button type="button" @click="openSearch">打开搜索</button>
<label v-if="showSearch">
搜索
<input ref="searchInput" type="search" @keydown.esc="showSearch = false" />
</label>
</template>深水区:命令式 API 要有一张很短的清单
模板 ref 最健康的形态,是把浏览器必须命令式完成的事情压缩成几个命名函数:focus()、scrollIntoView()、play()、measure()。业务状态仍由响应式数据表达;模板 ref 只跨越“声明式模板无法直接表达”的最后一步。只要你发现自己正通过 ref 读取输入值、改文本、加 class、驱动子组件内部流程,就该先回到 props、emits 和状态模型。
<script setup lang="ts">
import { nextTick, ref, useTemplateRef } from 'vue'
const notice = ref('')
const noticeEl = useTemplateRef<HTMLElement>('noticeEl')
async function announceSaved() {
// 状态先决定内容和可见性。
notice.value = '已保存。'
// 只在确有焦点需求时,等节点出现后调用浏览器 API。
await nextTick()
noticeEl.value?.focus()
}
</script>
<template>
<button type="button" @click="announceSaved">保存</button>
<p v-if="notice" ref="noticeEl" tabindex="-1" role="status">{{ notice }}</p>
</template>这里 notice 才是事实来源;把 noticeEl.value!.textContent = '已保存' 写进函数,会让 Vue 与手写 DOM 争夺同一个节点的控制权。命令式代码越短,越容易确认它不会越过组件边界。
Vue 3.5 之前的兼容写法
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const searchInput = ref<HTMLInputElement | null>(null)
onMounted(() => searchInput.value?.focus())
</script>
<template><input ref="searchInput" /></template>v-if 与 watchEffect:引用会暂时消失
若目标被 v-if 卸载,ref 会回到 null。当你需要随它出现而执行命令式操作,可用 watchEffect 检查,不要在逻辑里假设“它一定永远在那里”。
<script setup lang="ts">
import { ref, useTemplateRef, watchEffect } from 'vue'
const showEditor = ref(false)
const editor = useTemplateRef<HTMLInputElement>('editor')
watchEffect(() => {
// 初次运行时 editor 为 null;v-if 创建后,ref 的变化会再次触发这里。
if (showEditor.value && editor.value) editor.value.focus()
})
</script>
<template>
<button type="button" @click="showEditor = true">开始编辑</button>
<input v-if="showEditor" ref="editor" aria-label="编辑内容" />
</template>v-for 中的 refs:有一组,不保证队形
Vue 3.5+ 中,在 v-for 内使用同一个 ref 名会得到元素数组;该数组顺序不承诺与源数组完全一致,不要用位置给业务对象配对。需要按业务 ID 查找时,用函数 ref 建 Map 更稳。
<script setup lang="ts">
import { onBeforeUpdate, ref } from 'vue'
type Row = { id: string; label: string }
const rows = ref<Row[]>([])
const rowElements = new Map<string, HTMLLIElement>()
function setRowRef(id: string, el: unknown) {
if (el instanceof HTMLLIElement) rowElements.set(id, el)
else rowElements.delete(id) // 卸载时 Vue 会传 null,顺手清理。
}
onBeforeUpdate(() => rowElements.clear())
function scrollToRow(id: string) {
rowElements.get(id)?.scrollIntoView({ block: 'nearest' })
}
</script>
<template>
<ul>
<li v-for="row in rows" :key="row.id" :ref="(el) => setRowRef(row.id, el)">
{{ row.label }}
</li>
</ul>
</template>组件 ref:只暴露按钮,不把机房钥匙交出去
使用 <script setup> 的子组件默认是私有的;只有 defineExpose() 明确列出的成员可通过组件 ref 调用。这是好事:父组件知道得越少,子组件越容易改而不牵一发动全身。
<!-- SearchBox.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const input = useTemplateRef<HTMLInputElement>('input')
function focus() {
input.value?.focus()
}
defineExpose({ focus })
</script>
<template><input ref="input" type="search" /></template><!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import SearchBox from './SearchBox.vue'
const searchBox = useTemplateRef<InstanceType<typeof SearchBox>>('searchBox')
</script>
<template>
<button @click="searchBox?.focus()">把焦点交给搜索框</button>
<SearchBox ref="searchBox" />
</template>排错路线:ref 是 null、旧节点还是错对象
- 确认生命周期。 setup 运行时 DOM 还没生成;首次挂载用
onMounted,由状态触发的新节点用await nextTick()。 - 确认条件存在性。
v-if关闭会把节点卸载,ref 回到null;每次访问都应处理“目标不存在”的正常分支。 - 确认集合身份。
v-for的 ref 数组不是业务索引表。需要定位某一项时,用函数 ref 和实体 ID 建 Map。 - 确认组件合同。 父组件需要的是
focus()还是“替我修改内部数据”?前者可用窄小的defineExpose,后者通常应改为 props / emits。
翻车博物馆
| 现象 | 根因 | 修复 |
|---|---|---|
setup 里读取 ref 是 null | DOM 尚未挂载 | 在 onMounted 或状态更新后的 nextTick 中访问 |
| 切换条件后偶发报错 | v-if 会让 ref 回到 null | 使用可选链并处理元素不存在的状态 |
| 父组件能读写子组件一大堆内部变量 | 过度暴露组件实例 | 用 props/emits;必要时只 defineExpose 一两个命令 |
| v-for 的 ref 顺序与数据对不上 | 把 ref 数组位置当业务身份 | 用 ID + 函数 ref / Map |
练习:做一个可访问的“跳到错误字段”功能
在 src/App.vue 创建“姓名 + 邮箱”表单:姓名去除两端空格后至少 2 个字符,邮箱必须包含一个位于首尾之间的 @。错误对象是唯一校验状态;为两个输入各建一个 useTemplateRef(),不得通过 input.value 读取业务数据。
提交时按“姓名 → 邮箱”的顺序找到第一个错误;先写入错误状态,再 await nextTick(),最后调用该输入的 focus() 与 scrollIntoView()。合法提交清空旧错误且不移动焦点;若条件分支让目标在等待期间消失,可选链必须安全结束。
参考答案:查看第一章练习答案中的第 08 题。
验收清单:如何判断练习完成
- 两项都空时聚焦姓名;姓名为
小白、邮箱为bad时聚焦邮箱;两项合法时显示成功且焦点不被强行移动。 - 错误字段不存在或条件切换时不会因空 ref 报错;
nextTick()只等待 Vue 本轮 DOM 提交,不是任意延时。 - 提示文字与校验结果由状态驱动,没有直接改 DOM 文本;暴露给父组件的 ref 方法保持在必要的最小范围。
通关自检:你能区分“用状态描述 DOM”与“必须命令式调用浏览器 API”,而且会让后者保持小而可控。