computed和watch幕后揭秘:依赖收集与自动更新的奥秘

236 阅读17分钟

前言

这篇文章主要分享一下关于vue3中computed以及watch的实现原理,也是从最近读的《Vue.js设计与实战》这本书中学到了很多东西,这本书中讲得东西都比较通俗易懂,想要更深入了解vue3的同学强烈建议去读这本书!那么在实现computed和watch之前需要先了解一些基本的概念,然后再慢慢过渡到实现原理。

副作用函数

首先需要了解什么副作用函数,其实就可以简单理解为如果一个函数内部使用或修改了外部的变量而该变量在其他地方也有被使用到,那么就说这个函数产生了副作用,被称为副作用函数

假设有下面这段代码:

    const obj = {
        name: 'xxx',
        age: 20
    }
    document.getElementById('app').innerText = obj.name;
    setTimeout(() => {
        obj.name = 'sss';
    }, 2000)

现在的需求是当obj.name属性修改后视图中也需要修改,那么我们就不得不在定时器中添加代码:

    setTimeout(() => {
        obj.name = 'sss';
        document.getElementById('app').innerText = obj.name;
    }, 2000)

这时页面中的值就会同步被修改,但当有很多属性时我们就不得不重复这一操作。那么有没有更好的方法呢?通过观察可以看到会执行大量重复的代码,我们可以封装一个名为effect的副作用函数然后把重复的代码扔进去。

    function effect() {
        document.getElementById('app').innerText = obj.name;
    }

但是这样写下来依然避免不了要重复调用effect函数,这时就需要结合响应式数据来帮助我们解决这一难题。

副作用函数与响应式数据的结合

我们知道在vue3中使用了proxy进行数据代理以实现数据的响应式,接下来我们将结合这一特性来解决和实现上述的问题和需求。

首先捋一下思路,通过创建obj对象的代理对象可以创建一个响应式数据data,然后在effect副作用函数中读取新创建的响应式对象data中的属性,因为这样会触发get操作,在get方法中我们可以对该副作用函数进行收集,当data中的属性发生变化时也就是在定时器中给属性重新赋值时会触发set方法,在set方法中就可以将在get方法中收集的副作用函数进行遍历调用以达到页面更新的效果。

    let bucket = new Set(); // 创建一个桶用来存储所有的副作用函数
    const data = new Proxy(obj, {
        get(target, key) {
            bucket.add(effect);
            return target[key]
        },
        set(target, key, newVal) {
            target[key] = newVal;
            bucket.forEach(fn => fn());
        }
    })
    function effect() {
        document.getElementById('app').innerText = data.name;
    }
    effect();

    setTimeout(() => {
        data.name = 'sss';
    }, 2000)

下面来逐步进行分析,首先需要创建一个用于收集副作用函数的集合,使用Set数据结构是因为当在多个地方使用了该属性保证只收集一次该属性的副作用函数。首先调用effect副作用函数将值显示在页面中,然后将所有原来的obj.name替换成响应式数据data.name这样一来在副作用函数中读取属性的操作将会触发get函数,在get函数中就可以将当前的副作用函数存起来,当定时器中修改该属性时就会触发set函数,这时只需要将刚才保存的副作用函数集合依次调用即可实现页面的同步更新,因为此时data.name已经是修改后的值了,是不是很神奇?更神奇的还在后面,这个需求到此就算实现了,但其中存在着很多问题。

副作用函数中的硬编码

硬编码的意思其实就是写固定的值,不利于后续复用。观察上面的代码可以看到effect这个函数名就属于硬编码,倘若换一个名称就会导致函数调用的地方以及存储副作用函数的地方都需要修改为新的名称,所以我们需要解决这种硬编码的问题,那么要如何解决呢?注册副作用函数机制

也就是说目前的代码并没有约束性,副作用函数的名称可以随便命名,那针对这一问题我们就可以提供一个专门用来注册副作用函数的函数,我们把这一函数命名为registerEffect

    let bucket = new Set();
    let activeEffect; // 当前的副作用函数
    const data = new Proxy(obj, {
        get(target, key) {
            bucket.add(activeEffect); // 解决硬编码
            return target[key]
        },
        set(target, key, newVal) {
            target[key] = newVal;
            bucket.forEach(fn => fn());
        }
    })
    // 专门用来注册副作用函数
    function registerEffect(fn) {
        activeEffect = fn;
        fn()
    }
    registerEffect(() => {
        document.getElementById('app').innerText = data.name;
    });

在这段代码中声明了一个activeEffect变量,在registerEffect函数中用来保存传递过来的副作用函数,可以看到我们只需要将副作用函数传递给注册副作用函数它就可以保存并自动调用当前的副作用函数,在get函数中我们就可以只维护activeEffect这一个变量,从而解决了硬编码的问题。

副作用函数的重复执行

此时就已经建立了一个相对完善的响应式系统了,但中间仍然有一些缺陷,比如修改一个对象中不存在的属性或修改副作用函数没有依赖到的属性时,副作用函数会被调用多次,因为被操作的字段(age、sex)并没有与副作用函数建立响应连接,所以当它们发生变化时副作用函数不应该重新被执行。

    registerEffect(() => {
        console.log("effect")
        document.getElementById('app').innerText = data.name;
    });
    
    setTimeout(() => {
        data.name = 'sss';
        data.age = 22; // 副作用函数中没有依赖的属性
        data.sex = 'male' // 对象中不存在的属性
    }, 2000)

那么此时effect会被打印四次,而我们只希望副作用函数中所依赖的属性发生变化时再调用副作用函数执行,也就是说副作用函数这里正常应该被执行两次,分别是默认调用和依赖属性发生变化时的调用。此时存在的问题已经很明显了即副作用函数与被操作的字段之间并没有建立明确的联系。简单的说就是修改对象中的age属性name属性的副作用函数也会被执行,我们希望修改某个属性只触发它对应的副作用函数所以就需要将属性与它对应的副作用函数一一对应起来,就是下图中的结构。

image.png

就像上图这样,target表示代理对象存储的数据结构为Map,key是对象中的属性存储的数据结构为Set,effect代表key对应的副作用函数,我们需要构建一个这样的结构,如此一来就可以准确的触发每个属性字段对应的副作用函数,下面来对之前收集的代码进行改造。

    let bucket = new Map();
    const data = new Proxy(obj, {
        get(target, key) {
            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);
            return target[key];
        },
        set(target, key, newVal) {
            target[key] = newVal;
            const depsMap = bucket.get(target);
            const effects = depsMap.get(key);
            // 有对应的副作用函数才会执行
            effects && effects.forEach(fn => fn());
        }
    })

首先将bucket结构改为Map数据结构,若之前没有对当前访问的对象进行存储则需要为其创建一个Map数据结构用来存储target,同理key也是只不过他的存储结构为Set数据结构,最后再将对应的副作用函数添加进去就会实现上述提到的存储结构,是不是还挺简单!

image.png

我们在修改了收集时的代码,在触发修改的地方自然也需要进行相应的调整即set函数。重新设置属性值的时候也很简单,只需要从bucket中找到当前属性对应的副作用函数,如果能找到则说明当前要修改的属性存在对应的副作用函数,将其执行即可。

这时候再次运行代码会发现,effect只会打印两次也就是说副作用函数只执行了两次,从而解决了副作用函数重复执行的问题。

由于这两个函数在后面会经常被使用到,所以可以单独把它们提取出来就形成了vue中常见的tracktrigger函数。

    const data = new Proxy(obj, {
        get(target, key) {
            track(target,key);
            return target[key];
        },
        set(target, key, newVal) {
            target[key] = newVal;
            trigger(target,key);
        }
    })
    function track(target,key){
         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);
    }
    function trigger(target,key){
         const depsMap = bucket.get(target);
         const effects = depsMap.get(key);
         // 有对应的副作用函数才会执行
         effects && effects.forEach(fn => fn());
    }

副作用函数的调度执行

所谓调度执行指的就是当触发副作用函数重新执行时有能力决定副作用函数的执行时机、次数。 这点是比较重要的,在后面computed和watch实现原理中都会用到,所以下面先来简单了解一下如何决定副作用函数的执行时机和执行次数两个案例。

执行时机

执行时机是指我们可以控制副作用函数的执行时机或者说整个代码的执行顺序。

 registerEffect(() => {
        console.log(data.age);
 });
 data.age = data.age + 1; // data.age++
 console.log("end...");
 // === result1 ===
 // 20
 // 21
 // end...
 
 // === result2 ===
 // 20
 // end...
 // 21

比如上面这段代码,如果想要改变一下输出的结果即result2中的顺序该如何做呢?我们知道21的输出是由于在修改age属性时重新触发了副作用函数而得到的所以可以以这里为切入点。

    function registerEffect(fn, options = {}) {
        const effectFn=()=>{
            activeEffect = effectFn;
            fn()
        }
        effectFn.options = options;
        effectFn();
    }
    
    registerEffect(() => {
        console.log(data.age);
    },{
        scheduler(fn){
            setTimeout(fn);
        }
    });
    data.age++;
    console.log('end...');
  
    function trigger(target, key) {
        const depsMap = bucket.get(target);
        const effects = depsMap.get(key);
        effects && effects.forEach(fn => {
            if (fn.options.scheduler) {
                fn.options.scheduler(fn);
            }else{
                fn();
            }
        });
    }

上面的代码就可以实现打印顺序的变化,逐步分析一下代码,首先在注册副作用函数中传入第二个参数为一个对象,对象中就是需要调度执行的函数,然后修改注册副作用函数中的代码,声明了effectFn新函数包裹原来的代码将传递过来的参数作为该函数的属性(函数是一种特殊的对象)并进行调用,其实效果是一样的只不过这样做扩展性更好一点,这个后面就会体会到。那么这样做的目的是什么呢?其实就是为了在进行trigger操作的时候优先执行传递过来的函数并将当前的副作用函数作为参数再传递回去 这里就有点回调函数的意思了,在scheduler调度器中使用定时器将传递过来的函数进行异步调用从而实现打印顺序的变化。

执行次数

    registerEffect(() => {
        console.log(data.age) // 20 21 22
    });
    data.age++;
    data.age++;

上面这段代码在没有调度器的情况下将输出20 21 22,如果我们只想关心结果不想关心中间的过渡状态,也就是说只输出20和22的结果的话就需要调度器的支持了,这里需要用到一些事件循环中的知识。

    let jobQueue = new Set();
    const p = Promise.resolve();
    let isFlushing = false;
    
    function flushJob() {
      if (isFlushing) return;
      isFlushing = true;
      p.then(res => {
          jobQueue.forEach(fn => fn());
      }).finally(() => {
          isFlushing = false;
      })
    }
    
    registerEffect(() => {
        console.log(data.age)
    }, {
        scheduler(fn) {
            jobQueue.add(fn)
            flushJob();
        }
    });

分析一下上述代码,连续对data.age执行两次自增操作会同步且连续执行两次scheduler调度函数,这意味着同一个副作用函数会被jobQueue.add(fn)语句执行两次,首先需要声明一个jobQueue任务队列为Set数据结构,利用其自动去重能力只保存当前的副作用函数。类似地,flushJob函数也会同步且连续地执行两次,但由于isFlushing标志的存在,实际上flushJob函数在一个事件循环内只会执行一次, 即在微任务队列内执行一次。当微任务队列开始执行时,就会遍历jobQueue并执行里面存储的副作用函数。由于此时jobQueue队列内只有一个副作用函数,所以只会执行一次,并且当它执行时,字段data.age的值已经是22了(同步任务优先于微任务),这样我们就实现了期望的输出。

上面的过渡到这里就结束了,下面开始进入正题来实现comuted计算属性和watch侦听器。

computed计算属性

首先来回顾一下computed函数的特点:

接受一个 getter函数,返回一个只读的响应式ref 对象。该 ref 通过 .value 暴露 getter 函数的返回值。

并且只有真正读取到属性的时候才会开始计算(懒计算,懒执行副作用函数),可以对计算的值进行缓存,只有当依赖的属性发生变化时才会重新计算。

首先实现第一个功能,目前来看现在实现的副作用函数会立即调用,在这个场景下我们并不希望它立即执行,而是懒执行。简单来说就是当访问某个属性的时候才开始对其副作用函数进行收集并调用,那么如何实现懒执行手动执行副作用函数呢?

    function registerEffect(fn, options = {}) {
        const effectFn = () => {
            activeEffect = effectFn;
            let res = fn()
            return res;
        }
        effectFn.options = options;
        if (!effectFn.options.lazy) {
            effectFn();
        }
        return effectFn;
    }
   // 计算属性
   function computed(getter) {
        const effectFn = registerEffect(getter, {
            lazy: true
        })
        const obj = {
            get value() { // 访问器属性
                return effectFn();
            }
        }
        return obj;
    }

    const sum = computed(() => data.name + "今年" + data.age);
    console.log(sum.value); // xxx今年20

通过传递lazy属性在副作用函数中进行判断并把当前的副作用函数返回出去就实现了用户可以手动调用副作用函数功能。在定义的computed函数中,它接收一个getter函数作为参数,我们把getter函数作为副作用函数(这样才可以实现对getter函数中对应属性的副作用函数的收集),用它创建一个lazy的副作用函数。computed函数的执行会返回一个对象,该对象的value属性是一个访问器属性,只有当读取value的值时,才会执行effectFn并将其结果作为返回值返回。

下一步就是要做缓存功能了,如果没有缓存的话即使getter函数中的值没有变也会导致副作用函数多次执行,像这样:

    const sum = computed(() => {
        console.log("computed"); // 会打印三次
        return data.name + "今年" + data.age;
    })
    console.log(sum.value); // xxx今年20
    console.log(sum.value); // xxx今年20
    console.log(sum.value); // xxx今年20
    function computed(getter) {
        let dirty = true; // 用来标识是否需要重新计算值,为 true 则意味需要计  算
        let value; // 用来缓存上一次计算的值
        const effectFn = registerEffect(getter, {
            lazy: true
        })
        const obj = {
            get value() {
                if (dirty) {
                    value = effectFn();
                    dirty = false;
                }
                return value;
            }
        }
        return obj;
    }

解决方法就是通过新增两个变量valuedirty,其中 value 用来缓存上一次计算的值,而 dirty是一个标识,代表是否需要重新计算。当我们通过 sum.value 访问值时,只有当 dirtytrue 时才会调用effectFn重新计算值,否则直接使用上一次缓存在 value 中的值。这样无论我们访问多少次 sum.value,都只会在第一次访问时进行真正的计算,后续访问都会直接读取缓存的 value 值。

再来看如何实现第三个功能,现在修改副作用函数中的属性无法重新进行计算。

    console.log(sum.value); // xxx今年20
    console.log(sum.value); // xxx今年20
    data.age = 22; 
    console.log(sum.value); // xxx今年20

原因在于修改完属性的值后虽然也触发了副作用函数的执行,但此时的dirty值依然为false,也就是说此时value的值保存的还是上次的值,但此时属性值已经发生了变化,解决这一问题的关键就在于设置值的时候如何同步修改dirty的值,这时候就需要调度器的登场了。

    function computed(getter) {
        let dirty = true;
        let value;
        const effectFn = registerEffect(getter, {
            lazy: true,
            scheduler() { // 新增代码
                if (!dirty) {
                    dirty = true;
                }
            }
        })
        const obj = {
            get value() {
                if (dirty) {
                    value = effectFn();
                    dirty = false;
                }
                return value
            }
        }
        return obj;
    }

这样当修改完属性值再次访问sum.value时就会重新执行副作用函数并更新value的值从而达到预期效果。

watch侦听器

watch 的第一个参数可以是不同形式的“数据源”:它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组。

watch 的实现本质上就是利用了副作用函数以及调度器函数,首先来实现一个参数为响应式对象的watch函数:

    function watch(source, cb) {
        registerEffect(()=>source.name, {
            scheduler() {
                cb();
            }
        });
    }
    
    watch(data, () => {
        console.log("watch")
    })
    data.name='sss'

上述代码实现了对data中的name属性的监听 ,想要对监听name属性的变化就需要传入副作用函数中,通过将传递的回调函数放入到调度函数中就可以实现在修改属性之后立即执行传入的回调函数。

但这样就又存在硬编码的问题了,现在只能监听name属性的变化,如果想要监听整个对象还需要对其进行递归操作。

    function watch(source, cb) {
        registerEffect(() =>traverse(source), {
            scheduler() {
                cb();
            }
        });
    }
    
    function traverse(data) {
        // 如果是基本数据类型直接返回
        if (typeof data !== 'object' || !data) return;
        for (let key in data) {
            traverse(data[key]);
        }
        return data;
    }
    
    watch(()=>data.name, () => {
        console.log("watch"); // watch watch
    })
    data.name = 'sss';
    data.age = 10;

watch函数还可以传递一个getter函数作为参数,在 getter 函数内部,用户可以指定该 watch 依赖哪些响应式数据,只有当这些数据变化时才会触发回调函数执行,我们只需要在内部做一个判断即可。

    function watch(source, cb) {
        let getter;
        if (typeof source == 'function') {
            getter = source;
        } else {
            getter = () => traverse(source);
        }
        registerEffect(getter, {
            scheduler() {
                cb();
            }
        });
    }
    
    watch(()=>data.name, () => {
        console.log("watch") // watch
    })

首先判断 source 的类型,如果是函数类型,说明用户直接传递了 getter 函数,这时直接使用用户的 getter 函数;如果不是函数类型,那么保留之前的做法,即调用traverse 函数递归地读取。

还有一个比较重要的功能就是在回调函数中可以获取到旧值与新值,那么如何获得新值与旧值呢?这需要充分利用 effect 函数的lazy 选项。

    function watch(source, cb) {
        let getter;
        let oldValue,newValue;
        if (typeof source == 'function') {
            getter = source;
        } else {
            getter = () => traverse(source);
        }
        const effectFn = registerEffect(getter, {
            lazy: true,
            scheduler() {
                // 重新执行副作用函数,得到的是新值
                newValue = effectFn();
                cb(oldValue,newValue);
                oldValue = newValue
            }
        });
        // 手动调用副作用函数,拿到的值就是旧值
        oldValue = effectFn();
    }

首先开启lazy选项创建了一个懒执行的副作用函数,通过手动调用effectFn 函数得到的返回值就是旧值,即第一次执行得到的值。当变化发生并触发 scheduler 调度函数执行时,会重新调用effectFn 函数并得到新值,这样我们就拿到了旧值与新值,接着将它们作为参数传递给回调函数就可以了,最后再更新原来的旧值oldValue避免下一次变更发生时得到错误的旧值。

写在最后

以上就是所有内容了,在说computedwatch的同时呢又引入了一个副作用函数的概念,会发现先把副作用函数搞透彻了再来看后者会轻松很多,因为可以看到基本都是围绕副作用函数来实现的,最后有不懂或者有错误的地方欢迎大家评论或私聊我哦!