状态驱动渲染 vs 事件驱动模型:前端架构的两套范式
在前端开发中,有两种截然不同的思维模型:
- 状态驱动渲染(State-Driven Rendering) :React / Vue 的核心范式
- 事件驱动模型(Event-Driven Model) :传统 DOM、Node.js、消息系统的核心范式
它们不是对立的,但混用会出问题。理解两者的本质差异,是写出可维护前端架构的关键。
一、状态驱动渲染:UI 是状态的函数
核心公式
ini
UI = f(state)
状态变了,UI 自动更新。开发者只需要关心:
"现在的状态是什么?"
而不是:
"我需要手动更新哪个 DOM?"
特征
javascript
// 状态
const [frame, setFrame] = useState(10);
// UI 自动跟随状态
return <Slider value={frame} />;
- 状态有当前值:任何时刻都可以读取
- 状态可持久:刷新、回放、时间旅行都合理
- UI 是派生的:状态是唯一事实来源(Single Source of Truth)
适合管理什么?
arduino
// ✅ 这些都是"事实",适合放状态
{
activeShardId: 0, // 现在在哪个分片
frameRange: [10, 20], // 现在显示哪些帧
selectedInstance: 'A', // 现在选中了谁
loadingStatus: 'ready', // 现在加载状态
roiRange: {...}, // 现在的空间范围
}
二、事件驱动模型:响应发生的事情
核心公式
ini
handler = f(event)
事件发生了,触发对应的处理函数。开发者关心的是:
"发生了什么事?"
而不是:
"现在的状态是什么?"
特征
javascript
bus.on('shard:didChange', ({ shardId }) => {
doSomething(shardId);
});
bus.emit('shard:didChange', { shardId: 1 });
- 事件没有当前值:发生的瞬间有意义,之后就没了
- 事件是一次性的:错过了就是错过了
- 消费者解耦:发布者不关心谁在监听
适合处理什么?
arduino
// ✅ 这些都是"发生的事",适合走事件
bus.emit('shard:willChange', ...) // 分片即将切换
bus.emit('shard:didChange', ...) // 分片切换完成
bus.emit('toast:show', ...) // 弹出提示
bus.emit('camera:flyTo', ...) // 相机飞行
bus.emit('frame:scrubEnd', ...) // 时间轴拖动结束
三、两者的本质差异
用一个问题来判断
"如果我现在问系统,这个东西有没有当前值?"
vbnet
有 → 状态(Zustand / useState)
没有 → 事件(Event Bus)
对比表
| 维度 | 状态驱动 | 事件驱动 |
|---|---|---|
| 核心问题 | 现在是什么 | 发生了什么 |
| 时间性 | 持久存在 | 瞬时发生 |
| 错过处理 | 不会错过(随时可读) | 会错过(没订阅就没了) |
| 典型载体 | Zustand / useState / Pinia | EventBus / CustomEvent |
| UI 关系 | 直接驱动渲染 | 触发副作用 |
| 调试方式 | 快照(Redux DevTools) | 日志追踪 |
四、最经典的混用错误
把事件存成状态
ini
// ❌ 用状态广播事件
store.setState({
lastEvent: 'shardChanged',
shardId: 1,
eventId: id++
});
// ❌ 用 useEffect 消费"伪事件状态"
useEffect(() => {
if (lastEvent === 'shardChanged' && eventId !== consumed.current) {
consumed.current = eventId;
doSomething();
}
}, [lastEvent, eventId]);
为什么会失控?
问题一:新组件挂载重复消费
arduino
// 组件 A 正常消费
// 组件 B 5 秒后挂载,读到旧的 lastEvent,再次触发
// React StrictMode 双重挂载,触发两次
事件已经是过去式,但状态还在,新来的组件照样消费。
问题二:开始写 eventId++ 打补丁
arduino
// 你在用状态手搓一个残缺的消息队列
{
lastEvent: 'shardChanged',
eventId: 42, // 区分新旧事件
consumedEventId: 41, // 记录消费位置
}
没有消费确认、没有顺序保证、没有错误处理。
问题三:store 开始堆垃圾字段
yaml
{
// 真正的状态
activeShardId: 0,
frameRange: [10, 20],
// 伪装成状态的事件(越来越多)
lastEvent: 'shardChanged',
lastToastMessage: '加载失败',
lastCameraTarget: { x: 0, y: 0, z: 0 },
lastExportResult: 'success',
}
没人能说清楚哪些是"当前值",哪些是"历史通知"。
五、另一个方向的混用:把状态当事件用
sql
// ❌ 用事件传递"应该是状态"的东西
bus.emit('frameRangeUpdated', { start: 10, end: 20 });
// 然后每个组件都要订阅,才能知道当前帧范围
bus.on('frameRangeUpdated', ({ start, end }) => {
setLocalFrame(start, end); // 各自维护本地副本
});
问题:
- 新挂载的组件不知道当前帧范围(错过了事件)
- 多个组件各自维护副本,状态不一致
- 本质上是在用事件模拟状态,同样是范式错位
六、正确的分工(以点云编辑器为例)
Zustand 管状态(事实)
yaml
// 任何时刻问都有意义
{
activeShardId: 0,
frameRange: [10, 20],
roiRange: { x: [-30,30], y: [-20,20], z: [-1,3] },
selectedInstance: 'A',
loadingStatus: 'ready',
}
Bus 发通知(事件)
php
// 只在发生的瞬间有意义
bus.emit('shard:willChange', { fromId: 0, toId: 1 }); // 做清理
bus.emit('shard:didChange', { shardId: 1 }); // 各模块响应
bus.emit('toast:show', { message: '加载完成' }); // UI 副作用
bus.emit('camera:flyTo', { x: 0, y: 0, z: 0 }); // 一次性动作
两者配合的典型流程
php
async function switchShard(newShardId: number) {
const oldId = store.getState().activeShardId;
// 1. 发事件:通知各模块准备
bus.emit('shard:willChange', { fromId: oldId, toId: newShardId });
// 2. 执行异步操作
await loadShardPcd(newShardId);
// 3. 更新状态:记录事实
store.setState({ activeShardId: newShardId });
// 4. 发事件:通知各模块响应
bus.emit('shard:didChange', { shardId: newShardId });
}
各模块各自监听,互不依赖:
dart
// rangeLayer 监听切换完成
bus.on('shard:didChange', () => rangeLayer.updateRange());
// cacheService 监听即将切换,提前预取
bus.on('shard:willChange', ({ toId }) => prefetchShard(toId));
// labelingData 监听即将切换,做清理
bus.on('shard:willChange', () => clearCachedLabelingData());
七、中间地带:Zustand subscribe
有一种情况可以不用 Bus,直接用 Zustand 的 subscribe:
ini
// 状态变了,顺带做副作用
store.subscribe(
state => state.frameRange,
(frameRange) => {
updateFrameStats(frameRange);
}
);
适合:
- 副作用和状态强绑定(状态变了一定要做)
- 逻辑简单、不需要跨模块广播
不适合:
- 副作用很多、很复杂
- 需要"即将发生"的时机(
willChange) - 需要携带额外上下文(比如
fromId)
八、判断口诀(直接用)
markdown
问自己三个问题:
1. 这个东西有"当前值"吗?
有 → Zustand
2. 这件事只在"发生瞬间"有意义吗?
是 → Bus
3. 副作用和某个状态强绑定吗?
是 → Zustand subscribe
九、总结
状态驱动和事件驱动是两套不同的范式,各自解决不同的问题:
- 状态驱动:让 UI 和数据保持一致,解决"现在是什么"
- 事件驱动:让模块之间低耦合协作,解决"发生了什么"
混用的根本原因是:把"发生了什么"存成了"现在是什么" ,或者反过来。
两者分工清晰之后,你会发现:
- store 里只有干净的状态
- 模块之间只通过事件通信
- 没有神秘的
useEffect副作用链 - 系统行为可预测、可追踪、可测试
状态是系统的记忆,事件是系统的神经。两者各司其职,架构才会稳。