一、原则:副作用放的位置
1️⃣ 核心原则
副作用(Effect / IO / 网络 / DB / Timer / 文件操作)应该只在响应式链条的末端产生,并且不污染 State 的纯函数更新逻辑。
换句话说:
Action -> Reducer (纯 State 变更)
-> Effect (副作用)
- Reducer = 纯函数,不执行网络 / DB / UI
- Effect / SideEffect = 可组合、可取消、可追踪的副作用
2️⃣ 为什么要这样
-
保证 UI 可预测性
- Reducer 执行不依赖异步 / 外部副作用 → 输入 State + Action → 输出 State 一定可预测
-
保证可测试性
- 纯 Reducer 可以单元测试,无需网络或文件
- Effect 可以 mock / 注入测试
-
保证可组合 / 可取消
- 在 Combine / Rx / TCA 中,Effect 可以延迟、并发、取消
- 避免副作用直接嵌入 body 或 State → UI 失控
二、响应式链条中的副作用位置
1️⃣ 理想链条
UI Event / Timer / Notification
↓
Action (输入)
↓
Reducer (纯函数)
↓
新 State
↓
UI 渲染
↓
Effect (异步副作用)
↓
发回 Action -> Reducer (循环)
- Effect 是 State 变更之外的输出
- 可以并发、延迟、取消
- 保持 State / UI 可预测
2️⃣ 错误做法示例
// 错误:Reducer 内直接发网络请求
func reducer(state: inout AppState, action: Action) {
switch action {
case .loadData:
api.fetch() { data in
state.items = data // 副作用直接改变 State
}
}
}
-
问题:
- Reducer 不再纯 → 难以测试
- State 改变随异步顺序 → UI 不可预测
- 并发多个 loadData → 竞态条件
-
这就是“响应式外壳 + 命令式内核”的开始
三、为什么很多项目退化成“响应式外壳 + 命令式内核”
1️⃣ 初期设计
- 使用 Combine / Rx 封装 UI → 响应式外壳
- State / Action / Publisher 形成“看起来响应式”的链条
2️⃣ 业务逻辑复杂
- 异步请求、缓存、数据库、文件操作越来越多
- 开发者为了“快” → 在 Reducer / State 内直接写网络 / DB
- 副作用混入 → Reducer 不再纯 → 状态不可预测
3️⃣ 高频修改 + 并发
- 响应式链条外的副作用,执行时机不可控
- UI / State 逻辑开始依赖副作用结果 → 回到命令式思路
🔑 工程规律总结
| 原则 | 做法 | 结果 |
|---|---|---|
| 副作用外置 | Reducer 返回 Effect / Publisher | 可测试、可取消、可组合、UI 可预测 |
| 副作用内置 | Reducer / State 内直接发请求 | 响应式外壳 + 命令式内核 → 状态不可预测,难回溯 |
核心区别 = 纯函数 Reducer + Effect vs 命令式副作用混入 Reducer
四、实践建议
-
Reducer = 纯函数
- 永远只负责计算新 State
- 不做 IO、网络、文件、定时器、动画
-
Effect / SideEffect 层
- 所有异步操作、外部 IO 放这里
- 可以取消 / debounce / retry
- 输出 Action → Reducer 再处理
-
UI 只观察 State / Action
- 不依赖副作用完成状态
- 保持渲染可预测
-
日志 & 调试
- 副作用可记录日志、可回放
- 保持响应式链条透明
五、一句话总结
副作用应该在响应式链条的末端、通过可组合 Effect 触发,Reducer 保持纯函数。
否则,UI 表面响应式,但内核命令式,最终不可预测、不可测试、不可回滚。