Vue 中的 MVVM
本篇文章很大一部分参考了Vue 变化检测原理(作者:berwin)这篇文章,并对其中一部分代码和表述进行了修改;
为什么是 MVVM?
曾经,在MVC
架构下,前端开发者会遇到三个主要问题:
- 需要直接操作 DOM 以更新数据,调用大量相同的 API,操作繁琐重复;
- 当
model
改变数据时,需要手动更新至view
中;当用户操作改变view
时,也需要将变化的数据同步至model
中,在大型应用中,数据的状态及同步是相当复杂多变,难以维护的;- 大量 DOM 操作使得页面渲染性能降低,影响用户体验
MVVM
的出现有效解决了前两个问题,而最后一个问题也因为 Google 推出的 v8 引擎对 Javascript 的高效处理而得到缓解:
在MVVM
架构中,view
代表着视图、模板,将模型/数据以定义好的方式展示出来,model
代表着模型/数据,可以在其中定义数据修改及业务逻辑,viewModel
负责在model
与view
中间的通信,也就是建立一个数据与视图的数据双向绑定机制;因此view
中的数据变化会更新到model
中,而model
中对数据的修改也会立刻反应在视图中;
在MVVM
架构下,开发者无需关心视图层的数据操作,只需关注具体的业务逻辑,viewModel
会自动实现view
与model
中的数据同步;
Vue 与 MVVM 的关系?
Vue.js
是一个典型的MVVM
风格的框架,它主要专注于其中的viewModel
部分,实现了数据双向绑定以及响应式的数据更新:
Vue 最独特的特性之一,是其非侵入性的响应式系统。数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。
如果以一个单组件.vue
为例的话,template 部分就相当于视图层,data 部分相当于model
,而该文件生成的 Vue 实例则相当于viewModel
了;
Vue 的响应式原理/变化侦测原理
Javascript 中监测变化有两种方法,一是Object.defineProperty
,二是 ES6 中的Proxy
;Vue 主要通过Object.defineProperty
方法并结合观察者模式(observer pattern)实现变化侦测(change detection):当一个普通的数据对象被传给 Vue 实例的data
选项,Vue 会遍历该对象的所有属性,并用Object.defineProperty
把这些属性全转化为getter
与setter
:
每个 Vue 实例都维护着一个watcher
,它会收集组件渲染时需要的属性作为依赖,当视图层或数据层调用了某个依赖项的setter
,Vue 便会通知watcher
重新渲染(re-render),调用组件渲染函数,更新关联的组件;
实现简单的变化侦测
开始前,首先需要考虑一个问题:
如何侦测到变化?
这个问题上面已经解释过了,使用Object.definePropery
,通过其中的setter
便能监测到变化;但我们可以对其进行简单的封装:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19function definePropety (obj, key, val) {
return Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () { return val; },
set (newVal) {
if (newVal !== val) {
// 这里的val相当于值本身
// 不能写成 this[key] = newVal, 会使setter递归调用导致栈溢出
val = newVal;
// do something...
}
}
})
}
const obj = {};
defineProperty(obj, 't', 1);
obj.t = 2;
console.log(obj.t); // 2
虽然看起来很厉害,但好像没什么用呢,东西都准备好了,下一步就是收集依赖了,既然是响应式,我们就需要知道谁使用了这个属性,并将它加入watcher
的通知名单中,这样setter
被调用后,只要按照名单一个个通知(notify)即可;那么如何收集依赖呢?
如何收集依赖?
1 | <template> |
模板中调用了key
属性的地方,就是需要进行依赖收集的地方,按照一开始所说的,还有个getter
访问器属性没用上,那自然是在调用getter
时收集依赖并存放进watcher
中了;假设我们的key
属性调用是一个函数,存在于window.target
属性上,那可以对key
维护一个数组,用来存储这个属性所有的依赖;所以需要对之前的defineProperty
函数修改一下了:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21function defineReactive (obj, key, val) {
const dependency = []; // 存放依赖的地方
return Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
dependency.push(window.target); // 收集依赖
return val;
},
set (newVal) {
if (newVal !== val) {
val = newVal;
defineReactive(obj, key, newVal);
// notify the wather 触发依赖
for (watcher of dependency) {
watcher(val, newVal);
}
}
}
})
}
这段代码存在着耦合,依赖收集的功能应该独立出来。我们可以将依赖收集过程抽象成一个具体的类Dep
,每个属性是Dep
的一个实例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51class Dep {
// static target: ?Watcher;
// id: number;
// subs: Array<Watcher>;
static target; // 类静态属性,可以通过类直接访问设置
constructor () {
this.id = uid++;
this.subs = [];
}
// 添加依赖
addSub (sub) {
this.subs.push(sub)
},
// 移除依赖
removeSub (sub) {
// TODO
},
depend () {
if (this.target instanceof Watcher) {
this.addSub(this.target);
}
},
notify () {
// stabilize the subscriber list first
const subs = this.subs.slice();
for (sub of subs) {
subs.update();
}
}
}
// 修改defineReactive函数中的耦合部分
function defineReactive (obj, key, val) {
const dependency = new Dep() // 存放依赖的地方
return Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
dependency.depend(Dep.target); // 收集依赖
return val;
},
set (newVal) {
if (newVal !== val) {
val = newVal;
defineReactive(obj, key, newVal);
// notify the wather 触发依赖
dependency.notify();
}
}
})
}
其中,我们要求实例的target
属性为Watcher
的实例;subs
属性是Watcher
类的数组;
watcher是什么?
虽然一直在提到watcher
,但似乎并没有给他以明确的定义,我们可以根据他在Dep
中的作用,得出他是一个存储属性依赖的对象;可以认为,收集依赖就是在收集watcher
;而之所以需要抽象出一个Watcher
类,是因为属性被使用的方式有很多,可能是在template中,也可能是在watch(如Vue中的watch选项)中:1
vm.$watch('key', (newVal, oldVal) => { /* ... */})
这段代码表示key
属性发生变化时,会调用第二个参数中的回调;假设每个属性的watch都是一个实例,我们便能抽象出Watch
类:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class Watcher {
constructor (expOrFn, cb) {
// parsePath用于解析路径(obj.key.name)
this.getter = parsePath(expOrFn);
this.cb = cb;
this.value = this.get();// 读取vm.$data中的值,同时会触发属性上的getter
},
// 获取
get () {
Dep.target = this; // !!在这里将当前watcher绑定在Dep上
// vm指当前的Vue实例
this.value = this.getter.call(vm, vm);
Dep.target = null;
},
update () {
const oldVal = this.value;
this.value = this.get();
this.cb.call(vm, this.value, oldVal);
}
}
每次实例化一个Watcher
时,构造函数中都会调用该数据的getter
,从而将当前实例/依赖添加进Dep
实例的subs
数组中;当把依赖注入到Dep
后,每次的setter
都会遍历依赖数组,调用每一个依赖的update
方法,触发回调函数;
其实不管是用户执行的 vm.$watch(‘key’, (value, oldValue) => {}) 还是模板中用到的data,都是通过 watcher 来通知自己是否需要发生变化的。
如果观测值是一个对象…
当观测值是一个对象时,我们需要遍历对象的属性,当属性中又涉及对象时,继续递归调用,直到对象的所有key都被观测:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29function walk (dataObj) {
if (Object.getPrototypeOf(dataObj).toString() !== '[object: Object]') {
return;
}
const keys = Object.key(dataObj);
for (key of keys) {
defineReactive(dataObj, key, dataObj[key]);
}
}
function defineReactive (obj, key, val) {
walk(obj); // 遍历对象的属性
const dependency = new Dep(); // 存放依赖的地方
return Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
dependency.depend(Dep.target); // 收集依赖
return val;
},
set (newVal) {
if (newVal !== val) {
// notify the wather 触发依赖
dependency.notify();
val = newVal;
}
}
})
}
数组怎么被侦测呢?
obj[key]
中不总是基本类型和对象,也有可能是数组,但数组中的增删改操作是不能被Object.defineProperty
观察到的,因为这些操作并没有改变变量指向的内存地址,也就是说无法真正触发setter
;
Vue中的做法是直接对数组原型出手,修改出一个能侦测到数组变化的“原型”,然后对数据中的数组原型进行替换,总结起来需要三步:
- 使用
Object.create
创建一个空对象来继承原生Array
对象的原型方法; - 对继承下来的对象用
Object.defineProperty
进行改造,主要是针对其中的数组方法,使其能够拦截数据变化; - 把改造后的对象替换原有
Array
类型数据的原型;
第一步1
const arrayFakeProto = Object.create(Array.prototype);
第二步1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// 需要改造的数组方法
const arrayMethods = ['push', 'shift', 'pop', 'unshift', 'slice', 'sort', 'reverse'];
arrayMethods.forEach(method => {
// 获取原始数组方法
const original = arrayFakeProto[method];
Object.defineProperty(arrayFakeProto, method, {
value (...args) {
// 调用这个方法时,打印出来
console.log(method + " is called");
return original.apply(this, args);
},
enumerable: true,
writable: true,
configurable: true
});
})
// 验证一下
const a = [];
Object.setPrototypeOf(a, arrayFakeProto);
a.push('a', 'd'); // push is called
console.log(a); // a, d
在进行下一步之前,我们先把这整个变化侦测过程抽象成一个类Observer
,它负责判断obj[key]
的类型,并对其实现变化侦测;并且该类的实例将成为一个控制数据更新的有力工具;
PS
问题1:Observer构造函数中对Dep的实例化与defineReactive中的重复是否有问题?
没有,
Observer
(既value
)中的dep
会与与key
上的dep
同步,这样通过data.__ob__
也能够访问到依赖列表,从而通知依赖,这是专门为数组设置的; 如:1
2
3_data: {
list: ['a', {b: 'b'}] // 在这里,key为list,而value为'a'和{b: 'b'}
}最终的结果就是这样:
list = 1
与list.push('1')
这两种行为都会正确被观测,而它们对应的依赖(相同)也会被通知;
1 | class Observer { |
这样,我们就拥有了Observer
,Dep
,Watcher
三个类,其中后两个类用于实现变化侦测中的观察者模式。各个类的作用总结起来是这样⬇:
Observer
: 实现数据的监听的具体流程,并根据数据类型进行特殊处理;Dep
: 负责观察者模式中的添加订阅和通知功能,具体来说扮演着依赖收集站的角色;Watcher:
观察者模式中的订阅者,接收Dep
中的通知并update
,扮演着各式的依赖;
2018-11-21
些许懵逼,还是得看源码,抽象过头还是有点乱1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19function Vue(options = {}) {
// 将option挂载至Vue实例上
this.$options = options;
// 绑定数据
const data = this._data = options.data;
// 将数据转化为getter/setter
new Observer(data);
// 能够以vm.a的方式访问数据
for (key in Object.key(data)) {
Object.definePropety(this, key, {
get () {
return this._data[key];
},
set (newVal) {
this._data[key] = newVal;
}
})
}
}
第三步
可以看到,我们成功拦截了Array
原型的方法,接下来,我们需要对push
,unshift
,splice
等可以新增数组元素的方法进行特别处理,对添加进来的数组元素进行观察;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34const arrayMethods = ['push', 'shift', 'pop', 'unshift', 'slice', 'sort', 'reverse'];
arrayMethods.forEach(method => {
// 获取原始数组方法
const original = arrayFakeProto[method];
Object.defineProperty(arrayFakeProto, method, {
value (...args) {
// 调用这个方法时,打印出来
console.log(method + " is called");
// 先储存数组处理后的结果
const result = original.apply(this, args);
// 用于update数组的引用,这里的this为整个数组元素
const ob = this.__ob__;
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
case 'splice':
inserted = args.slice(2);
break;
}
if (inserted) {
// 对新增元素进行观测
ob.observeArray(inserted);
}
// notify change
ob.dep.notify()
return result;
},
enumerable: true,
writable: true,
configurable: true
});
})
第四步
将这个修改后的原型挂载到原有的数组类型数据上:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class Observer {
constructor (value) {
this.value = value;
this.dep = new Dep(); // 专门用于收集Array类型的依赖
Object.defineProperty(value, '__ob__', {
value: this,
configurable: true,
writable: true
})
// 判断value是否为数组
if (Array.isArray(value)) {
Object.setPrototypeOf(value, arrayFakeProto);
this.observeArray(value);
}
else {
this.walk(value);
}
}
// ...
}
即使如此,还有一些对数组的操作是我们无法拦截到的,如:1
2arr[0] = 0; // 无法观测,因为数组的引用并未变化
arr.length = 0; // 数组已经被清空,但并不会被观测到
写完后其实只是对大致实现有个了解,但更深入的细节还得去阅读源码;