阅读 10385

Vue第二波ref语法提案来袭 这次会进入到标准吗?

⚠️本文为掘金社区首发签约文章,未获授权禁止转载

前言

其实之前Vue3做过好多次语法糖的提案,最经典的莫过于<script setup>提案。但一开始这个提案夹杂着ref语法糖,所以很多批评的声音接踵而来:什么Vue又开始创造新概念啦、不忠于JavsScript啦、不如叫<script lang="vue-script">之类的…

尤雨溪发现反对的意见大多数是对ref语法糖不满,于是继续细分,把<script setup>ref语法糖分成了两个不同的提案,如果不太清楚我说的到底是什么东西的话,可以点进这两篇文章看一看:《[译]尤雨溪: Ref语法糖提案》《Vue 3.0.3 : 新增CSS变量注入以及最新的Ref提案》

最近我看到<script setup>这个提案终于定稿了,已经进入Vue的标准里面去了,我们在用新版Vue的时候是默认支持这种写法的。不过由于ref这个提案反对意见太多,尤大怕如果不顾大家的反对意见坚决推进的话,可能会失去大家的信任从而流失一批用户、顺便再给自己多招点黑…

于是ref这个提案就被放弃掉了。正当我以为终于不用再搞那些花里胡哨的玩意之后,新版的ref语法糖提案又来了… 原来尤大解决ref.value属性这个决心一直都没有改变,你们不同意原来的写法?那好,换个语法再来一遍!

为什么老想做这个 ref 语法糖?

自从引入 Composition API 以来,一个主要未解决的问题是 ref 对象的使用。.value在任何地方使用都可能很麻烦,如果不使用 TS 的话,很容易就会忘记写这个.value属性,就像这样:

import { ref } from 'vue'

let loading = ref(true)

if (loading) {
    // 此处省略若干代码
    loading = false
}
复制代码

但实际上我们要写成这样才会正确运行:

if (loading.value) {
    // 此处省略若干代码
    loading.value = false
}
复制代码

这就很烦,所以一些用户特别倾向于只用reactive()这个函数,这样他们就不必面对ref.value属性了,就像这样:

import { reactive } from 'vue'

const state = reactive({
    loading: true
})

if (state.loading) {
    // 此处省略若干代码
    state.loading = false
}
复制代码

但其实这些写法在尤雨溪的眼里都不是最好的解决方案,于是他参考了Svelte的写法,用了几乎快被废弃掉的label语法:

ref: loading = true

if (loading) {
    // 此处省略若干代码
    loading = false
}
复制代码

这个语法为何遭到大家的强烈反对呢?因为我们声明一个变量通常会用letconst以及var关键字对吧,但这个压根儿就没用到任何声明的关键字,取而代之的是不伦不类的ref:。这个语法并不是尤雨溪自创的啊,它是JS里的label语法,但几乎没人用,可能有一部分人听都没听过,它主要是在多重嵌套的循环中配合breakcontinue使用的,就像这样:

let num = 0
outermost:
for (let i = 0; i < 10; i++) {
    for (let j = 0; j < 10; j++) {
        if (i == 5 && j == 5) {
            continue outermost
        } else {
            console.log(i, j, 88)
        }
        num++
    }
}
console.log(num) //95
复制代码

看不懂没关系啊,也没必要弄懂这种语法,因为它不够直观,用处也不是很大,所以几乎没什么人用它!不过既然没什么人在用,同时它还是JS的合法语法,那用它来告诉编译器这里是声明了一个ref变量岂不是很完美?

那么大家为何会如此反对呢?就是因为label语法压根儿就不是这么用的,人家原本是为了和breakcontinue配合使用的,虽然在别的地方用也不算是语法错误,但你这么做明显是修改了JS原本的语意!

那尤大新提的这个ref语法糖长什么样呢,我们来看一下:

<script setup>
let loading = $ref(true)

if (loading) {
    // 此处省略若干代码
    loading = false
}
</script>
复制代码

尤大心想:你们不是嫌我之前用了不规范的语法么?那我这回这么写应该没问题了吧!想想之前我们定义一个ref变量,首先需要先把ref引进来然后才能用:

import { ref } from 'vue'

const loading = ref(true)
复制代码

而新语法不用引,直接就能用,类似于全局变量的感觉。除了$ref这个特殊的全局变量呢,这次提案还有:$computed$fromRefs$raw这几个玩意。我们一个个来看,先看$computed

<!-- 以前 -->
<script setup>
import { ref, computed } from 'vue'

const num = ref(1)
const num_10 = computed(() => num.value * 10)
</script>

<!-- 现在 -->
<script setup>
let num = $ref(1)
const num_10 = $computed(() => num * 10)
</script>
复制代码

$fromRefs又是个啥呢?这玩意在之前没有啊!只听说过toRefs

<!-- 以前 -->
<script setup>
import { fromRefs } from 'vue' // 这个API并不存在
import { toRefs } from 'vue' // 这个API倒是有 也就是只有 to 没有 from
</script>
复制代码

其实这个$fromRefs正是为了配合toRefs而产生的,比方说我们在别的地方写了一个useXxx

import { reactive } from 'vue'

const state = reactive({
    x: 0,
    y: 0
})

export default = (x = 0, y = 0) => {
    state.x = x
    state.y = y
    
    return toRefs(state)
}
复制代码

于是我们在使用的时候就:

<script setup>
import { useXxx } form '../useXxx.js'

const { x, y } = useXxx(100, 200)

console.log(x.value, y.value)
</script>
复制代码

这岂不是又要出现尤大最不想看到的.value属性了吗?所以$fromRefs就是为了解决这个问题而生的:

<script setup>
import { useXxx } form '../useXxx.js'

const { x, y } = $fromRefs(useXxx(100, 200))

console.log(x, y)
</script>
复制代码

最后一个 API 就是$raw了,raw 不是原始的意思嘛!那么看名字也能猜到,就是我们用$ref所创建出来的其实是一个响应式对象,而不是一个基本数据类型,但语法糖会让我们在使用的过程中像是在用基本数据类型那样可以改来改去,但有时我们想看看这个对象长什么样,那么我们就需要用到$raw了:

<script setup>
const loading = $ref(true)

console.log(loading) // 其实打印的不是 loading 这个对象 而是它里面的值 相当于 loading.value
console.log($raw(loading)) // 这回打印的就是 loading 这个对象了
</script>
复制代码

嵌套在函数作用域内的语法糖用法(尚未实现)

从技术上来讲,$ref可以在任何地方被let声明使用,包括嵌套函数范围:

function useMouse() {
  let x = $ref(0)
  let y = $ref(0)

  function update(e) {
    x = e.pageX
    y = e.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return $raw({
    x,
    y
  })
}
复制代码

上面的代码将会被编译成这个样子:

import { ref } from 'vue'

function useMouse() {
  let x = ref(0)
  let y = ref(0)

  function update(e) {
    x.value = e.pageX
    y.value = e.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return {
    x,
    y
  }
}
复制代码

不过目前尚不支持这种写法,仅支持不在函数或者其他块级作用域中的ref语法糖。

尤大还不确定是否要做的功能

这种语法糖是否要在单文件组件的外部进行支持

这种语法糖本质上是可以通过babel等编译工具来转换成任何合法的JSTS代码的,但新语法目前仅支持写在<script setup>的单文件组件里,这是因为:

  • 尽管是语法上有效的JSTS语法,但它毕竟不是标准JS语义。JS里并没有$ref$computed这种全局变量。在单文件组件中的<script>加上一个setup属性就是用来表示里面的代码将会被预处理一些特殊行为。
  • 因为它被实现为@vue/compiler-sfc这个模块的其中一部分,所以它允许现有的Vue用户在开始使用新语法时不需要任何额外的babel等配置。
  • <script setup>的编译过程已经实现全AST解析,所以ref语法糖的变换可以重复使用相同的AST,并避免产生额外的解析开销。
  • 新语法的转换还会被编译器进行智能绑定。

如果新语法仅限于单文件组件

当我们不在单文件组件内写代码时会产生一定的心智负担。先前的研究表明,这种心理成本可能实际上减少了没有语法糖时的使用效率。

不同的语法也会产生摩擦,比方说我们想提取或跨组件重用逻辑时(就是我们俗称的hooks)。

不过幸运的是,由于变换规则相对而言比较简单,用语法糖编写的代码可以通过IDE插件来自动转换成没有语法糖的样子。

新语法如果支持所有文件

  • 解析成本:我们已经解析<script setup>里面的语法了,所以新的ref语法糖并不会明显增加额外的解析成本。然而,如果应用到所有的JSTS文件中去的话,将会显著增加额外的解析成本。
  • 这种新语法并不是标准的JavaScript语义,JS里并没有$ref$raw这种全局变量,让这种语法生效在Vue的特定环境之外可能是个坏主意。

如何开启新语法?

这种语法是随着Vue 3.2一同发布的,所以我们的Vue版本至少要大于等于Vue 3.2.0-beta.1。由于该语法是实验性的,默认是不启用的,我们需要自行配置:

在 vue-cli 脚手架中

我们需要在根目录下新建一个vue.config.js,然后在里面写:

module.exports = {
  chainWebpack: config => {
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        return {
          ...options,
          refSugar: true
        }
      })
  }
}
复制代码

在 Vite 中

我们需要在根目录下新建一个vite.config.js,然后在里面写:

import vue from '@vitejs/plugin-vue'

export default {
  plugins: [
    vue({
      script: {
        refSugar: true
      }
    })
  ]
}
复制代码

在自己搭建的 webpack 脚手架中

// webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /.vue$/,
        loader: 'vue-loader',
        options: {
          refSugar: true
        }
      }
    ]
  }
}
复制代码

注意事项

首先这个新语法还是实验性质的,并未进入标准,尽量不要在主要项目中开启,因为实验性语法不一定就会进入标准。第一波ref语法糖提案被毙掉之后,我看到有人跑到GitHub上大加吐槽:

WX20210812-160513@2x.png

翻译:

我注意到 3.2 的测试版已经取消了第一波ref语法糖的支持。我非常失望。因为我已经使用 ref 语法糖半年多了,据我所知它是 vue3 的一部分。

与其他人不同,我认为理解起来或学习起来并不难。vue3已经出来快一年了,ref语法糖都已经9个月了。我都已经在我的团队中推进了ref语法糖的使用,它运行良好,以至于我们现在专门使用 Composition API 来进行开发。语法糖带来了很多好处,因为.value真的很无聊,这是与vue2Options API 的最大区别,使用语法糖可以不用写.value就具备响应式的能力和可组合性的魔力。

但是对于我和我的团队来说,这种变化非常糟糕,我们已经广泛使用了ref语法糖。我不知道我是不是少数,但我都已经用了半年多了,因为它得到了非常好的 IDE 支持(感谢@johnsoncodehk ),而且在用的时候也没发现任何的bug,无论是对对象结构还是对原始值的访问都很棒。这对我的开发体验来说是一个很大的改进。

我看了一下新的语法糖,和原来的没什么区别,不还是需要编译器做魔术嘛!因为没有了 label 语法导致它看起来更像原生js,但其实根本就不是。访问原始值和对象结构也变得更加乏味。添加了很多新的 API$ref$computed$fromRefs$raw, 不知道以后还会不会有$shallowRef, 或者$watch

也不知道别人会不会接受这个新语法糖提案,但是至少是伤害了原本支持和使用第一波ref语法糖的人。由于 3.1.4 现在可以通过选项控制语法糖是否生效,我希望至少能够通过配置保留住第一波语法糖的写法。

尤雨溪在最后说到:

WX20210812-162239@2x.png

如前所述,本提案中使用的标签语法存在各种缺陷——特别是与标准 JS 行为的语义不匹配,我们正在放弃这个提议。

再次声明一遍:请记住,标记为实验性的功能是用于评估和收集反馈意见的。它们可能随时更改或中断。除非功能的相应 RFC 已合并,否则无法保证 API 的稳定性@vue/compiler-sfc使用实验性功能时的警告应该已经很清楚了。通过选择实验性功能,您承认您愿意在功能更改或删除时重构您的代码。

#369提出了一个新版本的 ref 语法糖,它不依赖于挪用label语法,也不需要专门的工具支持。它目前在 3.2.0-beta 中发布,并取代了本提案的实现。同样,这也是实验性的,因此上述所有内容也适用于新提案。

所以说尽可能不要在主要项目中使用它,我们可以没事写个 demo 试验一下,或者在自己的个人项目中使用,不然的话很可能就会像上面那位老哥吐槽的那样了…

其他人也觉得谁让你那么用了,既然用了就要承担风险:

WX20210812-162955@2x.png

你没有考虑到API 是作为实验性质引入的,以便能够根据用户反馈对其进行调整(很多人不喜欢 label 语法)。它使用户能够试验API,在某些情况下,这对于 API 的体验感至关重要。当你使用实验性功能时,你将接受如果后续版本不兼容的话,你会对原来的代码进行重构甚至不得不将其删除的风险。在 API 稳定并合并到 RFC 之前,它也不是 Vue 的一部分

不过话虽如此,你应该试试新版的ref语法糖,然后再来提供反馈。因为说不定你可能更喜欢新版的语法糖而不是现有版本。

也有人支持吐槽的那位老哥:

WX20210812-163708@2x.png

新一波语法糖提案似乎仍旧令人费解,但这是我们在不改变 JS 原始语法的情况下所能做的最好的事情了(因为有些人总是介意这一点)我同意同时保留新旧两种语法糖。

个人观点

当然这种新语法肯定是有人喜欢有人讨厌的,我个人是比较反感这个新语法的,如果屏幕前的你喜欢这个新语法的话,那么请跳过我对这段对新语法的吐槽,以免因观点不合产生激烈互喷等情况。

首先我认为最大的弊端就是尤雨溪提出来的:这种语法糖是否要在单文件组件的外部进行支持?

如果仅在单文件组件里支持,我们在外头写hooks的时候还是要写.value属性,一会需要写一会不用写的这样不一致的写法很容易写错(虽然有工具提示可以降低错误)。但还是很烦,而且这边用着ref函数,到了另一边又变成了$ref

如果在所有文件都支持的情况下吧,又不得不用到babel等工具进行转换,对性能又是个负担。而且有一个很难受的点就是我们还有customRef这种比较高级的API,引用官网上的一个案例:

<template>
    <input v-model="text" />
</template>

<script>
function useDebouncedRef(value, delay = 200) {
  let timeout
  return customRef((track, trigger) => {
    return {
      get() {
        track()
        return value
      },
      set(newValue) {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
          value = newValue
          trigger()
        }, delay)
      }
    }
  })
}

export default {
  setup() {
    return {
      text: useDebouncedRef('hello')
    }
  }
}
</script>
复制代码

这种岂不是又要写.value属性?那在单文件组件里就会出现这个变量需要写.value,那个变量又不需要写的状况,很容易把人搞的头大。虽说以后对customRef这种API可能会单独再出一个$customRef语法糖,但我觉得就算写了个.value属性也没啥吧?至于就跟它较上劲了么… 虽说有时候写多了确实会稍微有点烦,但至少还是很容易理解的嘛:用.value属性触发了Proxygettersetter从而引发依赖收集或更新视图等操作。

还有一些其他的API如:provideinject 等,目前的语法糖并未对它们进行兼容,所以还是会出现一会需要.value一会又不需要的情况。

还有一个最重要的点就是:一个框架的写法老是变来变去的很不利于推广,想想看Vue3.0Vue 3.2之间有多大的差异,这次开了个坏头的话,以后就更加助长了尤大魔改编译的风气。当然他也确实是为了我们好,改的这些东西也是为了我们写起来更加的方便,有的改的也确实是不错,比如:《Vue超好玩的新特性:在CSS中引入JS变量》

还有现在已经定了稿的<script setup>语法糖,以前我们引入一个组件老需要再注册一遍:

import Xxx from 'Xxx.vue'

export default {
    components: {
        Xxx
    }
}
复制代码

写多了这样的代码确实有点烦,现在我们只需要引进来就行,不用注册,但这样本质上并没有改变语意,反而新的语法糖明显改变了语意:

let loading = $ref(true)
复制代码

按理说loading应该是个Proxy代理对象,但是它现在却变成了一个布尔类型的值,而且还多出来个莫名其妙的$ref函数。

当然你可能会说:你不喜欢不用不就得了?话这么说没错,但是你不用不代表别人也不用,有的人用有的人不用,这样的话在语法层面就已经产生了割裂。我们终究是要看别人代码的,有时候是接手一个遗留下来的项目,有时是在GitHub上看看别人的项目,在有的人用有的人不用的情况下就很难受。

大家怎么认为呢?可以在评论区留个言看看是喜欢这种语法糖的人多还是反对它的人多。

结语

我们把新语法糖的提案地址放在这里:github.com/vuejs/rfcs/…,希望大家可以积极参与并进去评论,但一定要注意的一点是:要用英文!

可能有人会说:都是中国人用什么英文?虽说用英文尤大可以看得懂,但评论区不全是中国人,Vue还是有相当一批外国粉丝的,而且也不全是美国人,那些不是英国人美国人的开发者,他们如果也只图自己痛快而说自己国家的母语的话,想必我们就没有办法进行沟通了,同时这也会进一步拉近国人在海外的形象:别人都用英文,就你们中国人用自己的语言,不遵守规则。

那可能有人英文水平真的很差,我们可以这样嘛:找到百度翻译,输入中文后翻译成英文,然后再把英文复制过去。虽然这样做翻译的可能不完全准确,但最起码能达到勉强看懂的地步。同时还有一个技巧就是把翻译成英文的句子再翻译回中文,看看有哪些地方的语意发生了明显的变化,我们再针对那个地方重新自己写一遍。

往期精彩文章

文章分类
前端