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

2022-08-19 09:13:27 浏览数 (1)

调度执行

执行调度的实质就是将更多的控制权交给用户,比方说在执行副作用函数的时候可以让用户特定的去处理一些方法,例如回顾上一节的代码执行一个自增同时输出 status 的方法:

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

function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn);
        activeEffect = effectFn
        effectStack.push(effectFn); 
        fn()
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
    }
    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 // 重置effectFn的deps数组
}
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);
}
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(obj.status); // * 打印
})
obj.status  ; // * 自增
console.log('执行完毕');

// 这里输出的就是:
// 2 
// 3
// 执行完毕

如果想让他们之间的打印顺序改变的话,最快速的方法就是将他们的执行顺序稍微改动一下,例如:

代码语言:javascript复制
// ...
effect(() => {
    console.log(obj.status); // * 打印
});
console.log('执行完毕');
obj.status  ; // * 自增

// 这里输出的就是:
// 2 
// 执行完毕
// 3

但是这一种是人为的去修改这一段的逻辑,具有不确定性的逻辑不能够写死,必须是要是让代码就是直接实现这样的操作。

这时候需要借用一个东西叫做调度器。调度器可以理解为控制器或者粗略的理解就是一个控制参数。

diaodu-1.pngdiaodu-1.png

以往开发时需要对函数进行特定化的处理,都会通过一个 options 进行这个函数内部的一些特殊控制或者处理。只不过,这里的控制器需要是一个函数,因为就是在这里让用户传入特定的方法。

将函数修改为:

effect 函数挂载参数

代码语言:javascript复制
function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn);
        activeEffect = effectFn
        effectStack.push(effectFn);
        fn()
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
    }
    effectFn.options = options; // * 挂载 scheduler 函数
    effectFn.deps = [] 

    effectFn()
}

trigger 函数执行副作用函数的时候,先判断是否存在 scheduler ,如果存在则优先执行 控制器的函数

代码语言: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());
    effectsToRun.forEach(effectFn => {
        if (effectFn.options.scheduler) {
						 // * 有 -> 则优先执行法
            effectFn.options.scheduler(effectFn);
        } else {
        		 // * 没有 -> 正常执行
            effectFn();
        }
    });
}

触发执行时

代码语言:javascript复制
effect(() => { console.log(obj.status) },
    {
        scheduler(fn) {
            setTimeout(() => {
                fn();
            }, 2000);
        }
    }
)

// 这里输出的就是:
// 2 
// 执行完毕
// 3

// 和前面人为修改函数的执行结果是一样的

以上就是使用调度器的方法,在这个基础上可以稍微改一下,不去改变副作用函数的执行顺序,而是强调开始和结果,不需要执行过程中副作用函数的执行结果,例如:

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

// 以上的输出结果是
// 2
// 3
// 4

像这样的情况其实可以做优化,像在 vue 中数值更新应该往往是最后面的那一步,而中间的计算是不用去执行函数的,他只要计算出来的最终结果再做一次更新。而上面的例子就是一个简单思路,需要优化成打印头和尾不需要中间的实现过程。

其中一个要点就是利用 set 结构去重的特性和另一个比较有意思的用法是使用微任务来输出一个最后的结果,所谓先宏后微,它的核心要点就是先执行完毕再执行遍历输出结果。

diaodu-3.pngdiaodu-3.png

代码如下:

代码语言:javascript复制
const jobQueue = new Set();
const job = Promise.resolve();
let isFlush = false

function flushJob() {
    if (isFlush) return;
    isFlush = true;

    job.then(() => {
        jobQueue.forEach(item => item())
    }).finally(() => {
        isFlush = false;
    })
};

effect(
    () => {
        console.log(obj.status)
    },
    {
        scheduler(fn) {
            jobQueue.add(fn);
            flushJob();
        }
    }
    ,
)

obj.status  ;
obj.status  ;
obj.status  ;
obj.status  ;

调度器增加一个队列存放副作用函数,每一次调度存放副作用函数之后执行刷新队列操作。由于 job 是一个微任务函数,所以 一定是等下面 4 个 status 自增完毕再执行。另外,这里 isFlush 在第一次执行之后,一整个周期之内都不会执行微函数,换句话说就是微函数只执行一次。只有在微函数执行完毕之后 isFlush 才会复原。

计算属性 computed

在 computed 可以通过一个参数控制副作用函数的执行,他的原理也就是和上面的调度器一样,他通过一个参数去控制:

代码语言:javascript复制
effect(
    () => console.log(obj.status),
    {
        lazy: true,
    },
)

之后,在 effect 中增加一个关于 lazy 的判断,同时,副作用函数不再立即执行,整一个副作用函数作为一返回值,变成自定义执行:

代码语言:javascript复制
function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn);
        activeEffect = effectFn

        effectStack.push(effectFn);
        fn()
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
    }
    effectFn.options = options;
    effectFn.deps = [];
		
		// * 通过判断 lazy 是否执行
    if(!options.lazy){
    
        effectFn()
    }
    // * 返回整个副作用函数
    return effectFn;
}

// ...
// 调用时候
const effectFn = effect(
    () => obj.data   obj.msg,
    {
        lazy: true,
    },
)
console.log(effectFn())

拿到副作用函数再执行输出结果,但是到这里还没有实现可以取到结果的功能,目前只是可以控制是否立即执行,现在打印出来的是 undefined 。

接着再优化 effect 整个函数:

代码语言:javascript复制
function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn);
        activeEffect = effectFn

        effectStack.push(effectFn);
        // * 将副作用函数赋值
        const res = fn() 
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
        // 返回副作用函数
        return res
    }
    
    effectFn.options = options;
    effectFn.deps = [];

    if(!options.lazy){
        effectFn()
    }
    return effectFn;
}
// ..
console.log(effectFn())
// info ok

这个时候就可以拿到 obj.data obj.msg 的结果了。

实现计算属性

能拿到结果之后接下来可以实现计算属性了。新增加一个函数 computed :

代码语言:javascript复制
function computed(getter){
    const effectFn = effect(
        getter,
        {
            lazy: true,
        },
    );
    const obj = {
        get value(){
            return effectFn();
        }
    }
    return obj
}
const sum = computed(() => obj.data   obj.msg);

console.log(sum.value)
obj.msg = 'success';
console.log(sum.value);

// 输出结果
// info ok 
// info success

将原来的 effect 封装在 computed 中,副作用函数通过 getter 参数传递,effectFn 的返回值 sum 需要通过访问 value 才能执行副作用函数,得到返回值。

解决重复执行问题

上面函数的问题是每次访问 value 都会执行一次计算,这样是不合理的,需要将计算完成的值做一个缓存,只有当值发生变化的时候才会执行计算。

解决重复执行的方法就是,使用 dirty 和 value 参数来控制。

代码语言:javascript复制
function computed(getter){
    let value ;
    let dirty = true;

    const effectFn = effect(
        getter,
        {
            lazy: true,
        },
    );
    const obj = {
        get value(){
            if(dirty){
                value = effectFn(); // * 执行
                dirty = false; // * 标记不可再次更新
            }
            return value;
        }
    }
    return obj
}
const sum = computed(() => obj.data   obj.msg);
console.log(sum.value)
obj.msg = 'success';
console.log(sum.value)
// 输出结果
// info ok 
// info ok 

重新设置标记 dirty 值

这里输出了同样的结果说明 msg 参数被修改之后,副作用函数没有再次执行,是因为第一次执行的时候 dirty 被标记成了不可再次更新,需要做的就是将 dirty 设置为 true。

解决这个问题就是 使用调度器,在刷新副作用函数的时候,将 dirty 重新设置为 true 。

代码语言:javascript复制
function computed(getter){
    let value ;
    let dirty = true;

    const effectFn = effect(
        getter,
        {
            lazy: true,
            // * 重置 dirty 的值
            scheduler(){
                dirty = true;
            }
        },
    );

    const obj = {
        get value(){
            if(dirty){
                value = effectFn();
                dirty = false;
            }
            return value;
        }
    }

    return obj
}
const sum = computed(() => obj.data   obj.msg);
console.log(sum.value)
obj.msg = 'success';
console.log(sum.value);
// 输出结果
// info ok 
// info success

重新计算属性

到前面为止,已经可以拿到了返回值同时再值变动之后重新执行也可以看到新的值,现在还又一个问题,就是当 obj 的属性重新赋值的时候,可以触发副作用函数的执行,就是在使用 vue computed 的时候 ,其中的一个计算属性被修改之后,会触发副作用函数的重新执行。

这里需要对上面的函数再做一点改进,需要单独的在设置和读取的时候再做一层处理,代码如下:

代码语言:javascript复制
function computed(getter){
    let value ;
    let dirty = true;

    const effectFn = effect(
        getter,
        {
            lazy: true,
            scheduler(){
                dirty = true;
                // * 当函数设置的时候,触发响应
                trigger(obj, 'value')
            }
        },
    );

    const obj = {
        get value(){
            if(dirty){
                value = effectFn();
                dirty = false;
            }
            // * 读取的时候 ,触发响应
            track(obj, 'value')
            return value;
        }
    }

    return obj
}

effect(()=>{
    console.log(sum.value)
})
obj.msg = 'test'
// 输出
// info ok 
// infosuccess
// info11212

watch 监听的实现原理

watch 的实质就是利用了 effect 和 scheduler 的选项配合达到的一个效果:

代码语言:javascript复制
function watch(source, cb){
    effect(
        () => source.status,
        {
            scheduler(){
                cb();
            }
        }
    );
}
watch (
    obj,
    () =>{
        console.log(obj.status);
    }
)

obj.status  
// 输出
// 3

已经仅仅只是可以监听到 status 属性,还需要一个函数来封装它,变得更具通用性:

代码语言:javascript复制
function traverse(value, seen = new Set()){
    if(typeof value !== 'object' || value == null || seen.has(value)) return;
    seen.add(value);
    for(const k in value ){
        traverse(value[k], seen);
    }
    return value;
}

function watch(source, cb){
    effect(
        () => traverse(source),
        {
            scheduler(){
                cb();
            }
        }
    );
}
watch (
    obj,
    () =>{
        console.log(obj.status);
    }
)

watch 函数除了可以接收一个响应式数据还可以接收一个 getter 函数,他的目的是可以在 getter 内部让用户指定 watch 依赖哪一些响应式数据。且只有当这些数据发生变化的时候才会回调。

代码语言:javascript复制
function traverse(value, seen = new Set()){
    
    if(typeof value !== 'object' || value == null || seen.has(value)) return;

    seen.add(value);
    for(const k in value ){
        traverse(value[k], seen);
    }
    return value;
}

function watch(source, cb){
    let getter;
    if(typeof getter === 'function' ){
        getter = source
    }else{
        getter = () => traverse(source);
    }

    effect(
        () => getter(),
        {
            scheduler(){
                cb();
            }
        }
    );
}
watch (
    () => obj.status,
    () =>{
        console.log(obj.status);
    }
)

obj.status  

在监听到变化的时候,watch 还需要一个能力就是能获取到新值和旧值,这个就是需要使用到 lazy 这个选项。

代码语言:javascript复制
function traverse(value, seen = new Set()) {

    if (typeof value !== 'object' || value == null || seen.has(value)) return;

    seen.add(value);

    for (const k in value) {
        traverse(value[k], seen);
    }
    return value;
}

function watch(source, cb) {
    let getter;

    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source);
    }
    let oldVal;
    let newVal;

    const effectFn = effect(
        () => { return getter() },
        {
            lazy: true,
            scheduler(fn) {

                newVal = fn();
                cb(oldVal, newVal);
                oldVal = newVal;
            }
        }
    );;
    oldVal = effectFn();
}
watch(
    () => obj.status,
    (newVal, oldVal) => {
        console.log(obj.status);
        console.log(newVal)
        console.log(oldVal)
    }
)

obj.status  

watch 函数控制是否立即执行

watch 的回调执行可以通过一个参数来控制是否立即执行,这个参数就是 immediate 。执行代码如下:

代码语言:javascript复制
watch(
	() => obj.foo, 
	async () => {
    console.log('watch');
	}, 
	{
    immediate: true  // * 立即执行 
});

在 watch 里面将副作用函数封装起来,当 immediate 设置为 true 的时候,立即执行 scheduler 函数。当immediate 设置为 false 的时候。会执行一次副作用函数,单数不会执行 cb。cb 是在数据更新的时候通过 scheduler 调用的 ,代码如下:

代码语言:javascript复制
function watch(source, cb, options = {}) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }
    let oldValue, newValue
    let cleanup
    function onInvalidate(fn) {
        cleanup = fn
    }

    function job() {
        newValue = effectFn() 
        if (cleanup) cleanup() 
        cb(oldValue, newValue, onInvalidate)
        oldValue = newValue
    }

    const effectFn = effect(
        () => {
            return getter()
        },
        {
            lazy: true,
            scheduler(fn) {
                job() // * 当值发生改变的时候执行 cb 
            }
        }
    )
    if (options.immediate) {
        job()
    } else {
        oldValue = effectFn();
    }
}
// 输出结果
// oldValue:  1 newValue:  2
// oldValue:  2 newValue:  3
// oldValue:  3 newValue:  4
// oldValue:  4 newValue:  5
// oldValue:  5 newValue:  6

优化计算过程

可是使用一个参数控制最终的执行结果,例如在 vue 3 中的 flush , flush 为 post 的时候,将任务推送到微任务队列中, 在宏任务结束之后,执行微任务。

代码语言:javascript复制
const p = Promise.resolve();
let isFlushing = false 
function flushJob() {
    if (isFlushing) return 
    isFlushing = true 
    p.then(() => { 
        jobQueue.forEach(effectFn => effectFn())
    }).finally(() => {
        isFlushing = false 
    })
}
const effectFn = effect(
    () => {
        return getter()
    },
    {
        lazy: true,
        scheduler(fn) {
					 if (options.flush === 'post') {
					  // * flush如果是post,放到微任务队列中执行
                jobQueue.add(job)
                flushJob() 
            }else job()
        }
    }
)
// 输出结果
// oldValue:  1 newValue:  6

处理过期的副作用函数

其实就是一个过期的副作用函数执行之后返回结果先后顺序不同的问题,这一种现象称为竞态问题,如图:

watch-expired.pngwatch-expired.png

某一个对象做第一次修改的时候发出 post-1 请求,在第二次修改的时候发出 post-2 请求,之后开始返回结果,如果现在先返回的是 response-2 再返回 response-1 。由于 post-2 是最后一次发出的但是,他的结果却是最先回来,接着再返回 response-1 ,最后返回的“过期”结果,就会覆盖掉前一次返回的正确结果。

处理这个问题最核心的就是将后面返回的过期结果废弃

首先在 watch 回调参数里面需要一个是否过期的标记 expired , 用这个参数来标记回调是否过期。如果是过期的就不使用这个数据。

代码语言:javascript复制
watch(
    obj,
    async (newVal, oldVal, onInvalidate) => {
        let expired = false;
        onInvalidate(() => {
            expired = true;
        });
        const { status , data } = await fetch('/path/request');
        if(!expired){
            fetchData = data;
        }
    }
)

在 watch 这个函数里面,存放 onInvalidate 函数用来存储上一次的过期函数,当 watch 执行回调之前先执行 cleanup ,如果 cleanup 存在那么先执行,执行之后则第一次执行的副作用函数闭包里面的 expired 就会变成 true 。在变成了 true 之后,这个副作用函数的请求返回结果就不会再去覆盖,从而避免了过期副作用函数带来的问题,代码如下:

代码语言:javascript复制
function watch(source, cb, options = {}) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }

    let oldValue, newValue
    let cleanup // * 保存上一次回调的过期处理函数

    function onInvalidate(fn) {
        cleanup = fn
    }

    function job() {
        newValue = effectFn();
        if (cleanup) cleanup() // * 执行上一次的过期函数
        cb(oldValue, newValue, onInvalidate)
        oldValue = newValue
    }

    const effectFn = effect(
        () => {
            return getter();
        },
        {
            lazy: true,
            scheduler(fn) {
                // flush如果是post,放到微任务队列中执行
                if (options.flush === 'post') {
                    const p = Promise.resolve()
                    p.then(() => job());
                    
                    // * 此处可优化,处理成只输出最后一次的结果
                    // jobQueue.add(job)
                    // flushJob();
                } else job()
            }
        }
    )
		
		//  是否立即执行
    if (options.immediate) {
        job()
    } else {
        oldValue = effectFn();
    }
}

如图:

watch-expired-2.pngwatch-expired-2.png

响应式系统实现笔记第二部分完结,参考学习自: vue 设计与实现, JavaScript mdn 文档, ES6 入门教程

0 人点赞