实现一个watch函数 最少需要几行代码?我觉得是37

0 阅读6分钟

watch 的简单使用

想要知道 watch 的实现方式,先看下 watch函数 的基本使用

// 对 obj 的 a 属性进行监听
 watch(() => obj.a, (oldValue, newValue) => {
    console.log({
        oldValue, newValue
    })
})

上面是一个简单的watch使用,通过 ()=>obj.a\color{orange}{()=> obj.a} 对 属性a 进行监听,当a改变时,会执行第二个参数,从而打印出 oldValue, newValue 两个值。

全部代码

既然是围绕着 最少代码来讲,就先把代码展示出来。

这是实现对原始数据代理的,并不算在主要代码当中。

// 原始数据
 const data = {
        a: 1
    }

// 实现代理,对原始数据进行监听    
const obj = new Proxy(data, {
    get(target, key) {
        track(target, key);
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        trigger(target, key);
    }
})

下面就是主要的几个函数实现,但其实不少的代码只是添加数据和取出数据的简单操作
注: 下面的代码,删除注释与空行,是37行。并没有故意把多行代码强行用一行写。

    // 追踪属性读取,执行入桶操作
    function track(target, key) {
        if (!activeEffect) return;
        let objMap = bucket.get(target);
        objMap || bucket.set(target, objMap = new Map());
        let propSet = objMap.get(key);
        propSet || objMap.set(key, propSet = new Set());
        
        // 上面的只是向WeakMap里添加数据,这行是关键(入桶)
        propSet.add(activeEffect);
    }

    // 追踪属性修改,出桶与执行函数
    function trigger(target, key) {
        let objMap = bucket.get(target);
        if (!objMap) return;
        let propSet = objMap.get(key);
        
        // 上面的是 从WeakMap里取出数据,下面是关键,执行函数
        propSet && propSet.forEach(fn => {
            const { scheduler } = fn.options;
            scheduler ? scheduler(fn) : fn();
        })
    }

    // 桶
    const bucket = new WeakMap();
    let activeEffect;
    
    // effect函数,入口函数
    function effect(fn, options = {}) {
        activeEffect = fn;
        activeEffect.options = options;
        options.lazy || fn();
        return fn;
    }

    // 基于 effect函数,实现 watch函数
    function watch(getter, cb) {
        let oldValue, newValue;

        // 调用 effect函数,getter中的 ()=>obj.a 将被监听
        // 将值改变成,将执行 scheduler 函数,cb为watch第二个参数,
        // 并将 旧值与新值传给使用者
        const effectFn = effect(getter, {
            lazy: true,
            scheduler() {
                newValue = effectFn();
                cb(oldValue, newValue);
                oldValue = newValue;
            }
        })
        oldValue = effectFn();
    }

上面就可以实现简单的 watch 函数了。

下面我也尽量简短一些,去依次解释其中的几个函数

讲解一些功能

代理

watch 的逻辑是接收两个参数,第一个是要监听的数据,在这里就是一个 getter函数 ()=>obj.a\color{orange}{()=> obj.a} ,第二个是当数据改变时要执行的函数。而代理则是用来处理 第一个参数的。


 const data = {
        a: 1
    }

// 实现代理,对原始数据进行监听    
const obj = new Proxy(data, {
    //当读取 obj.a 时,get 方法会被调用
    get(target, key) {
        track(target, key);
        return target[key];
    },
    //当修改 obj.a 时,set 方法会被调用
    set(target, key, newVal) {
        target[key] = newVal;
        trigger(target, key);
    }
})

proxy 可以实现对原始数据代理,当对数据做一些操作时,会进入对应的拦截器。在get拦截器调用 track函数 ,实现入桶操作。在set拦截器 中调用 trigger函数 实现执行桶中数据的函数操作。

桶存在的目的就是为了让 属性与函数\color{orange}{属性与函数} 之间建立联系,如属性 obj.a 与 (oldValue,newValue)=>{console.log({oldValue,newValue})}\color{orange}{(oldValue, newValue) => \{ console.log(\{ oldValue, newValue \}) \}} 。当 a 改变时,调用这个函数,并将 oldValue,newValue 作为参数传入就可以了。

桶格式如下:

{
//  最外层存储不同的对象
    obj:{
    // 第二层 则是不同的属性
        'a':[ 
            // 第三层是 依赖该属性的不同函数
            (oldValue, newValue) => {console.log({oldValue, newValue})} 
            ]
   }
}

桶只用通过三层就可以让 对象-属性-函数 之间建立关联。因为可能有多个地方对 属性a 进行监听,所以第三层是个集合,而不是只能存储一个函数。

桶是用 WeakMap 存储的

入桶函数 track

桶在初始化时是个空的桶,我们需要向里面添加 属性与函数 关联关系

// 追踪属性读取,执行入桶操作
function track(target, key) {
    // activeEffect是要添加的函数
    if (!activeEffect) return;
    
    //添加第一层[对象(obj)],没有则初始化
    let objMap = bucket.get(target);
    objMap || bucket.set(target, objMap = new Map());
    
    //添加第二层[属性(a)],没有则初始化
    let propSet = objMap.get(key);
    propSet || objMap.set(key, propSet = new Set());

    // 添加第三层[函数],这就是入桶操作
    propSet.add(activeEffect);
}

其中上面就是三个步骤

  1. 先将 obj 作为key 添加到桶中
  2. 再将 a 作为key 添加到 obj 中
  3. 最后将 (oldValue, newValue) => { console.log({ oldValue, newValue }) } 函数作为值,添加到 a中。

执行函数 trigger

函数 入桶是为了在 属性修改时,取出调用,所以 trigger 就是为了从桶中取出函数再调用就行了

// 追踪属性修改,出桶与执行函数
function trigger(target, key) {
    // 取出第一层[对象(obj)]的值
    let objMap = bucket.get(target);
    if (!objMap) return;
    
    //取出第二层[属性(a)]的值
    let propSet = objMap.get(key);

    // 如果有值则循环,取出第三层[函数]
    propSet && propSet.forEach(fn => {
    
        // scheduler 是在watch 中传入的函数
        const { scheduler } = fn.options;
        scheduler ? scheduler(fn) : fn();
    })
}

上面层层读取,最终获取到 函数 scheduler ,该函数是在 watch函数 中传入的。所以当 属性修改时,就会调用对应的 scheduler函数。因为在某些场景下 是不需要传入 scheduler 的,所以没有就执行fn。

effect函数 —— Vue底层Api

effect函数 不仅是 Vue 用来实现 watch 的,而且还是用来实现 computed 的。下面的只是一个简单版,是为了能更简单的理解。

// 用来临时保存 函数的
 let activeEffect;
    
// effect函数,入口函数
function effect(fn, options = {}) {

    //临时保存函数
    activeEffect = fn;
    //保存配置项,如懒加载
    activeEffect.options = options;
    
    //如果是懒加载,则返回函数,由外部决定何时调用
    options.lazy || fn();
    return fn;
}

上面的 effect函数在这里主要是用来接收 watch函数的参数,和配置信息。如 scheduler函数 就是options 中的一个配置,懒加载也是。

watch的实现

到这里可以实现 watch函数了,因为 watch函数 就是通过调用 effect实现的。

 // 接收两个参数,getter 为 ()=>obj.a
 // cb 则是属性改变时要执行的函数
function watch(getter, cb) {

    // 该属性的 旧值与新值
    let oldValue, newValue;

    //将 ()=>obj.a 作为函数传入 effect,会被 activeEffect 接收。
    const effectFn = effect(getter, {
    
        //目前实现的watch不是立即执行的,所以 lazy为true
        lazy: true,
        
        // 当属性改变时,调用 scheduler函数,就是这个函数
        scheduler() {
            newValue = effectFn();
            cb(oldValue, newValue);
            oldValue = newValue;
        }
    })
    
    // 由于 实现的watch不是立即执行
    // 所以初始化时要获取 ()=>obj.a 的值,作为旧值
    oldValue = effectFn();
}

上面的 effectFn 会有些绕,我要细说一下

 const effectFn = effect(getter, {
    
    //目前实现的watch不是立即执行的,所以 lazy为true
    lazy: true,

    // 当属性改变时,调用 scheduler函数,就是这个函数
    scheduler() {
    
        // effectFn函数 就是 effect函数 的返回值
        // 而 effect函数中,如果 lazy为true时,则是 return fn;
        // 而 fn 就是 getter函数,就是 ()=>obj.a
        
        //所以 newValue 就是 obj.a 的值
        newValue = effectFn();
        
        // 调用 watch第二个参数,并将两个值传过去。
        cb(oldValue, newValue);
        
        // 更新旧值
        oldValue = newValue;
    }
})

把流程梳理一下

上面就实现了 简单版的 watch,复杂一些的则是有 更多的配置、解决一些bug、优化性能方面。所以知道是怎么一回事就很重要了。尤其是 effect函数 与 桶bucket。

  1. 我们首先通过 proxy 对原始数据进行代理,实现对数据读取操作的拦截
  2. 接着在读取属性时 做入桶操作,将要执行的函数与属性做一个关联
  3. 然后在修改属性时 从桶中取出要执行的函数,执行

这就是我所总结的 实现 watch的简易版,如果发现有错误的,还请指正。如果有阅读体验不好的,也可以说出,我会尽力去调整自己写的方式,但也会有自己的想法的。