跳到主要内容

实现 EventHub

· 阅读需 4 分钟
素明诚
Full stack development

当面试官让你实现 EventHub 或类似的事件系统时,他们主要考察以下几点:

  1. 基本的编程能力:这是显而易见的。你能否写出工作的代码,是否熟悉语言特性和基础概念。
  2. 数据结构的知识:如何高效地存储和检索事件监听器是关键。通常,哈希表(在 JavaScript 中是对象或 Map)是一个合适的选择。
  3. 设计模式:事件模型是观察者模式的一个实例。面试官可能希望你能识别并实现这一点。
  4. 异步编程:JavaScript 的事件和异步性质是其核心特性之一。对 Promise、async/await 或其他异步机制的了解和正确使用会是一个加分项。
  5. 错误处理:如何处理监听器中的错误,如何通知其他部分的应用程序,这都是重要的设计决策。
  6. 代码的可维护性和可读性:清晰、结构化、有注释的代码总是受到欢迎的。
  7. API 的设计:一个好的 EventHub 实现不仅要功能齐全,而且要易于使用。面试官可能会评估你设计的 API 是否直观和用户友好。
  8. 性能和优化:虽然初级实现可能不需要考虑这些,但面试官可能会问及性能问题,或者询问如何优化代码。
  9. 扩展性和灵活性:除了基本功能外,面试官可能还会询问如何扩展你的实现,例如添加事件组、命名空间、中间件等。
  10. 测试:对于一些高级职位或特定的公司,面试官可能会询问如何为你的 EventHub 实现编写测试。

如何编写

  1. 首先编写最基础的方法 on、emit、off,实现最基本的事件监听和触发功能
  2. 接着再在此基础上进行完善、once、bind、链式调用等
  3. 最后增加一些查看获取事件的方法

EventHub 案例

/*
* on 方法:注册事件监听器,本质就是往数组里面添加回调函数
* emit 方法:发射事件,本质就是遍历数组,执行回调函数
* off 方法:移除事件监听器,本质就是从数组里面移除回调函数
* once 方法:注册只触发一次的事件监听器,本质就是在回调函数执行后,移除该回调函数
* bind 方法:在指定上下文中注册事件监听器,本质就是利用 on 方法注册回调函数时,使用 bind 方法绑定上下文
* 链式调用:所有方法都返回 this,就支持链式调用
* */

class EventHub {
constructor() {
// 存储所有的事件及其对应的回调
this.events = {}
}

// 注册事件监听器
on(event, callback) {
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(callback)
return this // 支持链式调用
}

// 发射事件
emit(event, ...args) {
if (this.hasListener(event)) {
new Promise((resolve, reject) => {
const listeners = [...this.events[event]]
for (let callback of listeners) {
try {
callback(...args)
} catch (error) {
// 如果 error 事件的监听器也出错,直接拒绝 Promise
if (event === 'error') {
reject(error)
return
} else {
this.emit('error', error)
}
}
}
resolve()
}).catch(error => {
console.error('Error in emit method:', error)
})
}
return this
}

// 移除事件监听器
off(event, callback) {
if (this.hasListener(event)) {
this.events[event] = this.events[event].filter(cb => cb !== callback)
}
return this
}

// 注册只触发一次的事件监听器
once(event, callback) {
const wrappedCallback = (...args) => {
callback(...args)
this.off(event, wrappedCallback)
}
return this.on(event, wrappedCallback) // 利用 on 方法进行注册
}

// 在指定上下文中注册事件监听器
bind(event, callback, context) {
return this.on(event, callback.bind(context))
}

// 检查指定事件是否有监听器
hasListener(event) {
return this.events[event] && this.events[event].length > 0
}

// 获取所有已注册的事件类型
getEventTypes() {
return Object.keys(this.events)
}
}

// 使用示例
// 1. 获取 EventHub 的实例
const eventHub = new EventHub()

// 2. 注册事件监听器
eventHub.on('data', data => {
console.log(data)
throw new Error('Sample error from data listener')
})

eventHub.once('data', data => {
console.log(`Once: ${data}`)
})

const errorCallback = error => {
console.log(`Error: ${error.message}`)
}
eventHub.on('error', errorCallback)

const user = {
name: 'Alice',
showName() {
console.log(`Hello, my name is ${this.name}`)
}
}
eventHub.bind('showName', user.showName, user)

// 3. 发射事件
console.log('Before emit')
eventHub.emit('data', 'Some data')
eventHub.emit('showName')
console.log('After emit')

// 4. 移除事件监听器
eventHub.off('error', errorCallback)

// 5. 检查是否有监听器
console.log('Has data listener:', eventHub.hasListener('data')) // true
console.log('Has error listener:', eventHub.hasListener('error')) // false

// 6. 获取所有的事件类型
console.log('Event types:', eventHub.getEventTypes()) // ['data', 'error', 'showName']

使用 Jest 测试 EventHub

const EventHub = require('./EventHub');  // 调整路径以匹配你的EventHub文件位置

describe('EventHub', () => {
let eventHub;

beforeEach(() => {
eventHub = new EventHub();
});

test('should register and trigger an event', () => {
const callback = jest.fn();
eventHub.on('testEvent', callback);

eventHub.emit('testEvent', 'payload');

expect(callback).toHaveBeenCalledWith('payload');
});

test('should register and trigger an event only once', () => {
const callback = jest.fn();
eventHub.once('testEvent', callback);

eventHub.emit('testEvent', 'payload');
eventHub.emit('testEvent', 'payload');

expect(callback).toHaveBeenCalledTimes(1);
});

test('should unregister an event', () => {
const callback = jest.fn();
eventHub.on('testEvent', callback);
eventHub.off('testEvent', callback);

eventHub.emit('testEvent', 'payload');

expect(callback).not.toHaveBeenCalled();
});
});