前端知库

Vue 3.5 的 props 响应式解构和 useTemplateRef 改变了什么?

Vue 3.5 起,从 defineProps 解构出的变量会被编译器改写成 props.xxx,所以仍然响应式,还能用 JS 默认值语法代替 withDefaults;但传给 watch 或组合式函数时要包成 getter。useTemplateRef('name') 按运行时字符串匹配模板里的 ref 属性,不再要求变量名和 ref 名一致,动态 ref 也能用。

参考答案

简答

Vue 3.5 是 2024 年 9 月发布的小版本,没有破坏性变更。和日常写代码关系最大的两处,一个是 props 解构,一个是模板引用。

以前 const { count } = defineProps(...) 解构出来的 count 是个常量,父组件改了它也不变。3.5 起这个特性稳定并默认开启,编译器会把同一个 <script setup> 里用到的 count 改写成 props.count,所以它是响应式的;默认值也能直接写成 { count = 0 },不再需要 withDefaults。

模板引用以前要声明一个和 ref="input" 同名的 const input = ref(null),靠变量名对上。3.5 新增 useTemplateRef('input'),按运行时的字符串 ID 匹配,变量名随便起,ref 名是动态的也能用,配合工具还能推断类型。

要注意,解构出的 prop 直接传给 watch(count) 编译时会报错,要写成 watch(() => count);传给组合式函数也要包成 getter。模板引用要等组件挂载后才有值,挂载前是 null。

props 解构到底发生了什么

先看编译前后的对比。下面这段 <script setup>:

vue
<script setup>
import { watch } from 'vue';

const { count = 0 } = defineProps(['count']);

watch(() => count, () => {});
console.log(count);
</script>

用 @vue/compiler-sfc 3.5 编译后,setup 里的关键部分变成了这样:

JavaScript
watch(() => __props.count, () => {});
console.log(__props.count);

所以 count 并不是真的被解构成了一个普通变量。每次访问都被换成了对 props 对象的属性读取,Vue 的响应式靠属性访问收集依赖,这样就能追踪到。默认值 count = 0 则被编译成了组件的 props 默认值声明。

那为什么 watch(count, ...) 不行?按改写规则,它会变成 watch(__props.count, ...),传进去的是一个值,不是响应式数据源。Vue 编译器会直接报错,提示改成 () => count。同样,把解构的 prop 传给组合式函数时要写 useComposable(() => count),函数内部用 toValue() 读取。这个规则和版本差别在《defineProps、defineEmits 和 defineExpose 有什么作用?》《为什么 Vue 提倡 props 单向数据流?》里也有完整例子。

useTemplateRef 解决了什么麻烦

3.5 以前,<script setup> 里拿 DOM 元素要这样写:

vue
<script setup>
import { ref, onMounted } from 'vue';

const input = ref(null);

onMounted(() => {
  input.value.focus();
});
</script>

<template>
  <input ref="input" />
</template>

这里的约定是变量名必须和 ref 属性的值完全一样。读代码时看不出 input 是个模板引用,改名时两边也得一起改。

3.5 以后写成:

vue
<script setup>
import { useTemplateRef, onMounted } from 'vue';

const input = useTemplateRef('my-input');

onMounted(() => {
  input.value.focus();
});
</script>

<template>
  <input ref="my-input" />
</template>

区别在于匹配方式。以前是编译器分析静态的变量名,现在是运行时按字符串 ID 匹配,所以 ref 名可以带连字符,也可以是动态绑定、会变化的 ID。用 TypeScript 时,IDE 和 vue-tsc 会根据 ref 用在哪个元素或组件上自动推断 input.value 的类型。

v-for 里用 ref="items" 时,useTemplateRef('items') 拿到的是一个数组,挂载后填入元素。官方文档提醒,这个数组的顺序不保证和源数组一致。ref 加在组件上拿到的是组件实例,<script setup> 组件默认什么都不暴露,要用 defineExpose。

3.5 还带来了哪些变化

官方发布文章还列了这些:响应式系统重构,内存占用减少 56%,某些情况下大型深层响应式数组的追踪快了最多 10 倍;onWatcherCleanup() 用于在侦听器里注册清理,见《computed、watch 和 watchEffect 应该如何选择?》;<Teleport defer> 让目标元素可以晚于 Teleport 渲染,见《KeepAlive 与 Teleport 有哪些使用边界?》。

服务端渲染方面有三项:defineAsyncComponent() 支持 hydrate 选项做懒水合,比如 hydrateOnVisible();useId() 生成在服务端和客户端保持一致的 ID;data-allow-mismatch 用来消除服务端和客户端值必然不同时的水合警告,比如日期。

常见追问

升级到 3.5 后,原来用 withDefaults 的代码要改吗?

不用。3.5 没有破坏性变更,withDefaults 照样能用。只是新代码可以直接写解构默认值,更简洁。

解构的 prop 和普通变量在编辑器里怎么区分?

官方提到 Vue 的 VS Code 扩展有一个设置,可以给解构出来的 props 显示内联提示,开启后就能一眼看出来。

useTemplateRef 在 setup 里立刻读,为什么是 null?

因为元素要第一次渲染后才存在。官方建议在 onMounted 里访问;用 watchEffect 时要判断 input.value 是否存在,元素被 v-if 移除后它也会变回 null。