Mmear's 😘.

Vue学习笔记(二):变化侦测原理

字数统计: 3.4k阅读时长: 14 min
2018/11/16 Share

Vue 中的 MVVM

本篇文章很大一部分参考了Vue 变化检测原理(作者:berwin)这篇文章,并对其中一部分代码和表述进行了修改;

为什么是 MVVM?

曾经,在MVC架构下,前端开发者会遇到三个主要问题:

  • 需要直接操作 DOM 以更新数据,调用大量相同的 API,操作繁琐重复;
  • model改变数据时,需要手动更新至view中;当用户操作改变view时,也需要将变化的数据同步至model中,在大型应用中,数据的状态及同步是相当复杂多变,难以维护的;
  • 大量 DOM 操作使得页面渲染性能降低,影响用户体验

MVVM的出现有效解决了前两个问题,而最后一个问题也因为 Google 推出的 v8 引擎对 Javascript 的高效处理而得到缓解:

MVVM架构中,view代表着视图、模板,将模型/数据以定义好的方式展示出来,model代表着模型/数据,可以在其中定义数据修改业务逻辑viewModel负责在modelview中间的通信,也就是建立一个数据与视图的数据双向绑定机制;因此view中的数据变化会更新到model中,而model中对数据的修改也会立刻反应在视图中;

MVVM架构下,开发者无需关心视图层的数据操作,只需关注具体的业务逻辑,viewModel会自动实现viewmodel中的数据同步;

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把这些属性全转化为gettersetter:

每个 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
19
function 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div>
{{ key }}
<span>{{ key }}</span>
</div>
</template>

<script>
export default {
data () {
return { key: 1 }
},
watch: {
key (new, val) {
// do something...
}
}
}
</script>

模板中调用了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
21
function 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
51
class 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
20
class 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
29
function 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中的做法是直接对数组原型出手,修改出一个能侦测到数组变化的“原型”,然后对数据中的数组原型进行替换,总结起来需要三步:

  1. 使用Object.create创建一个空对象来继承原生Array对象的原型方法;
  2. 对继承下来的对象用Object.defineProperty进行改造,主要是针对其中的数组方法,使其能够拦截数据变化;
  3. 把改造后的对象替换原有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 = 1list.push('1')这两种行为都会正确被观测,而它们对应的依赖(相同)也会被通知;

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
51
52
53
54
55
56
57
58
59
60
class Observer {
constructor (value) {
this.value = value;
this.dep = new Dep(); // value中的dep,与key中的dep同步,专门用于收集Array类型的依赖
Object.defineProperty(value, '__ob__', {
value: this,
configurable: true,
writable: true
})
// 判断value是否为数组
if (Array.isArray(value)) {
this.observeArray(value);
}
else {
this.walk(value);
}
}
/**
* Walk through each property and convert them into
* getter/setters. This method should only be called when
* value type is Object.
*/
walk () {
if (Object.getPrototypeOf(dataObj).toString() !== '[object: Object]') {
const keys = Object.key(dataObj);
for (key of keys) {
defineReactive(dataObj, key, dataObj[key]);
}
}
// defineReactive(vm, key, value);
}
/**
* value type is Array
*/
observeArray (items) {
for (item of items) {
new Observer(item);
}
}
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;
wald(newVal);
}
}
})
}
}

这样,我们就拥有了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
19
function 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
34
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");
// 先储存数组处理后的结果
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
20
class 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
2
arr[0] = 0; // 无法观测,因为数组的引用并未变化
arr.length = 0; // 数组已经被清空,但并不会被观测到

写完后其实只是对大致实现有个了解,但更深入的细节还得去阅读源码;


参考资料

CATALOG
  1. 1. Vue 中的 MVVM
    1. 1.1. 为什么是 MVVM?
    2. 1.2. Vue 与 MVVM 的关系?
  2. 2. Vue 的响应式原理/变化侦测原理
    1. 2.1. 实现简单的变化侦测
      1. 2.1.1. 如何侦测到变化?
      2. 2.1.2. 如何收集依赖?
      3. 2.1.3. watcher是什么?
      4. 2.1.4. 如果观测值是一个对象…
      5. 2.1.5. 数组怎么被侦测呢?
  3. 3. 参考资料