vue2响应式原理
Object.defineProperty()
要理解 vue2 数据响应式原理,我们首先要了解Object.defineProperty()方法。下面这些概念引自MDN。
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
该方法允许精确地添加或修改对象的属性
对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。
存取描述符还具有以下可选键值:
get
属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。 默认为 undefined。
set
属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。 默认为 undefined。
划重点
- Object.defineProperty() 操作的目标始终是对象的属性。
- 对象有存(set)取(get)描述符,使用 Object.defineProperty() 方法可以实现对对象属性进行存取拦截。
- Object.defineProperty() 可以给对象添加一个属性,并且可以给属性设置一些数据描述符(是否可枚举,是否可写···)。
这里要记住哦,后边会考!这里要记住哦,后边会考!这里要记住哦,后边会考!
设置a属性值为3,不可以修改值,不可以被枚举
例1:
Object.defineProperty(obj, 'a', {
value: 3,
// 是否可写
writable: false
// 是否可以被枚举
enumerable: false
});
拦截对象属性的存取操作
接下来我们尝试使用存取描述符拦截对象属性的存取操作。
使用 Object.defineProperty 给属性设置了 getter 和 setter,那么每次读取该属性,就会自动调用 getter 函数,且读取的值就是 getter 函数返回的值,每次修改设置该属性的值就会自动调用 setter 函数。
例2:
Object.defineProperty(obj, 'a', {
// getter
get() {
return 4;
},
// setter
set(newValue) {
console.log(newValue);
}
});
console.log(obj.a) // 4
obj.a = 5 // 5 这里会输出5,因为调用了 set 函数
console.log(obj.a) // 4,这里还是会输出 4,因为读取的值就是 get 函数返回的值
相信大家从 例2 就能看出由于 get 函数返回的是一个常数 4,所以无论何时读取 obj 的 a 属性得到的值都为 4 ;并且给 a 属性赋新值后,新值也没有地方保存。所以不得不使用一个变量作为返回值返回和保存新值。
例3:
var temp;
Object.defineProperty(obj, 'a', {
// getter
get() {
return temp;
},
// setter
set(newValue) {
temp = newValue;
}
});
console.log(obj.a) // undefined 初始并没有给属性 a 赋值。
obj.a = 5
console.log(obj.a) // 5
这样写看似已经完美了,但是会造成新问题,temp 是全局变量,有可能会造成变量污染。所以最好的方法应该是在 Object.defineProperty() 方法外边再封装一层函数达成闭包(这里的 val 就相当于上面代码的 temp 变量)。
例4:
function defineReactive(data, key, val) {
Object.defineProperty(data, key, {
// getter
get() {
return val;
},
// setter
set(newValue) {
// 若新值没有变化
if (val === newValue) {
return;
}
val = newValue;
}
});
}
封装一个简单的 defineReactive 函数
好了我们已经实现了一个函数能够对对象属性进行简单的拦截存取操作。可以试试下面的例子(这里稍微改造一下 defineReactive 函数,增加两条输出以证明拦截成功):
function defineReactive(data, key, val) {
Object.defineProperty(data, key, {
// getter
get() {
console.log('触发读值拦截');
return val;
},
// setter
set(newValue) {
console.log('触发存值拦截');
// 若新值没有变化
if (val === newValue) {
return;
}
val = newValue;
}
});
}
var obj = {};
defineReactive(obj,'a',4);
console.log(obj.a) // 触发读值拦截 4
obj.a = 5; // 触发存值拦截
console.log(obj.a) // 触发读值拦截 5
数据响应式
我们先来聊聊什么是响应式数据?我认为响应式数据通俗点儿来说就是当当前数据改变的时候,所有依赖于它的结果都会发生改变。
分析分析这句话可以得到两个问题:
- 我怎么知道数据什么时候发生变化呢? 答:使用 Object.defineProperty 对对象属性进行 存值拦截,当执行 set 函数的时候就说明数据改变了。
- 数据发生变化时我怎么知道哪些结果是依赖本数据的呢?答:使用 Object.defineProperty 对对象属性进行 取值拦截,当执行 get 函数时就将读取属性值的依赖存起来,那么当数据发生变化时,就会遍历存起来的依赖并更新。
Tip: 这里的依赖就是 Watcher 这里重点在于介绍 vue2 的响应式数据原理,所以不会具体阐述如何存取副作用函数。简单来说就是一种数据与数据结构的映射关系,可以认为是一个Map。
obseve.js
还记不记得重点一:Object.defineProperty 的操作目标始终是对象的属性。
所以本文件的代码就像是一个分拣员,它只做了一个简单的分拣工作,若需要做数据响应式的数据不是对象就不做任何处理,若是对象就调用 observer 类作相应处理。
import Observer from './Observer.js';
export default function (obj) {
// 如果value不是对象,什么都不做
if (typeof obj != 'object') return;
else new Observer(obj);
}
observer.js(注意区分 observe.js)
这个类的作用有两个:
- 将需要做响应式的数据传入并保存,将 Observer 的实例挂载到数据的
__ob__
属性上,使数据和 Observer 建立联系。同时也会实例一个 DEP 对象挂载在 Observer 实例上。(Observer类是把一个普通的object类变成每一层都能相应的类,Dep类的作用是添加,移除,通知和收集订阅者,这不是重点,了解即可)。 - 再次细分数据是普通对象还是数组,并分别进行不同的处理。
实现功能一
本函数的作用就是给数据添加__ob__
属性,使数据和 Observer 建立联系。这里就是应用的 Object.defineProperty 方法能够给对象添加属性的能力。
def.js
export const def = function (obj, key, value, enumerable) {
Object.defineProperty(obj, key, {
value,
enumerable,
writable: true,
configurable: true
});
};
实现功能二
判断数据是一个数组还是一个普通对象。
- 若是数组就需要循环调用 observe 函数(这里实际上就进入了递归),判断数组中每个元素是否是对象继而决定需不需要对它做数据响应式。
- 若是对象就需要遍历对象的属性调用 defineReactive 方法,对对象属性进行存取拦截
注意这里数组和对象的处理方法不同,所以需要有两个函数分别循环不同的类型。
Observer.js
import { def } from './utils.js';
import defineReactive from './defineReactive.js';
import { arrayMethods } from './array.js';
import observe from './observe.js';
import Dep from './Dep.js';
export default class Observer {
constructor(obj) {
// 每一个Observer的实例身上,都有一个dep
this.dep = new Dep();
// 给实例(this,一定要注意,构造函数中的this不是表示类本身,而是表示实例)添加了__ob__属性,值是这次new的实例
if (typeof value.__ob__ == 'undefined') {
def(obj, '__ob__', this, false);
}
// Observer类的目的是:将一个正常的object转换为每个层级的属性都是响应式(可以被侦测的)的object
// 检查它是数组还是对象
if (Array.isArray(obj)) {
// 如果是数组,要强行的蛮干:将这个数组的原型,指向arrayMethods
Object.setPrototypeOf(obj, arrayMethods);
// 让这个数组变的observe
this.observeArray(obj);
} else {
this.walk(obj);
}
}
// 遍历
walk(obj) {
for (let k in obj) {
defineReactive(obj, k);
}
}
// 数组的特殊遍历
observeArray(arr) {
for (let i = 0, l = arr.length; i < l; i++) {
// 逐项进行observe
observe(arr[i]);
}
}
};
对象响应式处理
本函数就是使用 Object.defineProperty() 方法对数据进行 getter 和 setter 拦截,同时实例化 DEP 类。若对象的属性还是一个对象,那么就会调用 observe方法递归处理。只不过这里递归是几个函数循环调用实现递归,并不是函数自己调用自己。
注意:
1. 执行 getter 时进行依赖搜集。
2. 执行 setter 时说明数据变了,也需要对新值进行使用 observe 方法进行相应是处理,同时通知订阅者数据改变了。
defineReactive.js
import observe from './observe.js';
import Dep from './Dep.js';
export default function defineReactive(data, key, val) {
const dep = new Dep();
if (arguments.length == 2) {
val = data[key];
}
// 子元素要进行observe,至此形成了递归。这个递归不是函数自己调用自己,而是多个函数、类循环调用
let childOb = observe(val);
Object.defineProperty(data, key, {
// 可枚举
enumerable: true,
// 可以被配置,比如可以被delete
configurable: true,
// getter
get() {
// 如果现在处于依赖收集阶段
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
}
}
return val;
},
// setter
set(newValue) {
if (val === newValue) {
return;
}
val = newValue;
// 当设置了新值,这个新值也要被observe
childOb = observe(newValue);
// 发布订阅模式,通知dep
dep.notify();
}
});
};
数组响应式处理
因为对于数组对象来说,它有自己的方法(push,pop···),所以不能简单的对它进行存取拦截就好了,vue的做法是重写了数组原型对象上的七个方法,既然是重写,那么就不能剥夺原来的函数功能,所以我们 首先要做的就是先备份一下原来的数组原型对象,接下来就是重写方法。
这里直接将七个方法名作为数组元素存进数组里,目的是便于循环遍历元素重写函数。
注意:重写的的三个方法里有三个插入方法需要做特殊处理,因为是插入所以需要对新插入的值也做响应式处理。
import { def } from './utils.js';
// 备份Array.prototype
const arrayPrototype = Array.prototype;
// 以Array.prototype为原型创建arrayMethods对象,并暴露
export const arrayMethods = Object.create(arrayPrototype);
// 要被改写的7个数组方法
const methodsNeedChange = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
];
methodsNeedChange.forEach(methodName => {
// 备份原来的方法,因为push、pop等7个函数的功能不能被剥夺
const original = arrayPrototype[methodName];
// 定义新的方法
def(arrayMethods, methodName, function () {
// 恢复原来的功能
const result = original.apply(this, arguments);
// 把类数组对象变为数组
const args = [...arguments];
// 把这个数组身上的__ob__取出来,__ob__已经被添加了,为什么已经被添加了?因为数组肯定不是最高层,比如obj.g属性是数组,obj不能是数组,第一次遍历obj这个对象的第一层的时候,已经给g属性(就是这个数组)添加了__ob__属性。
const ob = this.__ob__;
// 有三种方法push\unshift\splice能够插入新项,现在要把插入的新项也要变为observe的
let inserted = [];
switch (methodName) {
case 'push':
case 'unshift':
inserted = args;
break;
case 'splice':
// splice格式是splice(下标, 数量, 插入的新项)
inserted = args.slice(2);
break;
}
// 判断有没有要插入的新项,让新项也变为响应的
if (inserted) {
ob.observeArray(inserted);
}
// 通知订阅者数据已修改。
ob.dep.notify();
return result;
}, false);
});
依赖搜集
DEP 类的主要作用是进行依赖搜集和通知订阅者数据更新了,搜集是在 getter 中进行的。通知订阅者则是调用 notify 方法,遍历订阅者,一个一个调用订阅者的update方法更新数据。
dep.js
var uid = 0;
export default class Dep {
constructor() {
this.id = uid++;
// 用数组存储自己的订阅者。subs是英语subscribes订阅者的意思。
// 这个数组里面放的是Watcher的实例
this.subs = [];
}
// 添加订阅
addSub(sub) {
this.subs.push(sub);
}
// 添加依赖
depend() {
// Dep.target就是一个我们自己指定的全局的位置,你用window.target也行,只要是全剧唯一,没有歧义就行
if (Dep.target) {
this.addSub(Dep.target);
}
}
// 通知更新
notify() {
// 浅克隆一份
const subs = this.subs.slice();
// 遍历
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}
}
};
数据监听
定义依赖 Watcher,声明一些必要的方法。
import Dep from "./Dep";
var uid = 0;
export default class Watcher {
constructor(target, expression, callback) {
console.log('我是Watcher类的构造器');
this.id = uid++;
this.target = target;
this.getter = parsePath(expression);
this.callback = callback;
this.value = this.get();
}
update() {
this.run();
}
get() {
// 进入依赖收集阶段。让全局的Dep.target设置为Watcher本身,那么就是进入依赖收集阶段
Dep.target = this;
const obj = this.target;
var value;
// 只要能找,就一直找,直到找到目标对象的属性值
try {
value = this.getter(obj);
} finally {
Dep.target = null;
}
return value;
}
run() {
this.getAndInvoke(this.callback);
}
// 得到并唤醒
getAndInvoke(cb) {
const value = this.get();
if (value !== this.value || typeof value == 'object') {
const oldValue = this.value;
this.value = value;
cb.call(this.target, value, oldValue);
}
}
};
// 解析a.b.c
function parsePath(str) {
var segments = str.split('.');
return (obj) => {
for (let i = 0; i < segments.length; i++) {
if (!obj) return;
obj = obj[segments[i]]
}
return obj;
};
}
分析依赖搜集和数据监听
- Obsever 文件内实例化了一个 DEP 类并挂载到 Observer 实例上,又因为Observer 实例会通过
__ob__
挂载到数据身上,这样每一个做了数据响应式的对象都有能力通知依赖更新数据。 - defineReactive 文件内也实例化了一个 DEP 类,该实例处于闭包当中。并且我们发现 Watcher 的构造函数里会调用
this.get()
读取响应式数据的属性,此时就会触发响应式数据的 getter 拦截器,将 Watcher实例 当作依赖搜集起来。但是我们只有在由 Watcher 实例触发的 getter 时搜集依赖,并不会搜集普通的调用,那么此时就需要做个区分,可以看到 Watcher 的 get 函数中会将当前 Watcher 实例放在Dep.target
属性上,
Dep.target = this;
var value;
// 只要能找,就一直找,直到找到目标对象的属性值
try {
value = this.getter(obj);
} finally { //数据读取完以后需要将 Dep.target 置空,避免下一次使用出现混乱
Dep.target = null;
}
然后在数据的 getter 函数中做一个简单的判断,只在 Dep.target 不为空的时候收集依赖。
if (Dep.target) {
dep.depend();
...
}
- 由分析1和2可知,Observer 类和 defineReactive 闭包中都有一个 Dep 实例,这是不是重复了呢?其实不然,他们的分工不同,defineReactive 闭包中的 Dep 实例用于由对象本身修改而触发 setter 函数导致闭包中的 Dep 通知所有的 Watcher 对象更改。而 Observer 类中 Dep 实例则是在对象本身增删属性或者数组变化的时候导致 Dep 通知所有的 Watcher 对象更改。
- Dep 使用发布订阅模式,当数据发生变化时,会循环依赖列表,把所有的 Watcher 都通知一遍。这里注意由于在 defineReactive 闭包中都有一个 Dep 实例,所以所有依赖于当前数据的 Watcher 都保存在当前闭包中的 Dep.subs 中,这样就不会通知错 Watcher 了。
vue2响应式缺陷
响应式数据的缺陷
细看 Observer 类中是处理普通对象的方式是遍历普通对象的属性,对每一个属性做存取拦截,这就导致一个问题,当我给响应式对象新添加一个属性的时候,该属性不是并响应式的,因为在遍历该对象属性的时候还没有该属性,也就不能在该属性改变的时候通知依赖更新。数组如果通过下标修改元素值也是无法触发响应式通知依赖修改的。
解决方案
Vue.$set | this.$set
这是vue官方提供给我们的两个方法,它们本质上是一样,只不过是不同的叫法罢了。基本用法
this.$set(targetObj,targetPro,value)
// 参数说明:
// 普通对象
// targetObj:要添加新属性的目标
// targetPro:新的属性名
// value:新属性的值
// 数组
// targetObj:要修改元素的数组
// targetPro:要修改的元素下标
// value:新值
使用这两个方法就可以给一个对象新添加一个响应式属性或者修改数组的某一元素。
源码(源码引自这里)
export function set (target: Array<any> | Object, key: any, val: any): any {
if (process.env.NODE_ENV !== 'production' &&
(isUndef(target) || isPrimitive(target))
) {
warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
}
// 判断目标值是否为数组,并且key值是否为有效的数组索引
if (Array.isArray(target) && isValidArrayIndex(key)) {
// 对比数组的key值和数组长度,取较大值设置为数组的长度
target.length = Math.max(target.length, key)
// 替换目标值
target.splice(key, 1, val)
return val
}
// 如果目标值是对象,并且key值是目标值存在的有效key值,并且不是原型上的key值
if (key in target && !(key in Object.prototype)) {
// 直接更改目标值
target[key] = val
return val
}
const ob = (target: any).__ob__ // 判断目标值是否为响应式的
if (target._isVue || (ob && ob.vmCount)) { // 如果是vue根实例,就警告
process.env.NODE_ENV !== 'production' && warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
return val
}
if (!ob) { // 如果目标值不是响应式的,那么值需要给对应的key赋值
target[key] = val
return val
}
// 其他情况,目标值是响应式的,就通过 defineReactive 进行数据监听
defineReactive(ob.value, key, val)
// 通知更新dom操作
ob.dep.notify()
return val
}
基本原理
当目标对象为数组时,则调用目标对象的 splice 方法将修改的数据变为响应式。
当目标对象为普通对象时依次判断:
- 首先判断这个属性是否在这个对象上,如果存在则设置属性为对应的属性值后直接返回val;
- 其次判断目标对象是否为Vue实例或者根数据对象,如果是则warn警告后返回;
- 再去判断这个目标对象是否是响应式的,如果不是响应式对象则直接赋值返回;
- 最后在给目标对象的属性添加响应式,通知dep实例的所有订阅者进行更新;
该方法的常见坑
注意由于在使用 this.$set
方法处理普通对象时会先判断这个属性是否在这个对象上,如果存在则设置属性为对应的属性值后直接返回val,这就会导致当对象有一个非响应式属性,你不知道的情况下再次使用 this.$set 为对象再次添加同名属性。这样的情况常规解法是先删除再添加。由于以前不懂原理,我就踩过这个坑,还找了半天,血和泪的教训啊!!!
一点思考
为什么要设置 observe 方法呢?
原因在上面介绍时似乎已经说明了,但是 vue2 的 data 要么是个对象,要么是个函数返回一个对象,怎么也不会传给 observe 一个基本类型啊,可见据此上面的原因就不十分站得住脚了,其实不然,还记得数组的处理吗?会把数组的每一项都传给 observe 处理,此时传入的数据就有可能是一个基本类型,所以才需要 observe 判断一下并进行不同处理。
为什么处理普通对象属性用 defineReactive 处理,数组元素要用 observe 处理呢?
因为无论该属性是基本类型还是对象类型都需要对它进行拦截处理,同时也会对该属性传给 observe 方法,如果是对象类型就会递归处理,而数组则不用对元素进行拦截,所以就直接把元素传给 observe 处理。
总结
vue2 数据响应式其实不难,简单来说就是对普通对象的属性进行递归的存取拦截,并会在读值拦截中搜集依赖,在存值拦截中通知所有依赖更新(发布订阅者模式),对数组重写了它的七个方法。只要记住主要逻辑,再把上面的代码好好看一看,理一理逻辑,你就会感到不过如此。但是在细节实现上还是很值的仔细推敲推敲的。
好了,这篇博客就到这里了,我是孤城浪人,一名还在前端路上摸爬滚打的菜鸟,此项目已开源到github。