什么是依赖收集?
在讨论依赖收集前,我们先来回顾下观察者模式。
观察者模式
一种实现一对多关系解耦的行为设计模式。
它主要涉及两个角色:观察目标、观察者。如图:
特点:观察者 要直接订阅 观察目标,观察目标 做出通知后,观察者 就要进行相应处理。
接着,我们看下观察者模式与依赖收集是怎样的关系。
依赖收集
依赖收集是 Vue.js 和 Mobx.js 核心的之一。 以Mobx.js举个例子,可以达到这样的效果:
const obsObject = observable({
A: 1,
B: 2,
C: 3
});
autoRun(() => {
console.log(obsObject.A + obsObject.C);
console.log(obsObject.C - obsObject.A);
});
obsObject.B = 4; // 什么都没有发生
obsObject.A = 5;
// --> 8 observe函数的回调触发了
// --> -2 observe函数的回调触发了
obsObject.C = 6;
// --> 11 observe函数的回调触发了
// --> 1 observe函数的回调触发了
其中最关键的函数在于 autoRun
,专业名词叫做依赖收集。
也就是当autoRun
用到了什么属性,就会和这个属性挂上钩,从此一旦这个属性发生了改变,就会触发回调。而没有用到的属性,无论怎样修改,它都不会触发回调。
由此引出依赖收集的定义:
依赖收集:通过自然地使用变量,来完成依赖的收集,当变量改变时,根据收集的依赖判断是否需要触发回调。
同时,也能看出依赖收集的场景,也是种一对多的方式,依赖的数据变更了,就一定要做出处理,所以观察者模式非常适用于解决依赖收集的问题。
依赖收集的实现
建议可先看下这篇文章,会便于后面的理解。
Sky.Gu:深入实践 ES6 Proxy & Reflectzhuanlan.zhihu.com尝试来实现一个依赖收集,首先使用Object.defineProperty
的实现方式。
Object.defineProperty实现方式
观察目标
首先假设 观察目标 是一个“英雄”。
const hero ={
name: '赵云',
hp: 3000,
sp: 150,
equipment: ['马', '矛']
};
可以看到观察目标中存在String
,Number
以及Array
这三种类型的属性。
使用Object.defineProperty对这三类属性进行观察,方法如下:
class Observable {
constructor(obj) {
return this.walk(obj);
}
walk(obj) {
const keys = Object.keys(obj);
keys.forEach((key) => {
this.defineReactive(obj, key, obj[key]);
})
return obj;
}
defineReactive(obj, key, val) {
if (Array.isArray(obj[key])) {
// Array添加push的钩子
Object.defineProperty(obj[key], 'push', {
value() {
this[this.length] = arguments[0];
}
})
Object.defineProperty(obj, key, {
get() {
return val;
}
})
} else {
Object.defineProperty(obj, key, {
get() {
return val;
},
set(newVal) {
val = newVal;
}
})
}
}
}
通过defineReactive
函数对数据对象“英雄”的各属性get
与set
设置了钩子,在get
中响应依赖收集,在set
中触发监听函数,至此该数据对象变得“可观察”。
现在,任何的读写操作“英雄”都会主动告诉我们,但也仅此而已,我们仍然不知道他是谁。
如果我们希望在修改英雄的hp或sp之后,英雄能够主动告知他的其他信息,这应该怎样才能办到呢?
观察者
Watcher(hero, 'health', () => {
return hero.hp > 2000 ? '强壮' : '良好';
});
定义了一个watcher作为“观察者”,它监听了hero的health属性。而health属性的值取决于hero.hp(当hero.hp发生变化时,hero.health也应该发生变化,前者是后者的依赖。我们把这个hero.health称为“计算属性”。
下面来构造这个“观察者”。首先,“观察者”接收三个参数,分别是观察目标、被监听的属性以及回调函数,回调函数返回一个该观察目标属性的值。
/**
* 当计算属性的值被更新时调用
* @param { Any } val 计算属性的值
*/
function onComputedUpdate (val) {
console.log(`英雄的健康状况是:${val}`);
}
/**
* 观察者
* @param { Object } obj 观察目标
* @param { String } key 观察目标的key
* @param { Function } cb 回调函数,返回“计算属性”的值
*/
function watcher (obj, key, cb) {
Object.defineProperty(obj, key, {
get () {
const val = cb();
onComputedUpdate(val);
return val;
},
set () {
console.error('计算属性无法被赋值!');
}
})
}
// 尝试使用
const hero = new Observable({
name: '赵云',
hp: 3000,
sp: 150,
equipment: ['马', '矛']
});
watcher(hero, 'health', () => {
return hero.hp > 2000 ? '强壮' : '良好';
});
hero.health;
hero.hp = 1000;
hero.health;
// -> 我的hp属性被读取了!
// -> 英雄的健康状况是:强壮
// -> 我的hp属性被修改了!
// -> 我的hp属性被读取了!
// -> 英雄的健康状况是:良好
一切都如我们所愿发生了,但细心的同学会发现上述代码中是通过手动读取hero.health
来获取“英雄”的健康状况,并不是他主动告诉我们的。如果希望让“英雄”能够在hp属性被修改后,第一时间主动发起通知,该如何实现?
依赖收集器
当观察目标的属性被读写时,会触发它的getter/setter方法。如果在观察目标的getter/setter里面,去执行观察者的onComputedUpdate()方法,就能够实现让观察目标主动发出通知的功能。
由于观察者的onComputedUpdate()方法需要接收回调函数的值作为参数,而观察目标中并没有这个回调函数,所以需要引申出一个依赖管理类dependenceManager
,这个类中管理了一个监听函数列表。
这个依赖管理类dependenceManager
被称为依赖收集器,实际作用是将观察目标与观察者连接起来,实现如下:
// dependenceManager类
class Dep {
constructor() {
this.deps = new Set(); // 监听函数列表
}
depend() {
if (Dep.target ) {
this.deps.add(Dep.target);
}
}
notify() {
this.deps.forEach((dep) => {
dep();
})
}
}
Dep.target = null; // 存放观察者的onComputedUpdate()方法
当一个“可观察”数据对象的属性值发生get
行为的时候,将需要监听的函数添加到dependenceManager
中对应的监听函数列表;
发生set
行为的时候,就会触发遍历dependenceManager
中对应的监听函数列表,并且执行。
实现
定义完依赖收集器,我们需要把观察者的onComputedUpdate()
方法赋值给Dep.target
:
/**
* 观察者
* @param { Object } obj 观察目标
* @param { String } key 观察目标的key
* @param { Function } cb 回调函数,返回“计算属性”的值
*/
function watcher (obj, key, cb) {
// 定义一个被动触发函数,当这个“被观测对象”的依赖更新时调用
const onDepUpdated = () => {
const val = cb();
onComputedUpdate(val);
}
Object.defineProperty(obj, key, {
get () {
Dep.target = onDepUpdated;
// 执行cb()的过程中会用到Dep.target,
// 当cb()执行完了就重置Dep.target为null
const val = cb();
Dep.target = null;
return val;
},
set () {
console.error('计算属性无法被赋值!')
}
})
}
在观察者内部定义了一个新的onDepUpdated()
方法,负责把观察者回调函数的值以及onComputedUpdate()
打包赋值给Dep.target
。
通过这样的操作,依赖收集器就获得了观察者的回调值以及onComputedUpdate()
方法。由于是全局变量,Dep.target
理所当然的能够被观察目标的getter/setter所使用。
然后修改观察目标中的defineReactive
方法,完成观察目标与观察者的连接。
defineReactive(obj, key, val) {
const dep = new Dep();
if (Array.isArray(obj[key])) {
// Array添加push的钩子
Object.defineProperty(obj[key], 'push', {
value() {
this[this.length] = arguments[0];
dep.notify(); // 触发观察者回调函数
}
})
Object.defineProperty(obj, key, {
get() {
dep.depend(); // 添加观察者回调函数
return val;
}
})
} else {
Object.defineProperty(obj, key, {
get() {
console.log(`我的${key}属性被读取了!`);
dep.depend(); // 添加观察者回调函数
return val;
},
set(newVal) {
console.log(`我的${key}属性被修改了!`);
val = newVal;
dep.notify(); // 触发观察者回调函数
}
})
}
}
下面尝试使用该实现:
const hero = new Observable({
name: '赵云',
hp: 3000,
sp: 150,
equipment: ['马', '矛']
});
watcher(hero, 'health', () => {
return hero.hp > 2000 ? '强壮' : '良好';
});
console.log(`英雄初始健康状况:${hero.health}`);
hero.hp = 1000;
// -> 我的hp属性被读取了!
// -> 英雄初始健康状况:强壮
// -> 我的hp属性被修改了!
// -> 我的hp属性被读取了!
// -> 英雄的健康状况是:良好
可以发现,现在“英雄”的任何属性变更都会主动被触发,从而实现了最基本的依赖收集功能。
优化
上述代码示例中,依赖收集器与观察目标完成了模块化,下面将对观察者也进行一次模块优化,并通过更丰富的使用来展示依赖收集的强大功能。
class Dep {
constructor() {
this.deps = new Set();
}
depend() {
if (Dep.target) {
this.deps.add(Dep.target);
}
}
notify() {
this.deps.forEach((dep) => {
dep();
})
}
}
Dep.target = null;
class Observable {
constructor(obj) {
return this.walk(obj);
}
walk(obj) {
const keys = Object.keys(obj);
keys.forEach((key) => {
this.defineReactive(obj, key, obj[key]);
})
return obj;
}
defineReactive(obj, key, val) {
const dep = new Dep();
if (Array.isArray(obj[key])) {
// Array添加push的钩子
Object.defineProperty(obj[key], 'push', {
value() {
this[this.length] = arguments[0];
dep.notify();
}
})
Object.defineProperty(obj, key, {
get() {
dep.depend();
return val;
}
})
} else {
Object.defineProperty(obj, key, {
get() {
dep.depend();
return val;
},
set(newVal) {
val = newVal;
dep.notify();
}
})
}
}
}
class Watcher {
constructor(obj, key, cb, onComputedUpdate) {
this.obj = obj;
this.key = key;
this.cb = cb;
this.onComputedUpdate = onComputedUpdate;
return this.defineComputed();
}
defineComputed() {
const self = this;
const onDepUpdated = () => {
const val = self.cb();
this.onComputedUpdate(val);
}
Object.defineProperty(self.obj, self.key, {
get() {
Dep.target = onDepUpdated;
const val = self.cb();
Dep.target = null;
return val;
},
set() {
console.error('计算属性无法被赋值!');
}
})
}
}
const hero = new Observable({
name: '赵云',
hp: 3000,
sp: 150,
equipment: ['马', '矛']
});
new Watcher(hero, 'health', () => {
return hero.hp > 2000 ? '强壮' : '良好';
}, (val) => {
console.log(`英雄的健康状况是:${val}`);
});
new Watcher(hero, 'job', () => {
return hero.sp < 3000 ? '武将' : '谋士'
}, (val) => {
console.log(`英雄的职业是:${val}`);
});
new Watcher(hero, 'weapon', () => {
return hero.equipment;
}, (val) => {
console.log(`英雄的武器是:${val}`);
});
console.log(`英雄初始健康状况:${hero.health}`);
// -> 英雄初始健康状况:强壮
console.log(`英雄初始职业:${hero.job}`);
// -> 英雄初始职业:武将
console.log(`英雄初始武器:${hero.weapon}`);
// -> 英雄初始武器:马,矛
hero.name = '诸葛亮';
console.log(`英雄的名字是:${hero.name}`);
// -> 英雄的名字是:诸葛亮
hero.hp = 1000;
// -> 英雄的健康状况是:良好
hero.sp = 4000;
// -> 英雄的职业是:谋士
hero.equipment.push('羽扇');
// -> 英雄的武器是:马,矛,羽扇
我们将“英雄”作为观察目标,分别新建了3个观察者(health、job与weapon),这3个观察者分别依赖观察目标的不同属性的变动而出发响应的回调函数,并在观察目标的属性变更时,主动通知观察者进行处理。
Proxy实现方式
在了解了依赖收集的实现方式后,再尝试使用Proxy的方式实现依赖收集。
主要的改动在于采用Proxy
替代Object.defineProperty
。
先来看下观察目标Observable
类的变动
class Observable {
constructor(obj) {
return this._createProxy(obj);
}
_createProxy(obj) {
const dep = new Dep();
const handler = {
get(target, key, receiver) {
// console.log(`我的${key}属性被读取了!`);
// 加入观察者队列
dep.depend(key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log(`我的${key}属性被修改为${value}了!`);
//内部调用对应的 Reflect 方法
const result = Reflect.set(target, key, value, receiver);
//执行观察者队列
dep.notify(key);
return result;
}
}
return new Proxy(obj, handler);
};
}
内部封装了handler
方法,不再需要遍历数据对象的属性即可实现“可观察”,最后返回一个Proxy
对象作为观察目标。
再来看下观察者Watcher
类的改动
class Watcher {
constructor(obj, key, callback, onComputedUpdate) {
this.obj = obj;
this.key = key;
this.callback = callback;
this.onComputedUpdate = onComputedUpdate;
return this._defineComputed();
}
_defineComputed() {
const self = this
const onDepUpdated = () => {
const val = self.callback();
this.onComputedUpdate(val);
}
const handler = {
get(target, key, receiver) {
console.log(`我的${key}属性被读取了!`);
Dep.target = onDepUpdated;
const val = self.callback();
Dep.target = null;
return val
},
set() {
console.error('计算属性无法被赋值!')
}
}
return new Proxy(this.obj, handler);
}
}
同样,内部封装了handler
方法,替换了原Object.defineProperty
的getter实现。
最后看下依赖收集器的变动
class Dep {
constructor() {
this.deps = new Set();
}
depend(key) {
if (Dep.target) {
this.deps.add({
key,
target:Dep.target
});
}
}
notify(key) {
this.deps.forEach((dep) => {
if(dep.key===key){
dep.target();
}
})
}
}
Dep.target = null;
由于使用Proxy不再遍历数据对象的属性,为了避免与变更属性不相关的回调函数被执行,需要在添加与触发方法中传入key作为判别参数。
最后封装一个autoRun函数来作为依赖收集的入口
const autoRun = function (handler) {
handler();
};
让我们使用相同的例子来看下实现结果:
const heroObs = new Observable(hero);
const heroHealth = new Watcher(heroObs, 'health', () => {
return heroObs.hp > 2000 ? '强壮' : '良好';
}, (val) => {
console.log(`英雄的健康状况是:${val}`);
});
const heroJob = new Watcher(heroObs, 'job', () => {
return heroObs.sp < 3000 ? '武将' : '谋士'
}, (val) => {
console.log(`英雄的职业是:${val}`);
});
autoRun(() => {
console.log(`英雄初始健康状况:${heroHealth.health}`);
// -> 我的health属性被读取了!
// -> 英雄初始健康状况:强壮
console.log(`英雄初始职业:${heroJob.job}`);
// -> 我的job属性被读取了!
// -> 英雄初始职业:武将
});
heroObs.name = '诸葛亮';
// -> 我的name属性被修改为诸葛亮了!
console.log(`英雄的名字是:${heroObs.name}`);
// -> 英雄的名字是:诸葛亮
heroObs.hp = 5000;
// -> 我的hp属性被修改为5000了!
// -> 英雄的健康状况是:强壮
heroObs.hp = 1000;
// -> 我的hp属性被修改为1000了!
// -> 英雄的健康状况是:良好
heroObs.sp = 4000;
// -> 我的sp属性被修改为4000了!
// -> 英雄的职业是:谋士
const heroObs2 = new Observable(hero.equipment);
const heroWeapon = new Watcher(heroObs2, 'weapon', () => {
return [...heroObs2];
}, (val) => {
console.log(`英雄的武器是:${val}`);
})
autoRun(() => {
console.log(`英雄初始装备:${heroWeapon.weapon}`);
// -> 我的weapon属性被读取了!
// -> 英雄初始装备:马,矛
});
heroObs2.push('羽扇');
// -> 我的2属性被修改为羽扇了!
// -> 我的length属性被修改为3了!
// -> 英雄的武器是:马,矛,羽扇
可以发现实现结果完全一致,细心的你一定发现当我们执行heroObs2.push('羽扇');
后,打印出了2条属性被修改的日志,原因是Array
被插入数据时被修改的出了内部数据外,数组的长度也依次被修改的缘故导致的。(Object.defineProperty
实现时也同样是这样)
依赖收集的优化
以上已经将依赖收集的实现方式做了基础的说明与实现,对代码性能有要求的你会发现在Proxy
实现方式的示例中存在性能上的问题。
heroObs.hp = 5000;
// -> 我的hp属性被修改为5000了!
// -> 英雄的健康状况是:强壮
heroObs.hp = 1000;
// -> 我的hp属性被修改为1000了!
// -> 英雄的健康状况是:良好
当连续对观察目标的同一个属性进行操作时,会多次执行回调函数,在实际使用中会造成性能损耗或重复刷新页面等问题。
异步优化
针对该现象,很容易想到使用异步编程中的宏任务与微任务的方式去解决,不了解的同学可以看下
Sky.Gu:深入浅出JavaScript异步编程zhuanlan.zhihu.com这篇文章。
每次修改观察目标的属性均会触发一次观察者的回调,那如果采用await将每次触发的观察者回调放入微任务列表中,在宏任务执行完之后再去执行是不是就可以解决多次刷新的问题?
修改观察者Watcher
类
class Watcher {
constructor(obj, key, callback, onComputedUpdate) {
this.obj = obj;
this.key = key;
this.callback = callback;
this.onComputedUpdate = onComputedUpdate;
this.idx = 0;
return this._defineComputed();
}
_defineComputed() {
const self = this;
const onDepUpdated = async (key) => {
await console.log('wait'); // 提示后续函数进入微任务序列
const val = self.callback();
this.onComputedUpdate(val);
}
const handler = {
get(target, key, receiver) {
console.log(`我的${key}属性被读取了!`);
Dep.target = () => { onDepUpdated(key) };
const val = self.callback();
Dep.target = null;
return val;
},
set() {
console.error('计算属性无法被赋值!')
}
}
return new Proxy(this.obj, handler);
}
}
尝试使用相同示例去运行:
const heroObs = new Observable(hero);
const heroHealth = new Watcher(heroObs, 'health', () => {
return heroObs.hp > 2000 ? '强壮' : '良好';
}, (val) => {
console.log(`英雄的健康状况是:${val}`);
});
const heroJob = new Watcher(heroObs, 'job', () => {
return heroObs.sp < 3000 ? '武将' : '谋士'
}, (val) => {
console.log(`英雄的职业是:${val}`);
});
autoRun(() => {
console.log(`英雄初始健康状况:${heroHealth.health}`);
console.log(`英雄初始职业:${heroJob.job}`);
});
heroObs.name = '诸葛亮';
console.log(`英雄的名字是:${heroObs.name}`);
heroObs.hp = 5000;
heroObs.hp = 1000;
heroObs.sp = 4000;
// 控制台输出序列为:
// -> 我的health属性被读取了!
// -> 英雄初始健康状况:良好
// -> 我的job属性被读取了!
// -> 英雄初始职业:武将
// -> 我的name属性被修改为诸葛亮了!
// -> 英雄的名字是:诸葛亮
// -> 我的hp属性被修改为5000了!
// -> wait
// -> 我的hp属性被修改为1000了!
// -> wait
// -> 我的sp属性被修改为4000了!
// -> wait
// -> 英雄的健康状况是:良好
// -> 英雄的健康状况是:良好
// -> 英雄的职业是:谋士
会发现执行序列被优化了,当宏任务运行结束后,再去执行我们希望延后执行的观察者回调函数。
但也能明显发现,两次修改heroObs.hp
导致英雄的健康状况是:良好
输出了两次,问题并没有被解决。
接着继续优化,采用的是一个全局Set来处理已经处理过的相同回调,避免重复运行,并再次使用一个异步函数去解决变更周期结束后Set的重置问题。
const autoRun = function (handler) {
handler();
};
class Dep {
constructor() {
this.deps = new Set();
}
depend(key) {
if (Dep.target) {
this.deps.add({
key,
target: Dep.target
});
}
}
async notify(key) {
this.deps.forEach((dep) => {
if (dep.key === key && dep.target) {
dep.target();
}
});
await Dep.computeArray.clear(); // 微任务执行清空Set操作
}
}
Dep.target = null;
Dep.computeArray = new Set();
class Observable {
constructor(obj) {
return this._createProxy(obj);
}
_createProxy(obj) {
const dep = new Dep();
const handler = {
get(target, key, receiver) {
// console.log(`我的${key}属性被读取了!`);
// 加入观察者队列
dep.depend(key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log(`我的${key}属性被修改为${value}了!`);
//内部调用对应的 Reflect 方法
const result = Reflect.set(target, key, value, receiver);
//执行观察者队列
dep.notify(key);
return result;
}
}
return new Proxy(obj, handler);
};
}
class Watcher {
constructor(obj, key, callback, onComputedUpdate) {
this.obj = obj;
this.key = key;
this.callback = callback;
this.onComputedUpdate = onComputedUpdate;
this.idx = 0;
return this._defineComputed();
}
_defineComputed() {
const self = this;
const onDepUpdated = async (key) => {
await console.log('wait');
// 判断是否已执行过相同回调函数
if (!Dep.computeArray.has(key)) {
Dep.computeArray.add(key);
const val = self.callback();
this.onComputedUpdate(val);
}
}
const handler = {
get(target, key, receiver) {
console.log(`我的${key}属性被读取了!`);
Dep.target = () => { onDepUpdated(key) };
const val = self.callback();
Dep.target = null;
return val;
},
set() {
console.error('计算属性无法被赋值!')
}
}
return new Proxy(this.obj, handler);
}
}
再次采用上面的示例,控制台输出序列符合预期。
// -> 我的health属性被读取了!
// -> 英雄初始健康状况:良好
// -> 我的job属性被读取了!
// -> 英雄初始职业:武将
// -> 我的name属性被修改为诸葛亮了!
// -> 英雄的名字是:诸葛亮
// -> 我的hp属性被修改为5000了!
// -> wait
// -> 我的hp属性被修改为1000了!
// -> wait
// -> 我的sp属性被修改为4000了!
// -> wait
// -> 英雄的健康状况是:良好
// -> 英雄的职业是:谋士
小结
- 采用
Proxy
/Object.defineProperty
将数据对象转变成可观察的观察目标; - 观察者通过函数表达其属性与观察目标属性间的关系,并可给定一个回调函数处理关系结果;
- 通过依赖收集器完成观察对象与观察者的连接;
- 以autoRun函数作为入口,对有依赖的观察者进行收集;
- 观察目标属性的任何变更都将自动告知观察者并触发相应回调函数。
其中依赖收集的关系如下图:
相信这样循序渐进的方式,可实战的代码能将依赖收集这个概念讲清楚,讲透彻。
日后的工作实战中可使用到的地方也非常多,希望这篇文章对大家有帮助,如果发现有任何错漏的地方也欢迎指出。
如果觉得文章能帮助你理解一些前端知识点,请点赞、关注本专栏,也可关注微信公众号。