一知半解最可怕
/**
* @description 订阅发布通知
*/
export class SubscriptionPublish {
private eventMap: Record<string, ((params: any) => any)[]>;
constructor() {
this.eventMap = {};
}
on(key: string, handler: (params: any) => any) {
if (!this.eventMap[key]) {
this.eventMap[key] = [];
}
this.eventMap[key].push(handler);
}
emit(key: string, params?: any) {
if (this.eventMap[key]) {
this.eventMap[key].forEach(handler => {
handler(params);
});
}
}
remove(key: string, handler: (params: any) => any) {
if (this.eventMap[key]) {
const res = this.eventMap[key].indexOf(handler);
res !== -1 && this.eventMap[key].splice(res, 1);
}
}
}
const defaultEvent = new SubscriptionPublish();
export default defaultEvent;
今天有幸跟组里的后端同事交流了一下,费解的发布订阅者模式到底是怎么实现的。
说到发布订阅者模式,就离不开单例模式,说到单例就离不开内存知识,所以先从内存开始说起吧。
内存知识参考
内存(memory)分为栈与堆(Stack & Heap)
栈内存适合存放生命周期短、占用空间小且固定的数据。
堆内存适合存放生命周期长,占用空间较大或占用空间不固定的数据。
函数调用(Function calling)
当函数被调用时,会将函数推入栈内存中,生成一个栈帧(Stack frame),栈帧可以理解为由函数的返回地址、参数和局部变量组成的一个块;当函数调用另一个函数时,又会将另一个函数也推入栈内存中,周而复始;直到最后一个函数返回,便从栈顶开始将栈内存中的元素逐个弹出,直到栈内存中不再有元素时则此次调用结束。
总体来说,对象是存储在堆内存中,并在栈内存里有一个引用,我们其实是一直拿这个引用。
在多数情况下,原始类型的数据储存在栈内存,而引用类型的数据(对象)则储存在堆内存。
回到单例模式,单例模式其实就是创建了一个对象,他一直在堆内存中。定义如下:
定义:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
单例模式是一种常用的模式,有些对象往往只需要一个,比如:线程池、全局缓存、浏览器中的 window 对象等。
在 Javascript 开发中,单例模式的用途同样非常广泛,比如做登录弹框,它在当前页面是唯一的,无论单击多少次,都只会创建一次,这就非常适合用单例模式来创建。
上述代码就很好理解了,代码中用到发布订阅的地方会引入此对象,
由于其创建的时候就是new过的,所以是一个单例,
然后用到的地方该监听的(on),该触发的(emit),其实本质都是一个实例在处理自己的方法,且这个单例在内存中不会消失,因为有引用存在。
再看看如下示例,它贴近于vue实现的emit和on逻辑,是不是看着就清晰许多。
参考地址
let eventEmitter = {
// 缓存列表
list: {},
// 订阅
on (event, fn) {
let _this = this;
// 如果对象中没有对应的 event 值,也就是说明没有订阅过,就给 event 创建个缓存列表
// 如有对象中有相应的 event 值,把 fn 添加到对应 event 的缓存列表里
(_this.list[event] || (_this.list[event] = [])).push(fn);
return _this;
},
// 监听一次
once (event, fn) {
// 先绑定,调用后删除
let _this = this;
function on () {
_this.off(event, on);
fn.apply(_this, arguments);
}
on.fn = fn;
_this.on(event, on);
return _this;
},
// 取消订阅
off (event, fn) {
let _this = this;
let fns = _this.list[event];
// 如果缓存列表中没有相应的 fn,返回false
if (!fns) return false;
if (!fn) {
// 如果没有传 fn 的话,就会将 event 值对应缓存列表中的 fn 都清空
fns && (fns.length = 0);
} else {
// 若有 fn,遍历缓存列表,看看传入的 fn 与哪个函数相同,如果相同就直接从缓存列表中删掉即可
let cb;
for (let i = 0, cbLen = fns.length; i < cbLen; i++) {
cb = fns[i];
if (cb === fn || cb.fn === fn) {
fns.splice(i, 1);
break
}
}
}
return _this;
},
// 发布
emit () {
let _this = this;
// 第一个参数是对应的 event 值,直接用数组的 shift 方法取出数组第一项。
let event = [].shift.call(arguments),
fns = [..._this.list[event]];
// 如果缓存列表里没有 fn 就返回 false
if (!fns || fns.length === 0) {
return false;
}
// 遍历 event 值对应的缓存列表,依次执行 fn
fns.forEach(fn => {
fn.apply(_this, arguments);
});
return _this;
}
};
function user1 (content) {
console.log('用户1订阅了:', content);
}
function user2 (content) {
console.log('用户2订阅了:', content);
}
function user3 (content) {
console.log('用户3订阅了:', content);
}
function user4 (content) {
console.log('用户4订阅了:', content);
}
// 订阅
eventEmitter.on('article1', user1);
eventEmitter.on('article1', user2);
eventEmitter.on('article1', user3);
// 取消user2方法的订阅
eventEmitter.off('article1', user2);
eventEmitter.once('article2', user4)
// 发布
eventEmitter.emit('article1', 'Javascript 发布-订阅模式');
eventEmitter.emit('article1', 'Javascript 发布-订阅模式');
eventEmitter.emit('article2', 'Javascript 观察者模式');
eventEmitter.emit('article2', 'Javascript 观察者模式');
// eventEmitter.on('article1', user3).emit('article1', 'test111');
/*
用户1订阅了: Javascript 发布-订阅模式
用户3订阅了: Javascript 发布-订阅模式
用户1订阅了: Javascript 发布-订阅模式
用户3订阅了: Javascript 发布-订阅模式
用户4订阅了: Javascript 观察者模式
*/
最后看看vue的实现思路,其实跟上面就类似了,
Vue 中实现的方法支持订阅数组事件。
function eventsMixin (Vue) {
var hookRE = /^hook:/;
Vue.prototype.$on = function (event, fn) {
var this$1 = this;
var vm = this;
// event 为数组时,循环执行 $on
if (Array.isArray(event)) {
for (var i = 0, l = event.length; i < l; i++) {
this$1.$on(event[i], fn);
}
} else {
(vm._events[event] || (vm._events[event] = [])).push(fn);
// optimize hook:event cost by using a boolean flag marked at registration
// instead of a hash lookup
if (hookRE.test(event)) {
vm._hasHookEvent = true;
}
}
return vm
};
Vue.prototype.$once = function (event, fn) {
var vm = this;
// 先绑定,后删除
function on () {
vm.$off(event, on);
fn.apply(vm, arguments);
}
on.fn = fn;
vm.$on(event, on);
return vm
};
Vue.prototype.$off = function (event, fn) {
var this$1 = this;
var vm = this;
// all,若没有传参数,清空所有订阅
if (!arguments.length) {
vm._events = Object.create(null);
return vm
}
// array of events,events 为数组时,循环执行 $off
if (Array.isArray(event)) {
for (var i = 0, l = event.length; i < l; i++) {
this$1.$off(event[i], fn);
}
return vm
}
// specific event
var cbs = vm._events[event];
if (!cbs) {
// 没有 cbs 直接 return this
return vm
}
if (!fn) {
// 若没有 handler,清空 event 对应的缓存列表
vm._events[event] = null;
return vm
}
if (fn) {
// specific handler,删除相应的 handler
var cb;
var i$1 = cbs.length;
while (i$1--) {
cb = cbs[i$1];
if (cb === fn || cb.fn === fn) {
cbs.splice(i$1, 1);
break
}
}
}
return vm
};
Vue.prototype.$emit = function (event) {
var vm = this;
{
// 传入的 event 区分大小写,若不一致,有提示
var lowerCaseEvent = event.toLowerCase();
if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
tip(
"Event \"" + lowerCaseEvent + "\" is emitted in component " +
(formatComponentName(vm)) + " but the handler is registered for \"" + event + "\". " +
"Note that HTML attributes are case-insensitive and you cannot use " +
"v-on to listen to camelCase events when using in-DOM templates. " +
"You should probably use \"" + (hyphenate(event)) + "\" instead of \"" + event + "\"."
);
}
}
var cbs = vm._events[event];
if (cbs) {
cbs = cbs.length > 1 ? toArray(cbs) : cbs;
// 只取回调函数,不取 event
var args = toArray(arguments, 1);
for (var i = 0, l = cbs.length; i < l; i++) {
try {
cbs[i].apply(vm, args);
} catch (e) {
handleError(e, vm, ("event handler for \"" + event + "\""));
}
}
}
return vm
};
}
/***
* Convert an Array-like object to a real Array.
*/
function toArray (list, start) {
start = start || 0;
var i = list.length - start;
var ret = new Array(i);
while (i--) {
ret[i] = list[i + start];
}
return ret
}