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>

这里存在三个不同层次:

  1. ref="inputElement" 是模板中的引用名称;
  2. <input> 渲染后对应一个真实的 HTMLInputElement
  3. <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()
})

这里有两个条件同时成立:

  1. onMounted() 回调执行时,当前组件的 DOM 已经完成初次挂载;
  2. ?. 仍然必要,因为元素可能受到 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-ifv-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() 时:

  1. visiblefalse 变为 true
  2. Vue 安排一次组件更新;
  3. nextTick() 等待这次更新结束;
  4. <div> 被创建并挂载;
  5. 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 时,组件实例上通常会暴露组件选项中的公开属性和方法,例如 datacomputedmethods 等。这些 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()

这样设计的因果关系是清楚的:

  • 资料内容属于业务状态,因此使用 propsemit
  • 聚焦输入框是瞬时命令,因此使用公开方法;
  • 父组件不读取子组件内部输入框;
  • 子组件可以在未来替换内部输入框实现,只要继续保留 focusName() 协议,父组件不必修改。

editorVisiblefalse 时,子组件被卸载:

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
    }

关键路径如下:

  1. setup() 阶段:引用通常是 null
  2. onMounted() 阶段:当前组件的初次 DOM 挂载已完成,可以访问已挂载的引用;
  3. 响应式更新阶段:引用可能因 v-if 或动态组件变化而指向新对象、旧对象或 null
  4. onUnmounted() 阶段:组件已经从 DOM 中移除,不能再把引用当作可用节点;
  5. 卸载后:应停止使用旧引用,并清理依赖该节点的第三方资源。

常用钩子的边界如下:

生命周期 模板引用的一般状态 适合做什么
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:onUpdatednextTick 的区别

如果状态变化会改变模板结构,需要等待 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 中时运行,应在 onActivatedonDeactivated 中管理,而不是只依赖 onMountedonUnmounted


十四、Teleport 不改变引用对象,但改变 DOM 所在位置

<template>
  <Teleport to="body">
    <div ref="dialogElement">对话框</div>
  </Teleport>
</template>

dialogElement 仍然引用真实的 HTMLDivElement,但该元素实际位于 body,而不是当前组件原本的 DOM 位置。

因此:

dialogElement.value?.getBoundingClientRect()

读取的是它在最终文档位置中的布局结果。

这会影响:

  • CSS 继承和定位上下文;
  • 祖先元素选择器;
  • 事件和遮罩层处理;
  • 第三方库对父节点的假设。

模板引用跟踪的是节点对象,而不是“模板文本中看起来的父子位置”。如果代码依赖 DOM 层级,应检查 Teleport 后的真实结构。


十五、SSR 边界:服务器没有浏览器 DOM

在服务端渲染中,服务器执行组件代码时没有浏览器的 windowdocument 和真实 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>

这里的生命周期因果关系是:

  1. onMounted() 前容器可能不存在,不能初始化;
  2. 初始化后,第三方库持有 DOM 和事件监听器;
  3. 组件卸载前,调用库提供的销毁方法;
  4. 把变量设回 null,避免继续误用旧实例。

如果容器由 v-if 控制,还需要在每次重新创建容器后重新初始化,而不能假设一次 onMounted() 永久覆盖所有节点:

watch(visible, async value => {
  if (value) {
    await nextTick()
    // 初始化新创建的容器
  } else {
    // 销毁旧实例
  }
})

具体是否需要 watchnextTickonUpdated,取决于第三方库的初始化条件。核心原则是:第三方实例的生命周期必须与它绑定的真实 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

诊断顺序:

  1. 子组件是否使用 <script setup>
  2. 方法是否写入 defineExpose({ ... })
  3. defineExpose 是否在顶层执行?
  4. 是否调用了错误的组件引用?
  5. 子组件是否仍然被 v-if 卸载?
  6. 动态组件当前是否已经切换?

子组件:

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 --> 子组件公开实例

两条通道适合不同问题。

适合 propsemit 的情况

例如:

  • 表单值;
  • 加载状态;
  • 是否展开;
  • 错误信息;
  • 选中项;
  • 用户提交结果。

这些都是持续存在的状态,应该由数据流表达。

适合模板引用和 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、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。