文章来源:我的博客
这里的响应式,是指作为现在 MVVM 主流思想下的前端框架采用的一种数据驱动视图的方案,即对数据监测,来进行相应的 DOM 更新。而 Vue 实现的方案也是比较特别的,其很巧妙的借助 JS 原生的对对象进行监听的 API 来处理,一定程度上提升了性能,但是也有其缺陷的地方。
小插曲
- 面试官:请你讲一下 Vue 的双向绑定是如何实现的
- 面试者: 使用了 Object.defineProperty 对对象进行绑定,然后通过 get 和 set 的方法来进行获取和监听改变视图
- 面试官:能具体的说一下 get 做了什么处理以及你说的 set 是如何改变视图的,或者说它怎么知道要改变那一块的视图的
- 面试者: emmm,不好意思,这个我不太清楚
- 面试官:那好,那你觉得这种方式会有什么缺陷呢
- 面试者: 对数组的支持不是很好,不会进行响应式
- 面试官:那么 Vue 在数组这一块,对数组的原生方法做了什么特殊处理呢
- 面试者: 不好意思,这个我不太清楚
- 面试官:好的,我的问题问完了,你回去等通知吧~
关于响应式这个问题,一直会被 vue 相关的面试面到,而往往第一个问题,谁都能答出,因为网上实在是太多面经了,就记住这一句话就够了,但是一旦深入就会暴露出问题
所以我们还是要从 vue 源码实现中来挖掘出更多的知识。
接下来,我们就可以带着小插曲的问题或者更多的疑问,去 vue 源码中一探究竟。
由于 Vue 响应式使用了发布订阅的设计模式,这边先简单的介绍一下这个模式。
发布订阅是观察者模式的一种升级。
实现一个简单的发布订阅 Demo,先让控制器对订阅者进行注册收集,然后当发布者一旦发生改变就会触发控制器,控制器来通知相应的订阅者进行更新
function Controller() {
this.list = []
add(type, fn) {
this.list.push(fn)
}
notice() {
this.list.forEach((item) => {
typeof item === 'function' && item()
})
}
}
const controller = new Controller()
controller.add(watcherA)
controller.add(watcherB)
controller.add(watcherC)
// 订阅者A
function watcherA() {
// do something
}
// 订阅者B
function watcherB() {
// do something
}
// 订阅者C
function watcherC() {
// do something
}
// 发布者
function Publisher() {
controller.notice()
}
这个类就是将一个数据转化成监听对象
// vue/src/core/observer/index.js
export class Observer {
value: any;
dep: Dep;
vmCount: number; // number of vms that have this object as root $data
constructor(value: any) {
this.value = value;
this.dep = new Dep();
this.vmCount = 0;
def(value, '__ob__', this);
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods);
} else {
copyAugment(value, arrayMethods, arrayKeys);
}
this.observeArray(value);
} else {
this.walk(value);
}
}
/**
* Walk through all properties and convert them into
* getter/setters. This method should only be called when
* value type is Object.
*/
walk(obj: Object) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i]);
}
}
/**
* Observe a list of Array items.
*/
observeArray(items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i]);
}
}
}
因为本身就是将数据转化成一个监听对象,所以 value 值肯定是要保留的
this.value = value;
然后他会生成一个 Dep 实例,可以直接从下文中的 分析可知(这边整个顺序确实不好整理),Dep 就是一个依赖收集,或者说是发布订阅中的控制层
this.dep = new Dep();
在 value 上绑定一个__ob__的属性,值为当前 Observer 的实例对象,可写可配置不可枚举,这个属性存在就代表了这个数据是已经被转化成监听对象的
def(value, '__ob__', this);
export function def(obj: Object, key: string, val: any, enumerable?: boolean) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true,
});
}
接着判断数据值是否是数组,从而进行不同的处理。首先看数组的情况
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods);
} else {
copyAugment(value, arrayMethods, arrayKeys);
}
this.observeArray(value);
}
先判断当前环境是否可使用__proto__,(__proto__不是规范,有一定的兼容性,实现原型链的一种方式)
支持:将数组原型上的(被修改后的)方法赋值给当前对象的原型链(__proto__)上,之后使用方法就会去原型链上找
protoAugment(value, arrayMethods);
function protoAugment(target, src: Object) {
// 直接赋值到原型链
target.__proto__ = src;
}
不支持:将数组的(被修改后的)原型方法绑定到对象的当前属性上,通过属性直接访问
copyAugment(value, arrayMethods, arrayKeys);
// 通过Object.defineProperty的方式添加数组方法做为值的属性
function copyAugment(target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i];
def(target, key, src[key]);
}
}
对部分方法特殊处理
那么这个数组方法有做了什么特殊处理呢,这就是小插曲的疑问,我们将在这里找到答案
首先获取数组的原型对象,创建一个新的对象,这就是没有改变过的数组原型方法
// src/core/observer/array.js
const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);
但这并不是我们拿到的 arrayMethods,vue 还对以下一些方法进行了特殊处理
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse',
];
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function(method) {
// cache original method
const original = arrayProto[method];
def(arrayMethods, method, function mutator(...args) {
const result = original.apply(this, args);
const ob = this.__ob__;
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break;
case 'splice':
inserted = args.slice(2);
break;
}
if (inserted) ob.observeArray(inserted);
// notify change
ob.dep.notify();
return result;
});
});
首先将需要修改的记个方法整成一个数组,以便直接循环,对这些方法进行改变。为什么只有这几个方法?因为这几个方法会对原数组进行改变
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse',
];
先获取原先的数组方法,作为缓存,然后将 arrayMethods 数组的指定方法改成执行 mutator 方法,先使用缓存方法借助 apply 来调用执行,所以原先的功能并不影响,只是会添加一些内容处理
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)
// ...
}
那么添加内容处理到底是什么呢,首先通过 this.__ob__来获得 value,前面提到已经将 this.__ob__指向了 value 的(def(value, ‘__ob__’, this)),而这些方法就是通过 value 来调用的
然后可以看到对 push,unshift,splice 三个方法进行了特别处理,因为这 3 个方法都是可以为数组添加新元素且直接改变原数组的,如果有新元素加入,就需要对整个数组重新进行绑定监听处理,最后在使用 Dep 实例中的 notify 方法去通知所有的订阅者更新!!!
// src/core/observer/array.js
const ob = this.__ob__;
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break;
case 'splice':
inserted = args.slice(2);
break;
}
if (inserted) ob.observeArray(inserted);
// notify change
ob.dep.notify();
为什么能加新元素的方法就需要进行特殊处理呢
这就要上面提到的 Object.defineProperty
只对已绑定的元素进行监听,新加的元素是没有被 Vue 进行数据监听的,所以一旦有通过这三种方式将新的元素加入,Vue
就会对这些新的项进行监听操作
处理完了数组调用的方法后,就是真正对数组进行转化成监听对象的步骤了,也就是调用了 observeArray 方法
// observeArray会对数组的每一个子项进行
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
可以从代码中看出,observeArray 会循环遍历对每一个子项属性进行了转化成监听对象的处理(这里不是指每一项都会被转化成监听对象,具体看下文)
export function observe(value: any, asRootData: ?boolean): Observer | void {
if (!isObject(value) || value instanceof VNode) {
return;
}
let ob: Observer | void;
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__;
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value);
}
if (asRootData && ob) {
ob.vmCount++;
}
return ob;
}
如果子项的值不是一个对象类型,或者值是 VNode 实例节点,直接退出
if (!isObject(value) || value instanceof VNode) {
return;
}
判断该值是否有__ob__属性,并且该属性的值是 Observer 的实例
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__;
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value);
}
满足条件的子项会被转化成监听对象
// 测试
data() {
return {
list: [1, 2, {
b: 3 }, 4]
}
}
::: tip 为什么不将所有子项都转成监听对象呢
这样如果改变那些没有转成监听对象的子项,不会不会触发更新吗
data() {
return {
a: [1, 2, 3, 4]
}
},
methods: {
handleChange() {
// 直接改变索引不会触发
this.a[0] = 6
}
}
是因为 Object.defineProperty 不支持吗?
编写一个对每个子项进行监听的 demo,发现是可以通过 Object.defineProperty 来对数组子项进行监听的
var arr = [3, 4, 5, 7];
arr.forEach((item, index) => {
Object.defineProperty(arr, index, {
set: function(val) {
console.log('set');
item = val;
},
get: function(val) {
console.log('get', item);
return item;
},
});
});
arr[0] = 10;
// 控制台输出'set'
通过测试,发现 Object.defineProperty 是可以支持的
首先尤大已经数了是出于性能的考虑,折中选择了这种方式。
那么到底会造成什么性能影响呢?
this.a[100000] = 1;
那么数组就会创建 100000 个空间,而中间的都是 empty 值,即便如此,也会遍历 100000 次,所以很浪费
当然 vue 也提供了 this.$set 的方式允许你将一个子项转换为监听对象(这里并不是永久的转成了监听对象),具体参考 API 篇章
而且 Vue3.0 将使用 proxy 来代替 Object.defineProperty,这些问题也将被解决,由于这里是 Vue2.6 的源码解析,所以不再具体展开
:::
如果是全局数据,就加一,作为统计
if (asRootData && ob) {
ob.vmCount++;
}
通过了数组处理的了解后,包括带着数组子项是对象是如何被转化成监听对象的问题,我们再来看看对象是如何处理的
// src/core/observer/index.js
if (Array.isArray(value)) {
// ...
} else {
this.walk(value)
}
/**
* Walk through all properties and convert them into
* getter/setters. This method should only be called when
* value type is Object.
*/
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
从源码注释中,我们也可以得知,其会将每一个属性进行 getter 和 setter 操作,也就是之前一直提到的 Object.defineProperty 操作,那么小插曲中的 get 和 set 到底做了什么的问题答案也将拉开帷幕
首先看到每一个属性都被 defineReactive 方法处理了,那么我们就来看看 defineReactive 做了什么
/**
* Define a reactive property on an Object.
*/
export function defineReactive(
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep();
const property = Object.getOwnPropertyDescriptor(obj, key);
if (property && property.configurable === false) {
return;
}
// cater for pre-defined getter/setters
const getter = property && property.get;
const setter = property && property.set;
if ((!getter || setter) && arguments.length === 2) {
val = obj[key];
}
let childOb = !shallow && observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
const value = getter ? getter.call(obj) : val;
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
if (Array.isArray(value)) {
dependArray(value);
}
}
}
return value;
},
set: function reactiveSetter(newVal) {
const value = getter ? getter.call(obj) : val;
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return;
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter();
}
// #7981: for accessor properties without setter
if (getter && !setter) return;
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = !shallow && observe(newVal);
dep.notify();
},
});
}
创建一个依赖收集器,用来收集依赖于该属性数据的内容
const dep = new Dep();
获取该属性的自有属性的描述符 如:{configurable: true, value: 1, writeable: true}
如果该属性不可被配置,那就不作处理,即不能转化成监听对象
const property = Object.getOwnPropertyDescriptor(obj, key);
if (property && property.configurable === false) {
return;
}
如果是初始化,就读取对象下的属性名来获取初始值
const getter = property && property.get;
const setter = property && property.set;
if ((!getter || setter) && arguments.length === 2) {
val = obj[key];
}
observe 已经在数组处理中遇到过了,就是将特殊对象处理成监听对象的,所以这里的作用就是如果对象的属性的值是可以被转化成监听对象的话,就需要转化,即递归将一个对象下的属性,包括子孙属性(符合条件的)都转化成监听对象。而这边返回的内容是指当前值的被转化后的值
let childOb = !shallow && observe(val);
终于轮到 Object.defineProperty 登场了,其主要是改变了属性的 4 个属性,定义属性是可枚举的且可配置的,然后有改些了 get 和 set 方法,那我们具体来看看 get 和 set 到底做了什么
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
// ...
},
set: function reactiveSetter(newVal) {
// ...
},
});
get 主要是处理了收集依赖的操作
首先先根据属性是否有 getter 方法,有说明已经初始化过了,就直接使用 getter 方法来获取值,没有的话就直接取对象下的属性名
1、然后根据是否有 Dep.target,这是一个 Watcher 实例,也就是对数据的依赖者(订阅者),所以这里就是收集依赖,如果有的话,就将它加入到属性的 Dep 实例(依赖收集器)中
2、如果属性的值也是一个监听对象,那么将该订阅者也加入到属性值监听对象的依赖收集器中,这样当属性的(监听对象)值发生改变时,也会通知订阅者。
3、如果值是一个数组,那么就对数组中的每一个(是监听对象)子项的依赖收集器中都加入(收集)这个订阅者
get: function reactiveGetter() {
const value = getter ? getter.call(obj) : val;
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
if (Array.isArray(value)) {
dependArray(value);
}
}
}
return value;
}
递归将值是数组且值为可转成监听对象的子项都收集当前订阅者
function dependArray(value: Array<any>) {
for (let e, i = 0, l = value.length; i < l; i++) {
e = value[i];
e && e.__ob__ && e.__ob__.dep.depend();
if (Array.isArray(e)) {
dependArray(e);
}
}
}
由此,当一个对象的属性发生改变时,或者一个对象属性值中的对象内容发生改变时,又或者对象属性数组的值中的对象发生改变,总而言之,只要对象下的监听对象发生改变,都会通知更新,不管多少层
收集完依赖,set 就是在改变数据的时候去通知这些依赖要更新
set: function reactiveSetter(newVal) {
const value = getter ? getter.call(obj) : val;
if (newVal === value || (newVal !== newVal && value !== value)) {
return;
}
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter();
}
if (getter && !setter) return;
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = !shallow && observe(newVal);
dep.notify();
}
和 get 一致,获取到属性的值,如果两个值相等,就没必要更新,或者自身值不等于自身,比如 NaN 等,也不用通知更新
const value = getter ? getter.call(obj) : val;
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return;
}
customSetter 就是一些内置的警告处理,比如修改当前值违反了约定,例子,当我们子组件改变 props 时,就是这个这里执行触发的警告
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter();
}
如果不存在 setter 只有 getter,即属性不可修改,那么就不做处理
如果存在 setter 就执行 setter,没有的话就直接将新值赋给 value
if (getter && !setter) return;
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
既然数据有变化,那么就需要对整个属性值再重新进行转化成监听对象,然后触发更新通知,这里通知的订阅者就是当前改变的监听对象中 Dep 实例 里的 Watcher 订阅者,数据改变哪一层,就会触发哪层的 setter 来通知其收集的依赖(订阅者)
childOb = !shallow && observe(newVal);
dep.notify();
类似以下 demo ,当改变子属性值的时候,只会触发被改变子属性的 setter,不会影响到整个对象
Observer(value) { for (let key in value) { let val = value[key]; if (typeof val !== 'object') continue; Object.defineProperty(value, key, { configurable: true, enumerable: true, get() { return val; }, set(newVal) { console.log('改变的是当前属性', key); val = newVal; }, }); Observer(val); } } Observer(data); data.a.b = 2; // 改变的是当前属性 b data.a = 1; // 改变的是当前属性 a ```
所以整个对象转化成监听对象(响应式对象)的过程就如图所示
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1SYZPMx3-1608509673364)(/源码/vue源码/响应式/vue响应式对象转化.svg)]
如下代码,很清晰的可以看到 Dep 类有添加删除依赖,收集依赖以及通知等方法,所以其担任的就是发布订阅中的控制层
// src/core/observer/dep.js
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor() {
// 每一个数据的标识符
this.id = uid++;
// 存放依赖
this.subs = [];
}
// 添加依赖
addSub(sub: Watcher) {
this.subs.push(sub);
}
// 删除依赖
removeSub(sub: Watcher) {
remove(this.subs, sub);
}
// 收集依赖
depend() {
if (Dep.target) {
Dep.target.addDep(this);
}
}
// 通知更新
notify() {
// stabilize the subscriber list first
const subs = this.subs.slice();
if (process.env.NODE_ENV !== 'production' && !config.async) {
// subs aren't sorted in scheduler if not running async
// we need to sort them now to make sure they fire in correct
// order
subs.sort((a, b) => a.id - b.id);
}
// 更新所有依赖
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}
}
}
什么时候会收集依赖和通知依赖更新呢 组件实例化(模板解析)时,绑定 data,watch
初始化时,computed 初始化时,但凡是会读取数据,都会触发 get 方法,而 get 方法又会触发
depend 方法来收集依赖(这块具体在前面 getter 方法中有讲述)当数据变更时,就会触发 notify 方法来通知订阅者(这块具体在前面 setter 方法中有讲述)
了解了收集依赖的容器(控制器),那么我们就来看看这个依赖数据的内容(订阅者)到底是什么?
官网:每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
首先在初始化一个组件时,就会生成一个 Watcher 实例,同时也会调用 get 方法接着通过 pushTarget,将当前组件的 Watcher 实例压栈,作为 Dep.target,这个就是之前 depend 方法添加的依赖
创建组件时,进入挂载阶段,会初始化一个 Watcher 实例,具体可以看生命周期篇章
Vue.prototype.$mount = function() {
return mountComponent();
};
export function mountComponent(
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
// ...
// 初始化Watcher
new Watcher();
// ...
// ...
return vm;
}
由于整个 Watcher 类的代码过长,这边就不贴整段源码了
构造函数中先将当前的 Watcher 实例添加到 vue 实例上,然后 id 递增,用以记录组件的加载顺序,然后调用了 this.get()方法
let uid = 0;
export default class Watcher {
constructor() {
vm._watchers.push(this)
this.id = ++uid // uid for batching
// ...
this.value = this.lazy
? undefined
: this.get()
}
get 方法先将当前 Watcher 实例赋值给 Dep.target,以便收集依赖时能够获取到,然后执行 this.getter 方法,也就是执行 updateComponent 方法,进行更新,具体看生命周期篇章,当更新完成后(updated),就删除 Dep.target,即退出当前订阅者
get() {
pushTarget(this);
let value;
const vm = this.vm;
try {
value = this.getter.call(vm, vm);
} catch (e) {
// ...
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value);
}
popTarget();
this.cleanupDeps();
}
return value;
}
其实我们可以发现,Dep 收集依赖的时候并不是直接将 Watcher 添加到自己的 subs 中的,而是调用 Watcher 实例的 addDep,虽然最终还是会反过来调用 addSub 来添加 Watcher 实例到 subs 中。那这样做的目的是什么呢?不是多此一举吗
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
其实我们可以 Watcher 的还有一个方法,就可以确认上述步骤的作用。当一个组件被销毁时,但是它所依赖的数据下面还绑定着它,那肯定是不行的,所以我需要清空那些数据下对该订阅者的删除,那么改如何找到有哪些数据下面是有这个订阅者的呢?这就是上述操作的解释,即反向将依赖的数据也维护一个数组在订阅者里,以便销毁的时候,清空数据下的订阅者
teardown () {
if (this.active) {
// remove self from vm's watcher list
// this is a somewhat expensive operation so we skip it
// if the vm is being destroyed.
if (!this.vm._isBeingDestroyed) {
remove(this.vm._watchers, this)
}
let i = this.deps.length
while (i--) {
this.deps[i].removeSub(this)
}
this.active = false
}
}
至此,大致就知道了何时收集依赖,以及 Dep 和 Watcher 的相关内容,便于在 Obsever 中遇到有更好的理解
这是当前组件的一个订阅者 Watcher 的实例,targetStack 是作为当前的一个组件“执行栈”,进入一个组件就压栈,回到上一个组件就出栈
// src/core/observer/dep.js
Dep.target = null;
const targetStack = [];
// push是进入一个组件,pop是出去一个组件,比如当前设置完父组件,开始设置子组件那么就会push,回归到父组件就会pop方法后,取栈顶元素
export function pushTarget(target: ?Watcher) {
targetStack.push(target);
Dep.target = target;
}
export function popTarget() {
targetStack.pop();
Dep.target = targetStack[targetStack.length - 1];
}
// 具体会在后面的 Observer 中见到
if (Dep.target) {
dep.depend();
}
以上就是所有响应式部分的内容,为了更好的理解,这边也画了个图,展现了 Vue 的关于响应式运作的过程
根据上面的发布订阅模式,先确定发布者,订阅者,以及控制者。分别是