Skip to content

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 元素或组件公开实例。

最小可运行例子:节点出现后再聚焦 ​

vue
<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() 的同步调用链中调用:

ts
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 }
}
ts
const { focus } = useFocusTarget('filter-input') // 同步建立关联

不要在点击后才调用 useFocusTarget();点击事件发生时 setup 已结束,当前实例归属不再可靠。事件只应调用同步阶段已经创建好的 focus()。

Vue 3.4 及更早版本 ​

旧版没有 useTemplateRef(),声明一个与模板 ref 名称配套的普通 ref:

ts
const input = ref<HTMLInputElement | null>(null)
vue
<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() 的白名单可见:

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" /></template>

父组件得到的是 { focus() },不是子组件全部 refs。defineExpose() 必须在任何 await 前执行。若父组件只是要传值或接收变化,优先 props / emits;组件 ref 适合聚焦、播放、测量这类天然命令式能力。

观察 ref 时也要处理离开 ​

条件渲染会让 ref 在元素与 null 之间变化。需要建立 ResizeObserver 等资源时,watcher 的每轮清理必须跟着元素身份走:

ts
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 题。

用心记录每一步成长