ref 还是 reactive,这是个问题

42,203 阅读11分钟

refreactive 可以称做是 Vue 响应式 API 中帝国双璧,一时瑜亮。

但问题也来了,为什么整俩?怎么选?

清华or北大

最近在开发中遇到了这个问题,来不及看 Vue 文档了,直接掘金搜索“ref reactive”热度 Top4 找答案。

Pasted image 20231218025437.png

大致是说 ref 好使,但为啥好使没讲清楚?主要是通过对 Vue 文档的理解以及使用经验,列举了一些场景对比。 这显然不够,只能知其然不知其所以然,让我比较震惊的是竟然没人从源码角度给解释解释。

当时时间比较紧,只能随便选了 ref 把代码推上去。

但是,“天性不可夺,吾辈心中亦有惑!”要是不求甚解,指不定哪天又掉到坑里,光靠加班慢慢爬出来,毕竟不是长久之计。 趁着这股子很上头的杀劲,研究研究。

图片

一时遇到问题求快,只能直接霸道找答案。现在缓过来了,回归王道,先阅读官网文档再 debugger 分析源码。

认认真真看了三遍 Vue 官方文档响应式基础深入响应式系统,并在本地运行了里面的演示 Demo。

两篇读罢,最大的收获竟然是能把 refcomputed 代码自己写出来,这可是头一回不看源码能写出代码逻辑,Vue 文档功力由此可见一斑,大大的赞。

先等等,回归初心,“ref 还是 reactive?” 文档的标准答案是:

因为 reactive() API 有一些局限性:

  1. 有限的值类型,即只支持对象类型,不支持基本类型(number, string, boolean, undefined, null)。
  2. 不能替换整个对象,否则响应失效。
  3. 对解构操作不友好,解构后响应失效。

所以建议使用 ref() 作为声明响应式状态的主要 API。

Vue 官方文档《响应式基础》

显然,这个答案并没有解决我的困惑。

  1. 如果说 reactive 不支持基本数据类型,那这有啥难的?直接像 ref 那样包裹一层对象写成 ({value})不就行了。

  2. 如果说不能替换整个对象,因为 reactive 返回的 Proxy 就是响应式对象本身,直接替换当然不行,其实你替换 ref 返回的 RefImpl 一样不行,监听的引用都换掉了,响应能不失效么?

  3. 解构不友好,和 2 同理。

综上所述,如果就这点区别非要把一个响应式功能硬搞出两个 API 出来,Vue 绝无可能走到今天全球前端框架 Top2 的江湖地位。

此题无解,我解。

图片

解铃还须系铃人,唯一破解的答案肯定在 Vue 源码实现里。不过源码毕竟不同于小作文,并不是像流水一样从上到下层层递进,而是像一张网,窜来窜去,直接阅读很容易怀疑人生。

先把 refreactive 返回日志打印出来,直观感受一下到底是个啥?有啥区别?

图片

对象数据类型基本数据类型
refRefImpl {_value: Proxy(Object)}RefImpl {_value: value}
reactiveProxy(Object)value, 无响应性

如果值是对象数据类型,ref API 返回的是 RefImpl,其中 _value 为对象代理 Proxy ,同 reactive API 返回。

如果值是基本数据类型,ref 返回的是 RefImpl,其中 _value 为对应的基本数据类型,同 reactive 返回的数值。注意,此时 reactive 返回的值不具备响应性。

至此,可能有朋友要下结论了,“ref 底层就是 reactive 实现,reactive 更强,用 reactive”,或者“reactive 是 ref 的子集,ref 更大,用 ref”。 的确,我身边的前端朋友也是这么回答的,一些网上的文章也这么认为,整体而言,站队 ref 的呼声更高。

还是那句话,我还是不信 Vue 就因为这么个理由,非整俩响应式 API 来给广大前端开发爱好者找点事做。

w700d1q75cms (1).jpg

基础类型没啥好说的,reactive 使用的是对象代理,也没想过把基础类型包一层对象,自然不支持,你如果站队 reactive,自己手动包成对象 reactive({count}) 就行了。

对象类型得展开说说,从 log 打印可以看出来,ref 返回的 RefImpl 除 _value 指向代理对象 Proxy 外,额外多挂了 dep__v_isRef__v_isShallow_rawValue 这四个值,这都是干啥的? 

要回答这些问题,就需要 debugger 源码了。

研究过源码的朋友都知道,直接一个猛子扎进去人肉读源码,八成得从入门到放弃。 我的做法是“先捋个思路,明确方向,带上目标才不至于在茫茫代码中迷失自己,有的放矢才能事半功倍”。 另外,不要只读静态源码,要有想法,边运行调试边理解想象,“读”万卷书不如“”万里路。

说到捋思路,书接上文,先来看看我怎么从深入响应式系统写出了 refcomputed 代码实现。

让我们从一个同时使用了 refcomputed 的小栗子入手。

图片

上面按钮点击会显示累加 1 后的数,同时下面按钮显示上面累加值 2 倍的数。

令人欣喜的是,Vue 官网中就有介绍直接 html 导入 vue.global.js 的 csn 代码,能直接下载到本地导入。 这可太方便啦,意味着我想咋改就咋改,至此,Vue 源码分析犹如囊中之物唾手可得。

这个示例没啥好说的,直接上代码👇

图片

注: <style> 标签我直接删了,占地方,源码详见diyVueV0RefComputed.html

上面示例需要完成以下 2 个功能点:

  1. 点击按钮如何更新 DOM 显示新数据?

  2. countA0Ref 数据变化后,countA1ComputedRef 如何监听到并且重新调用 computed 中的函数刷新值?

问题 1 官方文档的确没说,我在 vue.global.js 源码里面搜索关键字 mountrender,试了几下试出来的。 先是用 app.unmount()app.mount(),发现报错,再试了下 context.reload(),发现不生效,然后就看到了 _instance.update(),一试,竟然可以,运气的确是爆棚。

直接上代码,像 _instance 如何获取的就不赘述了,打 log 或者 debugger 多看看,总是会有惊喜等着你。

图片

问题 2 的确是深入响应式系统给我讲懂了,其中少不了运行文档示例进一步加深理解和想象。

ref 很好实现,就是包了一层对象,同时通过重写 value 属性的 setget 监听。

图片

这里面有意思的是第 3 行,也就是 refObj 在模版中不需要带迷人的 .value 小尾巴,但是在 js 代码里面却需要,知道为啥么?看图👇

图片

Vue 在通过模版转换成 DOM 操作时,如果遇到对象 __v_isReftrue,自动给带上迷人的 .value 小尾巴,等同于一块语法糖,给程序猿枯燥的生活加点甜。

是时候,真正的难点来了,countA1ComputedRef 是怎么跟着重新计算的呢?

在张口就问前,先想想,要是我做,怎么做? 初步想法应该是像 debugger 的堆栈一样,找到源头重新触发执行目标函数就可以,但是具体咋做呢? 函数执行还要上下文参数,以及怎么找到被修改的值再重新赋值回去?

图片

Vue 响应式 API 画龙点睛之笔也就在此,既然调用的时候再往回找不好找,那就在执行副作用函数时主动记录一下。通过一个全局变量 activeEffect 记录当前正在执行的副作用函数(即对应 computed 里面的函数),副作用函数执行完毕再将 activeEffect 清空。

当调用 refObj.value 时,拦截并判断当前 activeEffect 是否非空,如果 activeEffect 非空则表示 activeEffect 副作用函数访问了当前 refObj.value,将 activeEffect 记录下来。

当重新赋值 refObj.value 时,则将访问拦截时记录的所有副作用函数重新执行一遍,即完成了响应式跟踪触发。

看到这,聪明的你应该也明白了,为啥 computed 函数的入参函数没参数,其实主要是为了方便直接执行副作用函数,与其外部记录函数上下文参数传进来,不如函数内部直接通过“闭包”引用上下文。

图片

细心的朋友还会发现有个 queueFlush() 函数没有看到,这里我简单做了个优化,副作用不是在 set value 拦截内部立即触发,而是放在下个微任务里执行,在加入执行集合前进行去重过滤,这样就避免了一个副作用函数被重复执行多次,白白消耗性能。

图片

整个初步 refcomputed 实现源码详见diy-vue.v0.global.js

试着运行看看吧!

图片

看到这,相信 Vue 响应式原理应该差不多火候了。 再次回归当初的那个问题:“ref 还是 reactive?” 上面只说了 ref 实现,reactive 还没有。 不过按理说,两个都是响应式 API,实现应该差不多。

继续举个小栗子步步逼近,分别定义 ref(count)reactive({count}),绑定到 <div> 标签展示,并且点击 count++。 点击断点 debugger 走一遍。

图片

图片

理解了 refcomputed 实现,再 debugger Vue 源码,会事半功倍,不用费多大劲就能猜出个七七八八,关键也就 3 点:

  1. reactive 使用对象代理 Proxy 来监听属性访问变化,最大的优势是所有属性访问都能监听到,哪怕是深层对象属性变化,ref 对于基本数据类型只能监听到 value 属性变化,如果是对象数据类型,则直接调用 reactive 创建对象代理赋值给 value 属性。这也是大家所说的 ref 底层也是 reactive 实现的。

  2. reactive 因为用的是对象代理,不方便在原对象上记录额外的副作用记录信息,所以存了个全局变量 const targetMap = new WeakMap() ,数据结构是官方文档中提到的 WeakMap<target, Map<key, Set<effect>>>ref 因为自身包了一层对象,所以副作用直接可以存储在该对象的 dep 属性里面,数据结构是 Set<effect>

  3. 如果 ref 参数是对象,如 const countObjRef = ref({count: 0}) ,即支持直接 countObjRef.value 访问修改(将会被 countObjRefvalue 属性的 setget 监听),也支持 countObjRef.value.count++(将会被 countObjRef.value 的对象代理 Proxy 监听)。 这点将非常关键,是回答 “ref 还是reactive?” 问题的突破口。

其他逻辑不再赘述了,上 refreactive 实现代码👇

图片

整个 refreactivecomputed 完全源码详见 diy-vue.v1.global.js

回到最初的问题,“ref 还是 reactive?”

与其说“ref 还是 reactive?”,不如说 “基础数据类型监听还是对象数据类型监听?” 这才是我一直苦苦寻找的“为什么 Vue 非要整俩响应式 API”的根源。 ref 因为内部做了两者兼顾,自然而然成了 Vue 官方推荐写法。

对这个问题,我的答案是:

如果响应数据是基本数据类型,建议使用 ref,即使多了个迷人的 .value 小尾巴,也还行。

如果是对象类型且仅内部修改这种简单场景,特别是深层对象,建议使用 reactive,毕竟 refObj.value.xxx 显然没有 reactiveObj.xxx 用的爽,使用 ref 本身也只是个壳,里面用的就是reactive,杀鸡焉用牛刀。

如果是对象类型且有重置清空这种复杂场景(常见表单和筛选项),那必须使用 ref 了,因为 reactiveObj 被重新赋值后,对象代理 Proxy 被清掉,此时已再无监听可能,最终导致响应失效。 但是对 refObj.value 重新赋值响应依旧有效,因为此时 refObjvalue 属性的 setget 监听依旧在。

图片

当然了,如果你嫌混用麻烦且不想纠结这些技术细节,使用 ref 是相对较好的选择,一方面不会出问题,另外因为 ref 是 Vue 官网推荐写法,在多人协作开发情况下,随大流能提升代码可读性,有效降低学习成本和协作运维成本。

上述所有示例代码仓库详见sheng-vue-playground

更多

不得不说,Vue 文档是迄今为止我认为可以媲美 Google 的 Android 开发者文档,内容简洁明了,且不止于告诉你怎么用,还有实现原理层面的深入浅出,甚至光看文档也能理解原理。 回想当年进击 ReactNative,只能凭借方法论和 debugger 源码来理解,感兴趣的朋友可以看看我写的 进击ReactNative-疾如风进击ReactNative-徐如林-React源码解析

欢迎评论区讨论、交流,或者微信搜索“书强号”公众号关注、shengshuqiang01加微信,期待和大牛的你做朋友,教学相长。

下一篇“Vue 组件 v-model 等价展开、computed 和 watch 三种写法谁更优雅?”。

我是美团小象超市营销前端负责人盛书强,欢迎加入我们大前端团队。

谨以此文,检验 Vue 入门的我。