likes
comments
collection
share

一个完善的响应式系统

作者站长头像
站长
· 阅读数 48

一个完善的响应式系统

《Vue.js设计与实现》第四章的学习笔记

先了解一些概念

副作用函数与纯函数:

副作用函数:外部环境中的状态(html,css,js等的状态)会因为运行完这个函数而发生变化的被称为副作用函数。

纯函数:不使用函数外部的值,函数执行不影响函数外部的状态,返回值只与输入有关,并且相同的输入总会返回相同的输出。

响应式数据的实现

响应式数据依赖于Proxy实现,在副作用函数中如果有读取过响应式数据,那么之后每次修改响应式数据的值都会重新调用这个副作用函数,怎么实现呢,我们先写副作用函数:

let activeEffect = null;
const effect = (fn) => {
  activeEffect = fn;
  activeEffect();
}

effect为注册副作用函数的函数,将传入的fn注册为activeEffect,即为当前需要运行的副作用函数,然后执行副作用函数。

编写响应式数据的实现:

在此之前我们需要定义一个bucket桶来建立所有响应式对象的所有键值与键值所对应的副作用函数的依赖,方便之后收集依赖

bucket的数据结构长这样:

一个WeakMap,key值为所用响应式对象的引用地址,value为一个Map,Map的key值为单个响应式对象的key值,value为set集合,set集合里放着所有用到这个key值的副作用函数。

将一个data对象变为一个响应式的对象

const data = {
    text: '123',
}
// 定义收集依赖与触发依赖的track函数与trigger函数
const track = (target, key) => {
    if(!activeEffect) return;
    let depsMap = bucket.get(target);
    if(!depsMap) {
        depsMap = new Map();
        bucket.set(target, depsMap);
    }
    let deps = depsMap.get(key);
    if(!deps) {
        deps = new Set();
        depsMap.set(key, deps);
    }
    deps.add(activeEffect);
}
const trigger = (target, key) => {
    const depsMap = bucket.get(target);
    if(!depsMap) return;
    const effects = depsMap.get(key);
    effects && effects.forEach(effectFn => {
        effectFn();
    })
}
const proxy = new Proxy(data, {
    get(target, key, receiver) {
        // 这一步叫做收集依赖
        track(target, key);
        return target[key];
    },
    set(target, key, newVal, receiver) {
        // 触发依赖,需要注意一下要先设置target[key] = newVal,再触发依赖拿到新的值去响应
        target[key] = newVal;
        trigger(target, key);
    }
})
effect(() => {
    document.body.textContent = proxy.text;
})

测试一下:

effect(() => {
    document.body.textContent = proxy.text;
})

打开f12在控制台修改proxy.text的值,发现页面的值跟也跟着proxy.text的值变化,测试成功

清除依赖:

并不是写在了副作用函数里的响应式数据就一定会读取到,如let a = true ? obj.a : obj.b,obj.b就不会被读取到,又比如

false && obj.a,obj.a就不会被执行,我们副作用函数里有可能会有类似这样的代码,前面的true,false有可能是一个变量,响应式数据有可能会执行有可能不执行,在响应式数据不被执行的时候我们不应该将依赖再收集,因为这样会产生不必要的副作用函数重新执行。需要我们清除依赖。清除依赖需要我们每次执行副作用函数之前时,将所有的key对应的deps,也就是那个set里,将此副作用函数删去,要完成这个,需要记录此副作用函数对应了哪些deps,需要给副作用函数定义一个属性deps数组,来存。然后每次运行之前再遍历数组去删。

// 定义一个清除依赖的方法
const cleanup = (effectFn) => {
  for(let i=0; i<effectFn.deps.length; i++) {
    effectFn.deps[i].delete(effectFn);
  }
  effectFn.deps.length = 0;
}
// 改造注册副作用函数的函数
const effect = (fn) => {
    const effectFn = () => {
        activeEffect = effectFn;
        cleanup(effectFn);
        fn();
    }
    effectFn.deps = [];
    effectFn();
}

// 修改track函数
const track = (target, key) => {
    if(!activeEffect) return;
    let depsMap = bucket.get(target);
    if(!depsMap) {
        depsMap = new Map();
        bucket.set(target, depsMap);
    }
    let deps = depsMap.get(key);
    if(!deps) {
        deps = new Set();
        depsMap.set(key, deps);
    }
    deps.add(activeEffect);
    activeEffect.deps.push(deps); // 新增
}

在trigger里有这样一段

effects && effects.forEach(effectFn => {
    effectFn();
})

遍历effects,执行副作用函数,但执行effectFn之前会清除依赖,也就是将effectFn从effects里delete了,执行完了effectFn之后会重新触发收集依赖,将effectFn又重新add进了effects里,这样会形成无限循环。

类似

let set = new Set();
set.add(1);
set.forEach(() => {
    console.log(1);
    set.delete(1);
    set.add(1);
})

用另外一个set包裹一下就可以避免死循环

let set1 = new Set();
set.add(1);
let set2 = new Set(set);
set2.forEach(() => {
    console.log(1);
    set1.delete(1);
    set1.add(1);
})

这么做相当于用set2的循环来操作set1,set1的修改不会影响到set2,所以在trigger里也需要做同样的调整

const trigger = (target, key) => {
    const depsMap = bucket.get(target);
    if(!depsMap) return;
    const effects = depsMap.get(key);
    const effectsToRun = new Set(effects);
    effectsToRun.forEach(effectFn => {
        effectFn();
    })
}

测试一下清除依赖后代码是否能正常运作

// 将之前的data改为
const data = {
    ok: true,
    text: '123',
}
effect(() => {
    console.log('test');
    document.body.textContent = proxy.ok ? proxy.text : 'not';
})

在控制台输入proxy.text修改为456,打印test,页面显示456,将proxy.ok修改为false,打印test,页面显示not,再将proxy.test修改为789,控制台不输出,页面没反应,测试成功。

嵌套的effect函数:

在注册副作用函数的effect的回调函数里再注册一个副作用函数就产生了effect函数的嵌套,如:

effect(function effectFn1(){
    effect(function effectFn2(){
        /* ... */
    })
})

在原始对象data身上增加两个字段,foo,bar

const data = {
    /* ... */
    foo: 1,
    bar: 2,
}
effect(function effectFn1() {
    console.log('执行effectFn1');
    effect(function effectFn2() {
        let tmp2 = proxy.bar;
        console.log('执行effectFn2');
    })
    let tmp1 = proxy.foo;
})

当我们修改proxy.foo时我们希望effectFn1重新执行,并且effectFn1的重新执行会触发effectFn2的执行,修改proxy.bar时只有effectFn2重新执行,但当我们实际修改proxy.foo的值时控制台只有一个输出'执行effectFn2'。

这是因为我们的effect注册副作用函数里面activeEffect这个全局变量只有一个值,在effectFn2执行后,activeEffect就为effectFn2了,后来的把前面的覆盖掉了,我们需要再用一个副作用函数栈来存这样的嵌套的副作用,让activeEffect始终指向副作用函数栈的栈顶副作用函数。如下:

let activeEffect = null;
const effectStacks = [];
const effect = (fn) => {
    const effectFn = () => {
        cleanup(effectFn);
        activeEffect = effectFn;
        effectStacks.push(effectFn);
        fn();
        effectStacks.pop();
        activeEffect = effectStacks[effectStacks.length - 1];
    }
    effectFn.deps = [];
    effectFn();
}

再测试上面嵌套的effect函数,修改proxy.foo的值,顺利输出’执行effectFn1‘,’执行effectFn2‘

避免无限递归:

在副作用函数中写下如下代码:

effect(() => { 
    proxy.foo++;
})

这段代码会导致无限递归,因为proxy.foo++,等价于proxy.foo = proxy.foo + 1;这既读取了proxy.foo的值,又修改了proxy.foo的值,会先触发track,在函数还未运行完又会触发trigger,再次调用副作用函数,如此递归循环地调用,要避免此问题只需要在trigger调用副作用函数之前判断一下activeEffect是否等于调用的副作用函数即可。

const trigger = (target, key) => {
    const depsMap = bucket.get(target);
    if(!depsMap) return;
    const effects = depsMap.get(key);
    const effectsToRun = new Set(effects);
    effectsToRun.forEach(effectFn => {
        if(effectFn !== activeEffect) {
            effectFn();
        }
    })
}

副作用函数的可调度执行

可调度执行指我们可以在trigger里控制副作用函数执行或者不执行,什么时候执行,实现需要修改effect函数,给effect函数的入参新增一个options参数,options是一个配置对象,对象里添加一个scheduler方法,scheduler接收一个副作用函数作为参数。将这个options配置对象挂载到effectFn上,这样用户就可以自定义副作用函数的执行了

const effect = (fn, options={}) => {
    const effectFn = () => {
        cleanup(effectFn);
        activeEffect = effectFn;
        effectStacks.push(activeEffect);
        fn();
        effectStacks.pop();
        activeEffect = effectStacks[effectStacks.length - 1];
    }
    effectFn.options = options;
    effectFn.deps = [];
    effectFn();
}
// 改造trigger
const trigger = (target, key) => {
    const depsMap = bucket.get(target);
    if(!depsMap) return;
    const effects = depsMap.get(key);
    const effectsToRun = new Set();
    effects && effects.forEach(effectFn => {
        if(effectFn !== activeEffect) {
            effectsToRun.add(effectFn);
        }
    })
    effectsToRun.forEach(effectFn => {
        if(effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn);
        } else {
            effectFn();
        }
    })
}

测试一下,异步延迟2s触发副作用函数

effect(function effectFn1() {
    console.log(proxy.foo);
}, {
    scheduler(fn) {
        setTimeout(() => {
            fn();
        }, 2000);
    }
})

在控制台改变proxy.foo的值,两秒后,控制台输出值,测试成功。

计算属性与lazy

实现vue中的计算属性,在此之前,我们在effect的options参数里新加一个配置项,lazy,让副作用函数不立即执行

effect(() => {
    console.log(proxy.foo);
}, {
    lazy: true,
})

若options.lazy为true,那么我们不立即执行副作用函数,修改effect

const effect = (fn, options={}) => {
    const effectFn = () => {
        // ...
    }
    effectFn.options = options;
    effectFn.deps = [];
    if(!options.lazy) {
        effectFn();
    }
    return effectFn;
}

若lazy不为true或未定义,则立即执行副作用函数,否则不执行,并且返回effectFn,这样我们就可以在调用effect时不立即执行副作用函数,并且拿到副作用函数,手动执行。

将传递给effect的函数看作getter,getter可以返回任何值,手动执行副作用函数时可以拿到这个值,如下

const effectFn = effect(() => proxy.foo + proxy.bar, {
    lazy: true,
})

const value = effectFn();

实现如上的效果,执行副作用函数,拿到proxy.foo+proxy.bar的值,要实现还需要对effect做一点点调整

const effect = (fn, options={}) => {
    const effectFn = () => {
        activeEffect = effectFn;
        cleanup(effectFn);
        effectStacks.push(effectFn);
        const res = fn();
        effectStacks.pop();
        activeEffect = effectStacks[effectStacks.length - 1];
        return res;
    }
    effectFn.options = options;
    effectFn.deps = [];
    if(!options.lazy) {
        effectFn();
    }
    return effectFn;
}

拿到fn函数执行后的返回值,并作为effectFn的返回值。

至此就可以实现计算属性了

const computed = (getter) => {
    const effectFn = effect(getter, {
        lazy: true,
    });

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

测试一下let obj = computed(() => proxy.foo + proxy.bar);,在控制台修改proxy.foo或者proxy.bar的值,再打印obj.value,看看是不是它俩相加的结果,结果正确,测试成功。但每次访问obj.value时都会调用一次副作用函数来求得结果,vue里面是实现了对计算结果的缓存的,再调整一下,将结果缓存下来。

const computed = (getter) => {
    let value;
    let dirty = true;
    const effectFn = effect(getter, {
        lazy: true,
        scheduler(fn) {
            dirty = true;
        }
    });

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

定义一个dirty变量与一个value变量,value用来缓存结果,dirty用来判断是否需要重新调用副作用函数,第一次调用时,dirty为true,需要调用副作用函数,dirty变为false,若getter依赖的响应式数据没有发生变化,则不会触发scheduler,dirty一直为false,返回缓存的value,当getter依赖的数据发生变化时,dirty重置为true,这时才需要再调用副作用函数更新缓存结果。

至此已经实现了计算属性的缓存了,但还有一个问题,就是,在副作用函数里访问计算属性的值,如下:

const obj = computed(() => proxy.foo + proxy.bar);
effect(() => {
    console.log(obj.value);
})

在vue里,proxy的值改变了,是会触发计算属性的更新的,但上面写的,当proxy.foo或proxy.bar变化时,不会触发副作用函数重新打印obj.value。观察上述代码,发现读取obj.value会触发computed内部的副作用函数读取proxy.foo和proxy.bar的值,这是一个副作用函数嵌套,我们之前关于嵌套的改造那部分就知道了,内部的副作用函数只会影响内部的响应式数据,不会影响外部的,所以proxy.foo或者proxy.bar的改变只会改变computed内部effectFn的返回结果,不会触发这里的console.log打印,而要在这里实现打印,需要将计算属性的value变为响应式数据,改造一下computed

const computed = (getter) => {
    let value;
    let dirty = true;
    const effectFn = effect(getter, {
        lazy: true,
        scheduler(fn) {
            trigger(obj, 'value');
            dirty = true;
        }
    });

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

手动调用一下track函数与trigger函数,读取obj.value值的时候调用track,修改getter依赖数据时,在scheduler里调用trigger,这其实就相当于在new Proxy()里的getter和setter里调用track和trigger将数据变为响应式的,至此就实现了一个完整的计算属性

测试一下:

let obj = computed(() => proxy.foo + proxy.bar);
effect(() => {
    console.log(obj.value);
})

正确

watch侦听器的实现:

watch用来监听响应式数据的变化,触发回调函数的执行,其原理就是利用了scheduler调度器来实现的。如下:

effect(() => {
    console.log(obj.foo)
}, {
    scheduler(fn) {
        console.log('obj.foo改变了')
    }
})

obj是一个响应式数据,在副作用函数里读取了obj.foo,绑定了依赖,obj.foo变化会触发trigger,trigger触发scheduler的执行,这样就达到了目的。

现在假设obj时一个响应式对象,我们要监视它身上的所有属性的变化,像这样调用watch:watch(obj, cb),cb是回调函数。

watch的实现如下:

const scanSource = (source, seen=new Set()) => {
    if(typeof source !== 'object' || source === null || seen.has(source)) return;
    seen.add(srouce);
    for(let k in srouce) {
        scanSorce(source[k], seen);
    }
    return source;
}

const watch = (source, cb) => {
    effect(() => scanSource(source),{
        scheduler(fn) {
            cb();
        }
    })
}

在watch函数里调用effect,传入的回调函数用来读取source身上的全部属性,读取完一遍,那么source身上的全部属性也就与副作用函数建立起了依赖了,由于我们定义了scheduler调度方法,所以当source身上的某一个属性发生变更时,trigger会触发scheduler的执行,scheduler的执行就会执行我们传入的cb回调函数,至此就实现了watch侦听器了。scanSource函数就是用来递归遍历读取一下source身上的全部属性的。

在vue里,watch不仅可以传入一个响应式数据,还可以传入一个getter函数,当getter依赖的值发生变化,也会触发回调的执行,调整一下watch函数的实现。

const watch = (source, cb) => {
    let getter;
    if(typeof source === 'function') {
        getter = source;
    } else {
        getter = () => scanSource(source);
    }
    effect(getter, {
        scheduler(fn) {
            cb();
        }
    })
}

判断传入的source是否是函数,如果是函数,直接将source赋值给getter,传getter的效率要比传一个响应式对象的效率要更高。

vue里cb回调函数是可以拿到新值与旧值的,但在此处我们还拿不到,需要再对watch做一点调整,充分利用lazy配置项。

const watch = (source, cb) => {
    let getter;
    if(typeof source === 'function') {
        getter = source;
    } else {
        getter = () => scanSource(source);
    }
    let newVal, oldVal;
    const effectFn = effect(getter, {
        scheduler(fn) {
            newVal = effectFn();
            cb(newVal, oldVal);
            oldVal = newVal;
        },
        lazy: true,
    })
    oldVal = effectFn();
}

定义两个变量newVal,oldVal,利用lazy配置项,让副作用函数在每次响应式数据更新时获取新值,将第一次调用副作用函数获取到的值作为旧值。调用完新值后再将这次的新值赋给下一次的旧值,有点快慢指针的味道。

在vue中,watch可以接收第三个参数,第三个参数是一个配置项,配置项里可以配置immediate选项,让watch侦听器立即执行一次

watch(obj, () => {
    console.log('数据变化了')
}, {
    immediate: true,
})

立即执行跟在数据变化后执行要做的事是一样的,也就是scheduler里需要做的内容,调整一下watch

const watch = (source, cb, options={}) => {
    let getter;
    if(typeof source === 'function') {
        getter = source;
    } else {
        getter = () => scanSource(source);
    }
    let newVal, oldVal;
    const job = () => {
        newVal = effectFn();
        cb(newVal, oldVal);
        oldVal = newVal;
    }
    const effectFn = effect(getter, {
        scheduler(fn) {
            job();
        },
        lazy: true,
    })
    if(options.immediate) {
        job();
    } else {
        oldVal = effectFn();
    }
}

将scheduler里的内容封装成一个函数,只需要在配置immediate为true时执行一下,就是立即执行了。

options里还可以配置flush,flush为'post'时需要将watch的回调函数放到下一个微任务中去异步执行,只需要微调一下scheduler里的内容就可以了

const effectFn = effect(getter, {
    scheduler(fn) {
        if(options.flush === 'post') {
            queueMicrotask(job);
        } else {
            job(); 
        }

    },
    lazy: true,
})

queueMicrotask接收一个回调函数,将这个回调函数放到下一个微任务中去执行。

以上就是《Vue设计与实现》第四章的绝大部分内容。

转载自:https://juejin.cn/post/7257029942856548411
评论
请登录