相信根据上一篇文章,你已经大致明白Vue的工作原理了,但还有很长一部分路要走,我花了三天时间,满打满算估计有16个小时,才把这些弄明白,其中写了三版的代码,接下来的内容要比上一篇难理解20倍不止。

因为我们将要引入Dep类,我在一开始看的时候,看了两个多小时,都没明白Dep类的作用是什么,甚至有的代码都看不懂,而且困难版的Vue实现还支持嵌套数据,在下面的内容中你可能会难以理解这些点:

  • Dep的作用
  • Dep和watcher的关系
  • Dep是怎么保存Watcher的
  • Dep和变量的关系
  • 访问子变量时,如何触发父变量收集依赖
  • 等等

让我们开始吧。


逻辑梳理

照常先梳理一遍逻辑,首先实例化Vue类,在实例化时,先触发observe,递归地对所有data中的变量进行订阅,并且,这里注意,每次订阅之前,都会生成一个dep实例,dep是指依赖,为什么使用依赖这个词你后面会明白,因此每一个只要是Object类型的变量都有一个dep实例,比如在下例中,data就有一个dep,number也有一个dep。

还要额外说明,这个dep是闭包产生的,因此所有与dep有关的操作,都要放到defineReactive函数内部执行。

// 此处只展示和讲解有关的代码,后面不再赘述
window.myapp = new Vue({
    el: "#app",
    data: {
        number: {
            big: 999
        },
    },
});

export default class Vue {
    constructor (options: any = {}) {
        this.$options = options;
        this.$el = options.el;
        this.$data = options.data;
        this.$methods = options.methods;
        this.observe(this.$data);
        new Compiler(this.$el, this);
    }

    observe (data) {
        if (!data || typeof data !== "object") {
            return;
        }
        Object.keys(data).forEach(key => {
            this.defineReactive(data, key, data[key]);
        })
    }

    defineReactive(data, key, value) {
        this.observe(value);
        let dep = new Dep();
        this.$dps.push(dep);
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: false,
            get () {
                 // 由于需要在闭包内添加watcher,所以通过Dep定义一个全局target属性,暂存watcher, 添加完移除
                if (Dep.target)
                    // dep.addSub(Dep.target);
                    dep.depend();
                    /**
                     * dep.depend();
                     * 两种写法一致
                     */
                return value;
            },
        })
    }
}

继续顺逻辑之前再讲解一下dep类,便于你后面理解。

先定义一个全局的uid,便于分别每一个dep实例,在创建dep的时候绑定并自加1,每一个dep,都会有一个subs队列,里面存放的是watcher,我们之前说过,每一个data以及其中凡是对象的变量,都唯一对应一个dep,如果想要实现从model -> View的绑定,只需要这样做,我们把所有的发布者watcher都放到一个dep中,当我们改变一个变量时,只需要拿到这个变量对应的dep即可,因为dep有一个subs队列,存放的全是相关的发布者watcher,只需要遍历subs并且调用其中发布者的update方法即可更新页面,这就是设计dep类的思想。

let guid = 0;

export default class Dep {
    static target: any = null;
    subs: Array<any>;
    uid: number;
    constructor() {
        this.subs = [];
        this.uid = guid ++;
    }
    addSub(sub) {
        this.subs.push(sub);
    }
    depend () {
        Dep.target.addDep(this);
    }
    notify() {
        this.subs.forEach(sub => {
            sub.update();
        })
    }
}

Dep.target = null;

其中最难理解的就是depend()函数和Dep.target。target是一个静态变量,所有的dep实例的target都指向同一个东西,也就是说这个target是全局唯一的,你把它理解为全局变量即可,这个target是一个什么呢?它其实就是一个watcher。它的工作原理是这样的,我们在defineProperty的get事件被触发时会进行依赖收集(现在不明白依赖收集没关系),你会经常触发get事件,但我们现在指定——你如果想要拿到这块砖,只能从我手上取,而且我的手上经常是空的,当创建一个watcher时,就把这个watcher放到我的手上,然后告诉你,“嘿,可以拿了!”,这样你才能拿到,等你把依赖收集完了,我就把砖从手上扔掉,因此你虽然经常会触发get事件,但其实你什么都拿不到。

再结合上一个代码片段中的一部分

get() {
    if (Dep.target) // 看看手上有没有砖
        dep.depend(); // 拿砖
    return value;
},

是不是好理解很多,我先创建一块砖,然后放到手上,然后我主动触发一次你的get事件,你一下就拿到了这个watcher,接着我就把watcher扔掉,因此平时你什么都拿不到。

你如何通过dep.depend()拿到这块砖?这也是个难点,我们后面再说。


现在已经为所有变量创建好了订阅,接着我们开始编译模板。compiler上线了。

export default class Compiler {

    private $el: HTMLElement;
    private $fragment: DocumentFragment;
    private $vm:any;
    private $compileUtil: any;
    private Updaters: any;

    constructor (el: string, vm) {
        this.$el = document.querySelector(el);
        this.initUpdaters();
        this.initCompileUtil();
        if (this.$el) {
            this.$vm = vm;
            this.$fragment = this.node2Fragment(this.$el);
            this.compileHTML(this.$fragment);
            this.$el.appendChild(this.$fragment);
        }
    }
}

先从document拿到指定的HTMLelement #app,并且推到一个documentFragment里,不懂的去百度documentFragment。然后编译这个fragment,最后再把fragment推回#app节点。

接下来就很好理解了,遍历里面的每一个childNode,然后再递归遍历,获取每一个node的attribute,通过字符串格式判断是什么类型的指令。

这里主要说两类

  1. 如果是事件类型,比如v-on:click="add",就去$vm.$methods去找对应的函数名,然后绑定到这个上面。
    eventHandler(node: HTMLElement, eventType: string, methodName: string) {
        // 从vm中拿取同名的函数,并为node创建一个事件监听,并把执行的回调函数绑定到vm.data上
        const callback = this.$vm.$methods && this.$vm.$methods[methodName];
        callback && node.addEventListener(eventType, callback.bind(this.$vm.$data));
    }
    
  2. 如果是常规指令,比如v-model v-bind之类,用一个compileUtil去处理,这样写出来的代码相比于上一篇文章中的一堆if,也更简洁,可读性更高,重点是扩展性更强。在这篇文章中,我只实现了v-model以及{{}}指令的解析。分别对应函数,compileUtil.model(),和compileUtil.text()
    initCompileUtil () {
        const that = this;
        this.$compileUtil = {
            model (node: HTMLElement, exp:string) {
                that.bindWatcherAndDep(node, exp, "model");
                let value = that.getDeepValue(exp);
                node.addEventListener("input", (e: any) => {
                    if (value === e.target.value) {
                        return;
                    } else {
                        that.setDeepValue(exp, e.target.value);
                    }
                })
            },
            text (node, exp: string) {
                that.bindWatcherAndDep(node, exp, "text");
            }
        }
    }
    

在这里,我把每一个util都用bindWatcherAndDep方法做转发,统一交给bindWatcherAndDep函数处理,这样代码更清晰。当然对于不同的util,它们都有自己独特的处理。比如model是针对input和textarea的指令,因此,相当于要实现双向绑定,我们现在在搞的是从model到View的绑定,因此我们要在这里额外实现view到model的绑定,很简单,监听input事件即可。对于text来说,它是一个很简单的指令, 甚至不需要特殊处理,所以只需要交给bindWatcherAndDep函数即可。

我们现在来重点看bindWatcherAndDep函数。顾名思义,它的功能就是绑定watcher和dep两者之间的关系,这是非常重要的一步。

	/**
     * 把watcher绑定到对应的dep上
     * @param node 当数据改变时,watcher发布更新,该数据对应所要更新的HTML节点
     * @param exp 更新的数据的表达式,例如number.big
     * @param dir substring后指令名,例如model,text
     */
	bindWatcherAndDep(node: HTMLElement, exp: string, dir: string) {
	    let updateFn = this.Updaters[dir + "Updater"];
	    // 初始化model层 -> View层的数据
	    updateFn && updateFn(node, this.getDeepValue(exp));
	
	    new Watcher(this.$vm, exp, (value) => {
	        updateFn && updateFn(node, value);
	    });
	}

这里的updater的作用和bind函数一样,不管你是什么类型指令的更新操作,全都扔到Updater去集中处理,这里只关心绑定的逻辑,不关心你不同指令节点的更新是如何实现的。首先获得dir对应的更新函数,并且触发一次,这也是第一次用vue.$data的内容去同步视图view中的信息,这不就是从model 到view的更新吗?因此,这些更新函数可以被重复利用,这也是为什么选择把他们抽离出来的理由。第一次同步完成了,这时候vue.$data的信息就和view中的内容同步了,不过如果我们model中的数据再发生改变,要怎么通知view去更新呢?因此,我们需要把dep和watcher绑定起来。

总的来说我们已完成的工作如下,model已经闭包地拥有了自己的dep,html节点也和watcher关联了起来,以及html的内容如何更新,都由watcher的callback/updater函数决定了,就差把watcher推到对应的dep里了,这样,只要model一变化,就通知自己的dep去更新自己subs队列里的watcher,watcher再调用自己callback/updater函数即可完成view的更新,因此就差红色的部分没有完成
在这里插入图片描述
其实并没有什么函数把watcher和dep绑定起来,这一步其实是watcher自己完成的。

export default class Watcher {
    private vm;
    private exp;
    private cb;
    private value;
    private depIds = {};
    constructor (vm, exp, cb) {
        this.vm = vm;
        this.exp = exp;
        this.cb = cb;
        // 创建时必须触发一次依赖收集
        this.triggerDepCollection();
    }
    update () {
        this.triggerDepCollection();
        this.cb(this.value);
    }
    addDep (dep) {
        if (!this.depIds.hasOwnProperty(dep.uid)) {
            dep.addSub(this);
            this.depIds[dep.uid] = dep;
        }
    }
    // 收集依赖,因为触发了definePropreity的get()
    // or re-collection
    triggerDepCollection () {
        Dep.target = this;
        this.value = this.getDeepValue();
        Dep.target = null;
    }
    getDeepValue () {
        let data = this.vm.$data;
        this.exp.split(".").forEach(key => {
            data = data[key];
        })
        return data;
    }
}

当编译html代码时,我们碰到了一个需要收集的变量,现在为其创建一个watcher,并在watcher内部与dep建立联系。我们称这步为依赖收集,我们可以看到,在构造函数的最后一行,triggerDepCollection()意味这个watcher自己触发依赖收集,这个函数先把我们先前提到的Dep.target设为watcher自身,就是把自己作为一块砖头放在手上,然后getDeepValue()这里你只需要知道去访问了一次exp变量,这就触发了exp变量的get事件,就是提醒exp的dep,“你可以收集我了”,get事件的主要内容就是收集这个依赖,然后再结合最开始提到的代码,触发dep.depend()

get () {
	// 略
	if (Dep.target) dep.depend();
}

这也是一行十分抽象的代码,它又调用了dep的Dep.target.addDep(this),也就是当前的watcher的addDep(this)

class Dep {
	// 略
	depend () {
	    Dep.target.addDep(this);
	}
	addSub(sub) {
	    this.subs.push(sub);
	}
}

watcher的addDep(this)又调用了这个dep的addSub()

class Watcher {
	// 略
	addDep (dep) {
        if (!this.depIds.hasOwnProperty(dep.uid)) {
            dep.addSub(this);
            this.depIds[dep.uid] = dep;
        }
    }
}

。。。你说气人不气人。意思就是,我现在要收集依赖,只需要dep调用自己的addSub(watcher),把watcher推到自己的subs队列就完事了,但现在,dep把自己传给watcher,然后watcher再把自己传给dep,dep再把watcher加到自己的队列,这样岂不是多此一举?其实不然。就在于watcher的addDep这一步,关键在于判断这个dep的uid是不是自己加入过的dep,也可以用set实现,这里引用上一篇文章里提到过的文章中的一段注释,写得比较清晰:

  1. 每次调用update()的时候会触发相应属性的getDeepvalue,getDeepvalue里面会触发dep.depend(),继而触发这里的addDep
  2. 假如相应属性的dep.id已经在当前watcher的depIds里,说明不是一个新的属性,仅仅是改变了其值而已,则不需要将当前watcher添加到该属性的dep里
  3. 假如相应属性是新的属性,则将当前watcher添加到新属性的dep里,如通过 vm.child = {name: ‘a’} 改变了 child.name 的值,child.name 就是个新属性,则需要将当前watcher(child.name)加入到新的 child.name 的dep里,因为此时 child.name 是个新值,之前的 setter、dep 都已经失效,如果不把 watcher 加入到新的 child.name 的dep中,通过 child.name = xxx 赋值的时候,对应的 watcher 就收不到通知,等于失效了。因此每次更新都要重新收集依赖。
  4. 每个子属性的watcher在添加到子属性的dep的同时,也会添加到父属性的dep,监听子属性的同时监听父属性的变更,这样,父属性改变时,子属性的watcher也能收到通知进行update,这一步是在 this.get() --> this.getVMVal() 里面完成,forEach时会从父级开始取值,间接调用了它的getter,触发了addDep(), 在整个forEach过程,当前wacher都会加入到每个父级过程属性的dep,例如:当前watcher的是’child.child.name’, 那么child, child.child, child.child.name这三个属性的dep都会加入当前watcher。

然后,所有的内容就完成了,watcher也和dep绑定完毕。

Logo

前往低代码交流专区

更多推荐