Vue 3.5 新特性:那些文档里容易被忽略的宝藏 API
今天聊聊——那些文档里容易被忽略的宝藏 API。它们虽然不像响应式重构那样”重磅”,但在日常开发中却能实实在在地解决痛点。
让我带你一起探索这四个被低估的 Vue 3.5 新特性:
1. useTemplateRef:终于可以和字符串 ref 说再见了
传统写法的痛点
在 Vue 3.5 之前,我们获取模板 refs 需要这样做:
<scriptsetup>import { ref, onMounted } from 'vue'// ⚠️ 变量名必须与模板中的 ref 属性值完全匹配const myInput = ref(null)onMounted(() => {myInput.value?.focus()})</script><template><!-- ref 属性值必须叫 "myInput" --><inputref="myInput"type="text" /></template>
这种写法有几个明显的问题:
- 命名强耦合
:script 和 template 中的命名必须完全一致 - 类型推断不友好
:IDE 只能推断为 Ref<any> - 不支持动态 ref
:无法在 v-for 循环中动态绑定不同元素的 ref
useTemplateRef 的优雅解法
Vue 3.5 引入了 useTemplateRef,彻底解决了这些问题:
<scriptsetup>import { useTemplateRef, onMounted } from 'vue'// 通过字符串 ID 获取 ref,与变量名完全解耦const inputRef = useTemplateRef('my-input')onMounted(() => {// 更精准的类型推断inputRef.value?.focus()})</script><template><inputref="my-input"type="text" /></template>
动态 ref 绑定(v-for 场景)
这是 useTemplateRef 最大的亮点——支持动态 ref:
<scriptsetup>import { ref, useTemplateRef } from 'vue'const items = ref([{ id: 1, name: 'Alice' },{ id: 2, name: 'Bob' },{ id: 3, name: 'Charlie' }])// 动态获取每个 item 的 refconst getItemRef = (id: number) => useTemplateRef(`item-${id}`)const focusItem = (id: number) => {const el = getItemRef(id)el.value?.scrollIntoView({ behavior: 'smooth' })}</script><template><divv-for="item in items":key="item.id"><div:ref="`item-${item.id}`">{{ item.name }}</div><button @click="focusItem(item.id)">滚动到此处</button></div></template>
TypeScript 支持
配合 @vue/language-tools 2.1+,你还能获得自动补全和类型检查:
// IDE 会根据模板中的 ref="my-input" 自动推断类型const inputRef = useTemplateRef<HTMLInputElement>('my-input')
💡 提示:useTemplateRef 通过运行时字符串 ID 匹配 refs,因此完美支持动态 ref 绑定,这是旧写法无法实现的。
2. useId:SSR 场景下的 ID 生成神器
SSR 的 Hydration Mismatch 噩梦
在做 SSR(服务端渲染)时,你是否遇到过这样的警告?
这个问题经常出现在表单元素的 ID 生成上。比如:
// ❌ 旧写法:SSR 时会生成不一致的 IDconst inputId = Math.random().toString(36).substr(2, 9)const labelId = `label-${inputId}`
服务端和客户端生成的随机 ID 完全不同,导致 hydration 失败。
useId 的稳定保证
Vue 3.5 的 useId 专门解决这一问题:
<scriptsetup>import { useId } from 'vue'// 生成的 ID 格式如:v-0-1-2-3const inputId = useId()const checkboxId = useId()</script><template><form><div><label:for="inputId">用户名</label><input:id="inputId"type="text" /></div><div><label:for="checkboxId">记住我</label><input:id="checkboxId"type="checkbox" /></div></form></template>
生成的 HTML(SSR 与客户端完全一致)
<!-- 服务端渲染 --><form><div><labelfor="v-0-1">用户名</label><inputid="v-0-1"type="text" /></div></form><!-- 客户端 hydration --><!-- 客户端生成的 ID 完全相同!--><form><div><labelfor="v-0-1">用户名</label><inputid="v-0-1"type="text" /></div></form>
自定义 ID 前缀
可以通过 app.config.idPrefix 来自定义前缀:
// main.tsimport { createApp } from 'vue'import App from './App.vue'const app = createApp(App)app.config.idPrefix = 'my-app'app.mount('#app')
这样生成的 ID 就会变成 my-app-0-1-2-3 的格式。
与 UI 库配合
很多 UI 组件库(如 Element Plus、Radix Vue)已经在内部使用 useId 来生成无障碍属性(ARIA)。如果你在使用这些库做 SSR,确保使用 createSSRApp 而不是 createApp:
// 服务端import { createSSRApp } from 'vue'import App from './App.vue'const app = createSSRApp(App)// 客户端import { createSSRApp } from 'vue'import App from './App.vue'const app = createSSRApp(App)app.mount('#app')
3. onWatcherCleanup:watch 清理逻辑的终极形态
传统onInvalidate的局限
在 Vue 3.5 之前,watch 的清理只能通过回调函数的第三个参数:
watch(userId, (newId, oldId, onCleanup) => {const controller = new AbortController()fetch(`/api/users/${newId}`, { signal: controller.signal }).then(r => r.json()).then(data => {// 处理数据})// ⚠️ 需要在 async 操作之前调用onCleanup(() => {controller.abort()})})
这种写法有两个问题: 1. 只能注册一个清理函数:如果有多次清理,后面的会覆盖前面的 2. 语义不够直观:onCleanup 作为参数传入,不够”声明式”
onWatcherCleanup的改进
Vue 3.5 引入了全局可用的 onWatcherCleanup:
<scriptsetup>import { ref, watch, onWatcherCleanup } from 'vue'const userId = ref(1)const userData = ref(null)watch(userId, (newId) => {const controller = new AbortController()fetch(`/api/users/${newId}`, { signal: controller.signal }).then(r => r.json()).then(data => {userData.value = data})// ✅ 声明式清理,更直观onWatcherCleanup(() => {controller.abort()})})</script>
多个清理函数(Vue 3.5 新能力)
这是 onWatcherCleanup 相比 onInvalidate 的重大升级——可以注册多个清理函数:
<scriptsetup>import { ref, watch, onWatcherCleanup } from 'vue'const searchQuery = ref('')const results = ref([])const isLoading = ref(false)watch(searchQuery, (query) => {if (!query) return// 多个独立的清理逻辑const debounceTimer = setTimeout(() => {performSearch(query)}, 300)onWatcherCleanup(() => clearTimeout(debounceTimer))const controller = new AbortController()onWatcherCleanup(() => controller.abort())// 每个 onWatcherCleanup 都会被调用})</script>
典型场景:取消请求
<scriptsetup>import { ref, watch, onWatcherCleanup } from 'vue'const categoryId = ref('all')const products = ref([])watch(categoryId, async (newCategory) => {const controller = new AbortController()// ⚠️ 重要:必须在 await 之前注册清理onWatcherCleanup(() => {controller.abort()console.log('请求已取消')})try {const response = await fetch(`/api/products?category=${newCategory}`,{ signal: controller.signal })products.value = await response.json()} catch (e) {if (e.name !== 'AbortError') {console.error('获取商品失败', e)}}}, { immediate: true })</script>
⚠️ 重要提醒:onWatcherCleanup 必须在 await 之前调用,否则不会生效。如果需要在 await 之后清理,请继续使用 onCleanup 参数形式。
4. data-allow-mismatch:优雅处理不可避免的 Hydration 警告
什么时候会出现 Hydration Mismatch
有些数据天然就是客户端和服务端不同的,比如:
- 时间戳
:new Date().toLocaleString() 在服务端和客户端的时区可能不同 - 随机数
:Math.random() 服务端和客户端必然不同 - 本地化内容
:日期、数字的格式化方式因地区而异
旧方案:忽略警告
data-allow-mismatch 的优雅解决方案
Vue 3.5 允许你明确告诉 Vue:”这个值我知道会不一致,别报警告了”:
<scriptsetup>import { ref, onMounted } from 'vue'const currentTime = ref('')const randomValue = ref(0)// 客户端专属的数据onMounted(() => {currentTime.value = new Date().toLocaleString('zh-CN')randomValue.value = Math.floor(Math.random() * 100)})</script><template><div><!-- 允许文本内容不匹配 --><spandata-allow-mismatch>{{ currentTime }}</span><!-- 允许 children 不匹配 --><divdata-allow-mismatch="children">随机数:{{ randomValue }}</div><!-- 允许特定属性不匹配 --><spandata-allow-mismatch="style">生成于 {{ new Date().toLocaleString() }}</span></div></template>
限制不匹配的类型
data-allow-mismatch 可以接受以下值来精确控制:
|
|
|
|---|---|
|
|
|
| text |
|
| children |
|
| class |
|
| style |
|
| attribute |
|
实际应用场景
<template><!-- 时间显示(时区不同)--><timedata-allow-mismatch="text">{{ formatDate(new Date()) }}</time><!-- 服务端禁用,客户端启用的功能 --><buttondata-allow-mismatch @click="handleClick">随机挑战:{{ randomNumber }}</button><!-- 性能指标(每次都变)--><divdata-allow-mismatch="children">内存使用:{{ memoryUsage }}MB</div></template><scriptsetup>const formatDate = (date) => {return new Intl.DateTimeFormat('zh-CN', {dateStyle: 'full',timeStyle: 'medium'}).format(date)}</script>
💡 最佳实践:不要滥用 data-allow-mismatch,只有在真正无法避免不匹配时才使用。大多数情况下,应该使用条件渲染(v-if + onMounted)或 <ClientOnly> 组件来解决。
总结:什么时候该用这些 API
|
|
|
|---|---|
| useTemplateRef |
|
| useId |
|
| onWatcherCleanup |
|
| data-allow-mismatch |
|
升级建议
Vue 3.5 完全向后兼容,升级零风险:
可选:使用官方 codemod 一键迁移:
Vue 3.5 的这些宝藏 API 虽然不如响应式重构那样”耀眼”,但在日常开发中却能实实在在地提升开发体验和代码质量。希望这篇文章能帮助你发现一些之前忽略的好东西!
如果觉得有帮助,欢迎点赞、转发。我们下期再见!
注:本文由AI辅助生成
📚 相关资源
https://blog.vuejs.org/posts/vue-3-5, https://vuejs.org/guide/essentials/template-refs, https://vuejs.org/guide/scaling-up/ssr#client-hydration
夜雨聆风