[Vue3] 历数 ref 四宗罪,现在开始用 reactive

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

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

为什么要有 ref

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

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

js 复制代码
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 只支持对象类型,不支持基本数据类型(原始类型)。但是基本类型也需要支持响应式,该怎么办呢?

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

js 复制代码
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 })

js 复制代码
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 对象赋值也会冲掉原始引用

这是原博客举的例子

js 复制代码
let state = reactive({ count: 0 })

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

替换成 ref 也一样成立

js 复制代码
let state = ref({ count: 0 })

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

reactive 对解构操作不友好

这是原博客举的例子

js 复制代码
const state = reactive({ count: 0 })

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

替换成 ref 也一样成立

js 复制代码
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 对象的自动解包。

vue 复制代码
<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 的写法,还有更重磅的:

vue 复制代码
<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 对象本身不能直接传给子组件

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

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

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

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

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

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

vue 复制代码
<template>
    <div>{{ wrapper.aRef }}</div>
</template>

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

当然还是有漏洞可以钻

vue 复制代码
<template>
    <div :title="wrapper.aRef">Hover me</div>
</template>

这样 tooltip 会显示 [object Object]

现在开始使用 reactive

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

js 复制代码
const states = reactive({
    myState: '状态',
    myState2: '状态2',
})

使用的话

vue 复制代码
<template>
    <div>组件状态:{{ states.myState }}</div>
</template>

这样做的好处是

  1. 避免了上述所有 ref 的问题
  2. 通过 states 对象将组件中的所有状态集合到一起,让整个组件非常干净
  3. 在 IDE 中输入 states. 就能很方便的列出当前组件的所有状态并选择使用,对智能提示非常友好
  4. 在函数中可以对 states 做一次性解构,使用非常方便
js 复制代码
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 只存储状态

相关推荐
谢尔登11 小时前
defineProperty如何弥补数组响应式不足的缺陷
前端·javascript·vue.js
涔溪12 小时前
实现将 Vue2 子应用通过无界(Wujie)微前端框架接入到 Vue3 主应用中(即 Vue3 主应用集成 Vue2 子应用)
vue.js·微前端·wujie
T***u33313 小时前
前端框架在性能优化中的实践
javascript·vue.js·前端框架
jingling55514 小时前
vue | 在 Vue 3 项目中集成高德地图(AMap)
前端·javascript·vue.js
油丶酸萝卜别吃14 小时前
Vue3 中如何在 setup 语法糖下,通过 Layer 弹窗组件弹出自定义 Vue 组件?
前端·vue.js·arcgis
J***Q29220 小时前
Vue数据可视化
前端·vue.js·信息可视化
JIngJaneIL21 小时前
社区互助|社区交易|基于springboot+vue的社区互助交易系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·社区互助
ttod_qzstudio1 天前
深入理解 Vue 3 的 h 函数:构建动态 UI 的利器
前端·vue.js
1***s6321 天前
Vue图像处理开发
javascript·vue.js·ecmascript
一 乐1 天前
应急知识学习|基于springboot+vue的应急知识学习系统(源码+数据库+文档)
数据库·vue.js·spring boot