救命!我把 Vue3 响应式系统拆成了乐高积木!(Computed 篇(二))

32 阅读8分钟

说一下 Computed 第一篇 的悲惨结局

第一篇是讲 Vue响应式技术原理 文章,我花费了比较多的时间去写,但效果并不好。

我自觉在技术层面自己写的还不错,但为啥效果不好我也不清楚。

我试图去思考 响应式 的核心究竟是什么 ,看能不能单独围绕着核心来写。考虑到看 原理的有许多是刚开始接触,就希望通过 Computed 去作为一根线,去引导着文章从开头到结尾,从而实现 Computed 的核心功能,而其就是通过 响应式的 核心去实现的。

本文主要借鉴于 Vue.js设计与实现》\color{orange}{《Vue.js 设计与实现》},想要理解原理的,可以看看。我认为能不能写出原理的代码是次要。能理清思路并说的明白,那就很有价值了

关于上篇讲了些什么

因为 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 )

上面这个 res\color{green}{res} 通过调用 getValue\color{green}{getValue} 函数,并传入一个 函数作为参数,然后 调用 fn并返回其返回值,则 res 就是 obj.a+obj.b\color{green}{obj.a + obj.b} 的值。但是,如果 obj.a 或 obj.b 的值变了呢? 那res的值却不会变。

而响应式就是说 res\color{green}{res} 的值会随着 obj.a+obj.b\color{green}{obj.a + obj.b} 的值而自动改变。

简单说一下 响应式实现逻辑

想要 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 的值一直是 新的,则不停的调用 res=obj.a+obj.b\color{green}{res = obj.a + obj.b} 就可以了,所以我们可以将 res=obj.a+obj.b\color{green}{res = obj.a + obj.b} 作为一个函数,然后与 a 和 b 做一个绑定。

当通过 get 拦截器捕获到 obj 属性值读取时,将函数添加到对应的属性下;当 通过 set 拦截器捕获到 obj 属性值修改操作时,将 函数 从对应的属性下取出 执行,就可以自动调用 res=obj.a+obj.b\color{green}{res = obj.a + obj.b} ,则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}
    }
}

上面这个结构 以 对象>属性>依赖该属性的函数\color{orange}{对象->属性->依赖该属性的函数} 保存,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

由于 上面一开始的时候 obj.isShow\color{green}{obj.isShow} 为 true,所以 res=obj.a+obj.b\color{green}{res = obj.a + obj.b} ,此时 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 的值,因为此时 res=3\color{green}{res = 3} 。那这个时候如果 a 或 b 的改变还是会执行 res=obj.isShow?(obj.a+obj.b):3;\color{green}{res = obj.isShow ? ( obj.a + obj.b ) :3;} 这句代码,则完全没有意义。

那其实我们只需要在 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 了, 因为 hello=obj.a+obj.b\color{green}{hello = obj.a + obj.b} 并不受 isShow 改变而影响。所以我们要保留 对 function(){res=obj.isShow?(obj.a+obj.b):3;}\color{green}{function()\{res = obj.isShow ? ( obj.a + obj.b ) :3;\}} 函数的引用,到需要删除的时候,只需要删除这个函数就可以了。

cleanup 函数 解决不必要追踪

根据上面的逻辑走下来,我们是需要一个用于删除 effect函数(就是存在桶里的函数) 的功能,如删除 obj.a 的 effect 其中的一个函数(function(){res=obj.isShow?(obj.a+obj.b):3;}\color{green}{function() \{res = obj.isShow ? ( obj.a + obj.b ) :3;\}})。

该函数想要实现删除对应的 函数,还需要两个值,一个是桶中对应的属性值:在下面中,就是 a的值

'a':[
    function(){res = obj.isShow ? ( obj.a + obj.b ) :3;},
    function(){hello = obj.a + obj.b;}
]

其次要知道 删除的是哪个函数,这里就是 function(){res=obj.isShow?(obj.a+obj.b):3;}\color{green}{function()\{res = obj.isShow ? ( obj.a + obj.b ) :3;\}}

好的,接下来代码要在三个地方修改:

修改 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 函数就增加了一行代码,effectFn.deps=[];\color{green}{effectFn.deps = [];} 这用来保存桶中对应属性的值,如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 只是清空数组,不需要创建。

上面就完成了清除属性下对应函数的的功能。