Vue 基础体系 · 第 26/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 模板引用:元素、组件实例、defineExpose 和生命周期边界
模板引用(Template Ref)是 Vue 用来取得模板中某个真实 DOM 元素或子组件实例的机制。它解决的是“组件已经渲染后,如何命令式地访问这个具体对象”这一类问题。
例如:
- 获取
<input>,调用原生focus(); - 获取
<video>,调用play()或pause(); - 获取子组件公开的方法;
- 在第三方 DOM 库要求传入真实元素时,提供挂载节点。
模板引用不是普通的数据绑定,也不是跨组件状态管理。它有明确的生命周期边界:模板引用只有在对应节点已经创建后才可靠,在节点被移除后也可能重新变成 null。
本文示例基于 Vue 3、Composition API、<script setup>、TypeScript 和现代 Vite 工具链。示例使用 Vue 3 通用写法 ref(null);Vue 3.5 提供的 useTemplateRef() 会单独标注。
一、先区分三种对象:模板、DOM 元素和组件实例
假设有如下模板:
<template>
<input ref="inputElement" />
<UserEditor ref="editorComponent" />
</template>
这里存在三个不同层次:
ref="inputElement"是模板中的引用名称;<input>渲染后对应一个真实的HTMLInputElement;<UserEditor>渲染后对应的是一个 Vue 组件实例的公开接口,而不是组件内部的某个 DOM 元素。
因此,引用的类型取决于 ref 所在节点:
const inputElement = ref<HTMLInputElement | null>(null)
const editorComponent = ref<InstanceType<typeof UserEditor> | null>(null)
它们的 value 分别可能是:
inputElement.value
// HTMLInputElement | null
editorComponent.value
// UserEditor 对外公开的组件实例 | null
最重要的区别是:
- 原生元素引用允许调用浏览器 DOM API;
- 组件引用只能访问该组件对外公开的实例接口;
- 子组件内部模板中的 DOM 不会因为父组件拿到了子组件实例就自动暴露出来。
如果把组件引用误认为 DOM 引用,就会产生错误:
editorComponent.value?.focus()
除非 UserEditor 明确暴露了名为 focus 的方法,否则这里并不等价于对内部 <input> 调用 focus()。
二、模板引用的基本生命周期:为什么初始值必须是 null
在 setup() 执行时,组件的模板通常还没有挂载完成。此时:
const inputElement = ref<HTMLInputElement | null>(null)
初始值必须允许 null,因为真实元素尚不存在。
Vue 完成渲染并把元素插入 DOM 后,才会把该元素写入引用:
inputElement.value = HTMLInputElement
组件卸载或条件不再成立时,Vue 会清理这个引用:
inputElement.value = null
因此,下面的代码是不安全的:
const inputElement = ref<HTMLInputElement | null>(null)
inputElement.value.focus()
// 可能在 setup 执行期间触发:
// Cannot read properties of null
正确做法是把访问放在合适的生命周期中,并处理 null:
import { onMounted, ref } from 'vue'
const inputElement = ref<HTMLInputElement | null>(null)
onMounted(() => {
inputElement.value?.focus()
})
这里有两个条件同时成立:
onMounted()回调执行时,当前组件的 DOM 已经完成初次挂载;?.仍然必要,因为元素可能受到v-if、异步渲染、条件切换或卸载影响。
onMounted() 不是“永远能拿到所有模板引用”的保证。它只保证当前组件的挂载过程已经完成;如果引用所在节点由异步组件、Suspense、条件渲染或其他延迟过程控制,就仍然需要根据实际状态判断。
三、一个可运行的元素引用示例
下面的组件提供一个输入框,并在挂载后聚焦。按钮可以在任意时刻再次聚焦。
<!-- src/components/SearchBox.vue -->
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
const inputElement = ref<HTMLInputElement | null>(null)
const keyword = ref('')
onMounted(() => {
inputElement.value?.focus()
})
async function focusInput() {
// 如果调用发生在修改响应式状态之后,
// 先等待 Vue 完成下一轮 DOM 更新。
await nextTick()
inputElement.value?.focus()
}
</script>
<template>
<section>
<label>
关键词
<input
ref="inputElement"
v-model="keyword"
type="search"
placeholder="输入关键词"
/>
</label>
<button type="button" @click="focusInput">
聚焦输入框
</button>
<p>当前关键词:{{ keyword }}</p>
</section>
</template>
使用:
<!-- src/App.vue -->
<script setup lang="ts">
import SearchBox from './components/SearchBox.vue'
</script>
<template>
<SearchBox />
</template>
运行:
npm create vite@latest vue-template-ref-demo -- --template vue-ts
cd vue-template-ref-demo
npm install
npm run dev
把上述文件放入项目后,浏览器打开 Vite 输出的地址即可看到输入框。
为什么这里需要 nextTick()
Vue 的响应式更新通常不是在赋值发生的同一行立即同步写入 DOM。例如:
const visible = ref(false)
visible.value = true
// 此时由 v-if 创建的元素可能还没有出现在 DOM 中
await nextTick()
// 这一轮 Vue 更新完成后,元素才可能可用
因此,如果模板引用所在元素是由状态控制的,典型顺序是:
visible.value = true
await nextTick()
inputElement.value?.focus()
这不是模板引用本身的特殊规则,而是 Vue 的异步 DOM 更新规则:响应式状态先改变,渲染器随后批量执行更新。
四、v-if、v-show 与引用是否存在
1. v-if 会创建和销毁节点
<script setup lang="ts">
import { nextTick, ref } from 'vue'
const visible = ref(false)
const panelElement = ref<HTMLDivElement | null>(null)
async function openPanel() {
visible.value = true
await nextTick()
console.log(panelElement.value)
}
</script>
<template>
<button type="button" @click="openPanel">打开</button>
<div v-if="visible" ref="panelElement">
面板内容
</div>
</template>
调用 openPanel() 时:
visible从false变为true;- Vue 安排一次组件更新;
nextTick()等待这次更新结束;<div>被创建并挂载;panelElement.value指向该HTMLDivElement。
再次让 visible 变为 false 后,节点被销毁,引用会回到 null。
2. v-show 通常保留节点
<div v-show="visible" ref="panelElement">
面板内容
</div>
v-show 通常不会销毁元素,而是切换 CSS 的 display。所以引用一般仍然指向同一个元素,但元素可能当前不可见。
这说明:
- “引用存在”不等于“元素可见”;
- “元素可见”也不等于“元素已经完成布局”。
如果需要读取尺寸,除了确认引用非空,还要确认浏览器已经完成对应布局;通常应在 DOM 更新后读取:
await nextTick()
const height = panelElement.value?.getBoundingClientRect().height
如果涉及浏览器绘制后的视觉结果,还可能需要 requestAnimationFrame(),但这属于浏览器渲染时序,不是模板引用的额外 API。
五、ref 与响应式状态不是同一种用途
下面两个 ref 的外形相同:
const count = ref(0)
const buttonElement = ref<HTMLButtonElement | null>(null)
但它们表示的对象不同:
count是应用状态,描述业务数据;buttonElement是渲染对象的句柄,描述当前 DOM 节点。
业务状态通常通过模板声明式地表达:
<button :disabled="count >= 3">
已点击 {{ count }} 次
</button>
而模板引用适合少量命令式操作:
buttonElement.value?.focus()
不应把 DOM 当作业务状态来源:
// 不推荐:通过读取 DOM 文本来判断业务状态
const text = buttonElement.value?.textContent
如果内容由 Vue 状态产生,应直接读取状态:
const message = ref('准备中')
原因是 DOM 是状态的渲染结果,而不是可靠的状态源。手动修改 DOM 还可能在下一次 Vue 更新时被覆盖。
六、组件模板引用取得的是“组件实例的公开接口”
组件引用的目标不是子组件的根 DOM,而是子组件实例:
<script setup lang="ts">
import UserEditor from './components/UserEditor.vue'
import { ref } from 'vue'
const editorComponent = ref<InstanceType<typeof UserEditor> | null>(null)
</script>
<template>
<UserEditor ref="editorComponent" />
</template>
父组件得到的对象可以理解为:
editorComponent.value
它代表 UserEditor 这个组件实例能被父组件访问到的公共表面。
但“公共表面”取决于组件写法。
Options API 组件
使用 Options API 时,组件实例上通常会暴露组件选项中的公开属性和方法,例如 data、computed、methods 等。这些 API 仍然受组件实例代理规则影响,不应把内部实现细节当成稳定协议。
<script setup> 组件
使用 <script setup> 时,组件默认是闭合的。父组件通过模板引用不能自动访问子组件内部声明的变量和函数。
例如:
<!-- src/components/UserEditor.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const name = ref('')
function reset() {
name.value = ''
}
</script>
<template>
<input v-model="name" />
</template>
父组件不能直接这样调用:
editorComponent.value?.reset()
因为 reset 没有被公开。
七、defineExpose:显式定义子组件的命令式接口
defineExpose 是 <script setup> 提供的编译器宏,用于声明哪些属性或方法可通过组件引用访问。
改写子组件:
<!-- src/components/UserEditor.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const name = ref('')
function reset() {
name.value = ''
}
function focus() {
// 这里的模板引用指向子组件内部的 input
nameInput.value?.focus()
}
const nameInput = ref<HTMLInputElement | null>(null)
defineExpose({
reset,
focus,
})
</script>
<template>
<label>
姓名
<input ref="nameInput" v-model="name" />
</label>
</template>
父组件:
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import UserEditor from './components/UserEditor.vue'
const editorComponent = ref<InstanceType<typeof UserEditor> | null>(null)
function resetEditor() {
editorComponent.value?.reset()
}
function focusEditor() {
editorComponent.value?.focus()
}
</script>
<template>
<UserEditor ref="editorComponent" />
<button type="button" @click="resetEditor">
重置
</button>
<button type="button" @click="focusEditor">
聚焦姓名输入框
</button>
</template>
这里发生了两层引用:
父组件
└─ editorComponent
└─ 子组件 UserEditor 实例
└─ nameInput
└─ 子组件内部的 HTMLInputElement
父组件没有直接取得 nameInput。它只能调用子组件公开的 focus(),由子组件自己决定如何聚焦内部输入框。
这体现了组件边界:
- 子组件掌握内部 DOM;
- 子组件通过
defineExpose暴露稳定的命令; - 父组件依赖公开命令,而不是依赖子组件内部节点结构。
defineExpose 的暴露内容
可以暴露方法、普通值和响应式值:
const count = ref(0)
const label = 'editor'
defineExpose({
count,
label,
})
通过组件引用访问时,公开的响应式引用会遵循组件实例公开代理的访问规则,通常可以直接使用:
editorComponent.value?.count
而不是手动写 .value。不过在 TypeScript 中,具体类型推导仍取决于 Vue 版本、IDE 和组件类型推导能力;不能因为运行时代理可以自动解包,就假设任意工具链都能完美推导全部类型。
defineExpose 必须在顶层暴露
defineExpose 是编译器宏,不需要导入:
defineExpose({
reset,
})
如果使用顶层 await,官方要求在 await 之前调用 defineExpose,否则后续暴露内容可能无法按预期登记:
<script setup lang="ts">
import { ref } from 'vue'
const ready = ref(false)
defineExpose({
ready,
})
await loadSomething()
ready.value = true
</script>
这里的关键不是“方法必须写在前面”,而是公开接口登记需要发生在顶层异步边界之前。该行为属于 <script setup> 编译语义,应以当前 Vue 版本文档为准。
八、完整的父子组件示例:公开方法、错误边界和状态传递
下面实现一个编辑器。父组件通过 props 传入数据,通过 emit 接收变化;只有“聚焦”和“清空”这类命令式操作使用模板引用。
<!-- src/components/ProfileEditor.vue -->
<script setup lang="ts">
import { ref } from 'vue'
interface Profile {
name: string
email: string
}
const props = defineProps<{
modelValue: Profile
}>()
const emit = defineEmits<{
'update:modelValue': [value: Profile]
}>()
const nameInput = ref<HTMLInputElement | null>(null)
function updateName(name: string) {
emit('update:modelValue', {
...props.modelValue,
name,
})
}
function updateEmail(email: string) {
emit('update:modelValue', {
...props.modelValue,
email,
})
}
function focusName() {
nameInput.value?.focus()
}
function clear() {
emit('update:modelValue', {
name: '',
email: '',
})
}
defineExpose({
focusName,
clear,
})
</script>
<template>
<form @submit.prevent>
<label>
姓名
<input
ref="nameInput"
:value="modelValue.name"
@input="updateName(($event.target as HTMLInputElement).value)"
/>
</label>
<label>
邮箱
<input
:value="modelValue.email"
type="email"
@input="updateEmail(($event.target as HTMLInputElement).value)"
/>
</label>
</form>
</template>
父组件:
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import ProfileEditor from './components/ProfileEditor.vue'
interface Profile {
name: string
email: string
}
const profile = ref<Profile>({
name: 'Ada',
email: 'ada@example.com',
})
const editor = ref<InstanceType<typeof ProfileEditor> | null>(null)
const editorVisible = ref(true)
function focusName() {
if (!editor.value) {
console.warn('编辑器尚未挂载,当前无法聚焦')
return
}
editor.value.focusName()
}
function clearProfile() {
editor.value?.clear()
}
</script>
<template>
<button type="button" @click="editorVisible = !editorVisible">
{{ editorVisible ? '卸载编辑器' : '挂载编辑器' }}
</button>
<ProfileEditor
v-if="editorVisible"
ref="editor"
v-model="profile"
/>
<button type="button" @click="focusName">
聚焦姓名
</button>
<button type="button" @click="clearProfile">
清空资料
</button>
<pre>{{ profile }}</pre>
</template>
这个示例有两条不同的数据路径:
父组件 profile
└─ v-model / props ──> 子组件显示内容
子组件输入事件
└─ emit update:modelValue ──> 父组件更新 profile
而命令式路径是:
父组件按钮
└─ editor.value.focusName()
└─ 子组件 nameInput.value?.focus()
这样设计的因果关系是清楚的:
- 资料内容属于业务状态,因此使用
props和emit; - 聚焦输入框是瞬时命令,因此使用公开方法;
- 父组件不读取子组件内部输入框;
- 子组件可以在未来替换内部输入框实现,只要继续保留
focusName()协议,父组件不必修改。
当 editorVisible 为 false 时,子组件被卸载:
editor.value === null
所以父组件中的调用必须使用可选链或显式判断。否则会出现:
Cannot read properties of null
这不是 TypeScript 多余的限制,而是模板引用真实生命周期的反映。
九、生命周期边界:每个时刻能否访问什么
可以把模板引用的变化简化为以下状态流:
stateDiagram-v2
[*] --> Setup
Setup --> Mounted: 初次渲染完成
Mounted --> Updated: 响应式状态导致更新
Updated --> Mounted: 更新完成
Mounted --> Unmounted: v-if/路由/父组件导致卸载
Unmounted --> [*]
state Setup {
[*] --> RefNull
}
state Mounted {
[*] --> RefObject
}
state Unmounted {
[*] --> RefNullAgain
}
关键路径如下:
setup()阶段:引用通常是null;onMounted()阶段:当前组件的初次 DOM 挂载已完成,可以访问已挂载的引用;- 响应式更新阶段:引用可能因
v-if或动态组件变化而指向新对象、旧对象或null; onUnmounted()阶段:组件已经从 DOM 中移除,不能再把引用当作可用节点;- 卸载后:应停止使用旧引用,并清理依赖该节点的第三方资源。
常用钩子的边界如下:
| 生命周期 | 模板引用的一般状态 | 适合做什么 |
|---|---|---|
setup() |
通常为 null |
创建引用、定义方法 |
onBeforeMount() |
通常还不能依赖真实 DOM | 准备挂载前逻辑 |
onMounted() |
当前组件初次挂载完成 | 获取尺寸、聚焦、初始化 DOM 插件 |
onBeforeUpdate() |
旧 DOM 仍可能存在 | 读取更新前状态,但要谨慎 |
onUpdated() |
当前更新已完成 | 处理更新后的 DOM,但避免再次造成无穷更新 |
onBeforeUnmount() |
节点仍可能存在 | 销毁插件、移除监听器 |
onUnmounted() |
组件已卸载 | 清理剩余资源;不要再使用节点 |
“当前组件已挂载”不等于“所有后代和所有异步内容都已完成”。例如:
- 子组件的挂载发生在父组件
onMounted()之前; - 异步组件可能受异步加载影响;
<Suspense>会改变异步依赖的显示和完成时机;Teleport会把 DOM 移动到其他容器;KeepAlive缓存组件时可能停用而不是卸载。
因此,复杂组合场景不能只凭父组件的一个 onMounted() 推断所有引用都存在。
十、父子组件的挂载顺序
对于普通同步组件,挂载顺序可以概括为:
父组件开始挂载
└─ 创建父组件模板
└─ 挂载子组件
└─ 子组件 onMounted()
└─ 父组件 onMounted()
所以在普通场景下:
// Child.vue
onMounted(() => {
console.log('child mounted')
})
// Parent.vue
onMounted(() => {
console.log('parent mounted')
})
输出顺序通常是:
child mounted
parent mounted
这解释了为什么父组件的 onMounted() 通常可以访问同步子组件的模板引用。
但这不是“任何子组件都已完成”的无限保证。异步组件、Suspense 和条件渲染会引入额外边界。更稳妥的方式是:
- 由子组件在自身准备好后通过事件通知父组件;
- 或由父组件根据
v-if条件和nextTick()明确等待; - 不把“父组件挂载完成”当作整个页面所有异步资源完成的信号。
十一、更新后的 DOM:onUpdated 与 nextTick 的区别
如果状态变化会改变模板结构,需要等待 DOM 更新后再读取元素:
import { nextTick, ref } from 'vue'
const expanded = ref(false)
const panel = ref<HTMLElement | null>(null)
async function expand() {
expanded.value = true
await nextTick()
const rect = panel.value?.getBoundingClientRect()
console.log(rect)
}
nextTick() 等待的是当前组件更新队列中的下一次 DOM 刷新。
onUpdated() 则是组件每次更新完成后都会执行:
import { onUpdated } from 'vue'
onUpdated(() => {
console.log(panel.value)
})
两者用途不同:
- 一次操作后等待一次 DOM 更新:使用
nextTick(); - 监听组件所有更新完成:使用
onUpdated()。
不要在 onUpdated() 中无条件修改会导致当前组件更新的状态:
onUpdated(() => {
expanded.value = !expanded.value
})
这会不断触发更新,形成循环。即使使用模板引用,也不能绕过响应式更新的反馈关系。
十二、v-for 中的模板引用:数组不是稳定的业务标识
在循环中使用相同的字符串引用:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const items = ref(['A', 'B', 'C'])
const itemElements = ref<HTMLElement[]>([])
onMounted(() => {
console.log(itemElements.value)
})
</script>
<template>
<li
v-for="item in items"
:key="item"
ref="itemElements"
>
{{ item }}
</li>
</template>
Vue 会收集多个匹配节点,itemElements.value 通常是元素数组。
但不能把数组下标当成稳定身份:
itemElements.value[0]
如果列表插入、删除或重排,下标与业务对象的对应关系可能变化。key 帮助 Vue 识别虚拟节点身份,但它不会自动把模板引用数组变成按 key 索引的映射。
如果必须按业务 ID 操作元素,可以使用函数引用自行维护映射:
<script setup lang="ts">
import { onBeforeUpdate, ref } from 'vue'
const itemElements = ref(new Map<string, HTMLElement>())
function setItemElement(id: string, element: Element | null) {
if (element instanceof HTMLElement) {
itemElements.value.set(id, element)
} else {
itemElements.value.delete(id)
}
}
onBeforeUpdate(() => {
itemElements.value.clear()
})
</script>
<template>
<ul>
<li
v-for="item in [
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
]"
:key="item.id"
:ref="element => setItemElement(item.id, element)"
>
{{ item.label }}
</li>
</ul>
</template>
这里的 :ref 是函数引用。Vue 在节点创建、更新和卸载时调用这个函数;卸载时会传入 null。通过 Map 保存节点时,必须删除旧引用,否则可能保留已经脱离 DOM 的元素。
这种方式适合确实需要按 ID 获取 DOM 的场景,不应仅为了避免写一个普通循环就引入额外映射。
十三、动态组件和组件引用类型
动态组件:
<component :is="currentComponent" ref="currentComponentRef" />
其引用对象可能随 currentComponent 改变而变化:
const currentComponentRef = ref(null)
如果当前组件从 EditorPanel 切换为 PreviewPanel,引用就不再代表同一个实例。父组件不能假设所有动态组件都提供相同方法。
更安全的设计是让动态组件共享一个明确协议,例如都暴露:
defineExpose({
refresh,
})
但仅靠运行时约定,TypeScript 未必能自动验证。若多个组件确实需要统一命令接口,可以在组件设计层规定相同的公开方法,并在切换后重新确认引用状态。
如果使用 <KeepAlive>:
<KeepAlive>
<component :is="currentComponent" ref="currentComponentRef" />
</KeepAlive>
组件切换时可能进入停用状态,而不是销毁。此时相关生命周期是:
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// 缓存组件重新激活
})
onDeactivated(() => {
// 组件暂时停用
})
停用和卸载不同:
- 停用:组件实例被缓存,状态通常保留;
- 卸载:组件实例销毁,后续引用可能变为
null。
如果第三方库必须只在元素实际处于活动 DOM 中时运行,应在 onActivated 和 onDeactivated 中管理,而不是只依赖 onMounted 和 onUnmounted。
十四、Teleport 不改变引用对象,但改变 DOM 所在位置
<template>
<Teleport to="body">
<div ref="dialogElement">对话框</div>
</Teleport>
</template>
dialogElement 仍然引用真实的 HTMLDivElement,但该元素实际位于 body,而不是当前组件原本的 DOM 位置。
因此:
dialogElement.value?.getBoundingClientRect()
读取的是它在最终文档位置中的布局结果。
这会影响:
- CSS 继承和定位上下文;
- 祖先元素选择器;
- 事件和遮罩层处理;
- 第三方库对父节点的假设。
模板引用跟踪的是节点对象,而不是“模板文本中看起来的父子位置”。如果代码依赖 DOM 层级,应检查 Teleport 后的真实结构。
十五、SSR 边界:服务器没有浏览器 DOM
在服务端渲染中,服务器执行组件代码时没有浏览器的 window、document 和真实 DOM 元素。模板引用不能在服务端阶段用于读取:
inputElement.value?.focus()
inputElement.value?.getBoundingClientRect()
这类操作应放在 onMounted() 中:
import { onMounted } from 'vue'
onMounted(() => {
inputElement.value?.focus()
})
onMounted() 不会在服务端渲染阶段执行,因此适合放置只允许浏览器运行的 DOM 逻辑。
但还要注意:如果 SSR 首次输出的 HTML 与客户端首次渲染结果不一致,可能触发 hydration mismatch。模板引用本身不能修复这种结构不一致;应保证服务端和客户端首次渲染使用一致的数据和模板条件。
十六、第三方 DOM 库的初始化和销毁
模板引用常见于图表、编辑器、地图和拖拽库。这类库通常需要真实 DOM 容器:
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import Chart from 'some-chart-library'
const chartContainer = ref<HTMLDivElement | null>(null)
let chart: Chart | null = null
onMounted(() => {
if (!chartContainer.value) {
return
}
chart = new Chart(chartContainer.value, {
// 真实库的配置项
})
})
onBeforeUnmount(() => {
chart?.destroy()
chart = null
})
</script>
<template>
<div ref="chartContainer"></div>
</template>
这里的生命周期因果关系是:
onMounted()前容器可能不存在,不能初始化;- 初始化后,第三方库持有 DOM 和事件监听器;
- 组件卸载前,调用库提供的销毁方法;
- 把变量设回
null,避免继续误用旧实例。
如果容器由 v-if 控制,还需要在每次重新创建容器后重新初始化,而不能假设一次 onMounted() 永久覆盖所有节点:
watch(visible, async value => {
if (value) {
await nextTick()
// 初始化新创建的容器
} else {
// 销毁旧实例
}
})
具体是否需要 watch、nextTick 或 onUpdated,取决于第三方库的初始化条件。核心原则是:第三方实例的生命周期必须与它绑定的真实 DOM 节点生命周期同步。
十七、版本敏感能力:useTemplateRef
Vue 3.5 引入了 useTemplateRef(),可以通过模板引用名称获得更直接的类型推导:
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
const inputElement = useTemplateRef<HTMLInputElement>('inputElement')
onMounted(() => {
inputElement.value?.focus()
})
</script>
<template>
<input ref="inputElement" />
</template>
这里有两个字符串必须对应:
useTemplateRef<HTMLInputElement>('inputElement')
<input ref="inputElement" />
useTemplateRef 属于 Vue 3.5 及更高版本能力。如果项目版本较旧,应使用通用写法:
const inputElement = ref<HTMLInputElement | null>(null)
<input ref="inputElement" />
两种写法的核心生命周期规则相同:挂载前可能为 null,条件卸载后也可能为 null。新 API 改善的是声明方式和类型推导,不会改变模板引用的时序边界。
十八、常见失败表现与诊断路径
1. 初始访问时报 null
失败代码:
const dialog = ref<HTMLDivElement | null>(null)
dialog.value.classList.add('open')
诊断:
- 是否在
setup()中立即访问? - 是否由
v-if控制? - 是否刚修改状态但没有等待
nextTick()? - 是否组件已经被卸载?
修复:
await nextTick()
dialog.value?.classList.add('open')
但如果节点仍受 v-if 控制,还要确认条件确实为 true。
2. 父组件调用子组件方法却是 undefined
诊断顺序:
- 子组件是否使用
<script setup>? - 方法是否写入
defineExpose({ ... })? defineExpose是否在顶层执行?- 是否调用了错误的组件引用?
- 子组件是否仍然被
v-if卸载? - 动态组件当前是否已经切换?
子组件:
function refresh() {
// ...
}
defineExpose({ refresh })
父组件:
child.value?.refresh()
3. TypeScript 报 possibly null
这是正确提示,而不是应被简单关闭的错误:
child.value.refresh()
引用的生命周期决定它确实可能为 null。应使用:
child.value?.refresh()
或者在确定条件后显式判断:
if (!child.value) {
return
}
child.value.refresh()
非空断言:
child.value!.refresh()
只适合调用者已经通过结构和时序证明引用一定存在的局部场景。若 v-if、异步组件或动态组件仍可能改变引用,非空断言只是在隐藏真实故障。
4. 引用数组顺序不符合预期
诊断:
- 是否在
v-for中使用了同一个字符串引用? - 列表是否会插入、删除或排序?
- 是否错误地把数组下标当成业务 ID?
- 是否在更新过程中读取了尚未完成的数组?
如果需要稳定对应关系,使用函数引用和 Map,或者直接通过数据和事件解决问题,避免用 DOM 数组承载业务索引。
十九、模板引用与组件通信的边界
组件之间通常优先使用声明式通信:
父组件 -- props --> 子组件
父组件 <-- emits -- 子组件
模板引用引入的是另一条命令式通道:
父组件 -- template ref --> 子组件公开实例
两条通道适合不同问题。
适合 props 和 emit 的情况
例如:
- 表单值;
- 加载状态;
- 是否展开;
- 错误信息;
- 选中项;
- 用户提交结果。
这些都是持续存在的状态,应该由数据流表达。
适合模板引用和 defineExpose 的情况
例如:
focus();scrollToTop();open()或close()这种明确的命令;- 暴露给父组件调用的第三方实例控制方法。
这些通常是瞬时动作,而不是父组件需要持续持有的业务状态。
如果父组件为了读取子组件内部状态而暴露大量变量:
defineExpose({
internalLoading,
internalCache,
internalElement,
internalError,
// ...
})
组件边界会变得脆弱。子组件稍微重构内部实现,父组件就可能同时需要修改。defineExpose 的价值不在于“把内部全部公开”,而在于定义少量、明确、可维护的命令式接口。
二十、关键规则归纳
模板引用可以用以下形式理解:
模板节点存在
└─> ref.value 指向元素或组件公开实例
模板节点不存在
└─> ref.value 为 null
响应式状态改变节点结构
└─> 先更新状态
└─> 等待 nextTick()
└─> 再读取新的 ref.value
对于原生元素:
const element = ref<HTMLElement | null>(null)
对于组件:
const component = ref<InstanceType<typeof Child> | null>(null)
对于 <script setup> 子组件:
defineExpose({
publicMethod,
})
对于生命周期:
setup()不保证模板引用已经存在;onMounted()适合首次访问同步 DOM;- 状态改变后用
nextTick()等待本轮 DOM 更新; v-if、动态组件和异步边界可能让引用变为null或指向新对象;onBeforeUnmount()适合销毁依赖 DOM 的第三方实例;- SSR 阶段不能执行浏览器 DOM 操作;
defineExpose暴露的是组件公开接口,不是自动暴露内部 DOM。
模板引用的本质是一个受生命周期约束的命令式句柄。理解“它指向谁、何时可用、何时失效、组件边界暴露了什么”,比记住某个写法更重要。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 组件生命周期:挂载、更新、卸载、副作用和父子顺序
- 下一篇:Vue 调度器与 nextTick:批量更新、Flush 时机和 DOM 可见性
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论