MVVM
概念
MVVM表示的是 Model-View-ViewModel
。
- Model:模型层,负责处理业务逻辑以及和服务器端进行交互
- View:视图层:负责将数据模型转化为UI展示出来,可以简单的理解为HTML页面
- ViewModel:视图模型层,用来连接Model和View,是Model和View之间的通信桥梁
View层和Model层并没有直接联系,而是通过ViewModel层进行交互。ViewModel层通过双向数据绑定将View层和Model层连接了起来,使得View层和Model层的同步工作完全是自动的。
实现数据绑定的方式及代表有:
- 发布订阅模式(Backbone)
- 数据劫持或代理(VueJS,AvalonJS) 通过
Object.defineProperty
或Proxy
,前者不能监听数组变化
,必须遍历对象的每个属性
,嵌套对象必须深层遍历
; 后者可以监听数组变化
,仍然需要深层遍历嵌套对象
,兼容性不如前者。 - 数据脏检查(AngularJs,RegularJS) 在
可能触发 UI 变更的时候
进行脏检查,如DOM事件,XHR响应事件、定时器等。
实现
双向数据绑定需要实现以下三个类:
Observer
监听器:用来监听属性的变化,并通知订阅者Watcher
订阅者:接受属性变化的通知,然后更新视图Compile
解析器:解析指令,初始化模版,绑定订阅者
接下来,我们按照Vue的实现方式来实现一个简单的MVVM框架。
- 实现监听器Observer
利用Obeject.defineProperty()
来监听属性变动,那么将需要observe的数据对象进行递归遍历,包括子属性对象的属性,都加上setter
和getter
。 当给这个对象的某个值赋值时,就会触发setter,那么就能监听到了数据变化。
当我们监听到属性发生变化之后我们需要通知 Watcher
订阅者执行更新函数去更新视图,在这个过程中我们可能会有很多个订阅者 Watcher
, 所以我们要创建一个容器 Dep
去做一个统一的管理。
function observer(data) {
if(!data || typeof data !== 'object'){
return;
}
Object.keys(data).forEach(key=>{
defineReactive(data, key, data[key]);
})
}
function defineReactive(obj,key,val){
observer(val); //递归监听子属性
var dep = new Dep(); //订阅者的依赖收集器
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function getter(){
if (Dep.target) {
dep.addSub(Dep.target); //在getter中将每个属性添加进监听
}
return val;
},
set: function setter(newVal){
if (newVal === val) {
return;
}
val = newVal; //数据重新赋值
console.log('监听到值变化了 ', val, ' --> ', newVal);
dep.notify(); //通知订阅器
}
})
}
function Dep() {
this.subs = [];
}
Dep.prototype.addSub = function (sub) {
this.subs.push(sub);
}
Dep.prototype.notify = function () {
console.log('属性变化, 通知 Watcher 执行更新视图的函数');
this.subs.forEach(sub => {
sub.update(); //这里调 Watcher 的视图更新函数
})
}
Dep.target = null; //全局target
复制代码
上面创建了一个监听器 Observer,我们现在可以给一个对象添加监听,然后改变属性观察有何变化。
var person = {
name: 'ben'
}
observer(person);
person.name = 'bob';
复制代码
通过控制台打印输出了:属性变化, 通知 Watcher 执行更新视图的函数
,证明监听器 Observer生效了。
- 实现订阅器Watcher
既然已经监听到了属性变化,就需要 Watcher 去执行更新了。Watcher 主要是接受属性变化的通知,然后去执行更新函数去更新视图
。
function Watcher(vm, prop, callback) {
this.vm = vm;
this.prop = prop;
this.callback = callback;
this.value = this.getValue();//new Watcher后,自动添加进监听
}
Watcher.prototype.update = function(){
const value = this.vm.$data[this.prop];
const oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.callback(value);
}
}
Watcher.prototype.getValue = function(){
Dep.target = this; //储存订阅器
const value = this.vm.$data[this.prop]; //因为属性被监听,这一步会执行监听器里的 get方法
Dep.target = null;
return value;
}
复制代码
以上两步就已经实现了一个简单的双向绑定了,我们将二者结合起来,看下效果。
function MVVM(options){
this.$options = options || {};
this.$data = this.$options.data;
this.$el = document.querySelector(this.$options.el);
this.init();
}
MVVM.prototype.init = function(){
var prop = 'name' //暂时写死属性名name
observer(this.$data)
this.$el.innerText = this.$data[prop]
new Watcher(this, prop, value => {
this.$el.innerText = value
})
}
复制代码
验证一下:
<!DOCTYPE html>
<html lang="en">
<head></head>
<body>
<div id="app">{{name}}</div>
</body>
<script src="test.js"></script>
<script>
const vm = new MVVM({
el: "#app",
data: {
name: "ben"
}
})
</script>
</html>
复制代码
页面上显示ben
,当我们在浏览的控制台中输出 vm.$data.name = 'jack'
,页面上会瞬时显示jack
。于是一个简单的双向数据绑定就这样实现了。 但是属性name是写死的,而且el.innerText也不符合扩展性,所以接下来我们来实现一个模板解析器。
- 实现编译器Compile
Compile 的主要作用是用来解析指令初始化模板,并且添加订阅者,绑定更新函数
。 因为在解析 DOM 节点的过程中我们会频繁的操作 DOM, 所以我们利用文档片段(DocumentFragment)来帮助我们去解析 DOM 优化性能。 首先对整个节点和指令进行处理编译,根据不同的节点去调用不同的渲染函数,绑定更新函数,编译完成之后,再把 DOM 片段添加到页面中。
function Compile(vm) {
this.vm = vm;
this.el = vm.$el;
this.fragment = null;
this.init();
}
Compile.prototype = {
init: function () {
this.fragment = this.nodeFragment(this.el);
},
nodeFragment: function (el) {
const fragment = document.createDocumentFragment();
let child = el.firstChild;
//将子节点,全部移动文档片段里
while (child) {
fragment.appendChild(child);
child = el.firstChild;
}
return fragment;
},
compileNode: function (fragment) {
let childNodes = fragment.childNodes;
[...childNodes].forEach(node => {
let reg = /\{\{(.*)\}\}/;
let text = node.textContent;
if (this.isElementNode(node)) {
this.compile(node); //渲染指令模板
} else if (this.isTextNode(node) && reg.test(text)) {
let prop = RegExp.$1;
this.compileText(node, prop); //渲染{{}} 模板
}
//递归编译子节点
if (node.childNodes && node.childNodes.length) {
this.compileNode(node);
}
});
},
compile: function (node) {
let nodeAttrs = node.attributes;
[...nodeAttrs].forEach(attr => {
let name = attr.name;
if (this.isDirective(name)) {
let value = attr.value;
if (name === "v-model") {
this.compileModel(node, value);
}
node.removeAttribute(name);
}
});
},
//省略...
}
复制代码
此时MVVM函数就变成了这样
function MVVM(options){
this.$options = options || {};
this.$data = this.$options.data;
this.$el = document.querySelector(this.$options.el);
this.init();
}
MVVM.prototype.init = function(){
observer(this.$data)
new Compile(this);
}
复制代码
- 添加数据代理
当前, 我们修改数据时时只能通过 vm.$data.name = 'jack'
这样去修改数据,而不是直接通过 vm.name = 'jack'
去修改,那么有没有办法做到呢? 答案就是添加一层数据代理。
function MVVM(options){
this.$options = options || {};
this.$data = this.$options.data;
this.$el = document.querySelector(this.$options.el);
//数据代理
Object.keys(this.$data).forEach(key => {
this.proxyData(key);
});
this.init();
}
MVVM.prototype.init = function(){
observer(this.$data)
new Compile(this);
}
MVVM.prototype.proxyData = function (key) {
Object.defineProperty(this, key, {
get: function () {
return this.$data[key]
},
set: function (value) {
this.$data[key] = value;
}
});
}
复制代码
Vue总结
- 任何一个 Vue Component 都有一个与之对应的 Watcher 实例。
- Vue 的 data 上的属性会被添加 getter 和 setter 属性。
- 当 Vue Component render 函数被执行的时候, data 上会被 触碰(touch), 即被读, getter 方法会被调用, 此时 Vue 会去记录此 Vue component 所依赖的所有 data。(这一过程被称为依赖收集)
- data 被改动时(主要是用户操作), 即被写, setter 方法会被调用, 此时 Vue 会去通知所有依赖于此 data 的组件去调用他们的 render 函数进行更新。
- Vue 无法检测 property 的添加或移除。由于 Vue 会在初始化实例时对 property 执行 getter/setter 转化,所以 property 必须在 data 对象上存在才能让 Vue 将它转换为响应式的。
var vm = new Vue({
data:{
a:1
}
})
// `vm.a` 是响应式的
vm.b = 2
// `vm.b` 是非响应式的
Vue.set(vm.someObject, 'b', 2) //可以通过Vue.set(object, propertyName, value) 方法向嵌套对象添加响应式 property
复制代码
- Vue 不能检测以下数组的变动:
- 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
- 当你修改数组的长度时,例如:vm.items.length = newLength
var vm = new Vue({
data: {
items: ['a', 'b', 'c']
}
})
vm.items[1] = 'x' // 不是响应性的
vm.items.length = 2 // 不是响应性的
Vue.set(vm.items, indexOfItem, newValue) //可以通过Vue.set(vm.items, indexOfItem, newValue) 触发响应式状态更新
复制代码
- Vue 在更
新 DOM 时是异步执行的
。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。 如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。 然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。
来源:oschina
链接:https://my.oschina.net/u/4413947/blog/4282319