又看到一个谈 ref
和 reactive
的文章,文章举了一堆似是而非的例子说明 reactive
不好。在文章末尾,还加了一句"官方文档建议",说"因为 reactive() 的局限性,所以建议使用 ref() 作为声明响应式状态的主要 API"
作为一个坚定的 reactive
拥护者和使用者,我觉得是时候出来反驳几句了。
为什么要有 ref
谈 ref
和 reactive
就不得不谈到这两者的核心: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)
本身看不出 a
和 b
哪个是 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 = 2
和 x + 2
造成的bug完全不是一个级别。如果 x + 2
中 x
没有自动 .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>
注意这里不能用 toRef
。toRef(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>
这样做的好处是
- 避免了上述所有
ref
的问题 - 通过
states
对象将组件中的所有状态集合到一起,让整个组件非常干净 - 在 IDE 中输入
states.
就能很方便的列出当前组件的所有状态并选择使用,对智能提示非常友好 - 在函数中可以对
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
只存储状态