【转】Vue 3.3 主要新特性详解

前言

Hi,这里是三咲智子,Vue 核心团队成员。Vue 3.3 的这次发布主要是为了改进 DX (开发者体验),新增了一些语法糖和宏,以及 TypeScript 上的改善。

  • 泛型组件
  • 在 SFC(单文件组件)中导入外部 TS 类型
  • defineSlots 来定义插槽的类型
  • defineEmits 更便捷的语法
  • defineOptions 定义组件选项
  • (试验性) 响应式的 props 解构
  • (试验性) defineModel 语法糖
  • 废弃 Reactivity Transform

在去年刚加入 Vue 的那段时间,我一直在为 Vue 3 贡献 PR,但最近才最终在 Vue 3.3 中落地。Vue 3.3 从 Vue Macros 一共吸收了五六个特性,今天来浅谈我自己贡献的部分。

defineOptions 宏

前情提要

想想我们在有 <script setup> 之前,如果要定义 propsemits 可以轻而易举地添加一个与 setup 平级的属性。 但是用了 <script setup> 后,就没法这么干了 —— setup 属性已经没有了,自然无法添加与其平级的属性。为了解决这一问题,我们引入了 defineProps 与 defineEmits 这两个宏。

但这只解决了 props 与 emits 这两个属性。如果我们要定义组件的 name、 inheritAttrs 或其他自定义的属性,还是得回到最原始的用法 —— 再添加一个普通的 <script> 标签。这样就会存在两个 <script> 标签。对我个人来说这是不可接受的。

  • 两个 script 标签可能会对 ESLint 插件或 Volar 有非预期的问题;
  • 如果两个 script 标签都存在 import 语句,Vue 会做一些奇怪的特殊处理;
  • 对于 DX (开发者体验)来说,不仅麻烦,还难以理解。

现状

所以我们在 Vue 3.3 中新引入了 defineOptions 宏。顾名思义,主要是用来定义 Options API 的选项。我们可以用 defineOptions 定义任意的选项, propsemitsexposeslots 除外(因为这些可以使用 defineXXX 来做到)。我们甚至可以不用 <template> 标签,而直接在 defineOptions 中用 h 函数或 JSX 写渲染函数 render(当然并不推荐这样用)。

🌰 例子

1
2
3
4
5
6
7
8
<script setup>
defineOptions({
name: 'Foo',
inheritAttrs: false,
// ... 更多自定义属性
})
</script>

Vue SFC Playground

背后的故事

关于这个特性的起源,是在重构 Element Plus 的组件为 <script setup> 的时候。对于组件库来说,我们希望自定义组件的名字(name 属性),而不是默认的文件名。但我又不希望回到原来的书写方式,就写了一个插件(梦开始的地方 🤣)unplugin-vue-define-options。几经修改才确定和实现了目前 Vue 3.3 有的 defineOptions 宏。

提升静态常量

这个功能是一个 SFC (单文件组件)编译器的优化。在 script 部分新增了 hoistStatic 选项。

模板中的 hoistStatic

template 下的 hoistStatic 与它基本类似。Vue 编译器有一个优化 —— 它能够让静态的元素节点提升到顶级作用域。这样仅在代码被加载的时候执行一次,而不是每次执行 render 函数的时候被重复执行(在极端情况下可能也有其弊端)。

让我们来看一个例子 🌰。

1
2
3
4
<template>
<div id="title">Hello World</div>
</template>

Vue SFC Playground

以上这段代码会被编译成以下这段 JavaScript (非关键代码已省略)

1
2
3
4
5
const _hoisted_1 = { id: 'title' }
function render(_ctx, _cache) {
return _openBlock(), _createElementBlock('div', _hoisted_1, 'Hello World')
}

我们可以看到 _hoisted_1 变量就是被编译器故意提升到顶层的代码。如果关闭此功能,则它会在 render 函数内。

script 标签的 hoistStatic

在 Vue 3.3 之前,只有上述一个功能。我们在 Vue 3.3 也做了类似的优化 —— 如果一个常量的值是 primitive value(基本类型,即为 string, number, boolean, bigint, symbol, null, undefined),那么该常量声明会被提升到顶层。(注:symbol 目前并未被实现)

因为这些类型的常量是无法被改变的,所以它不论在哪被声明,效果都是一样的。

作用

这个特性除了性能优化之外,还有一个更有用的地方。在 Vue 3.3 之前我们在使用宏时,是没有办法传递定义在 <script setup> 块的变量的。看看下面的例子。

1
2
3
4
5
6
7
<script setup>
const name = 'Foo'
defineOptions({
name,
})
</script>

Vue SFC Playground

我们会得到一个报错。

1
2
[@vue/compiler-sfc] `defineOptions()` in <script setup> cannot reference locally declared variables because it will be hoisted outside of the setup() function. If your component options require initialization in the module scope, use a separate normal <script> to export the options instead.

这是因为之前提到的原因,defineProps 会添加一个与 setup 平级的 props 属性,而 name 常量又是声明在 setup 函数内的。我们无法在函数外部引用一个在其内部的变量,这是因为该变量压根还没有被初始化。以下代码毫无疑问是错误的。

1
2
3
4
5
6
7
const __sfc__ = {
props: [propName],
setup(__props) {
const propName = 'foo'
},
}

在 Vue 3.3 之后,第 4 行的 propName 就会提升到第一行,那么代码就完全说得通了。本特性默认是开启的。在一般情况下,开发者也无需感知该特性是存在的。

背后的故事

开发这个特性的原因也与上一个类似。是因为 Element Plus 在用 defineOptions 设置好 name 之后,又需要在某些条件下抛出一个异常。而异常需要带有组件的名字,来方便用户调试。

1
2
3
4
5
6
7
8
9
10
11
<script setup>
const name = 'ElButton'
defineOptions({
name,
})
// ...
if (condition) {
throw new Error(`${name}: something went wrong.`)
}
</script>

所以我希望避免重复的代码,把组件名称抽离成一个常量,并分别在 defineOptions 与抛异常的时候引用。

defineModel 宏

动机

这个宏是一个纯粹的语法糖。在 Vue 3.3 之前,如果我们要定义一个双向绑定的 prop,是一件挺麻烦的事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts">
const props = defineProps<{
modelValue: number
}>()

const emit = defineEmits<{
(evt: 'update:modelValue', value: number): void
}>()

// 更新值
emit('update:modelValue', props.modelValue + 1)
</script>

我们需要先定义 props,再定义 emits 。其中有许多重复的代码。如果需要修改此值,还需要手动调用 emit 函数。

我在想:我们为什么不封装一个函数(宏)来简化这个步骤呢?因此就有了 defineModel 宏。

🌰 例子

1
2
3
4
5
<script setup>
const modelValue = defineModel()
modelValue.value++
</script>

以上繁琐的 7 行代码,在 Vue 3.3 中可以简写为两行!

与 useVModel 的区别

在 VueUse 中也有 useVModel 函数可以实现类似的效果。为什么我们还要引入这个宏呢?

这是因为 VueUse 只是把 props 和 emit 函数结合后,封装为了一个 Ref,它无法为组件定义 props 和 emits。这意味着开发者仍需手动分别调用 defineProps 和 defineEmits

背后的故事

😛 这个背后就没什么故事了,纯粹是觉得写着麻烦。起初是在 Vue Macros 做了 defineModel 的宏(现改名为 defineModels,与官方做出区分),用着效果还不错。

在 SFC 导入外部类型

背景

在 Vue 3.2 发布以来,Vue 有一个参与热度最高的 issue——如何在 defineProps 上使用外部的类型

为了解决这个问题,摆在我们面前的有两条路。

  • Vue SFC 编译器去调用 TypeScript 编译器。计算出最终的类型,并确定它到底包含了哪些类型(StringNumberBooleanFunction 等)。
  • 我们自己实现一个简易的 TypeScript 分析器,并解决大多数的场景。

众所周知,TypeScript 的类型体操有多恐怖。如果真的要完美地解决这个 issue,Vue 的 SFC 编译器就需要像 TypeScript 编译器一样,解析并计算出所有的类型。第一套方案虽好,但是也有个巨大的缺点:这必不可免地需要依赖 TypeScript 那套庞大而又臃肿的编译器,会极大地拖慢构建的时长。

最后,在 Vue Macros 中我还是选择了第二套方案。因此目前并不适合在宏中传递复杂的类型。不过这将在以后被逐步完善。

Vue 3.3 与 Vue Macros

Vue Macros 实现了这套简易分析器后,Vue core 也做了类似的实现。

但是就目前而言,仍有差异。Vue Macros 的迭代速度远远快于 Vue core 本身,Vue Macros 目前正在支持更多奇奇怪怪的语法,而 Vue 3.3 有部分语法仍不支持。

所以在未来,如果遇到了 Vue 无法解析的类型,不妨试试 Vue Macros。如果 Vue Macros 仍然不行,可以尝试附带最小可复现代码,在 Vue Macros 仓库发起一个 issue。或尝试换一个更简单、避免二次推断的语法。

defineSlots 宏

背景

Vue 3.3 新增了 defineSlots 宏。我们可以使用 defineSlots 自己定义插槽的类型。这个宏在简单的组件中几乎用不到,但对于一些复杂的组件非常有用,尤其是这个特性与泛型组件一起使用。或是在 Volar 无法正确地推测出类型时,我们可以手动指定。

🌰 例子

1
2
3
4
5
6
<script setup lang="ts">
const slots = defineSlots<{
default(props: { foo: string; bar: number }): any
}>()
</script>

我们手动定义了 default 组件的插槽作用域的类型。

🌰 真的例子

比如我们有一个分页器组件,我们可以通过插槽来控制具体的 item 要如何渲染。

1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts" generic="T">
// 子组件 Paginator
defineProps<{
data: T[]
}>()

defineSlots<{
default(props: { item: T }): any
}>()
</script>

1
2
3
4
5
6
7
<template>
<!-- 父组件 -->
<Paginator :data="[1, 2, 3]">
<template #default="{ item }">{{ item }}</template>
</Paginator>
</template>

我们在传递了 data 参数,它是 number[],所以 item 也会被推断为 numberitem 的类型会根据传给 data 参数的类型而改变。

defineEmits 更便捷的语法

这个特性也是一个纯粹的语法糖。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts">
const emits = defineEmits<{
(evt: 'update:modelValue', value: string): void
(evt: 'change'): void
}>()

// ⬇️ Vue 3.3 之后
const emits = defineEmits<{
'update:modelValue': [value: string],
'change': []
}>()
</script>

在 Vue 3.3 以前我们需要多写几个字符,现在能省则省。

废弃 Reactivity Transform 语法糖

早在年初,Vue 团队已经宣布废弃 Reactivity Transform 语法。具体则会在 Vue 3.3 中被废弃(deprecated),使用时会警告;在 Vue 3.4 则会被彻底移除。

我个人认为 Reactivity Transform 仍然有其用武之地的。

虽然被官方废弃了,但功能被移动到了 Vue Macros。这意味着你仍可以不必着急迁移至老语法,使用插件仍可以正常使用以及后续的漏洞修复。

对于具体为什么移除,可以阅读此篇评论

原文:https://xlog.sxzz.moe/vue-3-3