1

又看到一个refreactive 的文章,文章举了一堆似是而非的例子说明 reactive 不好。在文章末尾,还加了一句“官方文档建议”,说“因为 reactive() 的局限性,所以建议使用 ref() 作为声明响应式状态的主要 API”

作为一个坚定的 reactive 拥护者和使用者,我觉得是时候出来反驳几句了。

为什么要有 ref

refreactive 就不得不谈到这两者的核心:Proxy。它通过创建一个对象的代理,从而实现对此对象操作的拦截和自定义。细节就不详细说了,但是从定义上可以看到,Proxy 创建的一定是对象的代理。

这个设计是合理的。因为 Proxy 本身返回一个新对象,因为语法上的限制,没法操作原始值本身

const original = { a: 1 }
let proxy = new Proxy(original /* 原始对象 */, {} /* 拦截器组 */);

console.log(proxy); // Proxy(Object) {a: 1},注意前面的 Proxy(Object)
console.log(proxy === original); // false。proxy 本身是一个新的代理对象,和原始对象不是同一个引用
proxy = { b: 2 }; // 这个操作会吧 proxy 对象本身冲掉
console.log(proxy) // { b: 2 },注意前面的 Proxy(Object) 没了

可以看到,语法设计上用户没法操作被代理的对象本身。reactive 作为 Proxy 的直接封装,这个限制被延续到了 reactive 上。所以 reactive 只支持对象类型,不支持基本数据类型(原始类型)。但是基本类型也需要支持响应式,该怎么办呢?

很简单,找个对象包一下就行了

const x = reactive({ value: 1 });
console.log(x.value); // 1
x.value = 2;
console.log(x.value); // 2

尤大给 reactive({ value: X }) 起了个名字叫 ref,于是 ref 横空出世了

ref(X) 就是 reactive({ value: X })

const a = ref(1);
const b = reactive({ value: 1 });
console.log(a.value); // 1
console.log(b.value); // 1
a.value = 2;
b.value = 2;
console.log(a.value); // 2
console.log(b.value); // 2

没有任何区别。其他的工具函数例如 toRef 什么的很容易实现

仿杠:实际上的 ref 实现比 reactive({ value: X }) 复杂,尤大搞了一个 RefImpl 类,还加入了 __v_isRef 等标记位来识别这个对象是否是 ref 对象。但是本质上两者没什么区别

ref 的所谓优势,和 reactive 的所谓局限

有了上面的分析,就很容易对上面博客里的论点进行反驳

reactive 仅支持优先的值类型,ref 可以支持任意类型

反驳:reactive 用对象包一层也能支持任意类型

reactive 不能替换整个对象

反驳:reactive 用对象包一层也能替换整个对象。反过来,对 ref 对象赋值也会冲掉原始引用

这是原博客举的例子

let state = reactive({ count: 0 })

// 上面的 ({ count: 0 }) 引用将不再被追踪
// (响应性连接已丢失!)
state = reactive({ count: 1 })

替换成 ref 也一样成立

let state = ref({ count: 0 })

// 上面的 ({ count: 0 }) 引用将不再被追踪
// (响应性连接已丢失!)
state = ref({ count: 1 })

reactive 对解构操作不友好

这是原博客举的例子

const state = reactive({ count: 0 })

// 当解构时,count 已经与 state.count 断开连接
let { count } = state
// 不会影响原始的 state
count++

替换成 ref 也一样成立

const state = ref(0)

// 当解构时,count 已经与 state.value 断开连接
let { value: count } = state
// 不会影响原始的 state
count++

再强调一遍。reactive 是 Proxy 的封装;而 ref 可以看做 reactive 的简单封装,也就是 Proxy 的高次封装。reactive 的局限实际上是继承了 Proxy 的局限。

ref 的四宗罪

既然 ref(X)reactive({ value: X }) 等价,那么为什么还要写 reactive({ value: X }) 而不用 ref(X) 呢?这就要提到我个人认为尤大给 vue 的一个非常失败的设计了。

ref 对象使用时每次都要带一个 .value,而 .value 在语句中是没有业务含义的,就导致给人感觉语法上非常冗余。于是尤大就“体贴”地在 vue 模版引擎里搞了这样一个东西:ref 对象的自动解包。

<script setup>
    import { ref } from 'vue'
    const x = ref(1)
</script>
<template>
    <div>{{ x }}</div> <!-- 1 -->
</template>

这样看似让代码简洁了,但我认为这个做法绝对是弊大于利。有一下几点理由:

语法割裂

在 script 中,使用 ref 对象必须x.value,而在模板中,绝对不能写 x.value。而这个 .value 是夹在变量和后续表达式中间的,很容易被误用。

比如在 script 里有一个表达式 "SUM = " + (a.value + b.value),如果你要把这段语句挪到模版中,就必须把中间的 .value 删掉,得到 "SUM = " + (a + b),还好。如果要把模版中的 "SUM = " + (a + b) 写回到 script 代码里,就需要把 .value 再加回来,这就很麻烦,因为从 "SUM = " + (a + b) 本身看不出 ab 哪个是 ref 对象哪个不是;而且因为JS的动态类型特性,语法上 "SUM = " + (a + b) 是完全合法的,很容易造成误用

上面所述还是简单的情况。如果 ref 存储的是后端接口返回的一个包含 value 属性的对象,那么就会出现 obj.value.value。想想都觉得头疼。

心理负担

如果你接受了取值表达式中省略 .value 的写法,还有更重磅的:

<script setup>
    import { ref } from 'vue'
    const x = ref(1)
</script>
<template>
    <div>{{ x }}</div>
    <!-- 给 x 赋值也不写 .value: -->
    <button @click="x = 2">写法1</button>
    <!-- 甚至还可以写: -->
    <button @click="x++">写法2</button>
</template>

从写法上看,这段代码在尝试替换 x 的引用。我每次写这样的代码的时候里心里都会嘀咕:x = 2 真的不会把 x 的引用本身冲掉吗?x = 2x + 2 造成的bug完全不是一个级别。如果 x + 2x 没有自动 .value,显示在页面上的值会是 [object Object]2,这样的bug很容易发现。而 x = 2 中的 x 没有自动 .value,赋值操作只会把 x 的响应式引用冲掉,结果是点击没反应。如果页面的其他部分触发了刷新,页面还会正常显示。这种bug隐藏极深非常难发现和排查。

额外开销

在模版中,所有 ref 对象都会隐式 .value。然而 JS 是动态类型语言,编译期变量的类型是不固定的,那么 vue 模版编译器如何知道哪些对象是 ref 对象呢?答案是它不知道。于是模版中的使用变量的地方都被套了一层 unref,这样使用前都会先判断一下变量是否是 ref 对象,如果是就取 .value,如果不是就直接返回。

当然,我没测过这些判断条件对性能有多大影响,但肯定不是没有。注意,所有用到变量的地方都需要判断,所有页面,所有组件,包括 v-for 里面的

画蛇添足

因为所有 ref 在使用前都会被 unref,所以 ref 对象本身不能直接传给子组件

<!-- Parent -->
<script setup>
    import { ref } from 'vue'
    const aRef = ref(1)
</script>
<template>
    <Child :my-ref="aRef" />
</template>
<!-- Child -->
<script setup>
    import { isRef } from 'vue'
    defineProps(['myRef'])
</script>
<template>
    <div>{{ isRef(myRef) }}</div> <!-- false -->
</template>

注意这里不能用 toReftoRef(aRef.value) 会返回一个新的 ref 对象,跟原本的 aRef 没有关系。

如果非要传入原本的 aRef,可以这样写:

<!-- Parent -->
<script setup>
    import { ref } from 'vue'
    const aRef = ref(1)
    const wrapper = { aRef } // 这行代码必须写在 script 里面
</script>
<template>
    <Child :my-ref-wrapper="wrapper" />
</template>
<!-- Child -->
<script setup>
    import { isRef } from 'vue'
    defineProps(['myRefWrapper'])
</script>
<template>
    <div>{{ isRef(myRefWrapper.aRef) }}</div> <!-- true -->
</template>

代码通过将 ref 对象包在另一个对象里,以逃脱 unref 检查。这反过来也说明,vue 模版编译器只会对顶层对象做 unref 检查。

另外值得一提的是,我在测试中发现,如果你尝试输出 wrapper.aRef 的值

<template>
    <div>{{ wrapper.aRef }}</div>
</template>

仍然会输出 1 而非 [object Object]。经过一番调试发现了 vue 里面这样一个神奇的函数 toDisplayString。这个函数做了一番检查,发现 wrapper.aRef 是一个对象,所以尝试将其转换为 JSON 字符串。在自定义的 replacer 中又检查参数为 ref 对象,然后 unref 掉了。这番操作可谓武装到牙齿,厉害了我的 vue!

当然还是有漏洞可以钻

<template>
    <div :title="wrapper.aRef">Hover me</div>
</template>

这样 tooltip 会显示 [object Object]

现在开始使用 reactive

我的做法是,给每一个组件都定义唯一一个 states 变量,存储所有当前组件的状态

const states = reactive({
    myState: '状态',
    myState2: '状态2',
})

使用的话

<template>
    <div>组件状态:{{ states.myState }}</div>
</template>

这样做的好处是

  1. 避免了上述所有 ref 的问题
  2. 通过 states 对象将组件中的所有状态集合到一起,让整个组件非常干净
  3. 在 IDE 中输入 states. 就能很方便的列出当前组件的所有状态并选择使用,对智能提示非常友好
  4. 在函数中可以对 states 做一次性解构,使用非常方便
const states = reactive({
    a: 1,
    b: 2,
    c: 3
})

function sum() {
    // 永远不要在 setup 根函数中解构 states 对象
    // 始终使用 const 解构 states 对象,不要给解构得到的变量赋值
    const { a, b, c } = states 
    return a + b + c
}

明眼人能看出来这种用法与 option 语法的 this.myState 很接近。实际上 option 语法中的 this 本身就是一个巨大的 reactive 对象。但区别在于 option 语法中的 this 除了状态还有方法、组件参数和 vue 原生属性等七七八八的东西,而 states 只存储状态


CarterLi
1.3k 声望102 粉丝