数据劫持
Vue2的响应式原理的根本: Object.defineProperty 而由于我们需要一个变量来赋值getter和setter,通常会使用闭包。 于是引出第一个部分 defineReactive函数
//import Dep from './dep.js';
import observe from './observe.js';
export default function defineReactive(data, key, val) {
//const dep = new Dep()
// console.log('我是defineReactive', key);
if (arguments.length == 2) {
val = data[key];
}
// 子元素要进行observe,至此形成了递归。这个递归不是函数自己调用自己,而是多个函数、类循环调用
let childOb = observe(val);
Object.defineProperty(data, key, {
// 可枚举
enumerable: true,
// 可以被配置,比如可以被delete
configurable: true,
// getter
get() {
console.log('你试图访问' + key + '属性');
// 如果现在处于依赖收集阶段
return val;
},
// setter
set(newValue) {
console.log('你试图改变' + key + '属性', newValue);
if (val === newValue) {
return;
}
val = newValue;
// 当设置了新值,这个新值也要被observe
childOb = observe(newValue);
//发布订阅模式,通知dep
//dep.notify()
}
});
};
这个函数可以劫持到对象里的一个属性,那么要劫持多个属性,应当怎么办呢?于是引出了第二个部分,Observer类。
class Observer {
constructor(value) {
this.value = value
this.walk()
}
walk() {
Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
}
}
const obj = { a: 1, b: 2 }
new Observer(obj)
我们知道,劫持一个对象的多个属性,要遍历到每一层的属性,而上面的Obeserver类只能遍历第一层的属性。 为了遍历深层的属性,我们就需要用到递归遍历,这就引出了observe方法。
import Observer from './Observer.js';
export default function (value) {
// 如果value不是对象,什么都不做
if (typeof value != 'object') return;
// 定义ob
var ob;
if (typeof value.__ob__ !== 'undefined') {
ob = value.__ob__;
} else {
ob = new Observer(value);
}
return ob;
}
可以看到 observe方法内接收了value,然后以此创建一个observer类实例,在observer类实例中,又会遍历属性调用defineReactive方法(进入下一层),而在defineReactive方法内,又会调用observe方法。其逻辑如下图:
执行observe(obj)
├── new Observer(obj),并执行this.walk()遍历obj的属性,执行defineReactive()
├── defineReactive(obj, a)
├── 执行observe(obj.a) 发现obj.a不是对象,直接返回
├── 执行defineReactive(obj, a) 的剩余代码
├── defineReactive(obj, b)
├── 执行observe(obj.b) 发现obj.b是对象
├── 执行 new Observer(obj.b),遍历obj.b的属性,执行defineReactive()
├── 执行defineReactive(obj.b, c)
├── 执行observe(obj.b.c) 发现obj.b.c不是对象,直接返回
├── 执行defineReactive(obj.b, c)的剩余代码
├── 执行defineReactive(obj, b)的剩余代码
代码执行结束
到目前为止,我们就完成了对象的递归劫持,但是数组是无法劫持到的,我们需要方法劫持数组,正因为我们可以通过Array原型上的方法来改变数组的内容,所以ojbect那种通过getter/setter的实现方式就行不通了。vue2采用的方法是,创建一个arrayMethods对象,让其继承Array.prototype,方便调用原型链上的方法,然后重写以下几个方法:push, pop, shift, unshift, sort, reverse, splice, 然后每次调用时,都会执行这个对象里的方法。我们可以将这个对象理解成一个拦截器,覆盖Array.prototype
//array.js
import def from "./def";
const arrayPrototype = Array.prototype;
// 以Array.prototype为原型创建arrayMethod
export const arrayMethods = Object.create(arrayPrototype);
// 要被改写的7个数组方法
const methodsNeedChange = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];
// 批量操作这些方法
methodsNeedChange.forEach((methodName) => {
// 备份原来的方法
const original = arrayPrototype[methodName];
// 定义新的方法
def(
arrayMethods,
methodName,
function () {
console.log("array数据已经被劫持");
// 恢复原来的功能(数组方法)
const result = original.apply(this, arguments);
// 把类数组对象变成数组
const args = [...arguments];
// 把这个数组身上的__ob__取出来
// 在拦截器中获取Observer的实例
const ob = this.__ob__;
// 有三种方法 push、unshift、splice能插入新项,要劫持(侦测)这些数据(插入新项)
let inserted = [];
switch (methodName) {
case "push":
case "unshift":
inserted = args;
break;
case "splice":
inserted = args.slice(2);
break;
}
// 查看有没有新插入的项inserted,有的话就劫持
if (inserted) {
ob.observeArray(inserted);
}
return result;
},
false
);
});
Observer.js
__ob__的作用可以用来标记当前value是否已经被Observer转换成了响应式数据了
而且可以通过value.__ob__来访问Observer的实例
import def from "./def";
import defineReactive from "./defineReactive";
import observe from "./observe";
import {arrayMethods} from './array'
/**
* 将一个正常的object转换为每个层级的属性都是响应式(可以被侦测)的object
* Observer 类会附加到每一个被侦测的object上
* 一旦被附加,Observer会将object所有属性转换成getter/setter的形式
* 来收集属性的依赖,并且当属性发生变化时会通知这些依赖
*/
export default class Observer {
// 构造器
constructor(value) {
// 给实例添加__ob__属性,值是当前Observer的实例,不可枚举
def(value, "__ob__", this, false);
// __ob__的作用可以用来标记当前value是否已经被Observer转换成了响应式数据了;而且可以通过value.__ob__来访问Observer的实例
// console.log("Observer构造器", value);
// 判断是数组还是对象
if (Array.isArray(value)) {
// 是数组,就将这个数组的原型指向arrayMethods
Object.setPrototypeOf(value, arrayMethods);
// 早期实现是这样
// value.__proto__ = arrayMethods;
// observe数组
this.observeArray(value);
} else {
this.walk(value);
}
}
// 对象的遍历方式 遍历value的每一个key
walk(value) {
for (let key in value) {
defineReactive(value, key);
}
}
// 数组的遍历方式
observeArray(arr) {
for (let i = 0, l = arr.length; i < l; i++) {
// 逐项进行observe
observe(arr[i]);
}
}
}
有些同学可能会想,只要在setter
中调用一下渲染函数来重新渲染页面,不就能完成在数据变化时更新页面了吗?确实可以,但是这样做的代价就是:任何一个数据的变化,都会导致这个页面的重新渲染,代价未免太大了吧。我们想做的效果是:数据变化时,只更新与这个数据有关的DOM
结构,那就涉及到下文的内容了:依赖
收集依赖和派发更新
链接订阅发布模式,占个坑
在Vue中,我们定义一个Watcher类,这个类的作用是:每个Watcher实例订阅一个或多个数据,这些数据也被称为watcher的依赖,当依赖发生变化,watcher实例会调用依赖对应的回调函数,比如更新页面操作。
class Watcher {
constructor(data, expression, cb) {
// data: 数据对象,如obj
// expression:表达式,如b.c,根据data和expression就可以获取watcher依赖的数据
// cb:依赖变化时触发的回调
this.data = data
this.expression = expression
this.cb = cb
// 初始化watcher实例时订阅数据
this.value = this.get()
}
get() {
const value = parsePath(this.data, this.expression)
return value
}
// 当收到数据变化的消息时执行该方法,从而调用cb
update() {
this.value = parsePath(this.data, this.expression) // 对存储的数据进行更新
cb()
}
}
function parsePath(obj, expression) {
const segments = expression.split('.')
for (let key of segments) {
if (!obj) return
obj = obj[key]
}
return obj
}
由发布订阅模式我们可以推出,我们还需要在每个数据上建立一个数组,存放watcher,我们可以通过闭包在defineReactive中建立一个数组,这样每个数据就能有独立的存放watcher的数组,我们将这个数组命名为dep。
依赖收集
在Watcher类中,在定义初始化watcher实例时我们会调用this.get方法,这个方法会访问数据,也就是说这个get方法会触发getter,而我们当前的需求是将watcher存入到dep数组中,显然在getter中进行这一操作是没问题的。同时,我们也可以将Dep抽象为一个类。基于上述,我们就是在getter中把watcher实例存放到dep数组中,那么怎么获取watcher实例呢?如果我们给window的某个属性绑定上watcher实例,试想,有一个对象obj: { a: 1, b: 2 }
我们先实例化了一个watcher1
,watcher1
依赖obj.a
,那么window.target
就是watcher1
。之后我们访问了obj.b
,会发生什么呢?访问obj.b
会触发obj.b
的getter
,getter
会调用dep.depend()
,那么obj.b
的dep
就会收集window.target
, 也就是watcher1
,这就导致watcher1
依赖了obj.b
,但事实并非如此。为解决这个问题,我们做如下修改:
// Watcher的get方法
get() {
window.target = this
const value = parsePath(this.data, this.expression)
window.target = null // 新增,求值完毕后重置window.target
return value
}
// Dep的depend方法
depend() {
if (Dep.target) { // 新增
this.addSub(Dep.target)
}
}
想一个这样的场景:我们有两个嵌套的父子组件,渲染父组件时会新建一个父组件的watcher
,渲染过程中发现还有子组件,就会开始渲染子组件,也会新建一个子组件的watcher
。在我们的实现中,新建父组件watcher
时,window.target
会指向父组件watcher
,之后新建子组件watcher
,window.target
将被子组件watcher
覆盖,子组件渲染完毕,回到父组件watcher
时,window.target
变成了null
,这就会出现问题,因此,我们用一个栈结构来保存watcher
。
const targetStack = []
function pushTarget(_target) {
targetStack.push(window.target)
window.target = _target
}
function popTarget() {
window.target = targetStack.pop()
}
get() {
pushTarget(this) // 修改
const value = parsePath(this.data, this.expression)
popTarget() // 修改
return value
}
总代码
// 调用该方法来检测数据
function observe(data) {
if (typeof data !== 'object') return
new Observer(data)
}
class Observer {
constructor(value) {
this.value = value
this.walk()
}
walk() {
Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
}
}
// 数据拦截
function defineReactive(data, key, value = data[key]) {
const dep = new Dep()
observe(value)
Object.defineProperty(data, key, {
get: function reactiveGetter() {
dep.depend()
return value
},
set: function reactiveSetter(newValue) {
if (newValue === value) return
value = newValue
observe(newValue)
dep.notify()
}
})
}
// 依赖
class Dep {
constructor() {
this.subs = []
}
depend() {
if (Dep.target) {
this.addSub(Dep.target)
}
}
notify() {
const subs = [...this.subs]
subs.forEach((s) => s.update())
}
addSub(sub) {
this.subs.push(sub)
}
}
Dep.target = null
const TargetStack = []
function pushTarget(_target) {
TargetStack.push(Dep.target)
Dep.target = _target
}
function popTarget() {
Dep.target = TargetStack.pop()
}
// watcher
class Watcher {
constructor(data, expression, cb) {
this.data = data
this.expression = expression
this.cb = cb
this.value = this.get()
}
get() {
pushTarget(this)
const value = parsePath(this.data, this.expression)
popTarget()
return value
}
update() {
const oldValue = this.value
this.value = parsePath(this.data, this.expression)
this.cb.call(this.data, this.value, oldValue)
}
}
// 工具函数
function parsePath(obj, expression) {
const segments = expression.split('.')
for (let key of segments) {
if (!obj) return
obj = obj[key]
}
return obj
}
// for test
let obj = {
a: 1,
b: {
m: {
n: 4
}
}
}
observe(obj)
let w1 = new Watcher(obj, 'a', (val, oldVal) => {
console.log(`obj.a 从 ${oldVal}(oldVal) 变成了 ${val}(newVal)`)
})
重新总结一下
- 首先是数据劫持部分,数据劫持首先最核心的是
object.defineProperty
,通过这个函数可以完成单个对象属性的劫持,然后定义了Observer类,完成对象属性遍历劫持,再通过定义observe函数,判断传入的是否是对象,是则继续初始化observer类遍历。 - 然后是依赖收集和派发更新部分,我们定义一个Watcher类,每个watcher实例订阅一个或多个数据,并保存数据变化时触发的回调。每一个数据应当有一个数组,存放订阅该数据的watcher,我们定义这个数组为
dep
, 由于我们劫持了数据,而且watcher实例会在初始化时进行订阅数据,那么就会触发数据的getter,所以我们在getter中完成dep对watcher的收集。那么在数据更新时,我们就可以在数据的setter里遍历dep数组,调用每个watcher的回调函数,也就是通知视图层进行渲染,实现通知订阅者。
关于对数组的处理部分,我们定义了一个代理原型,在这个代理原型上,我们重写了数组七个方法。原因是obj.defineProperty是没有能力对超出原数组长度的元素进行劫持的,同时对数组每个元素进行劫持性能消耗又是巨大的,所以我们只在这七个方法上对数组元素进行劫持,同时,我们需要对如push、unshift这些新增的元素进行劫持。