你一定要懂 Vue 的响应式原理
开门见山的说,Vue 2.0 响应式原理的精髓就在于 Object.defineProperty
, 因此 Vue 不支持 IE8 以下的浏览器。这个方法可以定义一个对象的某个属性的描述符,来具体看一下:
const obj = {
k: 1
}
let value = 0;
Object.defineProperty(obj, 'k', {
enumerable: true, // 该属性是否可以被枚举
configurable: true, // 该属性的描述符是否可以修改
get: function() {
// 魔法的起源地,每当 obj 的 k 属性的值被读取,都会执行这个方法,Vue 在这里收集依赖
return value;
},
set: function(newVal) {
// Vue 在这里通知所有依赖,该属性被修改了
value = newVal;
}
});
上面的 get
和 set
函数分别就是 obj
对象 k
属性的 getter
和 setter
。
看起来很简单,实则还有很多问题:
- 依赖是什么?
- 依赖存在哪?
- 如何收集依赖?
我们一个一个来解决。
依赖是什么
在 Vue 里,依赖是 Watcher
对象的实例,我们先来列举一下 Vue 在什么情况下会创建 Watcher
:
- 一个组件在挂载的时候会为自己创建一个
Watcher
,因此每个 Vue 组件都会对应一个Watcher
。 - 初始化
computed
属性时,会为每一个属性创建一个Watcher
。 - 初始化
watch
属性时,会为每一个被watch
的属性创建一个Watcher
。 - 调用
$watch
方法的时候。
第 3 和第 4 种情况其实是一样的,唯一区别是,手动调用 $watch
会返回一个 unwatch
方法,可以用来取消 watch
。
接下来,简单了解 Watcher
对象的构造:
class Watcher {
constructor(vm, expOrFn, cb) {
this.depIds = new Set(); // 一个集合,存储 Dep 的 id,防止重复收集依赖
this.deps = []; // 一个列表,存储以自己为依赖的依赖表
this.vm = vm; // 响应式数据的上下文
if (typeof expOrFn === 'function') {
this.getter = expOrFn;
} else {
this.getter = parsePath(expOrFn); // parsePath 用于解析 expOrFn,返回一个方法用来读取并返回响应式数据的值
}
this.cb = cb; // 响应式数据更新后触发的回调函数
this.value = this.get(); // 用 get 方法取得数据,顺便 “摸一下” 响应式数据的 getter 方法,收集依赖
}
// 下面的 Dep 是一个类,用于维护响应式数据的依赖表,这里把 Watcher 自身添加到 Dep 的静态成员变量上,然后执行 this.getter 方法
// this.getter 在上面介绍过,会读取并返回响应式数据的值
get() {
Dep.target = this;
let value = this.getter.call(this.vm, this.vm);
Dep.target = undefined;
return value;
}
// addDep 方法会在 Dep 的 depend 方法中被调用,Dep 把自身作为参数传进来
// 简单判断没有重复收集之后,调用 Dep 的 addSub 方法来让 Dep 实例收集这个 Watcher
// 正是这里的交叉传递自身,让 Watcher 拥有把自己添加进响应式数据依赖表中的能力。
addDep(dep) {
const id = dep.id;
if (!this.depIds.has(id))
this.depIds.add(id);
this.deps.push(dep);
dep.addSub(this);
}
}
// 响应式数据更新后会通知所有的依赖(Watcher)执行该方法。
// 方法内部会调用 Watcher 的回调函数,并传入新旧值作为参数。
update() {
const oldVal = this.value;
this.value = this.get();
this.cb.call(this.vm, this.value, oldVal);
}
}
上面代码中 this.getter
的值是 parsePath(expOrFn)
返回的。下面来了解一下 parsePath
的原理:
// 解析简单路径,路径通常的模样为:'a.b.c'
const bailRE = /[^\w.$]/;
function parsePath(path) {
if (bailRE.test(path)) {
return;
}
const segments = path.split('.');
return function(obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return;
obj = obj[segments[i]];
}
return obj;
}
}
可以看到,parsePath
的返回值是一个函数,这个函数有个特点,就是会 “Touch”,摸一下 obj
在 path
路径下的对象,这会触发对象的 getter
,从而把 Watcher
自身添加进对象的依赖表里。
依赖存在哪?
在 Vue 里,每一个响应式数据都有各自的 Dep
对象,你可以这样得到它 data.__ob__.dep
。Dep
对象的结构很简单,就是一个维护依赖表的类:
let uid = 0;
class Dep {
constructor() {
this.id = uid++; // Dep 的 id
this.subs = []; // 一个列表用来存储依赖(Watcher)
}
// 添加一个依赖,sub 就是 Watcher 实例
addSub(sub) {
this.subs.push(sub);
}
// 故名思议,移除一个依赖
removeSub(sub) {
remove(this.subs, sub);
}
// 把 Dep.target 添加进依赖表,Dep.target 就是 Watcher
depend() {
if (Dep.target) {
Dep.target.addDep(this);
}
}
// 通知所有依赖触发更新,可以看到,更新是通过调用 Watcher 对象的 update 方法进行的
notify() {
const subs = this.subs.slice();
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}
}
}
如何收集依赖
每一个响应式数据的依赖都可能是不同的,因此他们都应该有自己的依赖表。
上面提到,依赖是 Watcher
,Watcher
在初始化的时候会把自己赋值到 Dep.target
,然后触发响应式数据的 getter
。
而收集依赖的方法是在响应式数据的 getter
中把 Dep.target
添加进依赖表,再结合上面 Dep
类的结构可以知道,收集依赖就是调用 Dep
对象的 depend
方法:
// 构造一个函数来把数据变成响应式数据
function defineReactive(data, key, val) {
const dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function() {
// 收集依赖
dep.depend();
return val;
},
set: function(newVal) {
val = newVal;
// 通知s所有收集到的依赖
for (let i = 0; i < dep.length; i++) {
dep[i].notify();
}
}
});
}
汇总
一个完整的依赖收集流程是这样的:
- 创建
Watcher
实例,初始化时访问响应式数据的值,把自身赋值给Dep
类的静态变量target
,触发getter
。 getter
中能访问到响应式数据的依赖表维护对象dep
,调用dep
的depend
方法。depend
方法会把Dep
实例自身作为参数传给Watcher
实例的addDep
方法。addDep
方法会把参数中的Dep
实例收集起来,并把自身作为参数传给Dep
实例的addSub
方法。addSub
方法会把参数中的Watcher
实例作为依赖收集起来。
最后来看一下 Vue 官方提供的响应式原理图:
稍微解释一下,Vue 会把模板编译成代码字符串,运行时渲染函数会执行代码字符串生成虚拟 DOM
,在生成虚拟 DOM
的过程中会读取许多响应式数据,读取值会触发对应的 getter
,getter
会把组件对应的 Watcher 收集到依赖表中,每当修改的响应式数据的值都会触发 setter
,setter
会通知依赖表中的每一个 Watcher
,接到通知后,Watcher
对应的回调函数会被执行,对应的组件就会重新生成虚拟 DOM
。
看到这里你也许会注意到,响应式的原理是在 getter
中收集依赖,当调用操作数组的原型方法或者使用索引取值的时候,是不会触发 getter
的,这时响应式会失效。为避免这种情况,Vue 的做法是把数组和对象分开进行不同的响应式处理,这就不是本文所要讲的内容了。
最后,我们学习一个框架,了解源码不是必需的,确是必要的,这样在遇到疑难杂症的时候就能知道是框架本身的机制,还是代码逻辑的问题。除此之外,Vue 本身的设计思路和编码方式其实也是一个很好的学习对象。