说一下 Computed 第一篇 的悲惨结局
第一篇是讲 Vue响应式技术原理 文章,我花费了比较多的时间去写,但效果并不好。
我自觉在技术层面自己写的还不错,但为啥效果不好我也不清楚。
我试图去思考 响应式 的核心究竟是什么 ,看能不能单独围绕着核心来写。考虑到看 原理的有许多是刚开始接触,就希望通过 Computed 去作为一根线,去引导着文章从开头到结尾,从而实现 Computed 的核心功能,而其就是通过 响应式的 核心去实现的。
本文主要借鉴于 ,想要理解原理的,可以看看。我认为能不能写出原理的代码是次要。能理清思路并说的明白,那就很有价值了。
关于上篇讲了些什么
因为 proxy 在 Vue3 的使用下名声大噪,所以我之前一直以为 proxy 就是响应式的核心,但后来才发现这只不过是冰山一角。effect 副作用函数 与 bucket桶结构 才是核心内容。
所以上篇主要是通过我们熟知的 computed 作为引入,从而在实现 computed 的过程中讲了如下:
- effect 入口副作用函数
- 要入桶的副作用函数
- bucket 桶结构
- 用于执行 副作用函数的 track 函数
- 用于收集 副作用函数的 trigger函数
- 实现懒加载
- 通过 effect 实现基本的 computed
简单说一下 响应式
let obj = {
a:1,
b:1
}
function getValue(fn){
return fn()
}
let res = getValue( ()=> obj.a+obj.b )
上面这个 通过调用 函数,并传入一个 函数作为参数,然后 调用 fn并返回其返回值,则 res 就是 的值。但是,如果 obj.a 或 obj.b 的值变了呢? 那res的值却不会变。
而响应式就是说 的值会随着 的值而自动改变。
简单说一下 响应式实现逻辑
想要 res 的值随着 obj.a 或 obj.b 的值改变而改变,其实很简单
let obj = {
a:1,
b:1
}
let res
function setValue(){
res = obj.a+obj.b
}
//更新值
setValue();
obj.a = 10;
//更新值
setValue();
obj.b = 20;
//更新值
setValue()
上面就实现了 res 的值是新值,就是说 只要在 obj.a 或 obj.b 的值改变时,调用 setValue 函数,就可以实现了。 而 setValue 的目的就是 重新计算,那这就要知道何时 obj 的属性时会变。这就是 proxy 的用处了。这里就不讲这个了。让我们快速进入 桶bucket 环节。
简单看下 啥是桶
上面知道了,想要让 res 的值一直是 新的,则不停的调用 就可以了,所以我们可以将 作为一个函数,然后与 a 和 b 做一个绑定。
当通过 get 拦截器捕获到 obj 属性值读取时,将函数添加到对应的属性下;当 通过 set 拦截器捕获到 obj 属性值修改操作时,将 函数 从对应的属性下取出 执行,就可以自动调用 ,则res的值就是新的了。
桶就类似一个 多层嵌套 的对象结构,就下面这样。
{
// 最外层存储不同的对象
obj:{
// 第二层 则是不同的属性
'a':function(){res = obj.a + obj.b},
'b':function(){res = obj.a + obj.b}
},
obj2:{
'a':function(){res = obj.a + obj.b},
'b':function(){res = obj.a + obj.b}
},
obj3:{
'a':function(){res = obj.a + obj.b},
'c':function(){res = obj.a + obj.b}
}
}
上面这个结构 以 保存,a改变了,则取出函数执行,b改变了,则也取出函数执行,就可以了。
介绍差不多了,我要开始这篇了。
优化代码 之 清除无效的监听 cleanup 函数
当我们实现 对依赖数据进行追踪时,倒是可以去想一下是否所有的数据变化都要追踪。如 a 或 b 的改变 影响了 res 的 值,那我们当然要对 a与b 进行追踪。但下面这种情况就有些不一样了。
const data = {
isShow:true,
a:1,
b:1
}
const obj = new Proxy(...)
let res
effect(()=>{
res = obj.isShow ? ( obj.a + obj.b ) :3;
})
obj.isShow = false
由于 上面一开始的时候 为 true,所以 ,此时 a 或 b 的改变都会影响 res,则 在桶中会存储如下的结构
{
// 最外层存储不同的对象
obj:{
// 第二层 则是不同的属性
'a':function(){res = obj.isShow ? ( obj.a + obj.b ) :3;},
'b':function(){res = obj.isShow ? ( obj.a + obj.b ) :3;},
'isShow':function(){res = obj.isShow ? ( obj.a + obj.b ) :3;}
}
}
为什么 isShow 也在其中呢? 因为 isShow 的改变也会影响res 的值。但是当 isShow 被设置为 false 后,可以确定的是,无论 a 或 b如何改变,都不会影响 res 的值,因为此时 。那这个时候如果 a 或 b 的改变还是会执行 这句代码,则完全没有意义。
那其实我们只需要在 isShow 改变时,将 a 与 b 的属性删除即可。像下面这样,就没有问题了。
{
obj:{
'isShow':function(){res = obj.isShow ? ( obj.a + obj.b ) :3;}
}
}
但 也许依赖该属性的不只一个,也许有多个,看下面这种情况
const data = {
isShow:true,
a:1,
b:1
}
const obj = new Proxy(...)
let res
effect(()=>{
res = obj.isShow ? ( obj.a + obj.b ) :3;
})
let hello
effect(()=>{
hello = obj.a + obj.b;
})
这种情况下,由于 hello 与 res 都依赖于 obj.a 与 obj.b ,所以 a 与 b的值会有两个函数,因为 a 或 b 的改变都会影响 res 与 hello,而这两个变量是通过不同的函数更新的。下面是这个的桶结构
{
obj:{
'a':[
function(){res = obj.isShow ? ( obj.a + obj.b ) :3;},
function(){hello = obj.a + obj.b;}
]
'b':[
function(){res = obj.isShow ? ( obj.a + obj.b ) :3;},
function(){hello = obj.a + obj.b;}
]
'isShow':[
function(){res = obj.isShow ? ( obj.a + obj.b ) :3;},
]
}
}
可以看到 由于 hello 与 res 都依赖于 a、b,所以这三个属性的改变都会去触发这两个函数,既然如此,当isShow 改变时,就不能直接删除 a 与 b 了, 因为 并不受 isShow 改变而影响。所以我们要保留 对 函数的引用,到需要删除的时候,只需要删除这个函数就可以了。
cleanup 函数 解决不必要追踪
根据上面的逻辑走下来,我们是需要一个用于删除 effect函数(就是存在桶里的函数) 的功能,如删除 obj.a 的 effect 其中的一个函数()。
该函数想要实现删除对应的 函数,还需要两个值,一个是桶中对应的属性值:在下面中,就是 a的值
'a':[
function(){res = obj.isShow ? ( obj.a + obj.b ) :3;},
function(){hello = obj.a + obj.b;}
]
其次要知道 删除的是哪个函数,这里就是
好的,接下来代码要在三个地方修改:
修改 effect 函数
let activeEffect;
// 用于存储多个 fn
const effectStack = []
function effect(fn, options = {}) {
// 创建副作用函数包装器
const effectFn = () => {
// 将当前副作用函数设置为活动的
activeEffect = effectFn;
// 将当前副作用函数压入栈中,解决嵌套effect问题
effectStack.push(effectFn);
// 执行原始函数并获取结果
let res = fn();
// 执行完毕后从栈中弹出
effectStack.pop();
// 恢复之前的activeEffect
activeEffect = effectStack[effectStack.length - 1];
// 返回函数执行结果
return res;
}
这是新增的代码,用于保留属性值,如a和b的值
effectFn.deps = [];
// 将options挂载到effectFn上,方便后续使用
effectFn.options = options;
// 如果不是懒执行模式,则立即执行副作用函数
options.lazy || effectFn();
// 返回副作用函数
return effectFn;
}
上面的 effect 函数就增加了一行代码, 这用来保存桶中对应属性的值,如a属性的值是一个 Set 数据结构,其中有两个 函数
- function(){res = obj.isShow ? ( obj.a + obj.b ) :3;},
- function(){hello = obj.a + obj.b;}
修改 track 函数
// track 的意思有追踪
function track(target,key){
// 该函数的目的是为了将 副作用函数 添加到桶中,既然没有副作用函数,当然就 return 了
if(!activeEffect) return;
// 依旧是老样子,在多个对象中,找到要操作的对象
let objMap = bucket.get(target);
// 如果是第一次操作该对象,则是新增逻辑,则为该对象分配一个数据空间
objMap||bucket.set(target,objMap = new Map());
// 逻辑同上,在多个属性中,找到要操作的属性
const propSet = objMap.get(key);
// 如果是第一次操作该属性,则是新增逻辑,则为该属性分配一个数据空间
propSet||objMap.set(key,propSet = new Set());
// 最后,将副作用函数添加进该属性的桶中
propSet.add(activeEffect);
这是新增的代码,用于将属性值添加到 deps 中
activeEffect.deps.push(propsSet);
}
第一步是创建一个 数组,用来保存对应属性的值。第二步就是在读取属性时,将属性的值添加到数组中。由于会读取a和b的值,所以 activeEffect.deps的值如下:
[
// 这是a的属性值
[
function(){res = obj.isShow ? ( obj.a + obj.b ) :3;},
function(){hello = obj.a + obj.b;}
],
// 这是b的属性值
[
function(){res = obj.isShow ? ( obj.a + obj.b ) :3;},
function(){hello = obj.a + obj.b;}
]
]
其实a或b的属性值并非完全是上面这样,这是被我简化了的,原型如下:
[
// 这是a的属性值
[
const effectFn = () => {
activeEffect = effectFn;
effectStack.push(effectFn)
// 函数被放在这里了,这里会调用并获取返回值,以备之后做懒加载用
let res = (function(){res = obj.isShow ? ( obj.a + obj.b ) :3;})();
activeEffect = effectStack[effectStack.length - 1];
return res;
},
const effectFn = () => {
activeEffect = effectFn;
effectStack.push(effectFn)
// 函数被放在这里了,这里会调用并获取返回值,以备之后做懒加载用
let res = (function(){hello = obj.a + obj.b;})();
activeEffect = effectStack[effectStack.length - 1];
return res;
},
]
]
其实可以看出来,之前的函数被包裹在 effectFn 函数中了,当然这样也是为了解决其他的问题,我在第一篇讲了,这里就不说这个了。
那其实我们要删除的函数引用就是一个 effectFn 函数,那我们可以在创建effectFn 函数的时候,就可以获取它了。
const effectFn = () => {
// 在这个地方可以直接获取 effectFn 函数
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn)
let res = fn();
activeEffect = effectStack[effectStack.length - 1];
return res;
}
cleanup
function cleanup(effectFn) {
// 循环 deps 获取到对应的属性值
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
// 该属性值不是数组,是一个 Set 结构
// effectFn 就是要删除的 函数
deps.delete(effectFn);
}
effectFn.deps.length = 0;
}
这里有一点需要注意的是 使用了 length = 0 而不是 effectFn.deps = [];
其实也是为了性能, 因为 = [] 是先要创建一个数据,并数组的内存地址的起始地址作为 effectFn.deps,而 length = 0 只是清空数组,不需要创建。
上面就完成了清除属性下对应函数的的功能。