前言
在上篇分析-Vue-的观察者模式(上)中,简单实现了“手动”版观察者模式,而在本篇中我们继续沿着该思路往下探索,看如何实现“自动”版的观察者。
原文地址:我的博客 ,服务器正在努力搭建,先用 github.io 顶一下
完善的观察者
紧接着上文的思路,因为我们只是实现了 Dep 依赖(管家),却还没有对平台的观察处理方式优化,因此我们需要将此步骤给完善起来。
首先,要实现发布者更新内容后自动触发订阅事件,那么我们需要的是对发布者内容改变的监听,ES5 中恰好有这么一种方法能满足我们的需求,那就是 Object.defineProperty(...)。其语法很简单,我们此处仅仅需要拦截 get 和 set 方法,大致写法为:
const data = {
name: 'test'
};
Object.defineProperty(data, name, {
get() {
return data.name;
},
set(newVal) {
// 调用 setter 方法,不可直接赋值,否则会死循环
// 错误写法 obj[key] = newVal
// 可查看 Vue 中的 setter 获取
}
})
既然有了能够观测发布者的方法,那么对于对象变更后的监听就能自动化处理了,那么先前手动触发 dep.depend() 和 dep.notify() 函数的操作就能整合入 Object.defineProperty 内了。
延续先前的逻辑,dep.notify() 方法执行都得在对象值变动之后,因此我们可以将此方法放入 set 函数内;而 dep.depend() 方法主要是将订阅者事件存储入自身的事件列表中,因此该方法执行一次就行,之后每次更新都触发 notify 来依次执行注册的函数即可。因此,在考虑只是传入简单对象的情况时,这阶段的代码思路大致是:
// (私人管家)依赖收集
class Dep {
constructor() {
this.subscriberList = [];
}
// 添加订阅方法
depend() {
// 此处添加对应订阅方法
}
// 发布者更新消息
notify() {
this.subscriberList.forEach(sub => {
// 依次处理订阅的方法
});
}
}
// (发布者和管家的联系方式)监听器
function observer(data = {}) {
Object.keys(data).forEach(key => {
let val = data[key];
const dep = new Dep();
Object.defineProperty(data, key, {
get() {
// get 时添加依赖,当有目标的时候才添加
if (target) {
dep.depend();
}
return val;
},
set(newVal) {
if (newVal === val) {
return;
}
// set 时触发更新
// 此处用 setter 做变动,此处偷懒一下,不要直接赋值,会反复触发 set
val = newVal;
dep.notify();
}
})
});
}
那么现在问题又有了。目前代码实现至此我们仅仅只是将发布者与其 Dep 依赖进行了关联,那么观察者又该如何将其获订阅者的事件与 Dep 存储的事件列表进行关联呢?从订阅者角度分析一下:
- 订阅者决定好要订阅的内容(订阅者的函数);
- 订阅者在对应的观察者平台上注册事件,绑定发布者信息;
- 观察者先联系到 Dep 管家,将订阅事件存放至管家处;
- 待管家拿到了发布者新情报,则响应所有存储好的订阅者的订阅事件了。
思路逐渐清晰,那么这么个流程我们可以简化为如下代码:
target = null;
class Dep {
constructor() {
this.sub = [];
}
depend() {
if (target && !this.sub.includes(target)) {
this.sub.push(target);
}
}
notify() {
this.sub.forEach(cb => cb());
}
}
function watcher(callback) {
target = callback;
// 此处触发一下监听对象的 get 方法,将 callback 方法加入 Dep 依赖的订阅队列中
// 然后清除掉 target,防止重复注册
target = null;
}
watcher(() => {
// 此处是订阅事件
})
这样我们就讲观察者的订阅方法和 Dep 依赖给绑定上了。考虑到 watcher 的通用性,这里写了一个加强版的 demo,如下:
let target = null;
// (私人管家)依赖收集
class Dep {
constructor() {
this.subscriberList = [];
}
// 将当前的 watcher 加入 dep 中
addSub(watcher) {
this.subscriberList.push(watcher)
}
// 添加订阅方法
depend() {
if (target) {
target.addDep(this);
}
}
// 发布者更新消息,触发所有的订阅方法
notify() {
this.subscriberList.forEach(sub => {
sub.update();
});
}
}
// (平台)观察者
class Watcher {
constructor(data = {}, key = '', cb = () => {}) {
this.cb = cb;
this._data = data;
this.key = key;
target = this;
// 触发 getter,存储本 watcher
this.value = data[key];
// 防止反复触发
target = null;
}
addDep(dep) {
dep.addSub(this);
}
update() {
const newVal = this._data[this.key];
this.value = newVal;
this.cb(newVal);
}
}
// (发布者和管家的联系方式)监听器
function observer(data = {}) {
Object.keys(data).forEach(key => {
let val = data[key];
const dep = new Dep();
Object.defineProperty(data, key, {
get() {
if (target) {
// get 时添加依赖
dep.depend();
}
return val;
},
set(newVal) {
if (newVal === val) {
return;
}
// set 时触发更新
val = newVal;
dep.notify();
}
})
});
}
// 发布者
const publisher = {
bookName: 'book',
bookContent: 'hello world'
}
// 管家开始观测发布者
observer(publisher);
// 订阅者在平台上订阅发布者的部分信息
new Watcher(publisher, 'bookName', name => {
console.log(`new book name is ${name}`);
});
new Watcher(publisher, 'bookContent', content => {
console.log(`new book content is ${content}`);
});
// 发布者发布信息
publisher.bookName = 'new book';
publisher.bookContent = 'new content';
Vue 观察者的实现
在看源码之前,首先让我们看看 Vue 的实现原理图:

先仅考虑 data 部分的观察者模式,我们可以看到大致步骤为:
- 为发布者 data 做数据监听处理;
- 为劫持方法添加 Dep 依赖收集,
get存储 target 目标,set 设置 target 目标; - 依赖收集触发后,触发 Watch 观察者,通知订阅者改变。
因为流程图中只是详细展示了响应式部分的变动,为了便于大家更清楚的理解这部分的流程,所以这里先解释一下 Vue 中完整的流程:
- 页面开始渲染,对 data 进行依赖收集;
- 依赖收集完毕,开始监测订阅者的方法,并存储原值方便变更时的对比;
- 因为依赖收集完毕,要存储原值则触发了 get 事件,此时添加 dep 依赖,将对应生成的 watcher 存储入依赖列表内
- 页面渲染完成,用户触发页面某些点击事件(比如按钮);
- 点击事件绑定了某个 methods 方法,该 methods 方法使得 data 部分内容更新了;
- 因为已经对 data 进行了依赖收集,触发了对应的 set 方法;
- set 方法对比新旧值发现值更新了,此时重新收集依赖新的值(特别是对象的情况),然后通知它的私有管家 Dep 进行更新;
- Dep 依赖开始遍历其所存储的数组,为该发布者的订阅者们发送消息,触发订阅事件。
- 各订阅者事件触发 update 更新,此时便流转到 render 渲染,之后页面就更新啦!
为了方便大家理解源码,这里手动实现了一个简单的 observer 模式,基本是按着源码的思路一点点撸出来的,注释很全,方便大家理解!
源码地址
所有的 demo 都写入仓库了,仓库地址为:传送门