我们讨论ref-sugar本质上在讨论三件事:设计原则,开发体验,实现魔法。
我们现在在该问题上争论,很多人直接从第三点出发,认为实现魔法不好,但实际上,根源在于第一点,vue3发布的ref没有遵循vue原有的设计原则,使得变量的使用在script和template中呈现了两种形态。
export default {
setup() {
const count = ref(0)
const plus = (inc) => count.value += inc // 脚本中使用 count.value
return { count, plus }
}
}
<div>{{ count }}</div> // 模板中使用 count
设计原则
Vue的设计原则包含两个基本点:1.通过直接修改状态变量而非调用接口完成响应式,2.接近原始js变量操作而非复杂操作的0成本理解。简单说就是,vue帮你封装好响应式,你只要无脑使用,做到真正0心智模型。但在ref这个点上,0心智失效了,因为我们在脚本中经常忘记.value。
对问题的解决,不能超出设计原则,这是vue所坚守的,超出原则的设计,比如这篇文章,虽然可以解决问题,但是一旦泛滥,那么vue就没啥价值了。
开发体验
在开发体验道路上分出了两条岔路,AoT和Runtime。前者把活交给编译过程,能力实现全靠工具,但是可以随意发明语法,想怎么玩都可以,爽,缺点就是脱离了那套编译工具跑不了,典型就是svelte。后者需要自己写一套基于原始js能力的系统实现效果,提供接口,按照接口文档用就完事了,浏览器里面随便跑,缺点是运行时代码加大,心智负担加剧(要记住用法)。
vue想尽一切办法在两者之间做平衡,几年来,长期坚持能够纯runtime实现,还是干不过js语言本身的限制。所以,新提案ref-sugar妥协了,是纯粹的AoT实现方案。当然,AoT也可以在浏览器端搞编译,就像当年less搞的一样,但很明显,这是歧途,自己在浏览器里面解释执行js是不明智的,太不安全了。
对该方案的喷点,多多少少是由于这个原因。因为如果把解决问题的方法丢给编译器去解决,那还有啥是编译器不能解决的?
所以,能够平息这次风波的根本解决办法,一定是:0心智负担+runtime的方案。连尤大都掉坑的问题,是不是无解了呢?接下来看看实现魔法。
实现魔法
我想,尤大掉坑的根源,可能是对设计原则的误解,认为vue必须是基于单一方案实现响应式,所谓单一方案,就是vue里面只能有数据拦截这一套方案。但是,响应式方案真不止数据拦截这一条路。vue1,2采用了defineProperty方案实现响应式,而vue3采用了Proxy,开发者的心智没有任何负担,反而叫好,这说明,问题的根源不在于实现响应式的内部细节,而在最终表现出来的效果。
所以,为什么vue只能基于单一方案实现响应式?为什么不能同时用两套响应式方案?在ref这个点上,脏检查机制就是一个赠送的方案。(这想法不是我提的)
let a = ref(0, () => a)
function handle() {
a = a + 1
}
<div @click="handle">{{ a }}</div>
另外,js里面有一些魔法可以用上了,主要是valueOf和Symbol.toPrimitive,这两个属性可以帮助我们完成对象的值运算。我简单举个例子你就能明白其中的道理。
var a = {
value: 1,
valueOf() { return this.value },
}
var b = {
value: 2,
valueOf() { return this.value },
}
接下来我们做这个运行:a + b得到的结果是3.我们还可以修改a.value = 2,再执行a + b结果是4. 道理就是这么简单。let a = ref(0, () => a) 中,a的结果是一个如上结构的对象,所以参与运行是OK的,对a.value进行改造,就可以做依赖收集了。比如:
let c = computed(() => a + b)
由于上面式子中会调用a.value和b.value,所以可以知道c对a,b有依赖。下面是最关键的:
a = a + 1
对a重新赋值,此时a的数据类型发生了变化,从对象变成了数字。但是没有关系,在computed进行依赖收集时,我们已经得到了一个计算公式:
c = (() => a)() + (() => b)()
也就是说,无论a, b是什么数据类型,计算后得到的c的结果是确定的。而关键的问题在于,一旦a丢失了object类型,就没有办法依靠vue的响应式设计完成响应式效果。此时,脏检查就来了。
- 用callback包装handle(效果类似computed,主要做依赖收集): handle = callback(() => a = a + 1)
- 执行handle时,a.value被收集,代表handle函数有可能对a产生副作用,每次执行handle,可能收集到的ref变量不同,由if...else决定,根据这个情况,还可以做一些优化
- 当handle被调用结束时,对收集到的所有依赖进行检查,检查依据是上一个值和这一次的() => a结果
- 当发现值不相等时,表示依赖被更新了,此时回溯所有computed,如果发现对a有依赖,那么对应computed属性需要被更新
和angularjs的脏检查不同,因为这里我可以明确知道自己依赖了哪些变量,所以我做的检查范围会小很多。而且由于nextTick机制的存在,脏检查也仅限于数据层面,不会触发DOM的更新。
结束
你看,仅仅给ref加了第二个参数,就可以把这个issue给结了。但是,你可能会说,我() => a真不想写啊!!!要解决这个问题,最好的办法,就是你去做TC39主席,然后强推PrimitiveProxy,并且让Proxy完全适用于PrimitiveProxy,这下世界安静了。