MiniVue系列 --- 依赖收集与追踪

442 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

为什么要收集依赖?

依赖的收集能让我们更精准的响应数据变化,举个栗子

new Vue({
    template: 
        `<div>
            <span>{{text1}}</span> 
            <span>{{text2}}</span> 
        <div>`,
    data: {
        text1: 'text1',
        text2: 'text2',
        text3: 'text3'
    }
});

假设我们之前写的cb就是更新template模板函数的话,那么text3其实是没必要去触发cb的。

另外还有一种场景,先看如下栗子:

let globalObj = {
    text1: 'text1'
};

let o1 = new Vue({
    template:
        `<div>
            <span>{{text1}}</span> 
        <div>`,
    data: globalObj
});

let o2 = new Vue({
    template:
        `<div>
            <span>{{text1}}</span> 
        <div>`,
    data: globalObj
});

此时如果执行去执行globalObj.text1 = 'hello,text1';,需要把2个地方更更新,意味着需要执行2个不同的cb。 也就意味着有2个地方收集了依赖,这样才能在set时,触发2个不同的cb。

发布订阅者模式

在继续讲解前,我们先看下什么是发布订阅者模式。比如小明去楼盘看房,暂时没看到适合的房子,此时售楼部的工作人员告诉小明,你可以在我这里订阅下楼盘信息,等有消息我就通知你。此时小明就是订阅者,而售楼部的工作人员是发布者,即有新消息时发布通知给订阅者小明。结合以上内容我们用代码实现下:

// 这是事件触发中心,发布和订阅都在此操作
class EventEmitter {
    // 需要定义一个deps来存所有的订阅者列表
    deps = []
    
    // 这是订阅的方法,需要传入订阅的事件名(小明名字),发布时需要执行的handler(小明留下的联系方法)
    $on(eventName, handler) {
        // 订阅的信息需要存入到 deps里,通过eventName来区分
        // 使用 || [] 保证了this.deps[eventName]里一定是一个[]
        this.deps[eventName] = this.deps[eventName] || []
        this.deps[eventName].push(handler)
    }
    
    // 发布者只要知道订阅者是谁(eventName),以及要传给订阅者的消息 (...args)就行了
    $emit(eventName, ...args) {
        // 因为 this.deps[eventName] 里的事件是一个数组,所以需要遍历
        if(!Array.isArray(this.deps[eventName])) return false
        this.deps[eventName].forEach(hanlder => {
            typeof hanlder === 'function' && hanlder(...args)
        })
    }
}

// 下面我们可以试下
const saler = new EventEmitter()

      // 小明在售楼员处订阅消息
      saler.$on('小明', args => { console.log('手机通知', args) })
      saler.$on('小明', args => { console.log('短信通知', args) })
      saler.$on('小明', args => { console.log('微信通知', args) })

      // 售楼员给小明发布消息,并遍历了几种不同端的方法
      saler.$emit('小明', '有新楼盘啦66-6')

但此处也有一些缺点,当然也不能说缺点,但是用于之前我们提到的Vue响应式数据变化的依赖追踪有一些不足: 1,发布订阅者只有一个消息中心(EventEmitter),而Vue是每一个组件一个Watcher 2,每个cb都有不同,这是根据Watcher自己来定的,比如A组件需要更新A组件的template,B组件需要更新B组件的template,但是他们都引用了同一个响应式数据。 3,最重要的一点,发布者和订阅者之间毫无关联,只是通过消息中心去订阅和发布,无法找到依赖之间的关联性。

观察者模式

综上,可以看下另外一种模式,观察者模式:

为了解决上面的问题,我们把 发布者和订阅者2个角色分开。

// 发布者
class Dep {
    // 发布者还是需要来记录订阅者信息
    subs = []
    
    // 添加订阅者
    addSub(sub) {
        // sub 是一个订阅者的实例化对象,这个实例化对象都有一个update方法
        if (sub && sub.update) {
          this.subs.push(sub)
        }
    }
    
    // 发布通知
    notify() {
        this.subs.forEach(sub => sub.update())
    }
}

// 订阅者很简单,只有一个update方法,用于响应发布者的通知notify
class Wathcher {
   constructor() {
    /* 在new一个Watcher对象时将该对象赋值给Dep.target,在get中会用到 */
    Dep.target = this;
   }
   update() {
      //此时update就可以不同的cb变化啦
      console.log('update')
   }
}

let dep = new Dep()
let watcher = new Watcher()

dep.addSub(watcher)

dep.notify()

上面的发布者和订阅者就有了对应的关联,我们可以知道是谁订阅了谁,另外一个发布者添加多个订阅者也毫不影响。

依赖收集

有了上面观察者模式的写法,我们来看下如何去收集依赖。 改造一下之前的 defineReactive方法

function defineReactive (obj, key, val) {
    /* 一个Dep类对象 */
    const dep = new Dep();
    
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter () {
            /* 将Dep.target(即当前的Watcher对象存入dep的subs中) */
            dep.addSub(Dep.target);
            return val;         
        },
        set: function reactiveSetter (newVal) {
            if (newVal === val) return;
            /* 在set的时候触发dep的notify来通知所有的Watcher对象更新视图 */
            dep.notify();
        }
    });
}

// 同时修改下Vue的代码
class Vue {
    constructor(options) {
        this.$data = options.data || {}
        observer(this.$data);
        /* 新建一个Watcher观察者对象,这时候Dep.target会指向这个Watcher对象 */
        new Watcher();
        /* 在这里模拟render的过程,为了触发test属性的get函数, 实际过程中是在compiler时触发收集 */
        console.log('render~', this.$data.test);
    }
}

整个响应式数据和依赖追踪就写完了。我们可以结合这张图总结下:

屏幕快照 2022-08-11 下午1.46.33.png

图片包含了整体流程,我们只看右边 data 和 watcher 这2块 1 一个组件对应一个watcher 2 在getter时收集依赖到watcher 3 在setter时通知watcher 4 结合 2 3 可知,watcher既是一个发布者也是一个订阅者,合称观察者

一些其他的tips


1 为什么要在Watcher实例化时给Dep.target赋值? 这个是利用了JS是单线程,所以代码都是同步执行的。在初始化组件时,也初始化了这个组件对应的watcher。同时这个组件上的data,在定义响应式数据时,对每个属性都实例化一个dep,但构造函数上的静态属性target始终指向前面实例化对象watcher,这样能保证一个组件下所有的deps都是指向对应组件的watcher,当然这个指向也可以通过一个全局变量去指定。

2 为什么一个组件会有多个deps而只有一个watcher? 我们看先看下面的代码

new Vue({
    data: {
        a: 'a',
        b: 'b'
    },
    template: `
        <div>
            <div>{{a}}</div>
            <div>{{b}}</div>
        </div>
    `,
    compiler() {
        // 这是一个模拟的伪代码,表示watcher里的update
        // 用于把上面的template里的{{a}}转成a
    }
})

按之前写的理解,每当 data.a 或者 data.b触发setter时,compiler都会重新执行。 那整个流程应该是

                data.a
component  ->             ->  watcher
                data.b

流程如下: 1 初始化组件,新建一个watcher 2 遍历data,的a 和 b属性,为其初始化对应的2个dep,并且把watcher添加到subs中 3 data.a 和 data.b触发setter时,遍历dep实例化对象中的subs列表,触发里面sub(就是watcher)的update(就是compiler),去进行组件的rerender。其中defineReactive里实例化的dep在defineProperty的descriptor里是一个闭关,所以能保存对应的dep对象。