前言
这是第二遍写这篇文章了,之前写了两个多小时,好不容易写完了,第二天发现莫名其妙没保存。。。又重新写了一遍,真的是心态爆炸。
vue用了很久了,很方便,相对于之前自己通过innerHtml,append等方式去手动更改视图,在vue中只需要更改数据data则会自动更新视图,实在是方便多了。不过用了这么久,没去好好整理过vue双向绑定的原理,只大概知道是通过Object.defineProperty这个方法来实现的,这边花了一下午看了大佬们的文章,好好整理了一下。
此篇文章的大部分源码是来自于此处 ,我手动敲了一遍加入了自己的理解,整理了一下自己的思路,本人是个菜鸟,有些地方不一定理解的正确,请包涵。
Object.defineProperty
这个方法就不具体介绍,可以点上述链接进行查看, 主要介绍这个方法中的get跟set,get就是在读取对象属性值的时候调用的方法,set就是在设置对象属性值的时候调用的方法,通过这两个方法,我们就可以实现数据劫持。
var Book = {}
var name = '';
Object.defineProperty(Book, 'name', {
set: function (value) {
name = value;
console.log('你取了一个书名叫做' + value);
},
get: function () {
return '《' + name + '》'
}
})
//通过get跟set就可以对对象属性进行劫持,进行自定义的操作。
Book.name = 'vue权威指南'; // 你取了一个书名叫做vue权威指南
console.log(Book.name); // 《vue权威指南》
MVVM
官方的解释:MVVM是Model-View-ViewModel的简写。它本质上就是MVC 的改进版。MVVM 就是将其中的View 的状态和行为抽象化,让我们将视图 UI 和业务逻辑分开。当然这些事 ViewModel 已经帮我们做了,它可以取出 Model 的数据同时帮忙处理 View 中由于需要展示内容而涉及的业务逻辑。
其实简单来说就是vue的双向绑定,数据的变化引起视图的变化,视图的变化也引起数据的更新。视图变化引起数据更新其实很简单,我们可以通过监听事件来进行数据更新。如
<input type="text" id="input"/>
document.getElementById("input").addEventListener("onchange",function(e){
//监听input的onchange事件,更改其他地方的数据
console.log(e)
})
重点是如何将数据的变化转换到视图的变化上去。根据上述的Object.defineProperty方法,我们可以实现数据劫持,那么在劫持数据后,我们其实可以在数据变更后手动更新视图。这样子我们可以实现一个最简单的双向绑定。
<span id="name"></span>
//在set中,当进行数据赋值的时候,将数据手动更新到视图上
var Book = {name:''}
Object.defineProperty(Book, 'name', {
set: function (value) {
document.getElementById("name").innerText=value
},
get: function () {
return value
}
})
发布-订阅者模式
上述的简单双向绑定有个很大的缺点,假设现在这个属性值需要在N个地方显示,那么就需要手动更新所有的innerText,这显然是不实际的,vue中使用了发布订阅者模式,将这种一对多的关系进行了处理。
<span id="name1"></span>
<span id="name2"></span>
<span id="name3"></span>
....
var Book = {name:''}
Object.defineProperty(Book, 'name', {
set: function (value) {
document.getElementById("name1").innerText=value
document.getElementById("name2").innerText=value
document.getElementById("name3").innerText=value
...
},
get: function () {
return value
}
})
这里有个发布订阅者模式的简单说明 -发布订阅者模式
发布---订阅模式又叫观察者模式,它定义了对象间的一种一对多的关系,让多个观察者对象同时监听某一个主题对象,当一个对象发生改变时,所有依赖于它的对象都将得到通知。
简单来说:发布订阅者模式类似于我们日常微博关注了一个博主。博主更新了动态,微博app就会通知我们所有的人。博主就是发布者,我们就是订阅者,而微博app则就是消息订阅器(用于完全解耦发布者跟订阅者的关系,上述简单说明中有提到)
原理实现
这段话摘自于原文
我们已经知道实现数据的双向绑定,首先要对数据进行劫持监听,所以我们需要设置一个监听器Observer,用来监听所有属性。如果属性发上变化了,就需要告诉订阅者Watcher看是否需要更新。因为订阅者是有很多个,所以我们需要有一个消息订阅器Dep来专门收集这些订阅者,然后在监听器Observer和订阅者Watcher之间进行统一管理的。接着,我们还需要有一个指令解析器Compile,对每个节点元素进行扫描和解析,将相关指令对应初始化成一个订阅者Watcher,并替换模板数据或者绑定相应的函数,此时当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。因此接下去我们执行以下3个步骤,实现数据的双向绑定:
1.实现一个监听器Observer,用来劫持并监听所有属性,如果有变动的,就通知订阅者。
2.实现一个订阅者Watcher,可以收到属性的变化通知并执行相应的函数,从而更新视图。
3.实现一个解析器Compile,可以扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。
-
实现监听器Observer
监听器其实就是将所有数据通过Object.Property进行劫持,劫持以后可以读取到数据的变化,而调用set方法去通知订阅者。注意一点, 在vue中,data为一个对象,其内部所有的属性值没有类型限制,可能也是对象,所以需要递归才能将所有的值进行劫持。
//判断是否是对象,是的话对每个属性进行劫持
function observer(data){
if(!data||typeof data!=='object'){
return
}
Object.keys(data).forEach(function(key){
defineReactive(data,key,data[key])
})
}
//数据劫持-将所有数据进行劫持
function defineReactive(data,key,value){
//递归调用,如果属性值为对象则进行递归
observer(data)
Object.defineProperty(data, key, {
set: function (newValue) {
return newValue
},
get: function () {
return value
}
})
}
-
实现消息订阅器Dep
消息订阅器的作用其实就是完全解耦发布者跟观察者。假设没有消息订阅器,当发布者内容更改后,我们通过调用观察者事件去通知观察者,这样子两者就耦合了,假设需要变更观察者的事件,那么发布者这边同样需要去更改,而通过消息订阅器在中间进行中转,可以解耦观察者跟订阅者。简单来说,消息订阅器就是个消息队列,先存入观察者,当数据变化时,一一通知队列中的观察者执行更新。这就是一对多的关系
//消息订阅器就是个队列
function Dep(){
this.subs=[]
}
//定义消息订阅器的加入队列跟执行队列的方法,sub其实就是观察者
Dep.prototype={
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
});
}
}
//更改defineReactive方法
function defineReactive(data,key,value){
observer(data)
//为每个属性值定义一个消息订阅器
var dep=new Dep()
Object.defineProperty(data, key, {
set: function (newValue) {
//当属性值发生变化时,通知消息订阅器,执行队列中的观察者更新
if(newValue===value){
return
}
dep.notify()
value=newValue
},
get: function () {
//这里将观察者加入到消息订阅器队列中,watch就是观察者,后面会实现watch
dep.addSub(watch)
return value
}
})
}
- 实现观察者Watch
当数据变化时,所有绑定了数据的观察者都会变化。我们以vue为例,数据变化引起视图变化,这些视图就是观察者。在上述实现消息订阅器的过程中,我们发现在get方法中,将观察者加入到消息订阅器中,说明只要观察者读取了这个属性值,就会将自身加入到订阅器队列中。需要注意的一点是:不能每次读取属性值就执行一次加入队列,这样子会存在无数重复的观察者在消息订阅器中,只需在初始化的时候,将自身加入队列即可。
function Watch(vm,exp,cb){
//cb代表视图更新的方法,vm代表整个实例,exp代表属性名
this.cb = cb;
this.vm = vm;
this.exp = exp;
//在初始化watch时,会执行get方法
//初始化的时候,读取下此属性,触发监听器的get,这样子就会将订阅者加入到订阅者管理器中
//此举作用是用于 只有在第一次初始化的时候才能去将订阅者加入到订阅管理器中
this.value = this.get();
}
//通过全局变量Dep.target来控制是否加入消息订阅器,保证了观察者只会被加入消息订阅器一次
Watcher.prototype = {
update: function() {
this.run();
},
run: function() {
var value = this.vm.data[this.exp];
var oldVal = this.value;
if (value !== oldVal) {
this.value = value;
//cb为传入的方法
this.cb.call(this.vm, value, oldVal);
}
},
get: function() {
Dep.target = this; // 缓存自己
var value = this.vm.data[this.exp] // 强制执行监听器里的get函数,读取了对象的属性
Dep.target = null; // 释放自己
return value;
}
};
//更改defineReactive方法
function defineReactive(data,key,value){
observer(data)
var dep=new Dep()
Object.defineProperty(data, key, {
set: function (newValue) {
if(newValue===value){
return
}
dep.notify()
value=newValue
},
get: function () {
//在new Watch的时候,执行了watch的get方法,将watch自身缓存到Dep.target中
if(Dep.target){
dep.addSub(Dep.target)
}
return value
}
})
}
- 实现selfVue
上面三步就完成了监听器-消息订阅器-观察者的模式。一个简单版的vue就出来了,我们将三者关联起来就可以实现简单的双向绑定。
function SelfVue (data, el, exp) {
this.data = data;
//将data进行数据劫持
observe(data);
// 初始化模板数据的值
el.innerHTML = this.data[exp];
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
return this;
}
<body>
<h1 id="name">{{name}}</h1>
</body>
<script type="text/javascript">
var ele = document.querySelector('#name');
var selfVue = new SelfVue({
name: 'hello world'
}, ele, 'name');
window.setTimeout(function () {
console.log('name值改变了');
selfVue.data.name = 'canfoo';
}, 2000);
</script>
上述就实现了一个简单的数据双向绑定,不过还有个缺点,我们生成了selfVue实例时,将data绑定到了selfVue上,这样子我们更改数据就必须要selfVue.data.name来进行更改,这边同样可以通过数据劫持,将selfVue.data的属性值代理到selfVue上
- 实现数据代理
//更改selfVue,进行属性代理
function SelfVue (data, el, exp) {
var self = this;
this.data = data;
//将data属性值绑定到selfVue本身上
Object.keys(data).forEach(function(key) {
self.proxyKeys(key);
});
observe(data);
el.innerHTML = this.data[exp];
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
return this;
}
//简单来说 self.key读取时,就会返回self.data[key]
SelfVue.prototype = {
proxyKeys: function (key) {
var self = this;
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get: function proxyGetter() {
return self.data[key];
},
set: function proxySetter(newVal) {
self.data[key] = newVal;
}
});
}
}
这样子上述就可以通过selfVue.name来进行数据更新,并引起视图的更新了。不过上述的方法还是有个很大的问题,跟之前简单的双向绑定一样,如果有很多地方绑定了这个属性值,则需要读取所有的el,并生成许多实例。这不太现实,这边还有最后关键的一步,解析器来进行DOM 的解析。
- 实现解析器Complie
关于为何要创建DOM片段 fragment,fragment说明这篇文章进行了解释,因为在vue中,会有很多生成DOM节点的操作,如果每次都进行append,会十分消耗性能,所以将整个DOM树劫持到fragment中,等全部解析完后生成DOM树重新append回真实DOM中,节省性能消耗。
//(先判断"{{}}")
// el->"#name" ,vm->{el:;data:;}
function Compile(elm){
this.vm = elm;
this.el = document.querySelector(elm.el);
this.fragment = null;
this.init();
}
Compile.prototype = {
init:function(){
if(this.el) {
//将需要解析的DOM节点存入fragment片段里再进行处理
this.fragment = this.nodeToFragment(this.el);
//接下来遍历各个节点,对含有指定的节点特殊处理,先处理指令“{{}}”:
this.compileElement(this.fragment);
//绑定到el上
this.el.appendChild(this.fragment);
}else{
console.log('DOM元素不存在');
}
},
//创建代码片段
nodeToFragment:function(el){
var fragment = document.createDocumentFragment();
var child = el.firstChild;
//这里注意一点,fragment是剪切DOM的元素的,所以递归会将整个DOM剪切过来
while(child){
//将DOM元素移入fragment
fragment.appendChild(child);
child = el.firstChild;
}
return fragment;
},
//对所有子节点进行判断,1.初始化视图数据,2.绑定更新函数的订阅器
compileElement:function(el){
var childNodes = el.childNodes;
var self = this;
[].slice.call(childNodes).forEach(function(node){
var reg = /\{\{(.*)\}\}/;//匹配" {{}} "
var text = node.textContent;
//判断" {{}} "
if(self.isTextNode(node) && reg.test(text)) {
self.compileText(node,reg.exec(text)[1]);
}
// 递归遍历子节点
if(node.childNodes && node.childNodes.length){
self.compileElement(node);
}
});
},
//当读取到{{}}的节点,进行值的初始化
compileText:function(node,exp){
var self = this;
var initText = this.vm[exp]; //proxyKeys中代理访问self_vue.data.name1 -> self_vue.name1
this.updateText(node,initText);//将初始化的数据初始化到视图中
new Watcher(this.vm,exp,function(value){//{},name, // 生成订阅器并绑定更新函数
self.updateText(node,value);
})
},
updateText: function (node, value) {
node.textContent = typeof value == 'undefined' ? '' : value;
},
isTextNode:function(node){
return node.nodeType == 3;//文本节点
}
};
解析器完成,解析器解析所有的DOM元素,进行初始化值跟将更新DOM元素的方法绑定到watch中。假设现在页面中有{{name}} 的地方有10个,解析器都会全部解析到,并一一绑定到watch上。这里我们更新下selfVue方法方法,将解析器与监听器结合起来,并改造成传入option格式。
// function SelfVue(data,el,exp){ //first
function SelfVue(options){
var self = this;
this.data = options.data;
this.el = options.el;
this.vm = this; //second
console.log(this)
Object.keys(this.data).forEach(function (key) {
self.proxyKeys(key);//绑定代理属性
});
observers(this.data);
//这样子解析器生成的时候会自动将观察者加入到消息订阅器中
new Compile(this);
options.mounted.call(this); // 所有事情处理好后执行mounted函数
return this;
}
至此一个简单的vue就完成了,主要就是用于数据的双向绑定。
<div id="app">
<h2>{{title}}</h2>
<input v-model="name">
<h1>{{name}}</h1>
<button v-on:click="clickMe">click me!</button>
</div>
var vue=new selfVue({
{
el: '#app',
data: {
title: 'hello world',
name: 'canfoo'
},
methods: {
clickMe: function () {
this.title = 'hello world';
}
},
mounted: function () {
window.setTimeout(() => {
this.title = '你好';
}, 1000);
}
}
})
上述中使用了v-model指令,但是在上面解析器中并没有解析v-model指令。其实所有的指令解析都是在compileElement方法中实现的,遍历所有节点,然后判断节点是否包含某些指令,包含的话,根据不同指令绑定不同的DOM操作方法。这边是摘自于其他文章的v-model的指令判断
Compile.prototype = {
init:function(){
if(this.el) {
//将需要解析的DOM节点存入fragment片段里再进行处理
this.fragment = this.nodeToFragment(this.el);
//接下来遍历各个节点,对含有指定的节点特殊处理,先处理指令“{{}}”:
this.compileElement(this.fragment);
//绑定到el上
this.el.appendChild(this.fragment);
}else{
console.log('DOM元素不存在');
}
},
//创建代码片段
nodeToFragment:function(el){
var fragment = document.createDocumentFragment();
var child = el.firstChild;
while(child){//将DOM元素移入fragment
fragment.appendChild(child);
child = el.firstChild;
}
return fragment;
},
//对所有子节点进行判断,1.初始化视图数据,2.绑定更新函数的订阅器
compileElement:function(el){
var childNodes = el.childNodes;
var self = this;
[].slice.call(childNodes).forEach(function(node){
var reg = /\{\{(.*)\}\}/;//匹配" {{}} "
var text = node.textContent;
/* 补充判断: */
if(self.isElementNode(node)){//元素节点判断
self.compile(node);
}else if(self.isTextNode(node) && reg.test(text)) {
//文本节点判断 ,判断" {{}} "
self.compileText(node,reg.exec(text)[1]);
}
if(node.childNodes && node.childNodes.length){
// 递归遍历子节点
self.compileElement(node);
}
});
},
//初始化视图updateText和生成订阅器:
compileText:function(node,exp){
var self = this;
var initText = this.vm[exp]; //代理访问self_vue.data.name1 -> self_vue.name1
this.updateText(node,initText);//将初始化的数据初始化到视图中
new Watcher(this.vm,exp,function(value){//{},name, // 生成订阅器并绑定更新函数
self.updateText(node,value);
})
},
updateText: function (node, value) {
node.textContent = typeof value == 'undefined' ? '' : value;
},
compile:function(node){
var nodeAttrs = node.attributes;
var self = this;
Array.prototype.forEach.call(nodeAttrs,function(attr){
var attrName = attr.name;
if(self.isDirective(attrName)){
//查到" v- "
var exp = attr.value;
var dir = attrName.substring(2);//" v-on/v-model "
if(self.isEventDirective(dir)){ // 事件指令
self.compileEvent(node,self.vm,exp,dir);
}else{
self.compileModel(node,self.vm,exp,dir);
}
node.removeAttribute(attrName);
}
})
},
compileEvent:function(node,vm,exp,dir) {
//代码片段<><>,{data:;vm:;el:;},v-on="add",on:
var eventType = dir.split(':')[1];//on
var cb = vm.methods && vm.methods[exp];
if(eventType && cb){
node.addEventListener(eventType,cb.bind(vm),false);
}
},
compileModel:function(node,vm,exp,dir){
//代码片段<><>,{data:;vm:;el:;},v-on="addCounts",model:
var self = this;
var val = this.vm[exp];
this.modelUpdater(node,val);
new Watcher(this.vm,exp,function(value){
self.modelUpdater(node,value);
});
node.addEventListener('input',function(e){
var newValue = e.target.value;
if(val === newValue){
return;
}
self.vm[exp] = newValue;
val = newValue;
})
},
modelUpdater:function(node,value){
node.value = typeof value == 'undefined' ? '' : value;
},
isTextNode:function(node){
return node.nodeType == 3;//文本节点
},
isElementNode:function(node){
return node.nodeType == 1;//元素节点<p></p>
},
isDirective:function(attr){//查找自定义属性为:v- 的属性
return attr.indexOf('v-') == 0;
},
isEventDirective:function(dir){ // 事件指令
return dir.indexOf('on:') === 0
}
};
结语
上面的就是VUE双向绑定的简单实现了。
这边通过这个知识点的学习,就可以明白数据是如何去影响视图的。还有一个知识点,在vue中,是无法检测到对象新增属性的变化和数组长度的变化。VUE官方说明
<span>{{price}}</span>
data(){
return {
name:''
}
}
prices=5 //并不会引起视图的变化
根据上述的vue双向绑定原理的实现,在初始化的时候,data传入的时候遍历所有属性进行劫持,不存在的属性并不会进行劫持,不存在对应的观察者,所以不会更新视图。
关于数组,数组不能通过arr[index]=val或arr.length=newval 来进行更改,关于这点,其实按照上述方式,arr[index]=val是可以监测到数组元素的变化。但是这边网上查了 文章说是因为性能问题所以没做这个。arr.length=newval会更改数组长度,无法进行监听变化。
补充说明
- data中的属性重新赋值,假设将空对象赋值成有属性的对象,或者空数组赋值成有数据的数组,这样子也是可以监听到属性变化的,上述源码中并没有实现这个功能,其实就是在set方法中加入判断,如果新的值为对象或数组,重新进行递归循环调用observe方法。
data(){
return{
book:{
}
}
},
method:{
setBook(){
this.book={
name:'vue',
price:'50'
}
}
}
Object.defineProperty(book,key,function({
enumerable: false,
configurable: true,
get: function proxyGetter() {
/*跟上述代码一样*/
},
set: function proxySetter(newVal) {
if(typeof newVal==='object'||typeof newVal==='array'){
observe(newVal)
}else{
/*跟上述代码一样*/
}
}
}))