状态驱动渲染和事件驱动模型

状态驱动渲染 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 副作用链
  • 系统行为可预测、可追踪、可测试

状态是系统的记忆,事件是系统的神经。两者各司其职,架构才会稳。

相关推荐
yuki_uix1 小时前
用 useState 管理服务端数据?不如试试 React Query 来“避坑”
前端
薛定e的猫咪1 小时前
Vibe Coding范式实战:用AI工具链(Stitch+Figma+ai studio+Trae)快速开发全栈APP
前端·人工智能·react.js·github·figma
折七1 小时前
2026 年 Node.js 后端技术选型,为什么我选了 Hono 而不是 NestJS
前端·后端·node.js
毕设源码-钟学长2 小时前
【开题答辩全过程】以 基于Vue的租房App为例,包含答辩的问题和答案
前端·javascript·vue.js
CappuccinoRose2 小时前
HTML语法学习文档 - 汇总篇
前端·学习·html5
a1117763 小时前
星球主题个人主页(纯HTML 开源)
前端·html
空条jo太郎3 小时前
echarts图表联动
前端
webkubor3 小时前
2026 年 把网页交互的主控权拿回前端手中 🚀
前端·javascript·人工智能
凯里欧文4273 小时前
极简版前端版本检测方案
前端·webpack