[ Vue ] Vue 设计原理之响应式系统实现笔记( 一 )

2022-07-28 22:10:58 浏览数 (2)

副作用函数

副作用函数实际上就是一个执行了之后会影响其他地方的函数,以下面一个例子, effect 执行之后会读取 obj 的 text 值 ,然后将这个值写入到 dom 节点中。

代码语言:javascript复制
const obj = {
    text: '1',
    name: 'vue'
};
function effect(){
    document.body.innerText = obj.text;
}

effect();

基础数据响应

使用 Proxy 来实现 ,它分为两个方法:

  1. get 操作存储副作用函数 fn ,
  2. set 执行已经存储的副作用函数 fn() 。
代码语言:javascript复制
const bucket = new Set();
const data = {
    msg: 1,
}
function effect() {
    window.document.body.innerText = data.msg;
}
const obj = new Proxy(data, {
    get(target, key) {
        bucket.add(effect);
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        bucket.forEach(fn => fn());
        return true;
    }
});

setTimeout(() => {
    obj.msg = 111;
}, 1300)

接下来就要做一些细节上的优化。

需要解决什么问题:首先,通过上面的例子可以看出,这样存放副作用函数会出现一个问题,就是如果使用了另一个函数名,那这一段逻辑就不能使用。所以,需要解决的是灵活配置副作用函数的问题,也就是不能写死。

使用收集器存储

方法就是将副作用函数存放到一个“收集器”里面,下一次在进行读取的时候,如果收集器里面有这个函数,就不再往收集器赋值,代码如下:

代码语言:javascript复制
const bucket = new Set();

let activeEffect;

function effect(fn) {
    activeEffect = fn;
    fn();
}

const data = {
    msg: 111323,
}

const obj = new Proxy(data, {
    get(target, key) {
        if (activeEffect) {
            bucket.add(activeEffect);
        }
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        bucket.forEach(fn => fn());
        return true;
    }
});

effect(
    () => {
        console.log('11')
        window.document.body.innerText = obj.msg;
    }
)

setTimeout(() => {
    obj.data = 3;
}, 1300);

需要解决什么问题:上面这个方法可以配置任意的副作用函数,但是现在有一个新的问题,就是给 obj 再赋一个完全不存在的值,这个时候副作用函数依然会执行。导致这个问题的原因是我们没有在副作用函数与操作的目标之间建立明确的关系,就是要对 target key fn(副作用函数), 给他们做一个关联绑定。

副作用函数函数和键绑定

使用 weakMap ,Set 和 Map 等 es6 的语法来完成这个绑定事件。

读取参数:

  1. 以 data 目标对象作为一个 key, 关联一个 Map 对象,这个 map 对象会包含各个 key 需要的指定方法。而如果需要以 data 对象为 key 那创建 weakMap 容器。
  2. 在 depsMap( map ) 中,创建 key 和 set 结构的关联,这个环节相当于给 target 中的每一个 key ,做了一个关联,set 结构中存储的就是副作用函数的集合。
  3. 将副作用函数存储到 set 集合中,总体看下来,最后每个事件的关联都是存储到了这个收集器 bucket 中。

设置参数:

  1. 根据 target 从收集器 bucket 中,读取 depsMap。
  2. 根据 key 从 depsMap 中读取对应的副作用函数再执行。

如图:

bind-1.pngbind-1.png

代码如下:

代码语言:javascript复制
const data = {
    msg: 'sucess',
}
let activeEffect;

function effect(fn) {
    activeEffect = fn;
    fn();
}

const bucket = new WeakMap();

const obj = new Proxy(data, {
    get(target, key) {

        if (!activeEffect) return target[key];

        let depsMap = bucket.get(target);
        if (!depsMap) {
            bucket.set(target, (depsMap = new Map()));
        }
        
        let deps = depsMap.get(target);
        if (!deps) {
            depsMap.set(key, (deps = new Set()));
        }
        
        deps.add(activeEffect);
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;

        const depsMap = bucket.get(target);
        if (!depsMap) {
            return;
        }

        const effects = depsMap.get(key);
        effects && effects.forEach(fn => fn());
    }
});

effect(() => window.document.body.innerText = obj.msg)

setTimeout(() => {
    obj.msg = 'error';
}, 1100);

这里使用 weakmap 的最重要的目的是防止内存泄漏。与 map 不同,weakmap 是可以被垃圾回收机制回收的。

清除遗留的副作用函数

出现副作用函数的遗留一般是发生在副作用函数内的分支切换,比如,一个副作用函数里面是这样的:

代码语言:javascript复制
window.document.body.innerText = (obj.status === 2) ? obj.msg : 'request error ';

如果这一段副作用函数如果是按照上面的副作用存储方式:

代码语言:javascript复制
function effect(fn) {
    activeEffect = fn;
    fn();
}

当 status === 2 时候,此时副作用函数的绑定关联是正确的,关联如下:

  1. status --> 关联副作用函数 effectFn
  2. msg --> 关联副作用函数 effectFn cleanup-1.pngcleanup-1.png

到这里是没有问题的,但是如果此时给 status 的值改为了 1 。按理在修改 msg 的时候副作用函数是不会再执行了才对,因为,此时 status = 1 他就不会走到 obj.msg 那里,也就是不会触发 obj 的 key 为 msg 的 get 事件。

但实际上,按照前面的副作用函数存储方法他还是会触发到的,因为,他在第一步 status = 2 的时候,已经给 status 和 msg 关联上了, 在 map 字段中已经存在了他们之间的关联集合。

所以,这里需要处理的就是,在执行副作函数的时候,要将上一次的关联清除掉,重新再关联事件。

在将 status 赋值为 1 执行了副作用函数之前讲他们之间的所有关联关系清除,再重新关联关系,以下是 status === 1 的时候 ,应该的关系:

cleanup-2.pngcleanup-2.png

处理思路:使用一个数组容器存储起他们之间的绑定关系,在清除的时候,遍历数组的 key 根据 key 一一对应删除关系,代码树下;

代码语言:javascript复制
function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn
        fn()
    }
    effectFn.deps = [];
    effectFn()
}

// 清除原来绑定的关系
function cleanup(effectFn) {
    for (let i = 0, len = effectFn.deps.length; i < len; i  ) {
        let deps = effectFn.deps[i] 
        deps.delete(effectFn)
    }
    effectFn.deps.length = 0;
}

// 在触发 set 的时候 ,将关联关系存储到一个数组集合
function track(target, key) {

    if (!activeEffect) return;

    let depsMap = bucket.get(target);
    if (!depsMap) {
        bucket.set(target, (depsMap = new Map()));
    }

    let deps = depsMap.get(key);
    if (!deps) {
        depsMap.set(key, (deps = new Set()));
    }
    deps.add(activeEffect);   
    activeEffect.deps.push(deps);  // 新增 :存储
}

这时已经完成了对数据的关联的处理,这里有一点是最开始我也没有想明白的地方,就是将关系做了一个存储,并且最后的删除操作不是直接操作 bucket 这个集合完成的,怎么就做到了删除关联关系,原因就是在存储的时候,push 进去的是他的各依赖的引用,所以最后在删除的时候其实就是删除原来指向的位置,换句话说就是已经操作了 bucket 。

接下来会遇到一个新的问题,就是如果单按上面的做法来看,延迟执行 set 之后,会出现无限循环的情况,原因就是在 set 方法那里。

代码语言:javascript复制
set(target, key, newVal) {
    target[key] = newVal;

    const depsMap = bucket.get(target);
    if (!depsMap) {
        return;
    }

    const effects = depsMap.get(key);
    effects.forEach(fn => fn());
}

简化一下前几段函数的逻辑:

代码语言:javascript复制
const deps = new Set([1]);
deps.forEach((item) => {
    set.delete(item);
    set.add(item);
})

这个和 set 这个结构本身的定义有关,在对 set 结构遍历的同时,删除一个 item 在添加一个 item ,这个时候 foreach 是没有停止的,他会重新被访问,导致无限重复循环下去。

所以这里的解决方法就是在 原来的 deps 的基础上,再套一层 set 结构,这样就避免了直接 foreach 的情况,带代码如下:

代码语言:javascript复制
set(target, key, newVal) {
    target[key] = newVal;
    const depsMap = bucket.get(target);
    if (!depsMap) {
        return;
    }
    const effects = depsMap.get(key);
    const effectsFix = new Set(effects);

    effectsFix.forEach(fn => fn());
}

最后的代码如下:

代码语言:javascript复制
const data = {
    msg: ' ok ',
    status: 2
}
let activeEffect;
const bucket = new WeakMap();

function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn
        fn()
    }
    effectFn.deps = [] 
    effectFn()
}

function cleanup(effectFn) {
    for (let i = 0, len = effectFn.deps.length; i < len; i  ) {
        let deps = effectFn.deps[i] 
        deps.delete(effectFn);
    }
    effectFn.deps.length = 0
}
function track(target, key) {

    if (!activeEffect) return;

    let depsMap = bucket.get(target);
    if (!depsMap) {
        bucket.set(target, (depsMap = new Map()));
    }

    let deps = depsMap.get(key);
    if (!deps) {
        depsMap.set(key, (deps = new Set()));
    }
    deps.add(activeEffect);   
    activeEffect.deps.push(deps);
}

const obj = new Proxy(data, {
    get(target, key) {
        track(target, key)
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;

        const depsMap = bucket.get(target);
        if (!depsMap) {
            return;
        }

        const effects = depsMap.get(key);
        const effectsToRun = new Set(effects);

        effectsToRun.forEach(fn => fn());
    }
});

effect(() => {
    console.log('set html ')
    window.document.body.innerText = (obj.status === 2) ? obj.msg : 'request error ';
})

setTimeout(() => {
    obj.status = 1;
}, 1100);

setTimeout(() => {
    obj.msg = 'request 2';
}, 2100);

到这里响应式的逻辑基本上已经成型,但是,现在需要看看副作用函数的情况,如果仅仅按照上面的逻辑来执行也是不够,这时要考虑副作用函数的在嵌套的情况下是不是有问题。

副作用函数嵌套问题

嵌套问题一定是会存在的一般是发生在引用组件的的情况下,举例:

代码语言:javascript复制
effect(()=>{
    effect(()=>{
       comp.render()
    })
   comp.render()
})

结合前面使用的例子来看,首先,第一次执行是正确的,但是执行完之后,再对 msg 或者 data 做修改的时候,就会有问题,代码和结果如下:

代码语言:javascript复制
effect(() => {
    effect(() => {
        effect(() => {
            console.log('fn3 run ', obj.msg);
        });
        console.log('fn2 run ', obj.msg);
    });
    console.log('fn1 run ', obj.data);
})
// 首次执行打印出

// fn3 run   ok 
// fn2 run   ok 
// fn1 run   ok 

// 修改 数据
setTimeout(() => {
    obj.data = 'details';
}, 1100);

setTimeout(() => {
    obj.msg = 'success';
}, 2100);

// 修改数据之后打印
// fn3 run   ok 
// fn3 run  success
...

这里后面打印的将都会是 fn3 ,原因是保存函数的时候是直接复制,而这里的嵌套函数最后存储下来的就是最里层的那个,也就是fn3且无法在复原, 也就是前面的那一部分代码:

代码语言:javascript复制
function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn
        fn()
    }
    effectFn.deps = [];
    effectFn()
}

问题在这里出现也就是在这里解决,增加一个数据,使用栈的思想,每次执行副作用函数之前,都将副作用 push 进栈,在执行完毕之后 pop出去,再将副作用函数指向栈顶,栈顶就是最内层的副作用函数。

这样就能解决多层嵌套的问题。

qiantao.pngqiantao.png

代码如下:

代码语言:javascript复制
function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn);
        activeEffect = effectFn
        effectStack.push(effectFn); 
        fn()
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
    }
    effectFn.deps = [] 
    effectFn()
}

避免无限递归循环

这个问题是,在同一个副作用函数中,既有读取操作也有设置操作,在这样的情况下就会出现栈溢出的情况,代码如下:

代码语言:javascript复制
effect(() => {
    obj.status  ;
})

在 effect 中使用 自增方法,这方法等同于:

代码语言:javascript复制
effect(() => {
	obj.status = obj.status   1;
})

读取了 status 之后 加 1 ,再设置回 obj 的 status 。 就是这里面会有个问题,首先,读取 status 的时候 ,在收集器里面添加了副作用函数,之后执行设置,执行设置就就出发副作用函数,接着再次执行刚才的操作,无限的循环下去。

解决这个问题的方法就是,增加一个条件判断限制在同样的副作用函数的情况下再次执行,书上称这样的方法叫“守卫条件”,emm,个人觉得这个词逼格还是有点高的,代码如下:

代码语言:javascript复制
function trigger(target, key, newVal){
    target[key] = newVal;
    const depsMap = bucket.get(target);
    if (!depsMap) {
        return;
    }

    const effects = depsMap.get(key);
    const effectsToRun = new Set();
    effects && effects.forEach(item =>{
        if(item !== activeEffect){
            effectsToRun.add(item);
        }
    })
    effectsToRun.forEach(fn => fn());
};

const obj = new Proxy(data, {
    get(target, key) {
        track(target, key)
        return target[key];
    },
    set(target, key, newVal) {
        trigger(target, key, newVal);
        return true;
    }
});

effect(() => {
    console.log('1')
    obj.status = obj.status   1;
    console.log('2')
});

小结 :

  1. 了解副作用函数
  2. 实现了基本响应式
  3. 解决了遗留副作用函数绑定问题 ( cleanup )
  4. 解决了副作用函数嵌套问题
  5. 解决了副作用函数中同时读取和设置导致的栈溢出问题

本节内容第一部分完结,参考学习自: vue 设计与实现, JavaScript mdn 文档, ES6 入门教程

0 人点赞