响应性语法糖 {#reactivity-transform}

:::warning 实验性功能 响应性语法糖目前是一个实验性功能,默认是禁用的,需要显式选择使用。在最终确定前仍可能发生变化,你可以查看 GitHub 上的提案与讨论来关注和跟进最新进展。 :::

:::tip 组合式 API 特有 响应性语法糖是组合式 API 特有的功能,并且需要一个构建步骤。 :::

ref vs. 响应式变量 {#refs-vs-reactive-variables}

自从引入组合式 API 的概念以来,一个主要的未能解决的问题就是 ref 和响应式对象的使用方式。到处使用 .value 无疑是很繁琐的,并且在没有类型系统的帮助时很容易漏掉。

Vue 的响应性语法糖是一个编译时的转换过程,使我们可以像这样书写代码:

  1. <script setup>
  2. let count = $ref(0)
  3. console.log(count)
  4. function increment() {
  5. count++
  6. }
  7. </script>
  8. <template>
  9. <button @click="increment">{{ count }}</button>
  10. </template>

这里的这个 $ref() 方法是一个编译时的宏命令:它不是一个真实的、在运行时会调用的方法。而是用作 Vue 编译器的标记,表明最终的 count 变量需要是一个响应式变量

响应式的变量可以像普通变量那样被访问和重新赋值,但这些操作在编译后都会变为带 .value 的 ref。比如上面例子中 <script> 部分的代码就被编译成了下面这样:

  1. import { ref } from 'vue'
  2. let count = ref(0)
  3. console.log(count.value)
  4. function increment() {
  5. count.value++
  6. }

每一个会返回 ref 的响应性 API 都有一个相对应的、以 $ 为前缀的宏函数。包括以下这些 API:

当启用响应性语法糖时,这些宏函数都是全局可用的、无需手动导入。但如果你想让它更明显,你也可以选择从 vue/macros 中引入它们:

  1. import { $ref } from 'vue/macros'
  2. let count = $ref(0)

通过 $() 解构 {#destructuring-with}

我们常常会让一个组合函数返回一个含数个 ref 的对象,然后解构得到这些 ref。对于这种场景,响应性语法糖提供了一个 $() 宏:

  1. import { useMouse } from '@vueuse/core'
  2. const { x, y } = $(useMouse())
  3. console.log(x, y)

编译输出为:

  1. import { toRef } from 'vue'
  2. import { useMouse } from '@vueuse/core'
  3. const __temp = useMouse(),
  4. x = toRef(__temp, 'x'),
  5. y = toRef(__temp, 'y')
  6. console.log(x.value, y.value)

请注意如果 x 已经是一个 ref,toRef(__temp, 'x') 则会简单地返回它本身,而不会再创建新的 ref。如果一个被解构的值不是 ref (例如是一个函数),也仍然可以使用,这个值会被包装进一个 ref,因此其他代码都会正常工作。

$() 的解构在响应式对象包含数个 ref 的对象都可用。

$() 将现存的 ref 转换为响应式对象 {#convert-existing-refs-to-reactive-variables-with}

在某些场景中我们可能已经有了会返回 ref 的函数。然而,Vue 编译器并不能够提前知道该函数会返回一个 ref。那么此时可以使用 $() 宏来将现存的 ref 转换为响应式变量。

  1. function myCreateRef() {
  2. return ref(0)
  3. }
  4. let count = $(myCreateRef())

响应式 props 解构 {#reactive-props-destructure}

现在的 <script setup> 中对 defineProps 宏的使用有两个痛点:

  1. .value 类似,为了保持响应性,你始终需要以 props.x 的方式访问这些 prop。这意味着你不能够通过解构 defineProps 因为得到的变量将不是响应式的、也不会更新。

  2. 当使用基于类型的 props 的声明时,无法很方便地声明这些 prop 的默认值。为此我们提供了 withDefaults() 这个 API,但使用起来仍然很笨拙。

而有了响应性语法糖,我们就也可以在 defineProps 时使用响应式变量相同的解构写法了:

  1. <script setup lang="ts">
  2. interface Props {
  3. msg: string
  4. count?: number
  5. foo?: string
  6. }
  7. const {
  8. msg,
  9. // 默认值正常可用
  10. count = 1,
  11. // 解构时命别名也可用
  12. // 这里我们就将 `props.foo` 命别名为 `bar`
  13. foo: bar
  14. } = defineProps<Props>()
  15. watchEffect(() => {
  16. // 会在 props 变化时打印
  17. console.log(msg, count, bar)
  18. })
  19. </script>

上面的代码将被编译成下面这样的运行时声明:

  1. export default {
  2. props: {
  3. msg: { type: String, required: true },
  4. count: { type: Number, default: 1 },
  5. foo: String
  6. },
  7. setup(props) {
  8. watchEffect(() => {
  9. console.log(props.msg, props.count, props.foo)
  10. })
  11. }
  12. }

保持在函数间的响应性 {#retaining-reactivity-across-function-boundaries}

虽然响应式变量使我们可以不再受 .value 的困扰,但它也使得我们在函数间传递响应式变量时可能造成“响应性丢失”的问题。这可能在以下两种场景中出现:

以参数形式传入函数 {#passing-into-function-as-argument}

假设有一个期望接收一个 ref 对象为参数的函数:

  1. function trackChange(x: Ref<number>) {
  2. watch(x, (x) => {
  3. console.log('x 改变了!')
  4. })
  5. }
  6. let count = $ref(0)
  7. trackChange(count) // 无效!

上面的例子不会正常工作,因为代码被编译成了这样:

  1. let count = ref(0)
  2. trackChange(count.value)

这里的 count.value 是以一个 number 类型值的形式传入,然而 trackChange 期望接收的是一个真正的 ref。要解决这个问题,可以在将 count 作为参数传入之前,用 $$() 包装:

  1. let count = $ref(0)
  2. - trackChange(count)
  3. + trackChange($$(count))

上面的代码将被编译成:

  1. import { ref } from 'vue'
  2. let count = ref(0)
  3. trackChange(count)

我们可以看到,() 的效果就像是一个转义标识() 中的响应式变量不会追加上 .value

作为函数返回值 {#returning-inside-function-scope}

如果将响应式变量直接放在返回值表达式中回丢失掉响应性:

  1. function useMouse() {
  2. let x = $ref(0)
  3. let y = $ref(0)
  4. // 监听 mousemove 事件
  5. // 不起效!
  6. return {
  7. x,
  8. y
  9. }
  10. }

上面的语句将被翻译为:

  1. return {
  2. x: x.value,
  3. y: y.value
  4. }

为了保持响应性,我们需要返回的是真正的 ref,而不是返回时 ref 内的值。

我们还是可以使用 () 来结局。在这个例子中,() 可以直接用在要返回的对象上,$$() 调用时任何对响应式变量的引用都会保留为对相应 ref 的引用:

  1. function useMouse() {
  2. let x = $ref(0)
  3. let y = $ref(0)
  4. // 监听 mousemove 事件
  5. // 修改后起效
  6. return $$({
  7. x,
  8. y
  9. })
  10. }

在已解构的 prop 上使用 $$() {#usage-on-destructured-props}

$$() 适用于已解构的 prop,因为它们也是响应式的变量。编译器会高效地通过 toRef 来做转换:

  1. const { count } = defineProps<{ count: number }>()
  2. passAsRef($$(count))

编译结果为:

  1. setup(props) {
  2. const __props_count = toRef(props, 'count')
  3. passAsRef(__props_count)
  4. }

TypeScript 集成 {#typescript-integration}

Vue 为这些宏函数都提供了类型声明 (全局可用) 并且类型都会符合你的使用预期。它与标准的 TypeScript 语义没有不兼容之处,因此它的语法可以与所有现有的工具兼容。

这也意味着这些宏函数在任何 JS / TS 文件中都是合法的,不是仅能在 Vue SFC 中使用。

因为这些宏函数都是全局可用的,它们的类型需要被显式地引用 (例如,在 env.d.ts 文件中):

  1. /// <reference types="vue/macros-global" />

若你是从 vue/macros 中显式引入宏函数时,则不需要像这样全局声明了。

显式启用 {#explicit-opt-in}

响应性语法糖目前默认是关闭状态,需要你显式选择启用。此外,接下来的所有配置都需要 vue@^3.2.25

Vite {#vite}

  • 需要 @vitejs/plugin-vue@^2.0.0
  • 应用于 SFC 和 js(x)/ts(x) 文件。在执行转换之前,会对文件进行快速的使用检查,因此不使用宏的文件应该不会有性能损失。
  • 注意 refTransform 现在是一个插件的顶层选项,而不再是位于 script.refSugar 之中了,因为它不仅仅只对 SFC 起效。
  1. // vite.config.js
  2. export default {
  3. plugins: [
  4. vue({
  5. reactivityTransform: true
  6. })
  7. ]
  8. }

vue-cli {#vue-cli}

  • 目前仅对 SFC 起效
  • 需要 vue-loader@^17.0.0
  1. // vue.config.js
  2. module.exports = {
  3. chainWebpack: (config) => {
  4. config.module
  5. .rule('vue')
  6. .use('vue-loader')
  7. .tap((options) => {
  8. return {
  9. ...options,
  10. reactivityTransform: true
  11. }
  12. })
  13. }
  14. }

仅用 webpack + vue-loader {#plain-webpack-vue-loader}

  • 目前仅对 SFC 起效
  • 需要 vue-loader@^17.0.0
  1. // webpack.config.js
  2. module.exports = {
  3. module: {
  4. rules: [
  5. {
  6. test: /\.vue$/,
  7. loader: 'vue-loader',
  8. options: {
  9. reactivityTransform: true
  10. }
  11. }
  12. ]
  13. }
  14. }