1 MVVM
双向数据绑定指的是,将对象属性变化与视图的变化相互绑定。换句话说,如果有一个拥有name属性的user对象,与元素的内容绑定,当给user.name赋予一个新值,页面元素节点也会相应的显示新的数据。同样的,如果页面元素(通常是input)上的数据改变,输入一个新的值会导致user对象中的name属性发生变化。
MVVM最早由微软提出来,它借鉴了桌面应用程序的MVC思想,在前端页面中,把Model用纯JavaScript对象表示,View负责显示,两者做到了最大限度的分离。
把Model和View关联起来的就是ViewModel。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。
总之一句话,数据与表现分离,当某一个数据改变时,页面上所有使用这个数据的元素的内容都会改变。下面是一个最简单的数据绑定的例子,来自Vue2.0源码阅读笔记–双向绑定实现原理,这个例子十分简单粗暴,就做了三件事:
- 创建 obj 对象,用来保存数据
- 监听 keyup 事件,当事件触发时,把选定的 input 标签的值赋给 obj 对象的 hello 属性。
- 改变 obj 对象 的 hello 属性的 set 方法,当 hell 被赋值时,将这个值同时赋值给选中的两个元素。
<!DOCTYPE html>
<head></head>
<body>
<div id="app">
<input type="text" id="a">
<span id="b"></span>
</div>
<script type="text/javascript">
var obj = {};
Object.defineProperty(obj, 'hello', {
get: function() {
console.log('get val:'+ val);
return val;
},
set: function(newVal) {
val = newVal;
console.log('set val:'+ val);
document.getElementById('a').value = val;
document.getElementById('b').innerHTML = val;
}
});
document.addEventListener('keyup', function(e) {
obj.hello = e.target.value;
});
</script>
</body>
</html>
1.1 实现数据双向绑定的方式
双向数据绑定底层的思想非常的基本,它可以被压缩成为三个步骤:
- 我们需要一个方法来识别哪个UI元素被绑定了相应的属性(上面的例子里直接选中了元素,而没有提供对外的函数)
- 我们需要监视属性和UI元素的变化
- 我们需要将所有变化传播到绑定的对象和元素
常见的实现数据绑定的方法,有大致如下几种:
- 发布者-订阅者模式
- 脏值检查
- 数据劫持
其中最简单也是最有效的途径,是使用发布者-订阅者模式。上面的例子就使用到了。
发布者-订阅者模式的思想很简单:使用自定义的data属性在HTML代码中指明绑定。所有绑定起来的JavaScript对象以及DOM元素都将“订阅”一个发布者对象。任何时候,如果某一个被绑定的内容(如JavaScript对象或者一个HTML输入字段)被侦测到发生了变化,我们将代理事件到发布者-订阅者模式,这会反过来将变化广播并传播到所有绑定的对象和元素。下面是一个来自谈谈JavaScript中的双向数据绑定的例子,我在注释里添加了一些我的理解。
function DataBinder(object_id){
//创建一个简单地PubSub对象
var pubSub = { // 一个pubSub 对象,内部有一个 callbacks 对象,保存回调函数
callbacks: {}, // 键名为触发回调函数的自定义事件名称,值为一个数组,每一项都是一个回调函数
on: function(msg,callback){ // on 方法 传入参数,一个字符串(就是自定义事件的名称),一个回调函数
this.callbacks[msg] = this.callbacks[msg] || []; // 以 msg 作为键名,创建数组(如果存在,等于原数组)
this.callbacks[msg].push(callback); // 将新的回调函数加入数组
},
publish: function(msg){ // publish 方法
this.callbacks[msg] = this.callbacks[msg] || []; // 根据 msg 传入的参数,调用 this.callbacks 对象 的 msg 属性保存的数组,如果没有,等于新建的空数组
for(var i = 0, len = this.callbacks[msg].length; i<len;i++){ // 循环调用所有注册在了 msg 里的回调函数
this.callbacks[msg][i].apply(this,arguments); // 调用注册的回调函数时,将 this 指向 publish 的调用者, 参数为 publish 函数调用时传入的参数
}
}
},
data_attr = "data-bind-" + object_id, // 产生一个字符串,对传入的参数进行处理,加上“data-bind”前缀加上,后面会用这个字符串作为属性名,获得需要绑定的元素
message = object_id + ":change", // 产生一个字符串,对传入的参数进行处理,加上“:change”后缀加上,后面会用这个字符串作为事件名,将事件派发给接收的元素
changeHandler = function(evt){ // 根据事件的触发者,判断是否是监听的数据
var target = evt.target || evt.srcElemnt, //IE8兼容 触发事件的元素
prop_name = target.getAttribute(data_attr); // 得到元素的 data_attr 属性
if(prop_name && prop_name !== ""){ // 根据元素属性,判断是否是监听的元素
pubSub.publish(message,prop_name,target.value); // 广播 message 事件,调用所以注册了 message 事件的函数,调用注册的回调函数时,将 this 指向 publish 的调用者, 参数为 publish 函数调用时传入的参数(publish 函数内部有 apply)
}
};
//监听变化事件并代理到PubSub
if(document.addEventListener){ // 监听整个文档的变化,并调用 changeHandler 函数
document.addEventListener("change",changeHandler,false);
}else{
//IE8使用attachEvent而不是addEventListener
document.attachEvent("onchange",changeHandler);
}
//PubSub将变化传播到所有绑定元素
pubSub.on(message,function(vet,prop_name,new_val){ // 调用 pubSub.on 方法,注册 message 事件的回调函数,本例中,message 事件也只绑定了一个回调函数,就是这个匿名函数,功能是将变化元素的值传入所有被监听的元素
var elements = document.querySelectorAll("[" + data_attr + "=" + prop_name + "]"), // 根据传入的参数,获得所以需要接受数据的元素
tah_name;
for(var i = 0,len =elements.length; i < len; i++){ // 循环对元素进行处理
tag_name = elements[i].tagName.toLowerCase();
if(tag_name === "input" || tag_name === "textarea" || tag_name === "select"){ // 根据元素的种类,确定数据额输出方式
elements[i].value = new_val;
}else{
elements[i].innerHTML = new_val;
}
}
});
return pubSub;
}
//在model的设置器中
function User(uid){
var binder = new DataBinder(uid), // 返回一个 pubSub 对象,其上保存了由传入参数 uid 确定的元素所有绑定的回调函数
user = {
attributes: {}, // 保存需要同步的数据
set: function(attr_name,val){ // 调用 set 方法,将需要同步的数据通过 publish 方法传给监听的元素
this.attributes[attr_name] = val;
//使用“publish”方法
binder.publish(uid+ ":change", attr_name, val,this);
},
get: function(attr_name){
return this.attributes[attr_name];
}
}
return user; // 函数作为一个构造函数时,返回一个对象,作为这个构造函数的实例
}
var user = new User(123); // 返回一个 user 对象,对象有一个 attributes 属性指向一个对象,这个对象保存这需要同步的数据
user.set("name","Wolfgang"); // 所有带有 data-bind-123="name" 属性的 html 标签都会被监听,它们的值会同步改变,保持相同
然后说脏检查,脏检查是一种不关心你如何以及何时改变的数据,只关心在特定的检查阶段数据是否改变的数据监听技术。简单来说,脏检查是直接检测数据是否改变,如果某一个被监听的数据改变,就将这个值传给所有被被监听者。
而数据劫持,就是通过对属性的 set get 方法进行改造,来监测数据的改变,发布消息给订阅者,触发相应的监听回调。
2 vue 数据双向绑定
已经了解到vue是通过数据劫持的方式来做数据绑定的,其中最核心的方法便是通过Object.defineProperty()来实现对属性的劫持,达到监听数据变动的目的。
要实现mvvm的双向绑定,主要进行了:
- 实现一个数据监听器Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者
- 实现一个指令解析器Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数
- 实现一个Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图
- mvvm入口函数,整合以上三者
例子大体来自这篇文章的,我根据自己的理解做了些修改,添加了一些注释
为了便于理解,首先,来实现一个消息的储存中转的构造函数:
var uid = 0; // 通过全局的 uid 给 Dep 实例增加唯一 id,以区分不同实例
function Dep() {
this.id = uid++; // 给 Dep 实例添加 id,并将全局的 uid 加1
this.subs = [];
}
Dep.prototype = {
addSub: function(sub) { // 增加 sub
this.subs.push(sub);
},
depend: function() {
Dep.target.addDep(this); // 将全局对象 Dep 的 target 属性指向的对象(这个函数的调用者 this)添加的 subs 里
},
removeSub: function(sub) { // 删处 sub
var index = this.subs.indexOf(sub);
if (index != -1) {
this.subs.splice(index, 1);
}
},
notify: function() { // 通知所有 subs 数据已更新
this.subs.forEach(function(sub) {
sub.update();
});
}
};
通过修改对象的属性,每一个绑定的属性都会有一个 Dep 实例。每一个 Dep 实例都会有一个 subs 属性,用来存储需要通知的对象,当对象属性改变时,通过 set 方法,调用这个属性的 Dep 实例的原型的 notify 方法,根据 subs 数组保存的内容,通知绑定了这个属性值的数据修改内容。
function Observer(data) {
this.data = data;
this.walk(data); // 调用原型的方法,处理对象
}
Observer.prototype = {
walk: function(data) {
var me = this;
Object.keys(data).forEach(function(key) { // 遍历 data 的属性,修改属性的 get / set
me.convert(key, data[key]);
});
},
convert: function(key, val) {
this.defineReactive(this.data, key, val);
},
defineReactive: function(data, key, val) { // 对属性进行修改
var dep = new Dep();
var childObj = observe(val);
Object.defineProperty(data, key, {
enumerable: true, // 可枚举
configurable: false, // 不能再define
get: function() {
if (Dep.target) {
dep.depend(); // 将全局的 Dep.target 添加到 dep 实例的 subs 数组里
}
return val;
},
set: function(newVal) {
if (newVal === val) {
return;
}
val = newVal;
// 新的值是object的话,进行监听
childObj = observe(newVal);
// 通知订阅者
dep.notify();
}
});
}
};
function observe(value, vm) {
if (!value || typeof value !== 'object') {
return;
}
return new Observer(value);
};
然后对 html 模板进行编译,根据每个节点及其的属性,判断是否包含 ‘{{}}’,’v-‘,’on’ 等特殊字符串,判断是否进行了绑定,将绑定了的属性个 get set 进行处理,
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();
this.$el.appendChild(this.$fragment);
}
}
Compile.prototype = {
node2Fragment: function(el) {
var fragment = document.createDocumentFragment(),
child;
// 将原生节点拷贝到fragment
while (child = el.firstChild) { // 如果 el 有资源素,就将其赋值给 child,返回 true
fragment.appendChild(child); // 将 child 从 el 转移到 fragment 下,el 会少一个资源素,进行下一轮循环
}
return fragment; // 返回 fragment
},
init: function() {
this.compileElement(this.$fragment); // 对 fragment 进行改造
},
compileElement: function(el) {
var childNodes = el.childNodes,
me = this;
[].slice.call(childNodes).forEach(function(node) { // 循环遍历节点,处理属性
var text = node.textContent;
var reg = /\{\{(.*)\}\}/;
if (me.isElementNode(node)) {
me.compile(node); // 处理元素节点
} else if (me.isTextNode(node) && reg.test(text)) { // 处理文本节点
me.compileText(node, RegExp.$1);
}
if (node.childNodes && node.childNodes.length) {
me.compileElement(node); // 递归调用,处理子元素
}
});
},
compile: function(node) {
var nodeAttrs = node.attributes, // 获得 dom 节点在 html 代码里设置的属性
me = this;
[].slice.call(nodeAttrs).forEach(function(attr) { // 对属性进行遍历,设置
var attrName = attr.name;
if (me.isDirective(attrName)) { // 判断是普通属性还是绑定指令,如果是指令,对指令进行处理
var exp = attr.value;
var dir = attrName.substring(2);
// 绑定了事件指令
if (me.isEventDirective(dir)) {
compileUtil.eventHandler(node, me.$vm, exp, dir);
// 普通指令
} else {
compileUtil[dir] && compileUtil[dir](node, me.$vm, exp);
}
node.removeAttribute(attrName); // 移除原本属性
}
});
},
compileText: function(node, exp) {
compileUtil.text(node, this.$vm, exp);
},
isDirective: function(attr) {
return attr.indexOf('v-') == 0;
},
isEventDirective: function(dir) {
return dir.indexOf('on') === 0;
},
isElementNode: function(node) { // 判断是不是元素节点
return node.nodeType == 1;
},
isTextNode: function(node) { // 判断是不是文本节点
return node.nodeType == 3;
}
}
最后,实现 watch,监视属性的变化。watch 的每个实例,会添加到希望监听的属性的 dep.subs 数组中,当监听的数据发生变化,调用 notify 函数,然后函数内部调用 subs 中所以 watch 实例的 updata 方法,通知监听这个数据的对象。受到通知后,对象判断值是否改变,如果改变,调用回调函数,更改视图
function Watcher(vm, exp, cb) {
this.cb = cb;
this.vm = vm;
this.exp = exp;
// 此处为了触发属性的getter,从而在dep添加自己,结合Observer更易理解
this.value = this.get();
}
Watcher.prototype = {
update: function() {
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中绑定的回调,更新视图
}
},
get: function() {
Dep.target = this; // 将当前订阅者指向自己
var value = this.vm[exp]; // 触发getter,添加自己到属性订阅器中
Dep.target = null; // 添加完毕,重置
return value;
}
};
最后,通过 MVVM 构造器,将上面及部分整合起来,实现数据绑定。
function MVVM(options) {
this.$options = options;
var data = this._data = this.$options.data;
observe(data, this);
this.$compile = new Compile(options.el || document.body, this)
}
上面的内容只是实现数据绑定的大概思路,其他内容我再慢慢完善。
3 vue 数据双向绑定的缺陷
3.1 vue 实例创建后,再向其上添加属性,不能监听
当创建一个Vue实例时,将遍历所有 DOM 对象,并为每个数据属性添加了 get 和 set。 get 和 set 允许 Vue 观察数据的更改并触发更新。但是,如果你在 Vue 实例化后添加(或删除)一个属性,这个属性不会被 vue 处理,改变 get 和 set。
如果你不想创建一个新的对象,你可以使用Vue.set设置一个新的对象属性。该方法确保将属性创建为一个响应式属性,并触发视图更新:
function addToCart (id) {
var item = this.cart.findById(id);
if (item) {
item.qty++
} else {
// 不要直接添加一个属性,比如 item.qty = 1
// 使用Vue.set 创建一个响应式属性
Vue.set(item, 'qty', 1)
this.cart.push(item)
}
}
addToCart(myProduct.id);
3.2 数组
Object.defineProperty 的一个缺陷是无法监听数组变化。
当直接使用索引(index)设置数组项时,不会被 vue 检测到:
app.myArray[index] = newVal;
然而Vue的文档提到了Vue是可以检测到数组变化的,但是只有以下八种方法, vm.items[indexOfItem] = newValue
这种是无法检测的。
push();
pop();
shift();
unshift();
splice();
sort();
reverse();
同样可以使用Vue.set来设置数组项:
Vue.set(app.myArray, index, newVal);
3.3 proxy 与 defineproperty
Proxy 对象在ES2015规范中被正式发布,用于定义基本操作的自定义行为(如属性查找,赋值,枚举,函数调用等)。
它在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。
我们可以这样认为,Proxy是Object.defineProperty的全方位加强版,具体的文档可以查看此处;
Proxy有多达13种拦截方法,不限于
apply、ownKeys、deleteProperty、has
等等,是Object.defineProperty不具备的。
Proxy返回的是一个新对象,我们可以只操作新的对象达到目的,而Object.defineProperty只能遍历对象属性直接修改。
Proxy作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利。
当然,Proxy的劣势就是兼容性问题,而且无法用polyfill磨平,因此Vue的作者才声明需要等到下个大版本(3.0)才能用Proxy重写。