【困难版】如何实现一个类vue的双向绑定——Vue2.0 | MVVM | 数据劫持+发布订阅
相信根据上一篇文章,你已经大致明白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,通过字符串格式判断是什么类型的指令。
这里主要说两类
- 如果是事件类型,比如
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)); }
- 如果是常规指令,比如
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实现,这里引用上一篇文章里提到过的文章中的一段注释,写得比较清晰:
- 每次调用update()的时候会触发相应属性的getDeepvalue,getDeepvalue里面会触发dep.depend(),继而触发这里的addDep
- 假如相应属性的dep.id已经在当前watcher的depIds里,说明不是一个新的属性,仅仅是改变了其值而已,则不需要将当前watcher添加到该属性的dep里
- 假如相应属性是新的属性,则将当前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 就收不到通知,等于失效了。因此每次更新都要重新收集依赖。
- 每个子属性的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绑定完毕。