这次更新的吸引力没有 3.4 那么大,首先最大的更新就是这个 props 解构了,这是一个编译器特性,能够追踪 props 结构出来的变量,然后把用到的地方自动添加 props. 前缀。

 1const { count = 0, msg = 'hello' } = defineProps<{
 2  count?: number
 3  message?: string
 4}>()
 5
 6function handle() {
 7    doSomething(count);
 8}

上面的代码等价于:

 1const props = withDefaults(defineProps<{
 2    count?: number;
 3    message?: string;
 4}>(), {
 5    count: 0,
 6    msg: "hello",
 7});
 8
 9function handle() {
10    doSomething(props.count);
11}

这个语法糖看似挺有用的,但细想一下就是个鸡肋。首先已经有 toRefs 可以将 props 转成一堆 ref,这次的新写法只是省了几个 props.,外加少创建几个 ref 对象而已,性能也没啥区别。

其次如果组件大一点,那么 setup 里头的变量和函数会相当多,这解构又把一堆变量从 props 命名空间拿到了顶层,搞得名字更混乱了,想必大家都知道给变量起名字是多烦人的事情。

最后该语法糖并不能满足所有的场景,在 Vue 的公告里也有提到这样的代码:

 1const { count = 0 } = defineProps<{ count?: number }>()
 2
 3
 4watch(count )
 5
 6
 7watch(() => count )

这就有点违反直觉了,要知道用 toRefs 解构 props 的话是可以直接监视解构出来的 ref。之所以这样是因为它会编译为 watch(props.count, ...)props.count是取出来的值而不是响应对象,无法监听。

新的 useId 函数返回一个实例级别的唯一 ID,这东西的感觉还是 SSR 才用得到,因为一般客户端只创建一个 Vue 实例,想要唯一 ID 的话都是整个全局整数然后每次取完加一。

而服务端渲染的话就会多次创建实例,如果使用全局变量则每次渲染都是不同的值,可能造成混合失败、以及缓存失效,而 useId 则可以避免此问题。

加上了该属性的元素在客户端混合时能忽略与 SSR 不一致的内容,直接用客户端渲染的结果覆盖。

说到 SSR 中的不一致内容,我遇到的都是时间相关的,因为 HTTP 请求头中没有客户端的时间信息,后端取不到,最终渲染的结果跟客户端的就不一致。在本站的文章页就有这个问题:

这是因为文章右下角有个时间要格式化,而服务端无法得知客户端的时区,导致渲染结果不同,这并不是什么大问题,但有个错误看着总是难受。而新版可以在元素上设置 data-allow-mismatch 来忽略该错误。

 1<time
 2    data-allow-mismatch
 3    :datetime='date.toISOString()'
 4>
 5    {{ data.toLocaleString() }}
 6</time>

虽然这个属性只跟 Vue 有关,但却会渲染到 HTML 上,成为一个多余的东西,有代码洁癖的我看着是真不爽。

以往要对 watch 函数加清理过程可以这样写,比如取消未完成的请求:

 1import { watch, onBeforeUnmount } from "vue";
 2
 3let controller = new AbortController();
 4
 5watch(xxx, (newId) => {
 6    controller.abort(); 
 7    controller = new AbortController();
 8
 9    fetch(`/api/${newId}`, { signal: controller.signal });
10});
11
12
13onBeforeUnmount(() => controller.abort());

这样写的问题是清理函数要写两遍,而且还要把controller放到顶层。有了新的 API 之后就可以这样了:

 1import { watch, onWatcherCleanup } from "vue";
 2
 3watch(xxx, (newId) => {
 4    const controller = new AbortController();
 5
 6    
 7    onWatcherCleanup(() => controller.abort());
 8
 9    fetch(`/api/${newId}`, { signal: controller.signal });
10});

onCleanup 的区别?

除此之外,watch 处理函数的最后一个参数可以接受一个回调,在清理时调用,这跟本次的新 API 功能是一样的,那么为什么还要加这个新函数呢?

我能想到的区别是解耦,就像 Composite API 和传统的选项 API 一样,新的写法支持将清理逻辑封装成可复用的函数,并同时注册多个清理函数。

记得 VueUse 里的很多函数返回的值都有暂停和恢复等方法,可以更细致的控制作用范围,此处更新中 Vue 自带的 watch 也支持这样做了。

 1export type WatchStopHandle = () => void;
 2
 3
 4export interface WatchHandle extends WatchStopHandle {
 5    pause: () => void;
 6    resume: () => void;
 7    stop: () => void;
 8}

在以前要暂停监视一段时间的话,要么取消然后再重新监视,要么搞个变量来跳过处理,不管怎样都要自己封一下,没法跟三方库组合。新版规范了暂停的接口,解决了这个问题。

<Teleport> 元素默认在挂载的时候就要拿到目标元素,这意味着如果挂载目标是它后面的元素,那渲染到它时还不存在,导致出错。新的 defer 属性指定 <Teleport> 在渲染完成后再去找目标元素,解决了这个问题。

 1<Teleport defer target="#container">...</Teleport>
 2<div id="container">后渲染的元素也能挂载到</div>

这功能我倒没用着,大部分情况用 <Teleport> 应该都是挂到全局节点,往组件里挂复杂度就高了不少。

本次更新的第二大功能非 useTemplateRef 莫属,简单来说该函数创建专门用于模版引用的 ref,可以动态决定元素绑到哪个 ref 上:

 1<template>
 2    <input type='text' :ref='refTarget' />
 3  <button @click="switchRef">切换</button>
 4</template>
 5
 6<script setup lang="ts">
 7import { useTemplateRef, shallowRef } from "vue";
 8
 9const refTarget = shallowRef("foo");
10const fooEl = useTemplateRef<HTMLInputElement>("foo");
11const barEl = useTemplateRef<HTMLInputElement>("bar");
12
13function switchRef() {
14  refTarget.value = refTarget.value === "foo" ? "bar" : "foo";
15}
16</script>

用法演示(掘金没法传视频)

像这样就能通过响应状态来决定<input>的 ref 是哪个,以前想实现同样的功能很是麻烦。

异步组件新增了一个 hydrate 属性,设为 hydrateOnVisible 使其仅在元素可见时才混合:

 1import { defineAsyncComponent, hydrateOnVisible } from 'vue'
 2
 3const AsyncComp = defineAsyncComponent({
 4  loader: () => import('./Comp.vue'),
 5  hydrate: hydrateOnVisible(),
 6
 7  
 8  
 9
10  
11  
12
13  
14  
15})

这个 API 比较底层,我的项目里没有适用的场景,但可以解决一些性能问题。

众所周知 Vue 3 把响应式的部分单独搞成了一个库@vue/reactivity,这样任何人都能够用它来构建自己的框架,该库包含refreactivea以及相关的辅助函数,但唯独缺了watch

从设计上看,watch用于监听响应对象,与 Vue 是无关的,它工作在更底层所以应当由@vue/reactivity导出。但实际上在 3.5 以前它却放在@vue/runtime-core里,这次终于给挪过来了。

WatchOptionsdeep 参数现在支持设为整数,用来指定监听的深度。

 1const state = reactive({
 2  a: {
 3    b: {
 4      c: {
 5        d: {
 6          e: 1
 7        }
 8      }
 9    }
10  }
11})
12
13watch(state, () => {
14    console.log('state changed')
15  },
16  { flush: 'sync', deep: 2 }
17)
18
19state.a.b = { c: { d: { e: 2 } } } 
20state.a.b.c = { d: { e: 3 } } 

本次优化据称降低了 56% 的内存占用,部分场景能达到 10 倍的性能提升,灵感来源于 Preact Signals 的链表实现。光凭这一点就该升级了,毕竟优化是白嫖的。

类型上也有写调整,比如 computed 支持 getter 和 setter 设为不同的类型

更新里还修复了 Custom Elements 的一堆问题,我没用到所以就不评价了。

这次的更新都是些小优化,没什么杀手级的特性,比起这些,我更期待的 Vapor、Suspense 也不知道今年能不能稳定。但毕竟没有 Breaking Change,升级还是无压力的。

个人笔记记录 2021 ~ 2025