vue2响应式原理

Object.defineProperty()

要理解 vue2 数据响应式原理,我们首先要了解Object.defineProperty()方法。下面这些概念引自MDN。

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

该方法允许精确地添加或修改对象的属性

对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。

存取描述符还具有以下可选键值:

get

属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。 默认为 undefined。

set

属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。 默认为 undefined。

划重点

  1. Object.defineProperty() 操作的目标始终是对象的属性
  2. 对象有存(set)取(get)描述符,使用 Object.defineProperty() 方法可以实现对对象属性进行存取拦截
  3. 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;},// setterset(newValue) {// 若新值没有变化if (val === newValue) {return; }val = newValue; }});
}

封装一个简单的 defineReactive 函数

好了我们已经实现了一个函数能够对对象属性进行简单的拦截存取操作。可以试试下面的例子(这里稍微改造一下 defineReactive 函数,增加两条输出以证明拦截成功):

function defineReactive(data, key, val) {Object.defineProperty(data, key, {// getter get() {console.log('触发读值拦截');return val;},// setterset(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

数据响应式

我们先来聊聊什么是响应式数据?我认为响应式数据通俗点儿来说就是当当前数据改变的时候,所有依赖于它的结果都会发生改变

分析分析这句话可以得到两个问题:

  1. 我怎么知道数据什么时候发生变化呢? 答:使用 Object.defineProperty 对对象属性进行 存值拦截,当执行 set 函数的时候就说明数据改变了。
  2. 数据发生变化时我怎么知道哪些结果是依赖本数据的呢?答:使用 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)

这个类的作用有两个:

  1. 将需要做响应式的数据传入并保存,将 Observer 的实例挂载到数据的 __ob__属性上,使数据和 Observer 建立联系。同时也会实例一个 DEP 对象挂载在 Observer 实例上。(Observer类是把一个普通的object类变成每一层都能相应的类,Dep类的作用是添加,移除,通知和收集订阅者,这不是重点,了解即可)
  2. 再次细分数据是普通对象还是数组,并分别进行不同的处理。

实现功能一

本函数的作用就是给数据添加__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的实例身上,都有一个depthis.dep = new Dep();// 给实例(this,一定要注意,构造函数中的this不是表示类本身,而是表示实例)添加了__ob__属性,值是这次new的实例if (typeof value.__ob__ == 'undefined') {def(obj, '__ob__', this, false);}// Observer类的目的是:将一个正常的object转换为每个层级的属性都是响应式(可以被侦测的)的object// 检查它是数组还是对象if (Array.isArray(obj)) {// 如果是数组,要强行的蛮干:将这个数组的原型,指向arrayMethodsObject.setPrototypeOf(obj, arrayMethods);// 让这个数组变的observethis.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++) {// 逐项进行observeobserve(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,// 可以被配置,比如可以被deleteconfigurable: true,// getterget() {// 如果现在处于依赖收集阶段if (Dep.target) {dep.depend();if (childOb) {childOb.dep.depend();}}return val;},// setterset(newValue) {if (val === newValue) {return;}val = newValue;// 当设置了新值,这个新值也要被observechildOb = observe(newValue);// 发布订阅模式,通知depdep.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;};
}

分析依赖搜集和数据监听

  1. Obsever 文件内实例化了一个 DEP 类并挂载到 Observer 实例上,又因为Observer 实例会通过 __ob__挂载到数据身上,这样每一个做了数据响应式的对象都有能力通知依赖更新数据
  2. 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. 由分析1和2可知,Observer 类和 defineReactive 闭包中都有一个 Dep 实例,这是不是重复了呢?其实不然,他们的分工不同,defineReactive 闭包中的 Dep 实例用于由对象本身修改而触发 setter 函数导致闭包中的 Dep 通知所有的 Watcher 对象更改。而 Observer 类中 Dep 实例则是在对象本身增删属性或者数组变化的时候导致 Dep 通知所有的 Watcher 对象更改
  2. 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] = valreturn 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] = valreturn val}// 其他情况,目标值是响应式的,就通过 defineReactive 进行数据监听defineReactive(ob.value, key, val)// 通知更新dom操作ob.dep.notify()return val
}

基本原理

当目标对象为数组时,则调用目标对象的 splice 方法将修改的数据变为响应式。
当目标对象为普通对象时依次判断:

  1. 首先判断这个属性是否在这个对象上,如果存在则设置属性为对应的属性值后直接返回val;
  2. 其次判断目标对象是否为Vue实例或者根数据对象,如果是则warn警告后返回;
  3. 再去判断这个目标对象是否是响应式的,如果不是响应式对象则直接赋值返回;
  4. 最后在给目标对象的属性添加响应式,通知dep实例的所有订阅者进行更新;

该方法的常见坑

注意由于在使用 this.$set 方法处理普通对象时会先判断这个属性是否在这个对象上,如果存在则设置属性为对应的属性值后直接返回val,这就会导致当对象有一个非响应式属性,你不知道的情况下再次使用 this.$set 为对象再次添加同名属性。这样的情况常规解法是先删除再添加。由于以前不懂原理,我就踩过这个坑,还找了半天,血和泪的教训啊!!!

一点思考

为什么要设置 observe 方法呢?

原因在上面介绍时似乎已经说明了,但是 vue2 的 data 要么是个对象,要么是个函数返回一个对象,怎么也不会传给 observe 一个基本类型啊,可见据此上面的原因就不十分站得住脚了,其实不然,还记得数组的处理吗?会把数组的每一项都传给 observe 处理,此时传入的数据就有可能是一个基本类型,所以才需要 observe 判断一下并进行不同处理。

为什么处理普通对象属性用 defineReactive 处理,数组元素要用 observe 处理呢?

因为无论该属性是基本类型还是对象类型都需要对它进行拦截处理,同时也会对该属性传给 observe 方法,如果是对象类型就会递归处理,而数组则不用对元素进行拦截,所以就直接把元素传给 observe 处理。

总结

vue2 数据响应式其实不难,简单来说就是对普通对象的属性进行递归的存取拦截,并会在读值拦截中搜集依赖,在存值拦截中通知所有依赖更新(发布订阅者模式),对数组重写了它的七个方法。只要记住主要逻辑,再把上面的代码好好看一看,理一理逻辑,你就会感到不过如此。但是在细节实现上还是很值的仔细推敲推敲的。

好了,这篇博客就到这里了,我是孤城浪人,一名还在前端路上摸爬滚打的菜鸟,此项目已开源到github。

vue2响应式原理解析并实现一个简单响应系统相关推荐

  1. Vue3的响应式原理解析

    Vue3的响应式原理解析 Vue2响应式原理回顾 // 1.对象响应化:遍历每个key,定义getter.setter // 2.数组响应化:覆盖数组原型方法,额外增加通知逻辑 const origi ...

  2. vue.js响应式原理解析与实现

    从很久之前就已经接触过了angularjs了,当时就已经了解到,angularjs是通过脏检查来实现数据监测以及页面更新渲染.之后,再接触了vue.js,当时也一度很好奇vue.js是如何监测数据更新 ...

  3. vuex原理解析并实现一个简单的vuex

    vuex的作用 官方 Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式 + 库.它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化. 个人理解 简 ...

  4. tomcat原理解析(一):一个简单的实现

    一 概述 前段时间去面试,被人问到了tomcat实现原理.由于平时没怎么关注容器的实现细节,这个问题基本没回答上来.所以最近花了很多时间一直在网上找资料和看tomcat的源码来研究里面处理一个HTTP ...

  5. 彻底理解Vue数据响应式原理

    彻底理解Vue数据响应式原理 当创建了一个实例后,通过将数据传入实例的data属性中,会将数据进行劫持,实现响应式,也就是说当这些数据发生变化时,对应的视图也会发生改变. const data = { ...

  6. 你不知道的Vue响应式原理

    文章首发于github Blog. 本文根据Vue源码v2.x进行分析.这里只梳理最源码中最主要的部分,略过非核心的一些部分.响应式更新主要涉及到Watcher,Dep,Observer这几个主要类. ...

  7. Vue.js 响应式原理

    文章目录 Vue 数据响应式原理 `Object.defineProperty()` 数据响应式原理 `Proxy` 相关设计模式 观察者模式 发布-订阅模式 Vue 响应式原理模拟 Vue 类 Ob ...

  8. vue 存储对象 不要监听_Vue源码解析----响应式原理

    从很久之前就已经接触过了angularjs了,当时就已经了解到,angularjs是通过脏检查来实现数据监测以及页面更新渲染.之后,再接触了vue.js,当时也一度很好奇vue.js是如何监测数据更新 ...

  9. matlabeig函数根据什么原理_vue3.0 源码解析二 :响应式原理(下)

    一 回顾上文 上节我们讲了数据绑定proxy原理,vue3.0用到的基本的拦截器,以及reactive入口等等.调用reactive建立响应式,首先通过判断数据类型来确定使用的hander,然后创建p ...

最新文章

  1. 基类的析构函数为什么要设置成virtual
  2. 【Java8】@FunctionalInterface
  3. Hadoop集群扩容和缩容:添加白名单和黑名单
  4. java8 stringbuilder_为什么 Java 8 中不再需要 StringBuilder 拼接字符串
  5. 【复杂系统迁移 .NET Core平台系列】之认证和授权
  6. unity 启动相机_Unity3D研究院之打开照相机与本地相册进行裁剪显示(三十三)...
  7. html块级页面居中,几个并排div的CSS / HTML居中
  8. 经济学家德鲁克的三个故事
  9. tomcat6升级到tomcat7配置的修改
  10. 云端(服务器)车牌识别SDK
  11. Docker容器网络访问慢问题
  12. chm sharp安卓版_CHM 阅读器
  13. 几行烂代码,我赔了16万。
  14. 常州大学计算机课程表,常州大学公课表
  15. kermit的安装、配置、使用
  16. 小杜机器人线下店_小度机器人怎么领养?小度机器人功能最新一览
  17. 无力吐槽:各位忠实的fans家人们,博客之星评选 我4000粉丝,尽然拼不过一个49粉的博主,期待你们帮忙
  18. 【路径规划】基于FMM快速行进法实现船舶路径规划附matlab代码
  19. Silverlight for Linux
  20. java实现仿qq界面及功能、网路编程、实现抽象工厂模式、线程池代码与测试

热门文章

  1. C/C++程序员求职面试指导
  2. NeuralProphet之六:多元时间序列预测
  3. JS 下载文件方法分享(解决图片文件无法直接下载和 IE兼容问题)
  4. 自媒体娱乐热点素材怎么找?
  5. swoole http请求出现1004 1005报错
  6. Flink 流批一体一站式平台 StreamX 来袭
  7. 位置偏差在马蜂窝推荐排序中的实践
  8. mac os平台使用python爬虫自动下载巨潮网络文件
  9. API:BUMO Keypair 指导
  10. 1+x证书Web前端开发中级理论考试(试卷1)