我手写了一个 EventEmitter,面试官追问了 6 个问题——第 4 个我没答上来

发布时间:2026/7/1 14:00:18
我手写了一个 EventEmitter,面试官追问了 6 个问题——第 4 个我没答上来 发布订阅模式是前端面试的高频手写题但大多数人只会写一个基础版的on/emit。面试官真正想考的不是你会不会写而是你写完之后能不能接住追问。我上次手写完 EventEmitter 后被连续追问了 6 个问题第 4 个关于内存泄漏的问题当场没答上来。这篇文章把完整实现和 6 个追问全部写出来下次遇到直接拿满分。先用 30 行写一个能用的版本面试的时候不要上来就写完美版先快速写一个核心能跑的版本再根据面试官的追问逐步完善。classEventEmitter{privateevents:Mapstring,SetFunctionnewMap();on(event:string,listener:Function){if(!this.events.has(event)){this.events.set(event,newSet());}this.events.get(event)!.add(listener);returnthis;}off(event:string,listener:Function){this.events.get(event)?.delete(listener);returnthis;}emit(event:string,...args:any[]){this.events.get(event)?.forEach(listener{listener(...args);});returnthis;}once(event:string,listener:Function){constwrapper(...args:any[]){listener(...args);this.off(event,wrapper);};this.on(event,wrapper);returnthis;}}30 行4 个核心方法on订阅、off取消、emit触发、once只触发一次。为什么用MapSet而不是普通对象 数组数据结构查找/删除去重说明对象 数组O(n)需手动判断删除要splice性能差Map SetO(1)自动去重delete直接删不用遍历写完这个版本面试官会满意吗不会。追问才刚开始。追问 1“链式调用怎么实现的”你可能注意到了每个方法最后都return this。这不是多余的——它让你可以这样写constemitternewEventEmitter();emitter.on(login,userconsole.log(${user}登录了)).on(logout,userconsole.log(${user}登出了)).once(firstVisit,()console.log(首次访问));面试加分点提一句jQuery、RxJS、Promise 都用了这个模式——叫 Fluent Interface流式接口。追问 2“once 的实现原理是什么为什么要用 wrapper”once的核心是用一个wrapper函数把原始listener包了一层once(event:string,listener:Function){constwrapper(...args:any[]){listener(...args);// 先执行原始回调this.off(event,wrapper);// 再把 wrapper 从事件列表中删掉};this.on(event,wrapper);// 注册的是 wrapper不是 listener}为什么不能直接this.off(event, listener)因为你注册的是wrapper事件列表里存的也是wrapper。如果你off(listener)找不到匹配项删不掉。追问陷阱面试官可能会问如果我在once回调执行之前就手动off这个 listener会发生什么答off(event, listener)找不到因为注册的是 wrapper不会删除。要解决这个问题需要在 wrapper 上挂一个原始引用once(event:string,listener:Function){constwrapper(...args:any[]){listener(...args);this.off(event,wrapper);};wrapper._originallistener;// 保存原始引用this.on(event,wrapper);}off(event:string,listener:Function){constlistenersthis.events.get(event);if(!listeners)returnthis;for(constfnoflisteners){if(fnlistener||fn._originallistener){listeners.delete(fn);break;}}returnthis;}这个细节能答上来面试官会认为你对设计模式的理解不是停留在背代码层面。追问 3“emit 的时候如果 listener 抛了异常后面的 listener 还会执行吗”当前实现不会。因为forEach中某个 listener 抛异常后整个循环就中断了。emitter.on(data,(){thrownewError(boom);});emitter.on(data,()console.log(我不会执行));emitter.emit(data);// 第二个 listener 被跳过了解决方案每个 listener 独立 try-catch。emit(event:string,...args:any[]){this.events.get(event)?.forEach(listener{try{listener(...args);}catch(error){console.error(Event ${event} listener error:,error);}});returnthis;}延伸Node.js 的 EventEmitter 不会帮你 catch它会直接抛出。如果没有监听error事件进程会崩。这就是为什么 Node.js 里经常看到emitter.on(error, handler)这种写法——它是兜底用的。追问 4“如果忘了 off会不会内存泄漏”会。这是我当时没答上来的问题。场景一个 React 组件在useEffect里注册了事件但组件卸载时没有off。// ❌ 内存泄漏useEffect((){emitter.on(update,handleUpdate);// 组件卸载了但 handleUpdate 还在 emitter 的事件列表里// emitter 持有 handleUpdate 的引用 → handleUpdate 持有组件闭包的引用 → 组件无法被 GC},[]);// ✅ 正确写法useEffect((){emitter.on(update,handleUpdate);return()emitter.off(update,handleUpdate);},[]);面试加分答法EventEmitter 的内存泄漏本质是引用链问题emitter → listener → 闭包 → 组件状态。解决方案有三个手动off最基本用WeakRef弱引用进阶用AbortController统一管理生命周期现代方案如果面试官继续追问AbortController方案on(event:string,listener:Function,signal?:AbortSignal){if(!this.events.has(event)){this.events.set(event,newSet());}this.events.get(event)!.add(listener);signal?.addEventListener(abort,(){this.off(event,listener);});returnthis;}// 使用constcontrollernewAbortController();emitter.on(update,handleUpdate,controller.signal);// 组件卸载时一键取消所有事件controller.abort();这个AbortController方案和fetch取消请求是同一个 API前端新标准正在往这个方向统一。追问 5“怎么实现带命名空间的事件”实际项目中事件名经常需要层级结构user.login、user.logout、order.create。如果要支持emitter.emit(user.*)触发所有user.开头的事件emit(event:string,...args:any[]){// 精确匹配this.events.get(event)?.forEach(fn{try{fn(...args);}catch(e){console.error(e);}});// 通配符匹配if(event.includes(.)){constprefixevent.split(.)[0].*;this.events.get(prefix)?.forEach(fn{try{fn(...args);}catch(e){console.error(e);}});}returnthis;}不需要完整实现通配符匹配面试中说出思路就够了。追问 6“Node.js 的 EventEmitter 和你写的有什么区别”特性手写版Node.js EventEmitter最大监听数无限制默认 10 个超过会警告防泄漏错误处理手动 try-catch必须监听error事件否则进程崩prependListener不支持支持在队列头部插入eventNames()不支持返回所有已注册的事件名listenerCount()不支持返回指定事件的监听器数量异步支持不支持本身是同步的但生态有EventEmitter2答这个问题的关键是最大监听数。Node.js 默认限制 10 个 listener超过会打印警告MaxListenersExceededWarning: Possible EventEmitter memory leak detected.这不是 bug是故意的——防止你忘了off导致内存泄漏。可以通过emitter.setMaxListeners(20)调整。完整最终版把 6 个追问的优化点都加上classEventEmitter{privateevents:Mapstring,SetFunctionnewMap();privatemaxListeners:number10;on(event:string,listener:Function,signal?:AbortSignal){if(!this.events.has(event)){this.events.set(event,newSet());}constlistenersthis.events.get(event)!;if(listeners.sizethis.maxListeners){console.warn(Warning:${event}has${listeners.size}listeners.);}listeners.add(listener);signal?.addEventListener(abort,()this.off(event,listener));returnthis;}off(event:string,listener:Function){constlistenersthis.events.get(event);if(!listeners)returnthis;for(constfnoflisteners){if(fnlistener||(fnasany)._originallistener){listeners.delete(fn);break;}}if(listeners.size0)this.events.delete(event);returnthis;}emit(event:string,...args:any[]){this.events.get(event)?.forEach(fn{try{fn(...args);}catch(e){console.error(e);}});returnthis;}once(event:string,listener:Function){constwrapper(...args:any[]){listener(...args);this.off(event,wrapper);};(wrapperasany)._originallistener;this.on(event,wrapper);returnthis;}setMaxListeners(n:number){this.maxListenersn;returnthis;}listenerCount(event:string):number{returnthis.events.get(event)?.size??0;}removeAllListeners(event?:string){if(event){this.events.delete(event);}else{this.events.clear();}returnthis;}}从 30 行的基础版到完整版每一行新增代码都对应一个追问。面试时先写基础版面试官追问时再逐步加——这比一上来就写完整版更能展示你的思维过程。你面试中被手写题难住过吗最难的是哪道评论区聊聊。