ref
和 reactive
可以称做是 Vue 响应式 API 中帝国双璧,一时瑜亮。
但问题也来了,为什么整俩?怎么选?
最近在开发中遇到了这个问题,来不及看 Vue 文档了,直接掘金搜索“ref reactive”热度 Top4 找答案。
大致是说 ref
好使,但为啥好使没讲清楚?主要是通过对 Vue 文档的理解以及使用经验,列举了一些场景对比。
这显然不够,只能知其然不知其所以然,让我比较震惊的是竟然没人从源码角度给解释解释。
当时时间比较紧,只能随便选了 ref
把代码推上去。
但是,“天性不可夺,吾辈心中亦有惑!”要是不求甚解,指不定哪天又掉到坑里,光靠加班慢慢爬出来,毕竟不是长久之计。 趁着这股子很上头的杀劲,研究研究。
一时遇到问题求快,只能直接霸道找答案。现在缓过来了,回归王道,先阅读官网文档再 debugger 分析源码。
认认真真看了三遍 Vue 官方文档响应式基础和深入响应式系统,并在本地运行了里面的演示 Demo。
两篇读罢,最大的收获竟然是能把 ref
和 computed
代码自己写出来,这可是头一回不看源码能写出代码逻辑,Vue 文档功力由此可见一斑,大大的赞。
先等等,回归初心,“ref 还是 reactive?” 文档的标准答案是:
因为 reactive() API 有一些局限性:
- 有限的值类型,即只支持对象类型,不支持基本类型(number, string, boolean, undefined, null)。
- 不能替换整个对象,否则响应失效。
- 对解构操作不友好,解构后响应失效。
所以建议使用 ref() 作为声明响应式状态的主要 API。
Vue 官方文档《响应式基础》
显然,这个答案并没有解决我的困惑。
-
如果说
reactive
不支持基本数据类型,那这有啥难的?直接像ref
那样包裹一层对象写成({value})
不就行了。 -
如果说不能替换整个对象,因为
reactive
返回的 Proxy 就是响应式对象本身,直接替换当然不行,其实你替换ref
返回的 RefImpl 一样不行,监听的引用都换掉了,响应能不失效么? -
解构不友好,和 2 同理。
综上所述,如果就这点区别非要把一个响应式功能硬搞出两个 API 出来,Vue 绝无可能走到今天全球前端框架 Top2 的江湖地位。
此题无解,我解。
解铃还须系铃人,唯一破解的答案肯定在 Vue 源码实现里。不过源码毕竟不同于小作文,并不是像流水一样从上到下层层递进,而是像一张网,窜来窜去,直接阅读很容易怀疑人生。
先把 ref
和 reactive
返回日志打印出来,直观感受一下到底是个啥?有啥区别?
对象数据类型 | 基本数据类型 | |
---|---|---|
ref | RefImpl {_value: Proxy(Object)} | RefImpl {_value: value} |
reactive | Proxy(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 来给广大前端开发爱好者找点事做。
基础类型没啥好说的,reactive
使用的是对象代理,也没想过把基础类型包一层对象,自然不支持,你如果站队 reactive
,自己手动包成对象 reactive({count})
就行了。
对象类型得展开说说,从 log 打印可以看出来,ref
返回的 RefImpl 除 _value
指向代理对象 Proxy 外,额外多挂了 dep
、__v_isRef
、__v_isShallow
、_rawValue
这四个值,这都是干啥的?
要回答这些问题,就需要 debugger 源码了。
研究过源码的朋友都知道,直接一个猛子扎进去人肉读源码,八成得从入门到放弃。 我的做法是“先捋个思路,明确方向,带上目标才不至于在茫茫代码中迷失自己,有的放矢才能事半功倍”。 另外,不要只读静态源码,要有想法,边运行调试边理解想象,“读”万卷书不如“行”万里路。
说到捋思路,书接上文,先来看看我怎么从深入响应式系统写出了 ref
和 computed
代码实现。
让我们从一个同时使用了 ref
和 computed
的小栗子入手。
上面按钮点击会显示累加 1 后的数,同时下面按钮显示上面累加值 2 倍的数。
令人欣喜的是,Vue 官网中就有介绍直接 html 导入 vue.global.js 的 csn 代码,能直接下载到本地导入。 这可太方便啦,意味着我想咋改就咋改,至此,Vue 源码分析犹如囊中之物唾手可得。
这个示例没啥好说的,直接上代码👇
注: <style>
标签我直接删了,占地方,源码详见diyVueV0RefComputed.html。
上面示例需要完成以下 2 个功能点:
-
点击按钮如何更新 DOM 显示新数据?
-
countA0Ref
数据变化后,countA1ComputedRef
如何监听到并且重新调用computed
中的函数刷新值?
问题 1 官方文档的确没说,我在 vue.global.js 源码里面搜索关键字 mount
、render
,试了几下试出来的。
先是用 app.unmount()
和 app.mount()
,发现报错,再试了下 context.reload()
,发现不生效,然后就看到了 _instance.update()
,一试,竟然可以,运气的确是爆棚。
直接上代码,像 _instance
如何获取的就不赘述了,打 log 或者 debugger 多看看,总是会有惊喜等着你。
问题 2 的确是深入响应式系统给我讲懂了,其中少不了运行文档示例进一步加深理解和想象。
ref
很好实现,就是包了一层对象,同时通过重写 value
属性的 set
和 get
监听。
这里面有意思的是第 3 行,也就是 refObj
在模版中不需要带迷人的 .value
小尾巴,但是在 js 代码里面却需要,知道为啥么?看图👇
Vue 在通过模版转换成 DOM 操作时,如果遇到对象 __v_isRef
为 true
,自动给带上迷人的 .value
小尾巴,等同于一块语法糖,给程序猿枯燥的生活加点甜。
是时候,真正的难点来了,countA1ComputedRef
是怎么跟着重新计算的呢?
在张口就问前,先想想,要是我做,怎么做? 初步想法应该是像 debugger 的堆栈一样,找到源头重新触发执行目标函数就可以,但是具体咋做呢? 函数执行还要上下文参数,以及怎么找到被修改的值再重新赋值回去?
Vue 响应式 API 画龙点睛之笔也就在此,既然调用的时候再往回找不好找,那就在执行副作用函数时主动记录一下。通过一个全局变量 activeEffect
记录当前正在执行的副作用函数(即对应 computed
里面的函数),副作用函数执行完毕再将 activeEffect
清空。
当调用 refObj.value
时,拦截并判断当前 activeEffect
是否非空,如果 activeEffect
非空则表示 activeEffect
副作用函数访问了当前 refObj.value
,将 activeEffect
记录下来。
当重新赋值 refObj.value
时,则将访问拦截时记录的所有副作用函数重新执行一遍,即完成了响应式跟踪触发。
看到这,聪明的你应该也明白了,为啥 computed
函数的入参函数没参数,其实主要是为了方便直接执行副作用函数,与其外部记录函数上下文参数传进来,不如函数内部直接通过“闭包”引用上下文。
细心的朋友还会发现有个 queueFlush()
函数没有看到,这里我简单做了个优化,副作用不是在 set value
拦截内部立即触发,而是放在下个微任务里执行,在加入执行集合前进行去重过滤,这样就避免了一个副作用函数被重复执行多次,白白消耗性能。
整个初步 ref
和 computed
实现源码详见diy-vue.v0.global.js。
试着运行看看吧!
看到这,相信 Vue 响应式原理应该差不多火候了。
再次回归当初的那个问题:“ref 还是 reactive?”
上面只说了 ref
实现,reactive
还没有。
不过按理说,两个都是响应式 API,实现应该差不多。
继续举个小栗子步步逼近,分别定义 ref(count)
和 reactive({count})
,绑定到 <div>
标签展示,并且点击 count++
。
点击断点 debugger 走一遍。
理解了 ref
和 computed
实现,再 debugger Vue 源码,会事半功倍,不用费多大劲就能猜出个七七八八,关键也就 3 点:
-
reactive
使用对象代理 Proxy 来监听属性访问变化,最大的优势是所有属性访问都能监听到,哪怕是深层对象属性变化,ref
对于基本数据类型只能监听到value
属性变化,如果是对象数据类型,则直接调用reactive
创建对象代理赋值给value
属性。这也是大家所说的ref
底层也是reactive
实现的。 -
reactive
因为用的是对象代理,不方便在原对象上记录额外的副作用记录信息,所以存了个全局变量const targetMap = new WeakMap()
,数据结构是官方文档中提到的WeakMap<target, Map<key, Set<effect>>>
。ref
因为自身包了一层对象,所以副作用直接可以存储在该对象的dep
属性里面,数据结构是Set<effect>
。 -
如果
ref
参数是对象,如const countObjRef = ref({count: 0})
,即支持直接countObjRef.value
访问修改(将会被countObjRef
的value
属性的set
和get
监听),也支持countObjRef.value.count++
(将会被countObjRef.value
的对象代理 Proxy 监听)。 这点将非常关键,是回答 “ref 还是reactive?” 问题的突破口。
其他逻辑不再赘述了,上 ref
和 reactive
实现代码👇
整个 ref
、reactive
和 computed
完全源码详见 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
重新赋值响应依旧有效,因为此时 refObj
对 value
属性的 set
和 get
监听依旧在。
当然了,如果你嫌混用麻烦且不想纠结这些技术细节,使用 ref
是相对较好的选择,一方面不会出问题,另外因为 ref
是 Vue 官网推荐写法,在多人协作开发情况下,随大流能提升代码可读性,有效降低学习成本和协作运维成本。
上述所有示例代码仓库详见sheng-vue-playground。
更多
不得不说,Vue 文档是迄今为止我认为可以媲美 Google 的 Android 开发者文档,内容简洁明了,且不止于告诉你怎么用,还有实现原理层面的深入浅出,甚至光看文档也能理解原理。 回想当年进击 ReactNative,只能凭借方法论和 debugger 源码来理解,感兴趣的朋友可以看看我写的 进击ReactNative-疾如风和进击ReactNative-徐如林-React源码解析。
欢迎评论区讨论、交流,或者微信搜索“书强号”公众号关注、shengshuqiang01加微信,期待和大牛的你做朋友,教学相长。
下一篇“Vue 组件 v-model 等价展开、computed 和 watch 三种写法谁更优雅?”。
我是美团小象超市营销前端负责人盛书强,欢迎加入我们大前端团队。
谨以此文,检验 Vue 入门的我。