欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

【困难版】如何实现一个类vue的双向绑定——Vue2.0 | MVVM | 数据劫持+发布订阅

程序员文章站 2024-02-01 14:16:58
...

相信根据上一篇文章,你已经大致明白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的更新,因此就差红色的部分没有完成
【困难版】如何实现一个类vue的双向绑定——Vue2.0 | MVVM | 数据劫持+发布订阅
其实并没有什么函数把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绑定完毕。

相关标签: vue 面试 前端