大厂面试核心:彻底搞懂发布订阅模式与手写EventEmitter

3 阅读6分钟

在前端大厂面试中,“手写发布订阅模式”是一道考察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)。
  • 观察者模式MutationObserverfs.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都会重新执行,导致反复卸载和重新订阅,性能很差。
    • 如果我们不依赖handleruseEffect只在挂载时执行一次,但此时闭包里的handler是旧的,无法获取最新的State。
    • 解决方案:利用useRef的可变性。useEffect只运行一次进行订阅,但在回调中通过handlerRef.current去调用,而useRef始终指向最新的函数引用。

Q2: 什么时候用发布订阅,什么时候用Context/Redux?

  • 回答

    • 发布订阅:适用于低频临时的交互通知。例如:全局弹窗、消息提示、模态框控制。它不需要触发组件的重新渲染来维持数据流,只是触发副作用。
    • Context/Redux:适用于高频全局共享的数据状态。例如:用户信息、主题配置、购物车数据。因为这些数据需要驱动UI的同步更新,而发布订阅模式如果用来传大量数据,容易导致组件更新不同步的问题。

Q3: 这种模式和React的Fiber架构冲突吗?

  • 回答:不冲突,但要小心。发布订阅是同步的(除非你内部用异步)。如果在emit时触发了大量的组件状态更新(setState),可能会阻塞主线程。在React 18的并发模式下,我们通常建议将发布订阅用于非渲染逻辑,或者配合startTransition来处理更新。

总结

在React面试中手写发布订阅,核心不在于那个class,而在于如何处理生命周期和闭包

关键得分点

  • 实现onoffemit基础功能,并注意emit时的数组拷贝。
  • useEffect中正确清理副作用。
  • 使用useRef解决闭包stale问题。
  • 明确区分它与状态管理库的使用场景。