订阅发布模式是开发领域常见的设计模式,在我们的开发中简直无处不在。这次我们一起来揭开其并不神秘的面纱。
何为订阅发布模式
订阅发布模式顾名思义分为订阅和发布两个动作。其实不止代码,生活中也有很多订阅发布模式的例子。
以双十一李佳奇直播为例子,为了该死的折扣,我们在淘宝上搜索李佳奇直播间,进入页面发现现在并不是直播时间,这时候页面上会有个开播提醒按钮。
当我们点击开播提醒的时候,其实就是执行了一个订阅操作,向消息中心订阅直播通知。
当下一场直播时间到了,消息中心将接收到直播间的开播事件,接着向所有订阅该直播间的用户推送通知。也就是淘宝APP向你推送消息,“李佳奇直播开始啦”,这时候你再执行对应的操作,打开淘宝,美滋滋地观看直播购物。
订阅发布的代码实现
我们以面试中常见的 EventEmitter
为例子,来简单实现一个订阅发布中心
// 订阅发布中心:事件发射器 class EventEmitter { constructor () { this.listeners = {}; } // 订阅事件 addListener (event, cb) { if (this.listeners[event]) { this.listeners[event].push(cb) } else { this.listeners[event] = [].concat(cb) } } // 取消订阅 removeListener (event, cb) { const events = this.listeners[event]; if (events) { const idx = events.indexOf(cb); if (idx > -1) { events.splice(idx, 1) } } } // 通知:执行事件 emit (event) { const events = this.listeners[event]; if (events) { for (let i = 0, len = events.length; i < len; i++) { events[i](); } } } } const eventEmitter = new EventEmitter(); // 商家订阅 eventEmitter.addListener('李佳奇直播', () => { console.log('商家:直播开始啦 上货咯') }) // 用户2订阅 eventEmitter.addListener('李佳奇直播', () => { console.log('用户:直播开始啦 剁手啦') }) // 用户1订阅 eventEmitter.addListener('李佳奇直播', () => { console.log('用户:直播开始啦 剁手啦') }) // 发布 eventEmitter.emit('李佳奇直播') 复制代码
有没有一种似曾相识的感觉。是的,我们前端经常使用的事件监听 addEventListener
不就是基于订阅发布模式实现的么。
vue源码中订阅发布的实现
我们都知道,只要问及 vue 实现原理,是个人都能扯些 数据劫持
订阅发布模式
等词汇。那么 vue 到底是如何实现它们的呢?接下来我们继续卷
数据劫持
数据劫持
是 vue 实现数据响应式的提前,其原理是利用 Object.defineProperty
劫持数据的 setter
getter
函数
const observeObj = { name: 'xiao ming' }; // 例子:劫持对象的name属性 let myname = null; Object.defineProperty(observeObj, 'name', { configurable: true, // 可配置 enumerable: true, // 可枚举 get () { console.log('有人访问observeObj.name啦') // 给他返回个我喜欢的变量 return myname; }, set (newVal) { console.log('有人设置observeObj.name啦', newVal); myname = 'xiao' + val; } }) 复制代码
如此,我们便劫持了 observeObj
的 name
属性,这就是我们所说的 数据劫持
。当后面访问或者赋值 observeObj.name
都会访问我们定义的劫持函数 get
set
,简直无法无天。
有了这个 Object.defineProperty
,vue 就有了监听开发者修改数据的能力。所以我们常说 vue 的原理是数据劫持,也就是这么个回事。
多级对象的set get
我们再来学习学习 Object.defineProperty
,看看多级对象是如何触发 set get
的,不为其它,只为了解的深入一线。
let deep = {}; const observeObj = { deep }; Object.defineProperty(observeObj, 'deep', { get () { console.log('get deep') return deep }, set (newVal) { console.log('set deep') deep = newVal } }) Object.defineProperty(deep, 'name', { get () { console.log('get deep.name') return 'xiaoming'; }, set () { console.log('set deep.name') } }) observeObj.deep.name = 2; // get deep set deep.name 依次访问deep属性的get name属性的set observeObj.deep.name = 2; // get deep set deep.name 依次访问deep属性的get name属性的set observeObj.deep.name; // get deep get deep.name 依次访问deep属性的get name属性的get observeObj.deep = {}; // 访问deep的set observeObj.deep.name; // 访问deep的get 复制代码
通过上面的示例我们可以得出
- 当我们设置链式属性的时候,实际上是会依次访问链中属性的
get
及末尾属性的set
object.deep1.deep2.name = 'x' // deep1 get -> deep2 get -> name set 复制代码
- 我们设置对象的
get
set
函数的时候,其实也是设置指针指向地址的变量对象。当我们重新设置对象地址时,之前设置的访问器不再访问
let deep = {} Object.defineProperty(deep, 'age', { get () { console.log('get deep.age') return 1; }, set () { console.log('set deep.age') } }) deep.age = 2 // 访问set // 更改指向地址 deep = {} deep.age = 3 // 不再访问set 复制代码
- 尽管设置重复相同的值,也会访问
set
deep.age = 2 // 访问set deep.age = 2 // 访问set +1 deep.age = 2 // 访问set +1 复制代码
vue数据响应式模拟
有了前面订阅发布及数据劫持的储备知识,我们就来结合其两者来简单模拟下 vue 的数据响应式
首先先实现我们的订阅发布中心 Dep
// 存储全局订阅者 let targetWatch = null // 订阅发布中心 class Dep { constructor () { // 订阅者 this.subs = []; } // 订阅 addSub (sub) { this.subs.push(sub) } // 发布通知 notify () { const subs = this.subs; for (let i = 0, len = subs.length; i < len; i++) { // 通知订阅者 subs[i].update(); } } } 复制代码
接着实现订阅者 Watcher
class Watcher { constructor (expFn, cb) { // 回调函数 this.cb = cb; // 通过全局变量来标记当前订阅者 targetWatch = this; // expFn用于触发订阅操作 this.expFn = expFn; this.expFn(); targetWatch = null; } update () { this.cb(); } } 复制代码
再来看看订阅动作,这里得先做数据劫持,实现监测器 Observer
class Observer { constructor (data) { this.walk(data); } walk (obj) { // 遍历对象属性 const keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { // 为每个键进行劫持 this.defineReactive(obj, keys[i]) } } defineReactive (obj, key) { let val = obj[key]; // 为每个key实例化独享的Dep实例 const dep = new Dep(); // 若键为对象则需递归劫持 if (dep && typeof val === 'object') { new Observer(val) } // 数据劫持器 Object.defineProperty(obj, key, { get () { // 订阅发布中心触发订阅 dep添加当前订阅者 if (targetWatch) dep.addSub(targetWatch) return val; }, set (newVal) { if (val !== newVal) { val = newVal // 订阅发布中心触发通知 dep.notify(); } } }) } } 复制代码
从 Observer
的 defineReactive
实现中我们可以看到订阅 dep.addSub(targetWatch)
及发布 dep.notify()
。
至此我们实现了
- 订阅发布中心 Dep
- 订阅者 Watcher
- 监听者 Observer
现在我们再来看看如何利用上面已实现的内容来做到数据响应式
// 我们需要监听的数据 const data = { name: '', deep: { age: 0 } } // 为data添加监听 new Observe(data); // 添加订阅者 new Watcher(function expFn() { // 访问name属性 data.name; console.log('这里是订阅函数,订阅了data.name',) }, function cb() { console.log('数据更新啦,得重新渲染页面了', data.name) }) // 添加订阅者 new Watcher(function expFn() { // 访问deep属性 data.deep; console.log('这里是订阅函数,订阅了data.deep',) }, function cb() { console.log('数据更新啦,得重新渲染页面了', data.deep) }) // 更改数据 data.name = 'new' // 数据更新啦,得重新渲染页面了 new data.name = 'new2' // 数据更新啦,得重新渲染页面了 new2 data.name = 'new3' // 数据更新啦,得重新渲染页面了 new3 复制代码
可以看到,通过上面得代码,我们实现了数据得自动监听及订阅发布,我们再次梳理下这个流程
- 实现订阅发布中心 Dep
- 实现订阅者 Watcher
- 实现监听者 Observer
- 以监听目标 data 为参数实例化 Observer
- data 下的每个属性都被遍及递归,进行数据劫持
- 每个属性实例化一个订阅发布中心 dep
- 实例化订阅者 watcher
- watcher 的第一个参数为 expFn 访问函数,访问 data 的某个属性
- 实例化时在构造函数中将实例 watcher 赋值给全局 targetWatch 调用 expFn,将访问到 data 的某个属性如 name
- 在 name 的 get 函数中,此时 targetWatch 有值,则为该属性对应的 dep 添加订阅者 watcher
- 开发者手动修改数据如
data.name = new
,将访问到 name 属性的 set 函数,此时判断前后值不相同,则通知 dep 数据更新 - dep 在属性的 set 函数中收到数据更新的通知,遍历调用订阅者 watcher 的 update 方法
- 在 update 方法中调用 cb 收到数据更新及获取最新数据,以便完成下一步渲染等操作
以上便是 vue 中数据监听及发布订阅模式的简单实现,实际上写的比较粗糙,没有去兼容数组及对新值进行劫持监听等
但这不是重点,重点是我们能从其中明白 vue 的实现原理即可,后面将分析实际源码去了解整个 vue 的实现。
补充
之前我们学习了下多层级下的 Object.defineProperty
表现,那么在我们的 DEMO 中,它又是如何表现得呢
// 我们需要监听的数据 const data = { name: '', deep: { name: '' } } // 增加添加订阅者 new Watcher(function expFn() { // 访问deep.name属性 data.deep.name; console.log('这里是订阅函数,订阅了data.deep.name',) }, function cb() { console.log('数据更新啦,得重新渲染页面了', data.deep.name) }) // 更改数据 data.deep.name = 'new' // 数据更新啦,得重新渲染页面了 new data.deep = { name: 'new2' } // 得重新渲染页面了 new2 data.deep.name = 'new2' 复制代码
表现符合预期,修改 deep.name
时,订阅者收到更新通知。
将 data.deep
指向新地址也会通知,因为我们在 expFn
中链式访问了多个属性的 get,实际上会有多个属性的 dep
添加按订阅者 watcher
,所以不管修改 data.deep
和 data.deep.name
都会触发发布通知。
但是此时再次修改 data.deep.name
不再触发更新,因为我们订阅的 name 属性的 deep 对象已经发生实际改变。
总结
本篇文章简单介绍了订阅发布模式及数据劫持,及实现了 vue 中结合这两者实现数据响应式的例子。实际上写的比较多也比较乱,希望能将就将就看看。本篇文章是为了后面的 vue 源码学习打基础,后面将实际从源码的角度分析 vue 中数据响应式的实现。