Skip to content

10|Vue 3.5:引用、清理、稳定 ID 与水合差异 ​

第四章概览 / 10。适用:Vue 3.5+。目标:知道新 API 缩短了哪段代码,也知道旧责任仍归谁。

四个 API,四条不同边界 ​

API3.5+ 合同旧版或替代方案
useTemplateRef(key)返回与同名模板 ref 同步的只读浅 ref;静态 SFC ref 可配合 language-tools 2.1+ 推断类型ref(null),变量名与模板 ref 对应;普通 setup 还要 return
onWatcherCleanup(fn)当前 watcher 即将重跑或停止前清理;必须在回调同步阶段、await 前登记watcher 回调参数 onCleanup(fn),Vue 3 可用且绑定当前 watcher
useId()生成应用内唯一、SSR / CSR 稳定的表单与 ARIA ID从 props / 请求状态传入确定 ID,或使用框架的稳定 ID 能力
data-allow-mismatch选择性抑制已知水合差异警告修复两端数据;旧版没有等价的安全“忽略”属性

这些 API 不改变同步注册、实例归属、每次调用隔离、Abort 与 SSR 请求隔离的基本规则。

最小可运行例子:只允许最后一次搜索写入 ​

vue
<script setup lang="ts">
import {
  onMounted,
  onWatcherCleanup,
  ref,
  useId,
  useTemplateRef,
  watch
} from 'vue'

interface SearchItem {
  id: string
  label: string
}

const fieldId = useId()
const input = useTemplateRef<HTMLInputElement>('search-input')
const keyword = ref('')
const results = ref<SearchItem[]>([])
const error = ref<string | null>(null)
const loading = ref(false)
let runId = 0

onMounted(() => input.value?.focus())

watch(keyword, async (rawValue) => {
  const value = rawValue.trim()
  const currentRun = ++runId

  if (!value) {
    results.value = []
    error.value = null
    loading.value = false
    return
  }

  const controller = new AbortController()
  onWatcherCleanup(() => {
    controller.abort()
    if (currentRun === runId) runId += 1
  })
  loading.value = true
  error.value = null

  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`, {
      signal: controller.signal
    })
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    const nextResults: SearchItem[] = await response.json()
    if (currentRun === runId) results.value = nextResults
  } catch (cause) {
    if (!controller.signal.aborted && currentRun === runId) {
      error.value = cause instanceof Error ? cause.message : String(cause)
    }
  } finally {
    if (currentRun === runId) loading.value = false
  }
})
</script>

<template>
  <label :for="fieldId">搜索</label>
  <input :id="fieldId" ref="search-input" v-model="keyword" />
  <p v-if="loading">加载中…</p>
  <p v-else-if="error" role="alert">{{ error }}</p>
  <ul v-else>
    <li v-for="item in results" :key="item.id">{{ item.label }}</li>
  </ul>
</template>

onWatcherCleanup() 在第一个 await 前同步登记,因此属于当前 watcher 轮次。新关键词使旧轮次失效并 Abort 请求;runId 再防止不能及时取消的 fetcher 或旧 finally 覆盖当前状态。组件卸载会停止同步创建的 watcher,当前轮清理也会执行。

每个 SearchField 实例都有自己的 refs、runId 和 watcher。若把 runId 或 results 放到模块顶层,两个组件乃至两个 SSR 请求就可能互相干扰。

useTemplateRef() 仍然可能是 null ​

新 API 改善命名与类型,不改变 DOM 生命周期。挂载前、v-if 移除后和卸载后,input.value 都可能为 null。异步显示节点时仍要 await nextTick();观察动态节点时仍要在 watcher cleanup 中断开 ResizeObserver。

useTemplateRef() 也可以引用子组件,但 <script setup> 子组件默认只暴露 defineExpose() 白名单。它不替代 props / emits。

onWatcherCleanup() 的同步门槛 ​

下面写法无效,因为登记发生在 await 之后:

ts
watch(id, async value => {
  const task = await createTask(value)
  onWatcherCleanup(task.cancel) // 太晚,当前同步登记窗口已结束
})

把可取消资源在等待前创建并立即登记。若维护 Vue 3.4 或确实要在等待后的代码中登记,使用 watcher 回调传入的 onCleanup;它绑定 watcher 实例,不受 onWatcherCleanup 的同步调用限制。但越晚登记,资源在前半段失效时越难管理,所以仍优先早登记。

useId() 管关联,不管业务身份 ​

useId() 适合 label / input、aria-describedby、标题区域等 ID。多次调用和多个组件实例会得到不同 ID,并保证 SSR 与客户端渲染稳定。一个页面有多个 Vue app 时可设置 app.config.idPrefix 避免应用间冲突。

它不适合作为 v-for 的 key:列表 key 要在重排、插入和删除时跟随同一业务项。也不要在 computed() 中调用 useId(),应在 setup 顶层创建一次,再由 computed 引用。

data-allow-mismatch 只抑制警告 ​

vue
<time data-allow-mismatch="text">{{ localTime }}</time>

允许值是 text、children(仅直接子节点)、class、style、attribute;不写值会允许所有类型。它不修复 DOM,也不提供安全保证。只有像服务器无法知道用户时区、而差异已被隔离在文本中的场景才考虑使用。

以下情况不应抑制:认证用户名、权限按钮、列表数量、随机 ID、无效 HTML 嵌套、请求间状态污染。这些差异会改变含义或结构,应统一首屏数据与渲染条件。

浏览器与 SSR 边界 ​

主例的 useId()、refs 和 watcher 可以在服务端 setup 中建立;模板 ref 保持 null,onMounted 不执行。输入事件和浏览器 fetch 只在客户端交互后发生。若使用 immediate: true 在 SSR 中请求,就要改成可在服务端运行的 fetcher、绑定请求上下文并序列化结果,不能偷偷依赖浏览器相对 URL。

练习:搜索框的四重合同 ​

完成 SearchField.vue:

  • 3.5+ 使用 useTemplateRef 自动聚焦;补一段 3.4 的 ref(null) 兼容写法。
  • 每个非空关键词发起请求;新输入与卸载会 Abort 旧请求,Abort 不显示错误,HTTP / 解析失败显示错误。
  • 用轮次标识保证旧结果和旧 finally 都不能覆盖当前 data / loading。
  • label 与 input 使用 useId();列表 key 必须使用 item.id,并解释两种身份为何不同。
  • 选择一个真正局部、不可避免的文本水合差异演示 data-allow-mismatch="text",再列出一个绝不能抑制的结构差异。
  • 用网络节流或受控 Promise 验证三次连续输入,只保留最后结果;两个组件实例互不共享状态。

参考答案:查看第 10 题。

用心记录每一步成长