10|Vue 3.5:引用、清理、稳定 ID 与水合差异
第四章概览 / 10。适用:Vue 3.5+。目标:知道新 API 缩短了哪段代码,也知道旧责任仍归谁。
四个 API,四条不同边界
| API | 3.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 请求隔离的基本规则。
最小可运行例子:只允许最后一次搜索写入
<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 之后:
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 只抑制警告
<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 题。