3. UI事件类型与传播
事件驱动架构的核心,说白了就是「谁关心谁去拿,不关心就别打扰」。我见过太多团队把组件通信写成了蜘蛛网——A 调 B,B 调 C,C 又回头调 A。最后改一个需求,牵出一堆 bug。
这一章我们就把三种最常用的模式掰开揉碎。你理解了它们,UI 架构里的通信问题就能解决八成。
3.1 发布-订阅模式:最灵活的解耦方案
发布-订阅模式,我习惯叫它「喇叭模式」。一个组件喊一嗓子,所有感兴趣的组件都能听到。喊的人不需要知道谁在听,听的人也不需要知道谁在喊。
核心三要素:
- 事件通道(Event Channel):消息的中转站,负责存储和分发
- 发布者(Publisher):触发事件的组件,只负责「喊」
- 订阅者(Subscriber):监听事件的组件,只负责「听」
我在项目中遇到过这样一个场景:一个复杂的表单页面,用户填写完信息后,需要同时更新导航栏的进度、侧边栏的摘要、底部的操作按钮状态。如果每个组件都直接调用对方的方法,那代码就乱成一锅粥了。
用发布-订阅模式就清爽多了:
// 一个简单的事件总线实现
class EventBus {
constructor() {
this.events = {};
}
// 订阅事件
on(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(callback);
// 返回取消订阅函数,方便清理
return () => {
this.events[eventName] = this.events[eventName]
.filter(cb => cb !== callback);
};
}
// 发布事件
emit(eventName, ...args) {
const callbacks = this.events[eventName];
if (!callbacks || callbacks.length === 0) return;
callbacks.forEach(callback => {
try {
callback(...args);
} catch (error) {
console.error(`事件 ${eventName} 执行出错:`, error);
}
});
}
// 只订阅一次
once(eventName, callback) {
const wrapper = (...args) => {
callback(...args);
this.off(eventName, wrapper);
};
this.on(eventName, wrapper);
}
// 取消订阅
off(eventName, callback) {
if (!this.events[eventName]) return;
if (!callback) {
delete this.events[eventName];
return;
}
this.events[eventName] = this.events[eventName]
.filter(cb => cb !== callback);
}
}
// 使用示例
const bus = new EventBus();
// 导航栏订阅表单变更
const unsubNav = bus.on('form:updated', (data) => {
updateNavProgress(data.progress);
});
// 侧边栏订阅表单变更
bus.on('form:updated', (data) => {
updateSidebarSummary(data.summary);
});
// 表单提交时发布事件
function handleFormSubmit(formData) {
// ...处理表单逻辑
bus.emit('form:updated', {
progress: 80,
summary: formData
});
}
我的习惯:事件名用命名空间格式,比如 module:action。这样既避免冲突,又方便调试时快速定位来源。
3.2 观察者模式 vs 发布-订阅模式
很多人把这两个模式混为一谈。其实它们有本质区别。我刚开始学的时候也搞混过,后来在重构一个老项目时才真正分清楚。
| 对比维度 | 观察者模式 | 发布-订阅模式 |
|---|---|---|
| 通信方式 | 直接通信,观察者持有目标引用 | 通过事件通道间接通信 |
| 耦合度 | 低耦合,但观察者需要知道目标 | 完全解耦,双方互不知晓 |
| 消息分发 | 目标直接通知所有观察者 | 事件通道负责分发 |
| 适用场景 | 单一数据源,多个展示层 | 复杂系统,跨模块通信 |
| 调试难度 | 相对容易,调用链清晰 | 较难,需要追踪事件流 |
观察者模式就像你直接告诉同事「这个数据变了,你更新一下」。而发布-订阅模式就像你在群里发了一条消息,谁看到了谁处理。
举个例子,一个股票行情组件:
// 观察者模式实现
class StockSubject {
constructor() {
this.observers = [];
this.price = 0;
}
addObserver(observer) {
this.observers.push(observer);
}
removeObserver(observer) {
this.observers = this.observers
.filter(obs => obs !== observer);
}
setPrice(newPrice) {
this.price = newPrice;
this.notify();
}
notify() {
this.observers.forEach(observer => {
observer.update(this.price);
});
}
}
class StockDisplay {
update(price) {
console.log(`当前股价: ${price}`);
}
}
// 使用
const stock = new StockSubject();
const display = new StockDisplay();
stock.addObserver(display);
stock.setPrice(100); // display 会收到通知
我曾经踩过的坑:在观察者模式里忘记移除观察者,导致内存泄漏。特别是单页应用里,组件销毁了但观察者还在,每次数据更新都会触发已销毁组件的回调。记得在组件卸载时调用 removeObserver。
3.3 中介者模式:集中管控通信
中介者模式,我更喜欢叫它「交通指挥模式」。所有组件之间的通信都经过一个中介者,由它来协调各方。这跟发布-订阅有点像,但中介者更「主动」——它知道谁该做什么。
你想想看,一个聊天室应用:用户 A 发消息,需要同时更新聊天列表、未读计数、用户状态、通知栏。如果每个组件都直接互相调用,那代码就成了一团乱麻。
// 中介者模式实现
class ChatMediator {
constructor() {
this.users = new Map();
this.rooms = new Map();
}
// 注册用户
registerUser(user) {
this.users.set(user.id, user);
user.setMediator(this);
}
// 注册房间
registerRoom(room) {
this.rooms.set(room.id, room);
room.setMediator(this);
}
// 发送消息
sendMessage(senderId, roomId, message) {
const room = this.rooms.get(roomId);
if (!room) {
console.error('房间不存在');
return;
}
// 中介者协调多个组件更新
this.updateChatList(senderId, roomId, message);
this.updateUnreadCount(roomId, senderId);
this.updateUserStatus(senderId);
this.notifyRoomMembers(roomId, message);
}
updateChatList(senderId, roomId, message) {
// 更新聊天列表
console.log(`[中介者] 更新聊天列表: ${senderId} 在 ${roomId} 发送了消息`);
}
updateUnreadCount(roomId, excludeUserId) {
// 更新未读计数,排除发送者
console.log(`[中介者] 更新 ${roomId} 的未读计数`);
}
updateUserStatus(userId) {
// 更新用户在线状态
console.log(`[中介者] 更新用户 ${userId} 的状态`);
}
notifyRoomMembers(roomId, message) {
// 通知房间内其他成员
console.log(`[中介者] 通知 ${roomId} 的成员: ${message}`);
}
}
// 用户类
class User {
constructor(id, name) {
this.id = id;
this.name = name;
this.mediator = null;
}
setMediator(mediator) {
this.mediator = mediator;
}
sendMessage(roomId, message) {
this.mediator.sendMessage(this.id, roomId, message);
}
}
// 使用
const mediator = new ChatMediator();
const alice = new User('1', 'Alice');
const bob = new User('2', 'Bob');
mediator.registerUser(alice);
mediator.registerUser(bob);
alice.sendMessage('room1', '大家好!');
// 中介者会协调所有相关组件更新
三种模式的选择建议:
- 简单的一对多通知 → 观察者模式
- 复杂的多对多通信 → 发布-订阅模式
- 需要集中控制业务逻辑 → 中介者模式
3.4 三种模式的对比与实战选择
我整理了一张对比图,方便你快速决策:
3.5 实战中的避坑指南
模式选对了,但实现细节没处理好,照样会出问题。我把自己踩过的坑分享给你:
我曾经犯过的错误:
- 事件名冲突:两个模块用了同一个事件名,结果互相干扰。后来我强制要求事件名必须带模块前缀,比如
user:login、cart:add。 - 忘记取消订阅:组件销毁了,但订阅还在。每次事件触发都执行已销毁组件的回调,轻则报错,重则内存泄漏。我习惯在组件卸载时统一清理所有订阅。
- 事件风暴:一个事件触发后,连锁触发多个事件,最后形成循环。我加了一个最大递归深度限制,超过 10 层就报错中断。
- 调试困难:事件流难以追踪。后来我写了一个调试中间件,每次事件触发都打印调用栈和参数。
我的调试小技巧:在事件总线上加一个 _debug 模式,开启后所有事件都会打印到控制台。线上环境关闭,开发环境开启,排查问题特别方便。
// 带调试功能的事件总线
class DebugEventBus extends EventBus {
constructor(debug = false) {
super();
this.debug = debug;
}
emit(eventName, ...args) {
if (this.debug) {
console.group(`[EventBus] ${eventName}`);
console.log('参数:', args);
console.log('订阅者数量:', this.events[eventName]?.length || 0);
console.groupEnd();
}
super.emit(eventName, ...args);
}
}
// 开发环境开启调试
const bus = new DebugEventBus(
process.env.NODE_ENV === 'development'
);
嗯,这三种模式你掌握了,UI 事件驱动架构的通信层就稳了。记住一个原则:能用发布-订阅就别用观察者,需要集中控制就用中介者。别为了用模式而用模式,合适才是最好的。