乐于分享
好东西不私藏

Vue 3.5 新特性:那些文档里容易被忽略的宝藏 API

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([  { id1name'Alice' },  { id2name'Bob' },  { id3name'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(服务端渲染)时,你是否遇到过这样的警告?

[Vue warn]: Hydration children mismatch in>: server rendered element contains fewer child nodes than client vdom.

这个问题经常出现在表单元素的 ID 生成上。比如:

// ❌ 旧写法:SSR 时会生成不一致的 IDconst inputId = Math.random().toString(36).substr(29)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)    }  }}, { immediatetrue })</script>

⚠️ 重要提醒onWatcherCleanup 必须在 await 之前调用,否则不会生效。如果需要在 await 之后清理,请继续使用 onCleanup 参数形式。


4. data-allow-mismatch:优雅处理不可避免的 Hydration 警告

什么时候会出现 Hydration Mismatch

有些数据天然就是客户端和服务端不同的,比如:

  • 时间戳
    new Date().toLocaleString() 在服务端和客户端的时区可能不同
  • 随机数
    Math.random() 服务端和客户端必然不同
  • 本地化内容
    :日期、数字的格式化方式因地区而异

旧方案:忽略警告

// 控制台里一堆红色警告…[Vue warn]: Hydration text content mismatch on 

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
仅允许 class 属性不匹配
style
仅允许 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

API
推荐使用场景
useTemplateRef
需要获取 DOM 引用,尤其在 v-for 中动态绑定 ref 时
useId
表单元素、ARIA 属性、任何需要在 SSR 中保持稳定的 ID
onWatcherCleanup
watch/watchEffect 中的清理逻辑(取消请求、清除定时器等)
data-allow-mismatch
不可避免的 SSR hydration 不匹配(如时间戳、随机数)

升级建议

Vue 3.5 完全向后兼容,升级零风险:

npm install vue@^3.5.0 npm install @vue/language-tools@^2.1.0 -D

可选:使用官方 codemod 一键迁移:

npx @vue/codemod upgrade-3.5

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