06|模板引用与组合式函数
第四章概览 / 06。目标:把
null当作模板 ref 的正常状态,区分 DOM ref、组件 ref 与公开能力,并写出 Vue 3.5 / 旧版分支。
模板 ref 是一段时间关系
模板 ref 不是“永远装着 DOM 的盒子”。首次渲染前它是 null;对应节点挂载后才有值;v-if 移除节点、组件卸载或动态分支切换时又会回到 null。SSR 没有真实 DOM,服务端也只会看到空值。
Vue 3.5+ 推荐 useTemplateRef(key)。它返回只读的浅层 ref,Vue 根据模板中同名 ref 属性写入 DOM 元素或组件公开实例。
最小可运行例子:节点出现后再聚焦
<script setup lang="ts">
import { nextTick, ref, useTemplateRef } from 'vue'
const visible = ref(false)
const query = ref('')
const input = useTemplateRef<HTMLInputElement>('filter-input')
async function openAndFocus(): Promise<boolean> {
visible.value = true
await nextTick()
if (!input.value) return false
input.value.focus()
return document.activeElement === input.value
}
function close() {
visible.value = false
}
</script>
<template>
<button type="button" @click="openAndFocus">打开筛选</button>
<button type="button" @click="close">关闭筛选</button>
<label v-if="visible">
关键词
<input ref="filter-input" v-model="query" />
</label>
</template>visible.value = true 只安排更新,nextTick() 等待这轮 DOM 提交。函数返回布尔值,让“节点未出现”成为可测试失败路径,而不是用非空断言把它藏掉。document 只在点击事件执行,服务端渲染不会触发这个函数。
在 composable 中保留实例归属
useTemplateRef() 要和当前组件模板建立关联,因此包含它的 composable 应在 <script setup> / setup() 的同步调用链中调用:
import { nextTick, useTemplateRef } from 'vue'
export function useFocusTarget(key: string) {
const target = useTemplateRef<HTMLElement>(key)
async function focus(): Promise<boolean> {
await nextTick()
if (!target.value) return false
target.value.focus()
return document.activeElement === target.value
}
return { target, focus }
}const { focus } = useFocusTarget('filter-input') // 同步建立关联不要在点击后才调用 useFocusTarget();点击事件发生时 setup 已结束,当前实例归属不再可靠。事件只应调用同步阶段已经创建好的 focus()。
Vue 3.4 及更早版本
旧版没有 useTemplateRef(),声明一个与模板 ref 名称配套的普通 ref:
const input = ref<HTMLInputElement | null>(null)<input ref="input" />在 <script setup> 中同名即可;在普通 setup() 中还要把 input 返回给模板。旧写法的变量本身可写,因此更要把它当作由 Vue 管理的句柄,不在业务代码中随意赋值。
Vue 3.5 配合 @vue/language-tools 2.1+ 时,静态模板 ref 在 SFC 中通常可以按对应元素自动推断类型;动态组件、泛型组件或复杂分支仍可能需要显式泛型。
组件 ref 只看得到公开实例
DOM ref 得到元素;组件 ref 得到子组件公共实例。<script setup> 子组件默认私有,只有 defineExpose() 的白名单可见:
<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" /></template>父组件得到的是 { focus() },不是子组件全部 refs。defineExpose() 必须在任何 await 前执行。若父组件只是要传值或接收变化,优先 props / emits;组件 ref 适合聚焦、播放、测量这类天然命令式能力。
观察 ref 时也要处理离开
条件渲染会让 ref 在元素与 null 之间变化。需要建立 ResizeObserver 等资源时,watcher 的每轮清理必须跟着元素身份走:
watchEffect((onCleanup) => {
const element = input.value
if (!element) return
const observer = new ResizeObserver(reportSize)
observer.observe(element)
onCleanup(() => observer.disconnect())
}, { flush: 'post' })flush: 'post' 让回调在本组件 DOM 更新后读取 ref。同步创建的 watcher 归属当前实例并会在卸载时停止;onCleanup 还保证同一实例内 v-if 换节点时,旧 observer 先断开。
失败路径
- 在 setup 根部直接
input.value!.focus():首次执行必然还没有 DOM。 - 只在
onMounted()访问一次:节点若一开始被v-if="false"隐藏,mounted 时仍是null。 - 对可空 ref 使用长期非空断言:切换分支后会把合法的
null变成运行时异常。 - 暴露整个子组件:父组件与私有字段耦合,重构会无故破坏调用方。
- SSR 顶层读取
document.activeElement:服务端没有document。
练习:可关闭的筛选框
实现 FilterPanel.vue:
- Vue 3.5 主实现使用
useTemplateRef('filter-input');另写 3.4 兼容声明。 openAndFocus()打开v-if节点,等待一次 DOM 更新,成功聚焦返回true;节点未出现返回false。close()后 ref 最终回到null;再次打开仍能聚焦。- 为输入框附加 ResizeObserver,节点切换和组件卸载时都必须
disconnect()。 - SSR 首屏保持关闭,不读取
window/document;用焦点断言而不只肉眼观察。
参考答案:查看第 06 题。