Vue 状态管理与复杂业务建模实战
很多后端同学写 Vue 时,最开始会把状态当作“页面变量”:当前页面需要什么,就在当前页面定义什么。这个模式在简单页面有效,但在复杂业务中很快会失控,主要表现为:
- 同一份业务数据在多个页面各存一份,修改后难以同步。
- 页面切换后状态残留,出现“上个用户操作影响下个用户”的脏数据问题。
- 组件通信链路越来越长,props 和 emits 层层传递,维护成本急剧上升。
- 一旦需求变成“跨页面联动 + 缓存 + 回滚”,代码几乎无法稳定演进。
本质原因是:没有把状态管理当成“业务建模问题”,而只是“组件技巧问题”。
本文目标是帮你建立一套可工程化落地的状态管理体系,核心基于 Vue3 + Pinia,并借鉴后端常用的分层与领域建模思想,让你在复杂需求下依然可控。
一、先给状态分层:不是所有状态都该进 store
最常见误区是“全量进 Pinia”或“全量留页面”。正确做法是分层。
1. 视图临时状态(View Local State)
例如弹窗开关、当前 tab、输入框临时值。生命周期通常只在当前页面或组件。
建议:优先使用组件内 ref/reactive。
2. 页面级业务状态(Page Domain State)
例如当前页面的筛选条件、分页信息、表格选中项,可能在页面内多个子组件共享。
建议:可在页面 composable 中集中管理,必要时再入 store。
3. 跨页面共享状态(App Shared State)
例如用户信息、权限、租户信息、全局字典、主题配置。
建议:放 Pinia,保证单一数据源。
4. 服务端权威状态(Server Source of Truth)
例如订单详情、库存、审批流节点。前端只是缓存或投影,不是最终真相。
建议:明确缓存策略,不要把前端状态当数据库。
这四层划分能解决 80% 的状态混乱问题。
二、Pinia 不是“全局变量仓库”,而是领域状态容器
后端同学可以把每个 store 理解为“领域上下文”。
1. 推荐按业务域拆 store
不要按技术类型拆(如一个 commonStore 放所有东西)。建议:
useAuthStoreuseUserStoreuseOrderStoreuseDictStore
每个 store 只负责一个明确领域,接口和状态边界清晰。
2. Store 的职责边界
state:持久业务状态。getters:纯衍生计算。actions:状态变更和异步流程。
不要在 getters 里做副作用,也不要在 actions 里直接操作 DOM。
三、一个可落地的 store 目录结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
src/
stores/
modules/
auth.ts
user.ts
order.ts
dict.ts
index.ts
services/
order-service.ts
user-service.ts
types/
order.ts
user.ts
说明:
stores管状态编排。services管外部交互(接口、缓存策略)。types管契约定义。
这样做能避免 store 既当控制器又当 DAO 的“大一统混乱”。
四、状态建模的关键:先定义不变量
后端做领域设计时会先定义不变量,前端也一样。
举例:订单页状态可定义为:
list与total必须来自同一次查询结果。filters变更后分页应重置到第一页。selectedIds只能属于当前list。detailCache的 key 必须是订单 id,且可失效。
把这些规则写进 action 逻辑,系统稳定性会高很多。
五、Pinia 进阶:组合式 store 与可测试性
1. 组合式定义(setup store)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export const useOrderStore = defineStore('order', () => {
const list = ref<OrderItem[]>([])
const total = ref(0)
const loading = ref(false)
const hasData = computed(() => list.value.length > 0)
async function queryOrders(params: QueryParams) {
loading.value = true
try {
const res = await fetchOrderPage(params)
list.value = res.list
total.value = res.total
} finally {
loading.value = false
}
}
return { list, total, loading, hasData, queryOrders }
})
优势:类型推导自然、复用组合式 API 更灵活。
2. 可测试性增强
把外部依赖(接口函数)注入或封装到 service,store 单测就更容易做 mock。
六、缓存策略:前端状态不是越“全”越好
前端缓存必须回答三个问题:
- 缓存什么(对象、列表、字典)?
- 缓多久(TTL)?
- 何时失效(主动失效、被动失效)?
1. 常见缓存模式
- 字典类:长 TTL,启动时预加载。
- 列表类:短 TTL,按筛选条件分 key 缓存。
- 详情类:读多写少可缓存,写后要精准失效。
2. 切忌“永不过期缓存”
很多“数据不一致” bug 都来自没有失效策略。
七、并发请求治理:避免状态回写覆盖
复杂页面经常出现并发请求。
场景:用户快速修改筛选条件,连续触发请求 A/B/C,结果 C 先回,A 后回,A 把 C 的新结果覆盖了。
解决方案:
- 请求序列号(只接受最新序号响应)。
- AbortController 取消旧请求。
- 在 action 内做“最后写入保护”。
这和后端并发写冲突处理思路一致。
八、乐观更新与回滚:提升交互体验的关键
比如切换用户状态开关:
- 前端先更新 UI(乐观更新)。
- 异步调用接口。
- 失败则回滚状态并提示。
建议封装一个通用 helper:
- 接收旧值、新值、提交函数、回滚函数。
- 统一处理 loading、错误提示和重试。
这样在多个页面复用时行为一致。
九、跨 store 协作:避免环依赖
复杂系统里 store 之间会互相依赖。常见错误是直接互调造成环依赖。
建议:
- 尽量通过 service 层协调。
- 必要时在 action 内延迟获取对方 store。
- 把共享逻辑提取为 composable 或 domain service。
不要让 store 彼此强耦合,否则后续改动风险很大。
十、和后端接口契约对齐:状态模型先行
后端同学有个天然优势:能主动推进契约一致性。
建议做法:
- 先定义 TS 类型再写页面。
- 分页结构统一(
list,total)。 - 错误码语义统一(业务错误 vs 系统错误)。
- 时间字段统一(时区、格式、空值语义)。
这些规范会直接降低状态处理复杂度。
十一、持久化策略:哪些状态该落 localStorage
不是所有 store 都要持久化。
推荐:
- 可以持久化:token、用户偏好、主题配置、非敏感字典。
- 不建议持久化:临时查询结果、敏感业务数据、一次性流程状态。
持久化前先问:刷新后恢复它,对用户是收益还是风险?
十二、调试与观测:状态系统也需要“可观测性”
后端有日志和链路追踪,前端状态也要有。
建议:
- 对关键 action 打结构化日志(开发环境)。
- 记录状态变更前后摘要(不要全量打印大对象)。
- 关键失败路径上报埋点(如回滚次数、请求冲突次数)。
这样排查“偶发状态错乱”会快很多。
十三、一个实战案例:审批工作台状态建模
需求:审批工作台包含“待办、已办、抄送”三栏,支持批量审批、详情侧栏、跨标签切换保留筛选条件。
常见失败实现:
- 三栏各写一套状态,代码重复。
- 筛选条件混在全局 store,互相污染。
- 详情缓存没有失效,导致旧数据残留。
更稳的实现:
- 抽象统一
TaskListState模型,按 tab key 存储。 - 筛选条件按 tab 维度隔离。
- 列表缓存和详情缓存分离管理。
- 批量操作后精准失效当前 tab 数据。
- 侧栏详情加载走独立 action,避免阻塞列表交互。
收益:代码复用高、边界清晰、回归可控。
十四、从页面驱动到领域驱动
初级写法通常是“页面上有什么就写什么”。进阶后要变成“业务规则驱动状态结构”。
你可以把前端状态层理解成一个轻量领域模型:
- 实体:订单、用户、任务。
- 值对象:筛选条件、分页参数。
- 领域服务:查询、提交、回滚、同步。
- 应用服务:跨模块编排。
这种建模思维会让大型前端系统更稳。
十五、五个反模式(尽量避免)
- 一个超大 store 管全部状态。
- 页面随意改 store 内部结构。
- getter 中做异步请求。
- action 里混杂 UI 副作用。
- 没有缓存失效策略。
出现任意两项,维护成本会迅速上升。
十六、迁移旧项目的最小策略
如果你接手的是老项目,不要一次性重构。
推荐节奏:
- 新需求先按新分层落地。
- 提炼重复逻辑到 service/composable。
- 把高频模块优先迁移到领域化 store。
- 每个迭代迁移一小块并补回归。
这样能持续改善,不会因“大重构”拖垮交付。
十七、测试建议:状态管理必须可回归
至少覆盖:
- action 成功/失败分支。
- 并发请求覆盖保护。
- 缓存命中与失效逻辑。
- 乐观更新回滚路径。
即使你暂时没有完善的前端测试基础设施,也建议先对关键 store 做最小单测。
十八、后端同学如何快速建立优势
你可以把后端经验直接迁移:
- 用不变量思维建模状态。
- 用事务思维设计提交与回滚。
- 用缓存思维设计状态失效。
- 用并发思维处理竞态覆盖。
这会让你在复杂前端业务里比“只会写组件”的路径更快进入核心区。
十九、总结
状态管理不是技术选型题,而是系统设计题。Pinia 只是工具,真正决定质量的是:
- 是否做了清晰的状态分层。
- 是否把业务不变量落实到 action。
- 是否有可观测、可回滚、可失效的机制。
当你做到这三点,你的 Vue 项目就能从“能跑”走向“可持续演进”。
二十、建议练习题(状态进阶)
- 用 Pinia 重构一个复杂页面,把状态分为四层并写说明文档。
- 给一个列表查询 action 加入并发覆盖保护。
- 为一个开关操作实现乐观更新 + 回滚通用封装。
- 把一个
commonStore拆成按领域划分的多个 store。 - 给关键 store 增加 3 个单测(成功/失败/并发)。
完成这组练习,你会明显感受到复杂业务的可控性提升。
二十一、状态版本化:给复杂页面加“可回溯能力”
在一些关键业务(例如审批、报价、配置平台)中,用户经常会提出“撤销上一步”“恢复草稿”“比较两次修改差异”。如果状态没有版本化策略,需求会非常痛苦。
可行方案:
- 为关键状态维护
history栈(限制长度,避免内存膨胀)。 - 每次提交前记录快照摘要,而不是全量深拷贝。
- 提供
undo/redoaction,统一处理状态回放。
注意:快照要基于业务语义抽取,不要盲目 clone 全对象,否则性能和内存都顶不住。
二十二、表单状态建模:草稿态与持久态分离
很多页面的 bug 都来自“编辑态和已保存态混在一起”。建议分离:
persistedModel:服务端确认过的数据。draftModel:用户当前正在编辑的数据。dirtyFields:记录变更字段,支持差异提交。
收益:
- 能做“仅提交变更字段”。
- 能准确提示“有未保存修改”。
- 能在保存失败后保留编辑上下文。
这套模型非常适合后端同学,和 ORM 实体的脏检查思路高度一致。
二十三、状态迁移与兼容:版本升级别破坏旧数据
当你持久化了一些前端状态(本地缓存),后续结构升级时要考虑兼容。建议在 store 增加 schemaVersion:
- 启动时检测本地版本。
- 旧版本执行迁移函数。
- 迁移失败就回退默认值并清理旧缓存。
这和后端数据库 migration 思路一致。没有版本迁移,线上会出现“新版本读不了旧缓存”的隐蔽问题。
二十四、状态观测面板:降低协作沟通成本
复杂系统里,前后端和测试经常对“当前状态到底是什么”理解不一致。建议在开发环境提供轻量状态观测面板:
- 展示核心 store 快照。
- 展示最近 action 调用序列。
- 展示关键缓存命中/失效记录。
这会极大提升联调效率。很多“我这边复现不了”的问题,本质是状态上下文不同。
二十五、状态治理的团队规范建议
为了避免项目后期失控,建议落地以下规范:
- 新增 store 必须有职责说明(一句话定义边界)。
- getter 必须保持纯函数语义。
- action 命名统一动词前缀(
fetch/update/reset/apply)。 - 跨 store 依赖必须说明原因,避免隐式耦合。
- 关键 action 必须覆盖失败路径。
这些规范看似简单,但会显著提升多人协作稳定性。
二十六、结语补充:状态管理是前端系统设计能力的核心
当业务变复杂,组件技巧的价值会下降,系统设计能力的价值会上升。状态管理就是这个分界点。你只要能把“状态分层、边界治理、并发控制、回滚恢复”做好,前端系统就能长期可维护。
对后端工程师来说,这并不是陌生领域,而是你擅长能力的迁移:把分层、事务、缓存、一致性、观测这些工程原则落到前端。做到这一点,你会成为团队里少数能稳定处理复杂前端业务的人。