背景
部门要开发一款小程序,技术栈为 Vue。
此时 Vue3 刚发布 beta 版,Taro3 还没有横空出世情况下,为了能享受 composition-api 的红利,我们将目标瞄向了 composition-api plugin
使用这个插件可以让 Vue2 支持 composition-api 部分功能
而这个部分功能就是引发问题的根源
现象
为了简化认知成本,写了 2 个简易 demo,技术栈均为 Vue2,分别用 composition-api plugin 和 optional-api 实现相似的功能
composition-api plugin
<template><div><div>CompositionApiPlugin</div><div>reactiveObj:{{reactiveObj}}</div><div>computedValue:{{computedValue}}</div><button@click="onClick">adda</button></div></template><script>import{reactive,defineComponent,computed,set}from'@vue/composition-api'exportdefaultdefineComponent({setup(){constreactiveObj=reactive({})constcomputedValue=computed(()=>{returnreactiveObj.a})return{reactiveObj,computedValue,onClick:()=>{set(reactiveObj,'a',1)}}}})</script>
optional-api
<template><divid="app"><div>OptionalApi</div><div>reactiveObj:{{reactiveObj}}</div><div>computedValue:{{computedValue}}</div><button@click="onClick">adda</button></div></template><script>importVuefrom"vue";exportdefault{data(){return{reactiveObj:{}}},computed:{computedValue(){returnthis.reactiveObj.a}},methods:{onClick(){Vue.set(this.reactiveObj,'a',1)}},}</script>
结果
定义一个空的响应式对象 reactiveObj
,一个值为 reactiveObj.a
的计算属性 computedValue
由于 Vue2 直接给响应式对象设置一个不存在的 key 会导致视图无法更新,所以这里借助 Vue.set
主动通知 reactiveObj
的订阅,触发更新
理论上 reactiveObj
和 computedValue
都会被更新,最终在页面上显示 { "a":1 } 和数字 1
来看结果
reactiveObj
都被成功更新了,而只有 optional-api 版本的 computedValue
得到了更新,这是为什么呢?
分析
找寻问题源头
打开 Vue devtools 查看数据源
可以发现 composition-api plugin 版本 computedValue
数据层面也没有更新
继续追查,computedValue
未被更新的原因无非 2 种
Vue.set
给 reactiveObj
赋值后,没有通知 reactiveObj
的订阅更新
Vue.set
通知了 reactiveObj
的订阅更新,但 computedValue
没有收到更新消息
为了弄清缘由,给 Vue.set
设置断点,查看此时 reactiveObj
里收集的订阅(下图 subs 数组)
可以发现,在调用 Vue.set
之前 reactiveObj
只收集了一个订阅,通过 expression
字段的值可以断定它是一个渲染 watcher(即和视图相关的 watcher,当渲染 watcher 更新时,视图会重新渲染)
这里问题来了,因为在 setup 时调用了 computed
函数,收集 reactiveObj
作为依赖,所以在 reactiveObj
里理论上应该还有一个订阅,即名为 computedValue
的计算 watcher
正确的逻辑应该为以下流程:
在 optional-api 里调试可以发现,此时的 reactiveObj
内确实存储了 2 个订阅,分别为渲染 watcher 和名为 computedValue
的计算 watcher
从 reactiveObj
缺少的订阅可以推倒出,应该是前面提到的第二种情况,即
composition-api plugin 版本的计算属性 computedValue
没有收集 reactiveObj
作为依赖,所以无论 reactiveObj
如何更新,computedValue
也无法被更新
分析问题原因
找到问题源头后,接着分析
为什么 computedValue
没有收集到 reactiveObj
作为依赖?
这里得简述下 Vue 依赖收集更新的原理
Vue 内部通过调用 Object.defineProperty
,拦截对象的 getter/setter,得到一个响应式对象
当计算属性访问(依赖)响应式对象时,会触发其 getter,在该对象的 __ob__.dep.subs
里添加一个订阅
当某个 key 的值被修改时,会遍历其 subs 里所有的订阅并通知更新
碎花我们给 2 个版本的计算属性分别打断点,查看计算属性依赖收集的行为
composition-api plugin
可以发现,在访问 reactiveObj.a
时,并没有触发 reactiveObj
的 getter,所以没有收集到依赖,也就是说,此时的计算属性值是一个常量
optional-api
从图里可以看到,optional-api 版本会触发 reactiveObj
的 getter
分析 reactive 函数
来到了最后一个问题,为什么在 composition-api plugin 里的 reactive
函数创建的响应式对象,没有触发 getter?
还是老办法,通过源码查看底层实现,reactive
的核心实现在 composition-api plugin src/reactivity/reactive.ts第 247 行
抛开一些边缘判断,其实 composition-api plugin 提供的 reactive
函数就是 Vue.observable
的语法糖
熟悉 Vue2 的同学应该了解, Vue.observable
返回的对象,本身并不是响应式的(但是会对其已知的 key 做递归的响应式绑定),这一点官方文档上也有明确标注
而 optional-api 里,reactiveObj
本身是响应式的,原因在于 optional-api 在组件初始化时,会对整个 data 做递归的响应式绑定
所以 composition-api plugin 里,computedValue
无法对一个普通对象收集依赖,而 optional-api 里,由于 reactiveObj
是响应式的,所以 computedValue
可以正常收集依赖
题外话
例子中的 reactiveObj
是一个空对象,并且是因为设置了一个不存在的值,导致视图没有更新
那么如果是一个已知 key 呢?将代码改造一下,给 reactiveObj
预先添加一个名为 a 的 key
<template><div><div>CompositionApiPlugin</div><div>reactiveObj:{{reactiveObj}}</div><div>computedValue:{{computedValue}}</div><button@click="onClick">adda</button></div></template><script>import{reactive,defineComponent,computed}from'@vue/composition-api'exportdefaultdefineComponent({setup(){constreactiveObj=reactive({a:1//预先添加的key})constcomputedValue=computed(()=>{returnreactiveObj.a})return{reactiveObj,computedValue,onClick:()=>{reactiveObj.a=2}}}})</script>
竟然也能成功更新!
前面提到 reactive
函数返回的对象虽然本身不是响应式,但是会对其已知的 key 做递归的响应式绑定
也就是说此时计算属性在访问 reactiveObj.a
时,会触发 a 的 getter, 最终实现依赖收集
但如果给 reactive
传入一个空对象,由于 composition-api plugin 底层还是会用 Object.defineProperty
拦截 getter/setter 作响应式的绑定,所以无法处理不存在的 key
当然解决办法也很简单,使用 Vue3 版本的 reactive
它的底层基于 Proxy
实现, 而 Proxy
会代理整个对象,可以拦截对象几乎任何操作,包括创建一个不存在的 key
<template><div><div>Vue3</div><div>reactiveObj:{{reactiveObj}}</div><div>computedValue:{{computedValue}}</div><button@click="onClick">adda</button></div></template><script>import{reactive,defineComponent,computed}from'vue'exportdefaultdefineComponent({setup(){constreactiveObj=reactive({})constcomputedValue=computed(()=>{returnreactiveObj.a})return{reactiveObj,computedValue,onClick:()=>{reactiveObj.a=1}}}})</script>
总结
计算属性需要收集依赖才能实现数据更新,而收集依赖的前提必须是响应式
composition-api plugin 里使用 reactive
函数创建的对象,和 optional-api 里在 data 里定义的对象,行为略有区别
前者创建的对象,本身并不是响应式的,但会对它已知的 key 作递归的响应式绑定
后者会在组件初始化时将 data 里的所有值作递归的响应式绑定
composition-api plugin 的 reactive
函数,和 Vue3 版本的 reactive
函数,行为略有区别
前者返回的是源对象,并且无法对不存在的 key 作响应式绑定
后者返回的是新的代理对象,允许对不存在的 key 作响应式绑定
作者:yeyan1996