vue3 源码学习(3)—— reactivity-依赖收集和触发更新

189 阅读8分钟

前言

本篇文章,旨在说明 effect 的作用,即通过执行传入的回调函数,触发代理对象的 get(取值) 和 set(赋值),以实现对响应式数据的依赖收集和触发更新

相关文章:

  1. vue3 源码学习(1)—— 搭建开发环境
  2. vue3 源码学习(2)—— reactivity-创建响应式对象的代理

effect 解析

关于 effect 的解析,这里仅以其核心功能为主,不会过多考虑其细节。

参数

effect 函数接受两个参数:第一个是函数(简称 fn),第二个是对象(简称 options)。

  1. fn 函数,默认会先执行一次,而后当数据状态变化时,就重新执行,其实就是触发更新。
const { effect, reactive } = VueReactivity;

const data = reactive({ name: '哈哈', age: 18 });

// 通过 effect 收集 age 属性
effect(() => {
    document.getElementById('app').innerHTML = data.age;
});

// 1 秒后将 age 的值改为 100
setTimeout(() => {
    data.age = 100;
}, 1000);

传入 effect 的回调函数 fn,在执行后,会触发响应式对象 dataget 函数,这时就可以用一个函数——track,对访问的属性 age 进行收集。

而后,当我们修改 age 的值时,它就会触发 dataset 函数,这时就可以用一个函数——trigger,来更新属性 age 的值。

Sep-15-2022 16-49-58.gif

  1. options 对象中存在若干个属性,可让开发人员调用 effect 时,做一些其它的操作。例如,scheduler 这个属性,它能让开发人员自己决定如何进行数据更新。
const { effect, reactive } = VueReactivity;
const data = { name: '哈哈', age: 18};
const state = reactive(data);

let waiting = false;
const runner = effect(() => {
    document.getElementById('app').innerHTML = state.age;
}, {
    scheduler() {
        console.log('scheduler-执行');
        if(!waiting) {
            waiting = true;
            setTimeout(() => {
                runner();
                waiting = false;
            }, 1000);
        }
    }
});

state.age = 1000;
state.age = 2000;
state.age = 3000;

在代码中,改写了三次 state.age,因此 scheduler 函数会被执行三次。但是,我们只想更新它最后一次,即最终结果的渲染 state.age = 3000。这样的话,我们就需要利用 scheduler 函数,在其内部写入逻辑代码来加以控制。

Sep-2.gif

返回一个函数

effect 会返回一个 run 函数,这个函数可以让用户手动执行渲染。同时,run 函数上还挂载了 effect 的实例对象。

const { effect, reactive } = VueReactivity;
const data = { name: '哈哈', age: 22};
const state = reactive(data);

const runner = effect(() => {
    document.getElementById('app').innerHTML = state.age;
},{});

console.log(runner, 'runner');
// 停止依赖收集
runner.effect.stop();
state.age = 90;

setTimeout(() => {
    state.age = 100;
    // 调用 runner 渲染
    runner();
}, 2000);

调用 effect 时,会默认执行一次传入的函数 fn,所以初始值是22。当执行 runner.effect.stop() 时,则会停止依赖收集,因此 age 就不会被渲染,页面上的数字不会从 22 变为 90

两秒后,由于 age 已改为 100,同时又调用了 runner() 进行渲染,所以页面数字变为 100

Jan-18-2023 16-52-36.gif

相互嵌套

effect 函数可以层层嵌套,你可以将其看成一个树形结构。

const { effect, reactive } = VueReactivity;

const obj = { name: '哈哈', age: 18 };
const data = reactive(obj);

effect(() => { // parent = null  activeEffect = e1
    
    // name -> e1
    data.name = '你好'; 
    
    effect(() => { // parent = e1  activeEffect = e2
        
        data.age = 200; // age -> e2
        
        effect(() => { // parent = e2  activeEffect = e3
        
            data.name = '测试'; // name -> e3
            
        });
    });
    
    // age -> e1
    data.age = 20; 
});

每一个 effect 函数,在调用后,都会创建一个 effect 的实例。在源码中,activeEffect 变量,存的是当前正在执行的 effect 的实例,parent 存的是其父级的 effect 实例。因此,每一个 effect 函数中包含的需要渲染的属性,都有着其相对应的 activeEffect

另外,每个当前的 effect 执行完后,需要将当前的 effect 实例——activeEffect,变成其父级的 effect 实例,而当前 effect 实例的父级——parent,则置为空。

这种实现方式和栈的工作原理(后进先出)相同,只是实现上有所差异。

effect 实现

创建 reactivity / src / effect.ts 文件模块,编写相应代码。

创建 effect 实例对象

// reactivity/src/effect.ts

export let activeEffect = undefined; // effect实例对象

// effect 类
export class ReactiveEffect {
    // 控制依赖收集,激活状态(true)收集,非激活状态(false)不收集,默认为 true
    public active = true; 
    
    // 当前 effect 实例对象的父级 effect 实例对象
    public parent = null;

    // deps 用于存储代理对象的各个属性所对应的 effect 实例对象
    public deps = [];
    
    // 构造函数
    // ts 语法中,public fn,就相当于 this.fn = fn
    constructor(public fn, public scheduler) {}

    run() {
        // 若是非激活状态,则不需要进行依赖收集,仅执行函数即可
        if (!this.active) {
            return this.fn();
        }

        // 依赖收集的核心:就是将当前的 effect 实例和将要渲染的属性关联到一起
        try {   
            // 保存当前的 effect 实例,以及其父级 effect 实例
            this.parent = activeEffect;
            activeEffect = this; 

            // fn 就是传入 effect 的函数, 这里又传入了 ReactiveEffect 类中
            // 当调用 fn 即执行 this.fn() 时,因其内部会对代理对象进行访问或修改,
            // 所以,会触发代理对象的 get 或 set 函数。因此,我们可以通过这两个函数
            // 可进行依赖收集,同时,也能够获取被导出的全局变量 activeEffect,即 effect 实例
            return this.fn();
        } finally {
            // 执行完成后,进行重置。就是把当前的 effect 实例,变成其父级的 effect 实例
            // 而当前 effect 实例的父级,则置为空。
            activeEffect = this.parent;
            this.parent = null;
        }
        
    }
}

// 响应式函数——effect
export function effect(fn, options) {
    const scheduler = options ? options.scheduler : null; // 调度函数是否存在
    const _effect = new ReactiveEffect(fn, scheduler); // 创建响应式实例对象
    const runner = _effect.run.bind(_effect); // 绑定 this 指向

    _effect.run(); // 默认执行一次
    runner.effect = _effect; // 将 effect 挂载到 runner 函数上

    return runner;
}

当执行 effect 函数时,就会通过 new ReactiveEffect(fn) 创建一个响应式实例 _effect ,而后调用 _effect.run() 实现依赖收集。

实例上的 run 方法,是实现响应式的关键所在,具体请参考代码中的注释。

依赖收集和数据更新

当我们执行 effect 时,其内部就会执行传入给它的函数 fn,若是 fn 内部有访问或修改代理对象(即响应式数据),那么就会触发其 getset 函数。因此,我们需要在 createGettercreateSetter 这两个函数中,分别做依赖收集和数据更新。

  1. createGetter 函数中调用 track 函数,以实现依赖收集。
// reactivity / src / baseHandler.ts

function createGetter(isReadonly = false, shallow = false) {
    return function get(target, key, receiver) {
        // 省略...
        
        // 依赖收集
        track(target, 'get', key);

        // Reflect.get 方法允许你从一个对象中取属性值。
        const result = Reflect.get(target, key, receiver);

        // 省略...
    };
}

属性的依赖收集,需要在获取属性值之前完成,所以 track 要在 Reflect.get 之前调用。另外,为了代码可读性,这里仅贴出代码关键部分,大家可根据小编的上一篇文章vue3 源码对比阅读。

  1. createSetter函数中调用 trigger 函数,以实现数据更新。
// reactivity / src / baseHandler.ts

function createSetter(shallow = false) {
    return function set(target, key, value, receiver) {
        
        // 省略...
        
        // Reflect.set 方法允许你在对象上设置属性。它返回一个 Boolean 值表明是否成功设置属性。
        const result = Reflect.set(target, key, value, receiver);

        // 是同一个对象,才能执行触发操作
        if (target === toRaw(receiver)) {
          // hadKey 为 false,表示对象执行新增操作,否则就是在修改。
          // 这样判断,是为了区分当前对象执行的操作(新增或修改),以便明确如何触发。
          if (!hadKey) {
            trigger(target, 'add', key, value, oldValue);
          } else if (hasChanged(value, oldValue)) {
            trigger(target, 'set', key, value, oldValue);
          }
        }
        
        // 省略...
    };
}

属性值的更新,需要在设置属性值之后完成,所以 trigger 要在 Reflect.set 之后调用。另外,为了代码可读性,这里仅贴出代码关键部分,大家可根据小编的上一篇文章vue3 源码对比阅读。

实现 track 和 trigger 函数

reactivity / src / effect.ts 中,定义并导出 tracktrigger 函数。

// reactivity/src/effect.ts

// targetMap => { target: { key: new Set() } },
// 其中 target 是一个 Map,其中存着每个属性(key) 对应的 Set。
// WeakMap 持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行
const targetMap = new WeakMap(); 

// 依赖收集
// activeEffect -> effect 实例对象
// 每个属性(key),记录下其对应的 activeEffect 对象(可以有多个,但要避免重复),
// 每个 activeEffect 对象,记录下其收集过的属性(可以有多个,但要避免重复),
// 这种多对多的双向记录,便于清理不需要的对应关系。
export function track(target, type, key) {
    // 当前 effect 实例对象是否存在
    if (!activeEffect) return false; 
    
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()));
    }

    let dep = depsMap.get(key);
    if (!dep) {
        depsMap.set(key, (dep = new Set()));
    }

    trackEffects(dep);
}
export function trackEffects(dep) {
    if (activeEffect) {
        let shouldTrack = !dep.has(activeEffect);
        if (shouldTrack) {
            dep.add(activeEffect); // 记录属性对应的 activeEffect
            activeEffect.deps.push(dep); // 记录 activeEffect 收集的属性
        }
    }
}

// 触发更新
export function trigger(target, type, key, value, oldValue) {
    const depsMap = targetMap.get(target);
    
    // 触发的对象不存在。例如,target 没有被收集过。
    if (!depsMap) return false; 
    
    // 获取属性对应的 effect 实例对象
    let effects = depsMap.get(key);

    if (effects) {
        triggerEffects(effects);
    }
}

export function triggerEffects(effects) {
    // 对于引用类型的对象,不要进行关联。执行之前,拷贝一份副本,用副本执行操作,以免造成死循环,导致栈溢出。
    effects = new Set(effects);
    effects.forEach(effect => {
        // 避免同一个 effect 重复调用,不然将导致栈溢出
        if (effect.scheduler) {
            effect.scheduler(); // 若用户传入了调度函数,则进行调用
        } else {
            effect.run();
        }
    });
}

导入 track 和 trigger 函数

reactivity / src / baseHandler.ts 文件模块中,导入依赖收集和触发更新的函数:tracktrigger

import { track, trigger } from './effect';

关于数组方法 includes、indexOf, lastIndexOf 的收集问题

由于,我们在 createGetter 函数中,对数组做了特殊处理。就是下面这段代码。

// reactivity / src / baseHandler.ts

const targetIsArray = isArray(target);
if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
    return Reflect.get(arrayInstrumentations, key, receiver);
}

当我们通过 includesindexOflastIndexOf 访问数组时,会被拦截住,不会继续往下执行,也就是说无法完成依赖收集。

所以,我们需要修改 createArrayInstrumentations 代码,其实就是添加上 track 方法,完成对属性依赖的收集。

// reactivity / src / baseHandler.ts

function createArrayInstrumentations() {
    // 省略...
    
    ['includes', 'indexOf', 'lastIndexOf'].forEach(key => {
        // this -> 原数组的代理对象,args -> 传入数组方法中的参数
        instrumentations[key] = function (this, ...args) {
            // 通过 toRaw 返回原数组,避免死循环导致栈溢出
            const arr = toRaw(this);
            
            // 依赖收集
            for (let i = 0, l = this.length; i < l; i++) {
                track(arr, 'get', i + '');
            }

            // 通过数组的原生方法调用,倘若参数 args 是响应式的,则要用 toRaw 将其还原为原始对象,然后再调用。
            const res = arr[key](...args);
            
            // 'includes' 如果找到匹配的字符串则返回 true,否则返回 false
            // 'indexOf' 和 'lastIndexOf' 如果没有找到匹配的字符串则返回 -1
            if (res === -1 || res === false) {
                return arr[key](...args.map(toRaw));
            } else {
                return res;
            }
        };
    });

    // 省略...

}

测试

倘若,你已完成上述代码,那么就已经实现了一个简易的 effect。下面,是测试案例。

测试案例:

const { effect, reactive } = VueReactivity;

const obj = { name: '哈哈', age: 18 };
const data = reactive(obj);

effect(() => {
    document.getElementById('app').innerHTML = `${data.name}${data.age}`;
});

setTimeout(() => {
    data.age = 101;
}, 1000);

结果展示:

Sep-09-2022 17-06-34.gif

结束

关于 effect,本篇文章在实现方面,可能存在叙述不详细的情况。因此,希望同学们还是跟着代码,实现一遍,只有写了,才能更好地理解代码以及注释部分。