在前端大厂面试中,“手写发布订阅模式”是一道考察JavaScript基础与设计模式的高频题。面试官不仅关注你能否写出可运行的代码,更看重你对模式本质的理解、对边界情况的处理,以及如何将其应用于React等现代框架中解决实际问题。
本文将带你从原理到实践,再到React深度集成,全方位掌握这一核心知识点。
核心概念辨析:发布订阅 vs 观察者
在动手写代码之前,必须清晰地区分“发布订阅模式”与“观察者模式”。这是面试官用来判断你理论基础是否扎实的第一道门槛。
通俗类比
| 模式 | 生活中的例子 | 核心特点 |
|---|---|---|
| 观察者模式 | 你关注了周杰伦的微博。周杰伦(目标)发动态时,直接通知你(观察者)。 | 强耦合。目标知道观察者的存在,直接维护观察者列表。 |
| 发布订阅模式 | 你订阅了微信公众号。公众号(发布者)把文章发给平台(事件中心),平台再推送给你(订阅者)。公众号根本不知道你是谁。 | 解耦。通过事件中心(Event Bus/中介)通信,双方互不干扰。 |
代码层面的区别
- 观察者模式:通常涉及
Subject(目标)和Observer(观察者)两个类。目标内部维护一个observers数组,状态改变时直接调用observer.update()。例如Vue的MutationObserver或React的useEffect依赖收集,本质上都带有观察者模式的影子。 - 发布订阅模式:核心是一个
EventEmitter(事件中心)。它维护一个events对象(事件池),发布者触发emit,订阅者注册on,两者通过事件名关联,互不见面。
应用场景
- 发布订阅:DOM事件机制(
addEventListener)、Node.js的events模块、状态管理库(如Zustand)、自定义事件总线(如mitt)。 - 观察者模式:
MutationObserver、fs.watch、React的useEffect。
第一阶段:手写通用EventEmitter
这是面试的基础分。你需要实现一个健壮的类,包含on(订阅)、off(取消订阅)、emit(发布)和once(单次订阅)。
1class EventEmitter {
2 constructor() {
3 // 核心:使用对象作为“事件池”来存储订阅关系
4 // 结构示例:{ 'click': [fn1, fn2], 'change': [fn3] }
5 this.events = {};
6 }
7
8 /**
9 * 1. 订阅事件
10 * @param {string} event - 事件名
11 * @param {function} fn - 回调函数
12 */
13 on(event, fn) {
14 if (!this.events[event]) {
15 this.events[event] = [];
16 }
17 this.events[event].push(fn);
18 }
19
20 /**
21 * 2. 取消订阅
22 * @param {string} event - 事件名
23 * @param {function} fn - 需要移除的回调函数
24 */
25 off(event, fn) {
26 const fns = this.events[event];
27 if (!fns) return;
28 // 使用 filter 过滤掉要移除的函数
29 this.events[event] = fns.filter(item => item !== fn);
30 }
31
32 /**
33 * 3. 发布事件
34 * @param {string} event - 事件名
35 * @param {...any} args - 传递给回调的参数
36 */
37 emit(event, ...args) {
38 const fns = this.events[event];
39 if (!fns) return;
40
41 // 关键点:使用 slice() 拷贝一份数组再遍历
42 // 原因:防止在回调执行过程中(如回调里调用了off)修改原数组长度,导致遍历出错
43 fns.slice().forEach(fn => fn(...args));
44 }
45
46 /**
47 * 4. 单次订阅
48 * @param {string} event - 事件名
49 * @param {function} fn - 回调函数
50 */
51 once(event, fn) {
52 const wrapper = (...args) => {
53 fn(...args);
54 // 执行完后立即取消订阅
55 this.off(event, wrapper);
56 };
57 this.on(event, wrapper);
58 }
59}
60
61// 导出单例,作为全局事件总线
62export const eventBus = new EventEmitter();
第二阶段:React深度集成
在React中,我们不能仅仅停留在写一个类上,必须结合Hooks来解决内存泄漏和闭包陷阱这两个核心问题。
封装usePubSub Hook
我们需要一个自定义Hook,它能在组件挂载时自动订阅,卸载时自动取消订阅,并且保证回调函数能访问到最新的State。
1import { useEffect, useRef } from 'react';
2import { eventBus } from './eventBus'; // 引入上面的类
3
4/**
5 * React专用的发布订阅Hook
6 * @param {string} event - 事件名
7 * @param {function} handler - 回调函数
8 */
9export const usePubSub = (event, handler) => {
10 // 1. 使用 useRef 保存最新的 handler,避免闭包导致拿到旧的 state
11 const handlerRef = useRef(handler);
12
13 // 2. 每次 handler 变化时更新 ref,确保永远持有最新的函数引用
14 useEffect(() => {
15 handlerRef.current = handler;
16 }, [handler]);
17
18 useEffect(() => {
19 // 3. 定义实际订阅的函数
20 // 这里不直接使用 handler,而是调用 ref 中的最新函数
21 const subscribeFn = (data) => {
22 handlerRef.current(data);
23 };
24
25 // 4. 订阅事件
26 eventBus.on(event, subscribeFn);
27
28 // 5. 组件卸载时清理订阅,防止内存泄漏
29 // 注意:必须移除同一个函数引用
30 return () => {
31 eventBus.off(event, subscribeFn);
32 };
33 }, [event]);
34};
实战场景:跨组件通信
假设我们有一个全局通知系统,组件A负责触发操作,组件B(位于组件树的其他位置)负责显示通知。
1// 订阅者组件:通知栏
2const NotificationBar = () => {
3 const [message, setMessage] = React.useState('');
4
5 // 订阅 'SHOW_NOTIFICATION' 事件
6 usePubSub('SHOW_NOTIFICATION', (data) => {
7 setMessage(data);
8 // 模拟3秒后自动消失
9 setTimeout(() => setMessage(''), 3000);
10 });
11
12 if (!message) return null;
13 return <div className="toast">{message}</div>;
14};
15
16// 发布者组件:操作按钮
17const ActionButton = () => {
18 const handleSave = () => {
19 // 模拟保存逻辑...
20 // 发布事件
21 eventBus.emit('SHOW_NOTIFICATION', '保存成功!');
22 };
23
24 return <button onClick={handleSave}>保存数据</button>;
25};
面试高频问答
Q1: 为什么在Hook中要用useRef来存handler?
-
回答:这是为了解决闭包陷阱。
- 在
useEffect中,如果我们直接依赖handler,那么每次handler变化(例如它依赖了某个state),useEffect都会重新执行,导致反复卸载和重新订阅,性能很差。 - 如果我们不依赖
handler,useEffect只在挂载时执行一次,但此时闭包里的handler是旧的,无法获取最新的State。 - 解决方案:利用
useRef的可变性。useEffect只运行一次进行订阅,但在回调中通过handlerRef.current去调用,而useRef始终指向最新的函数引用。
- 在
Q2: 什么时候用发布订阅,什么时候用Context/Redux?
-
回答:
- 发布订阅:适用于低频、临时的交互通知。例如:全局弹窗、消息提示、模态框控制。它不需要触发组件的重新渲染来维持数据流,只是触发副作用。
- Context/Redux:适用于高频、全局共享的数据状态。例如:用户信息、主题配置、购物车数据。因为这些数据需要驱动UI的同步更新,而发布订阅模式如果用来传大量数据,容易导致组件更新不同步的问题。
Q3: 这种模式和React的Fiber架构冲突吗?
- 回答:不冲突,但要小心。发布订阅是同步的(除非你内部用异步)。如果在
emit时触发了大量的组件状态更新(setState),可能会阻塞主线程。在React 18的并发模式下,我们通常建议将发布订阅用于非渲染逻辑,或者配合startTransition来处理更新。
总结
在React面试中手写发布订阅,核心不在于那个class,而在于如何处理生命周期和闭包。
关键得分点:
- 实现
on,off,emit基础功能,并注意emit时的数组拷贝。 - 在
useEffect中正确清理副作用。 - 使用
useRef解决闭包stale问题。 - 明确区分它与状态管理库的使用场景。