从源码分析双向绑定
这部分代码,是源码的简化版,相对比较容易理解。
html代码:
<body>
<div id="mvvm-app">
<input type="text" v-model="message" />
<p>{{message}}</p>
<button v-on:click="sayHi">change model</button>
</div>
</body>
<script src="./index.js"></script>
<script>
var vm = new MVVM({
el: "#mvvm-app",
data: {
message: "hello world"
},
methods: {
clickBtn: function(message) {
vm.message = "clicked";
}
}
});
</script>
从html代码,vue仅仅从初始化vm实例就完成了双向绑定,简直溜啊,我们还在想是用模块还是啥玩意搞的时候,人家就直接实例->视图,完成全部,秀啊。
在看看vm实例初始化过程中干了啥
function MVVM(options) {
this.$options = options;
var data = (this._data = this.$options.data),
self = this;
Object.keys(data).forEach(function(key) {
self._proxy(key);
});
observe(data, this);
this.$compile = new Compile(options.el || document.body, this);
}
我们来数:
- this.$options = options // 它将入参的引用地址缓存了一遍
- var data = this._data = this.$options.data // 他将入参的data挂载到自身_data,并在当前词法作用域声明一个data参数
- Object.keys(...)... // 它将data的key遍历,并调用自身原型的方法,_proxy
就此打断,我们来看看_proxy怎么玩
MVVM.prototype = {
_proxy: function(key) {
var self = this;
Object.defineProperty(self, key, {
configurable: false,
enumerable: true,
get: function proxyGetter() {
return self._data[key];
},
set: function proxySetter(newVal) {
self._data[key] = newVal;
}
});
}
};
这段代码,是将自身的 vm.data.key1,vm.data.key2 变成 vm.key1和vm.key2,这就是为什么你可以在vm的方法中调用this.key1的原因了。
继续构造函数的解析:
- observe(data, this); // 这里就是实现2的一部分过程
我们来看看observe
的实现
function observe(data) {
if (!data || typeof data !== "object") {
return;
}
if (Array.isArray(data)) {
throw new TypeError("data must Object");
}
defineReactive(data);
}
结果是它只做了一些类型判断,并调用了defineReactive
这个函数
我们看看defineReactive
的实现
function defineReactive(data) {
// 创建一个消息订阅器实例
var dep = new Dep();
for (let key in data) {
var type = Object.prototype.toString.call(data[key]);
if (type === "[object Array]") {
Object.defineProperty(data[key], "push", {
value: arrayMethods.push
});
} else if (type === "[object Object]") {
// 递归调用
defineReactive(data[key]);
} else {
proxy(data, key,dep);
}
}
}
function proxy(obj,prop,dep) {
var val = obj[prop];
Object.defineProperty(obj, prop, {
get: function() {
if (Dep.target) {
dep.depend();
}
return val;
},
set: function(newVal) {
val = newVal;
dep.notify();
}
});
}
这里
- 首先传入了data实例作为入参
- 创建一个消息容器实例dep
- 遍历data的key
- 判断data[key]的类型
- 数组 : 对数组的push、unshift(这里偷懒了没写)...方法进行劫持,这是个难点,有兴趣看section3
- 对象 : 对象直接递归调用
- 普通类型属性 : 调用proxy进行数据劫持
我们再来看proxy怎么实现的
- 首先,它有三个入参,分别是defineReactive遍历对象,defineReactive遍历出的key,容器实例dep
- Object.defineProperty(...),这里做真正的数据劫持,重新定义data.key的访问器属性[[Get]] 与[[Set]],在getter与setter中调用容器实例的depend与notify方法。实际上这是最难理解的地方,为什么是调用容器的方法,而不是直接写入操作DOM的代码呢?而且这些散落的dep容器对象是不可预测的。
好,现在data对象劫持完成了,再无数次递归后,你可以想象一下dep实例的分布。
假设data是这样的一个结构
data : {
user : {
name : '2222娘',
age : '18'
},
key : 1
}
dep的分布应该是这样的
data(dep1) : {
key : {
getter : function(){ dep1 },
setter : function() { dep1 }
},
user(dep2) : {
name : {
getter : function(){ dep2 },
setter : function() { dep2 }
},
age : {
getter : function(){ dep2 },
setter : function() { dep2 }
},
}
}
dep寄生在data实例以及子属性为对象的身上
好,回到vm的构造函数,看看这句
this.$compile = new Compile(options.el || document.body, this);
这里创建了一个Compile实例,并挂载到自身的$compile属性身上。来看Compile的构造函数
/**
* @param {dom} 传入的dom节点
* @param vm 传入的vm实例
*/
function Compile(el, vm) {
this.$vm = vm; // 挂载到自身
this.$el = this.isElementNode(el) ? el : document.querySelector(el); // 是节点直接用
if (this.$el) {
// 以下这句是提高性能的
this.$fragment = this.node2Fragment(this.$el);
// 调用原型方法
this.init();
// 调用完后,给$el添加$fragment
this.$el.appendChild(this.$fragment);
}
}
我们来看一下init方法
Compile.prototype = {
init: function() {
this.compileElement(this.$fragment);
},
compileElement: function(el) {
var childNodes = el.childNodes;
var self = this;
Array.prototype.slice.call(childNodes).forEach(function(node) {
var text = node.textContent;
var reg = /\{\{(.*)\}\}/; // 表达式文本呢
if (self.isElementNode(node)) {
self.compile(node);
} else if (self.isTextNode(node) && reg.test(text)) {
self.compileText(node, RegExp.$1);
}
// 遍历编译子节点
if (node.childNodes && node.childNodes.length) {
self.compileElement(node);
}
});
},
compile: function(node) {
var nodeAttrs = node.attributes;
var self = this;
Array.prototype.slice.call(nodeAttrs).forEach(function(attr) {
var attrName = attr.name; // v-text
if (self.isDirecitive(attrName)) {
var exp = attr.value;
var dir = attrName.substring(2);
if (self.isEventDirective(dir)) {
// 事件指令, 如 v-on:click
compileUtil.eventHandler(node, self.$vm, exp, dir);
} else {
// 普通指令
compileUtil[dir] && compileUtil[dir](node, self.$vm, exp);
}
}
});
}
isElementNode: function(el) {
return el.nodeType && el.nodeType === 1;
},
isTextNode: function(el) {
return el.nodeType && el.nodeType === 3;
},
isDirecitive: function(attrName) {
return attrName.indexOf("v-") == 0;
},
isEventDirective: function(dir) {
return dir.indexOf("on") === 0;
},
node2Fragment: function(el) {
var fragment = document.createDocumentFragment();
var child;
while ((child = el.firstChild)) {
fragment.appendChild(child);
}
return fragment;
},
compileText: function(node, exp) {
compileUtil.text(node, this.$vm, exp);
}
};
init()方法分析
- 调用compileElement()方法
- 对入参el提取所有子节点(这是准备遍历的节奏)
- 遍历子节点
- 判断节点类型
- 元素节点 :调用complie方法
- 文本节点 : 调用compileText
- 判断节点是否还有子节点,有就递归调用compileElement方法
complie方法分析
- 通过attributes提取节点的属性集合(类数组)
- 遍历这些元素属性
- 通过元素属性名判断是否是指令并判断指令类型
- 是事件指令(v-on) : 调用compileUtil单例的指令处理方法对node进行事件绑定
- 普通指令(v-model,v-bind) : 调用compileUtil单例的指令处理方法对node进行处理
指令处理集合compileUtil代码分析,我这里之分析几个重要的方法
// 指令处理集合
var compileUtil = {
text: function(node, vm, exp) {
this.bind(node, vm, exp, "text");
},
// ...省略
bind: function(node, vm, exp, dir) {
var updaterFn = updater[dir + "Updater"];
// 第一次初始化视图
updaterFn && updaterFn(node, this._getVMVal(vm, exp));
// 实例化订阅者,此操作会在对应的属性消息订阅器中添加了该订阅者watcher
new Watcher(vm, exp, function(value, oldValue) {
// 一旦属性值有变化,会收到通知执行此更新函数,更新视图
updaterFn && updaterFn(node, value, oldValue);
});
},
_getVMVal: function(vm, exp) {
var val = vm;
exp = exp.split(".");
exp.forEach(function(k) {
val = val[k];
});
return val;
},
model: function(node, vm, exp) {
this.bind(node, vm, exp, "model");
var me = this,
val = this._getVMVal(vm, exp);
node.addEventListener("input", function(e) {
var newValue = e.target.value;
if (val === newValue) {
return;
}
me._setVMVal(vm, exp, newValue);
val = newValue;
});
},
_setVMVal: function(vm, exp, value) {
var val = vm;
exp.forEach(function(k, i) {
// 非最后一个key,更新val的值
if (i < exp.length - 1) {
val = val[k];
} else {
val[k] = value;
}
});
},
// 事件处理
eventHandler: function(node, vm, exp, dir) {
var eventType = dir.split(":")[1],
fn = vm.$options.methods && vm.$options.methods[exp];
if (eventType && fn) {
node.addEventListener(eventType, fn.bind(vm), false);
}
}
};
当我们遇到v-model
这样的指令会调用compileUtil.model方法
入参
- 节点
- vm实例
- exp(属性值) v-model='xx' 的xx
调用过程
- 调用自身的bind方法
- _getVMVal 是通过vm实例的_data属性值的引用
- 为元素节点添加input事件监听,当有新的值传入时触发_setVMVal,调用vm[exp] = newVal
这里就完成了input中绑定原生事件,回调更新数据层
再看bind方法,以下是形参说明
- 当前指令的dom节点
- vm,vm实例
- exp(属性值) v-model='xx' 的xx
- dir 更新类型
bind: function(node, vm, exp, dir) {
var updaterFn = updater[dir + "Updater"];
// 第一次初始化视图
updaterFn && updaterFn(node, this._getVMVal(vm, exp));
// 实例化订阅者,此操作会在对应的属性消息订阅器中添加了该订阅者watcher
new Watcher(vm, exp, function(value, oldValue) {
// 一旦属性值有变化,会收到通知执行此更新函数,更新视图
updaterFn && updaterFn(node, value, oldValue);
});
}
// 更新函数
var updater = {
textUpdater: function(node, value) {
node.textContent = typeof value == "undefined" ? "" : value;
},
modelUpdater: function(node, value, oldValue) {
node.value = typeof value == 'undefined' ? '' : value;
}
// ...省略
};
这里它做这些事情
- 获取单例updater对应的dirUpdater方法。这里为modelUpdater
- 第一次使用bind时,初始化对应的dom节点(如v-model="text",text=2)则dom.value = 2
- 实例化一个Watcher,并传入回调函数,updateFn作为闭包传递了下去
再来看Watcher的构造函数
function Watcher(vm, exp, cb) {
this.cb = cb;
this.vm = vm;
this.exp = exp;
this.depIds = {};
// 此处为了触发get方法,从而在dep添加自己
this.value = this.get();
}
它做了如下事情
- 将vm,exp,cb挂载到自身
- 创建一个depIds的集合
- 触发它原型身上的get方法,并将返回值挂载到自己身上
再看它的原型get方法
Watcher.prototype = {
get: function() {
Dep.target = this; // 将订阅者指向自己
var value = compileUtil._getVMVal(this.vm,this.exp); // 触发getter,添加自己到属性订阅器
Dep.target = null; // 添加完毕 重置
return value;
},
update: function() {
this.run(); // this.run(); // 属性值变化收到通知
},
run: function() {
var value = this.get(); // 取到最新值
var oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal); // 执行compile中的回调 更新视图
}
},
addDep: function(dep) {
if (!hasOwnProperty(this.depIds, dep.id)) {
dep.addSub(this);
this.depIds[dep.id] = dep;
}
}
};
在它的get方法触发后
- Dep.target = this ,它将自己挂载到Dep静态变量target上。
- 再次调用comlileUtil._getVMVal(exp),逐层往下触发vm的getter
这个过程时非常的绕,首先,我们在编译时创建watcher的实例,创建完一个实例后,我们想通过将与v-model绑定属性相关的订阅者加入到wathcer实例中,但是这些实例是通过闭包保存在属性的getter与setter中,通过以上办法可以获取到这些分散的实例。总结为以下几步vm.data = { user : { name : 22333 }} v-model = "user.name" data(dep1) { user(dep2): { name :2222 } } dep1.depend(Dep.target) dep2.depend(Dep.target) Dep.prototype = { addSub: function(sub) { this.subs.push(sub); }, depend: function() { Dep.target.addDep(this); }, removeSub: function(sub) { var index = this.subs.indexOf(sub); if (index != -1) { this.subs.splice(index, 1); } }, notify: function() { this.subs.forEach(function(sub) { sub.update(); }); } } 触发Wathcer的addDep方法,则当前的Watch.depIds = [dep1,dep2], 并将传入dep1.addSub(Watch); dep2.addSub(Watch),双方都保留了各自的引用。
- 将当前的watcher挂载到全局变量中
- 触发data的各个getter
- getter中保留的dep引用,触发dep的depend方法
- depend方法触发全局变量的addDep方法,并将自身作为实参传入
- 全局变量watcher成功收集所有与之有关的dep实例
- Dep.target = null; 这句将自身暴露的引用删除
- return value 返回获取到的vm属性值
我们来看一下watcher与dep的引用数谁的多
data(dep1) : {
user(dep2) : { name : 22333}
}
<div>{{user.name}}</div>
<div>{{user.name}}</div>
<div>{{user.name}}</div>
这里会创建三个watcher ,
watcher1: { depIds :[dep1,dep2]}
watcher2: { depIds :[dep1,dep2]}
watcher3: { depIds :[dep1,dep2]}
dep1 : { subs : [watcher1,wathcher2,watcher3] }
dep2 : { subs : [watcher1,wathcher2,watcher3] }
以上就完成了 数据层 --(数据劫持)--> DOM
在触发某个属性的setter 后,有关的dep会通知所有订阅该属性的watcher,并触发watcher的更新视图方法。
事实上,最难理解的是加入dep与watcher这样的相互映射。有点像笛卡尔积与二维表
Watcher\ Dep | dep1 | dep2 | dep3 |
---|---|---|---|
wathcer1 | |||
wathcer2 | |||
wathcer3 | |||
watcher4 |
事实上,depIds的作用是用于记录当前watcher实例订阅dep实例,如果已经订阅过了,则不再订阅。
最后,当触发data.xxx = "xxx"的时候,dep就会调用notify通知相关的watcher更新视图
这就完成了 当数据层变化时,更新input或关联元素的value (2)
,最后,双向绑定就实现了
相关术语
收集依赖
对于dep.addSub(watcher)
这个过程,我们叫做收集依赖,这个过程实在complie中实现的,每次新建完watcher后,都会在相关的dep添加该watcher实例。
面试怎么回答?
双向绑定怎么实现啊 ? 面试你可不能回答大白话,毕竟造航母
答 :双向绑定的基本原理是在vm实例初始化过程中对data对象进行数据劫持,并创建订阅容器dep,在render(compile)过程中遍历每个节点并创建watcher依赖,创建依赖过程中通过getter触发订阅容器的依赖收集。最后,当data对象下的属性触发setter操作时,订阅容器通知相关依赖触发更新。
网友评论