- 本文来自《后端系统设计复盘:从游戏项目到通用后端》专栏。
- 文章内容源于我正在实现的一个 Balatro 风格实时游戏后端项目,但本文不会重点讲某个具体游戏功能怎么写,而是抽出一个更通用的后端设计问题:当一个状态对象里的字段越来越多时,什么时候应该继续放在一起,什么时候应该拆成更清晰的子状态。
- 这个问题不只存在于游戏后端,在订单系统、活动系统、审批系统、任务系统里也很常见。只要一个系统里存在"运行时状态",就很容易遇到类似问题:一开始只是几个字段,后面慢慢变成一个什么都能放的大对象。
这篇文章想表达的核心观点是:
- 状态对象字段变多本身不一定是问题,真正的问题是字段背后的业务归属、生命周期和更新边界开始变得不清楚。
- 状态拆分不是为了让代码看起来更复杂,而是为了让系统结构表达清楚:哪些状态应该一起变化,哪些状态应该分开维护。
- 顶层状态不应该变成字段仓库,而应该作为聚合入口,组织不同职责的子状态。
文章目录
- 一、这个问题是怎么出现的
- 二、什么是"状态垃圾桶对象"
-
- [1. 字段变多不是问题,归属不清才是问题](#1. 字段变多不是问题,归属不清才是问题)
- [2. 一个容易变乱的例子:阶段结束和流程结束混在一起](#2. 一个容易变乱的例子:阶段结束和流程结束混在一起)
- [3. 怎么判断一个对象开始变成状态垃圾桶](#3. 怎么判断一个对象开始变成状态垃圾桶)
- 三、先看重构前后对比
- 四、为什么不是一开始就拆得很细
- 五、什么时候应该拆分状态对象
-
- [1. 字段是否已经出现明显分组](#1. 字段是否已经出现明显分组)
- [2. 不同字段是否有不同生命周期](#2. 不同字段是否有不同生命周期)
- [3. 修改某类状态时,是否经常误伤其他状态](#3. 修改某类状态时,是否经常误伤其他状态)
- [4. 新增字段时,是否总是"不知道放哪"](#4. 新增字段时,是否总是“不知道放哪”)
- 六、拆分状态,不只是为了好看
-
- [1. 初始化更清楚](#1. 初始化更清楚)
- [2. 重置更安全](#2. 重置更安全)
- [3. 返回结构也更容易分层](#3. 返回结构也更容易分层)
- 七、通用后端里也有类似问题
- 八、我现在对聚合状态的理解
- 九、怎么判断这次拆分是有效的
-
- [1. 看新增状态时,会不会继续往顶层塞](#1. 看新增状态时,会不会继续往顶层塞)
- [2. 看修改某类状态时,影响范围是否更集中](#2. 看修改某类状态时,影响范围是否更集中)
- 十、本篇小结
一、这个问题是怎么出现的
- 下面我会用一个游戏运行时状态
GameState举例,但这里讨论的不是游戏特有问题,而是很多后端系统都会遇到的"聚合状态膨胀"问题。- 如果换成普通业务系统,
GameState可以类比成OrderState、ActivityRuntimeState、ApprovalState。它们本质上都在描述:当前这个业务实例运行到了什么状态。
为了让这篇文章可以独立阅读,先解释几个后面会反复出现的词:
| 名词 | 在本文里的含义 | 普通业务里的类比 |
|---|---|---|
GameState |
当前一局游戏的运行时状态 | 一笔订单的当前状态、一次审批流程的当前状态 |
PlayerState |
用户当前资源状态,比如手牌、牌堆、剩余次数 | 用户余额、库存、任务次数、可用权益 |
StageState |
当前阶段进度状态,比如当前关卡、目标分、阶段得分 | 订单节点、审批节点、活动阶段 |
Blind |
游戏里的一个阶段关卡 | 一个任务阶段、一个订单节点、一个流程节点 |
ante |
游戏里的阶段进度层级 | 第几轮、第几期、第几个流程阶段 |
| 生命周期 | 一个状态从创建、更新到重置或清理的过程 | 订单创建、支付、发货、完成、取消 |
在项目早期设计状态结构时,我并不是没有意识到这些字段后面可能会继续扩展,只是当时系统关注的事情还比较少,所以我更多是先按当前功能闭环来组织状态。
比如一个用户当前需要维护的状态,最开始无非就是:当前手牌、剩余牌堆、剩余出牌次数、剩余弃牌次数、当前分数、游戏是否结束。
这些字段直接放在一个 GameState 里,看起来完全没问题。
ts
type GameState = {
playerId: string;
deck: Card[];
hand: string[];
playsLeft: number;
discardsLeft: number;
round: number;
score: number;
targetScore: number;
gameStatus: "playing" | "finished";
};
如果只看早期字段,它们好像都只是"当前状态"。但随着后面继续加入阶段、进度、目标分、当前阶段得分、临时效果、奖励结果这些字段,问题就开始变得明显。
| 字段类型 | 示例字段 | 表面上看 | 后面会暴露的问题 |
|---|---|---|---|
| 用户资源字段 | hand、deck、playsLeft |
只是记录用户当前资源 | 需要跟随用户操作频繁变化 |
| 流程进度字段 | round、ante、blindType |
只是记录当前进度 | 会影响下一阶段如何推进 |
| 目标判断字段 | targetScore、currentBlindScore |
只是用于判断是否达标 | 会影响阶段是否结束 |
| 整体状态字段 | gameStatus |
只是表示是否结束 | 会影响后续接口能否继续执行 |
所以问题不是这些字段不能放在一起,而是它们一开始都像是"当前状态",但后面会慢慢变成不同生命周期、不同职责、不同更新时机的状态。
在项目早期,这种写法很自然。因为那时系统关注的事情很少:发牌、出牌、弃牌、补牌、算分、判断是否结束。所有状态都围绕"当前这一局"展开,所以放在一个对象里,也不会显得特别乱。
但随着功能继续往后加,GameState 里的字段开始越来越多。一开始只是用户当前状态,后面开始出现阶段状态、关卡状态、目标分、当前阶段得分、当前轮次、当前进度。再往后,还可能继续出现奖励、商店、临时效果、构筑状态。
这时候我就开始感觉到不对劲了:
这个对象好像什么都能放,但越来越看不出每个字段到底属于谁。
也就是我后来一直说的:
GameState开始有点变成一个状态垃圾桶对象了。
二、什么是"状态垃圾桶对象"
我现在理解的"状态垃圾桶对象",不是说这个对象从一开始就设计错了,而是它开始出现一种倾向:
只要有新状态,不知道放哪里,就先往这个对象里塞。
短期看,这样很方便。比如新增当前阶段得分,就加一个字段 currentBlindScore;新增当前阶段类型,就继续加一个字段 blindType;后面需要记录阶段进度,再加一个字段 ante。
每次都只是多一个字段,单独看都合理,也不一定会马上造成问题。真正麻烦的是,这种写法会慢慢变成一种默认习惯:只要遇到新状态,就先放进 GameState,而不是先判断这个状态到底属于哪一类业务含义。
1. 字段变多不是问题,归属不清才是问题
随着功能继续增加,原本还比较清爽的 GameState,很容易慢慢变成这样:
ts
type GameState = {
playerId: string;
deck: Card[];
hand: string[];
playsLeft: number;
discardsLeft: number;
handSize: number;
currentActionScore: number;
totalScore: number;
round: number;
ante: number;
blindType: BlindType;
targetScore: number;
currentBlindScore: number;
gameStatus: "initialized" | "playing" | "finished";
};
单看每个字段都合理。hand 表示当前手牌,deck 表示剩余牌堆,playsLeft 表示剩余出牌次数,targetScore 表示当前目标分,gameStatus 表示整体流程状态。它们都不是无意义字段,也不是随便乱加的字段。
但问题在于,这些字段放在一起后,背后的职责已经不一样了。
| 字段类型 | 示例字段 | 背后含义 |
|---|---|---|
| 用户资源状态 | hand、deck、playsLeft |
用户当前拥有什么、还能做什么 |
| 阶段进度状态 | round、ante、blindType |
当前流程推进到了哪里 |
| 分数结算状态 | targetScore、currentBlindScore |
当前阶段目标和累计结果 |
| 整体流程状态 | gameStatus |
整个流程是否还能继续 |
所以状态垃圾桶对象最麻烦的地方,不只是字段数量变多,而是字段背后的业务归属、生命周期、更新时机开始混在一起。
它不是一开始就乱,而是随着系统成长,慢慢变得难以维护。
2. 一个容易变乱的例子:阶段结束和流程结束混在一起
这种问题在早期通常不会一下子暴露出来。
比如一开始只有一个 gameStatus 字段,用来表示当前流程是否还在进行中:
ts
type GameState = {
// ... 其他字段
gameStatus: "playing" | "finished";
};
在功能还少的时候,这样写是够用的,因为当时只需要判断一件事:
当前游戏是否结束。
但后面流程开始变多之后,系统里可能会区分:当前阶段是否结束、当前关卡是否通过、整体流程是否结束、是否进入结算、是否进入下一阶段。
这时候如果继续把所有状态都塞进 gameStatus,就很容易变成这样:
ts
type GameState = {
// ... 其他字段
gameStatus: "playing" | "stageFinished" | "settlement" | "finished";
};
表面看只是多了几个枚举值,但实际上 gameStatus 的语义已经开始变得不清楚了。它到底是在表达当前阶段状态,还是整体流程状态?是在表达结算状态,还是页面展示状态?这些含义如果都压在一个字段里,后面的判断就会越来越别扭。
| 场景 | 混在一个字段里的问题 | 拆开后的表达 |
|---|---|---|
| 判断当前阶段是否结束 | 需要猜 stageFinished 算不算结束 |
stageState.stageOver |
| 判断整体流程是否结束 | 容易和阶段结束混在一起 | gameStatus |
| 判断是否进入结算 | 需要从枚举值里反推 | progress.settlement |
| 判断是否进入下一阶段 | 多个状态互相影响 | stageState + progress |
所以状态垃圾桶的典型演化路径,往往不是一开始就把代码写乱,而是每次都只是"顺手加一个字段"或者"顺手加一个枚举值"。等到流程变多之后,原来的字段就开始承载太多含义。
这也是我后来更倾向于拆分状态的原因:
不是字段不能加,而是要先判断这个字段到底属于哪个状态生命周期。
3. 怎么判断一个对象开始变成状态垃圾桶
类似的问题在很多后端系统里都会出现,只是字段名不一定叫 gameStatus。有时它可能是 orderStatus,有时是 activityStatus,也可能是一个越来越大的 runtimeState。
我现在会用下面这些信号,判断一个状态对象是不是已经开始往"状态垃圾桶"方向发展:
| 信号 | 具体表现 | 说明 |
|---|---|---|
| 新字段默认往大对象里塞 | 不知道放哪里,就先放到 GameState / OrderState |
顶层对象开始变成兜底位置 |
| 字段含义越来越不一样 | 资源、进度、结算、临时状态都在一个对象里 | 字段背后的业务职责开始分叉 |
| 重置逻辑越来越危险 | 重置某些字段时,总担心误删别的字段 | 状态生命周期边界不清 |
| 返回结构越来越平 | 所有字段都平铺给前端 | 前端也需要猜字段归属 |
| 新功能越来越难接 | 奖励、商店、临时效果都想继续往里塞 | 大对象开始承接过多未来变化 |
如果这些信号开始同时出现,就说明问题已经不只是"字段多"了,而是这个对象已经从"状态聚合"慢慢变成了"状态兜底"。
状态垃圾桶真正危险的地方,不是它马上会让系统跑不起来,而是它会让后续每一次修改都变得更不确定。新增字段时不知道应该放哪里,重置状态时不知道哪些字段应该一起重置,返回数据时也不知道哪些字段属于用户状态、阶段状态,还是本次结算结果。
所以我现在更愿意把这个问题提前看清楚:
大对象本身不一定错,但如果它开始承载不同职责、不同生命周期、不同更新边界的状态,就应该考虑拆分。
三、先看重构前后对比
我觉得这个问题用表格看会更直观。
| 对比项 | 重构前:字段平铺在聚合状态对象 | 重构后:按职责拆分子状态 |
|---|---|---|
| 字段组织 | 所有状态都放在一个对象里 | 按业务含义拆成多个子对象 |
| 阅读体验 | 字段多了以后很难看出归属 | 一眼能看出状态分组 |
| 初始化逻辑 | 容易混在一起 | 可以按模块初始化 |
| 重置逻辑 | 容易误删或漏删字段 | 可以按状态类型重置 |
| 扩展新功能 | 往大对象里继续塞字段 | 优先判断属于哪个子状态 |
| 维护成本 | 随字段数量增加而上升 | 结构更稳定 |
可以简单理解成:
| 结构 | 含义 |
|---|---|
重构前的 GameState |
什么都放的大对象 |
重构后的 GameState |
顶层聚合入口 |
PlayerState |
用户资源状态 |
StageState / BlindState |
阶段进度状态 |
这里的 BlindState 可以理解成更贴近当前游戏项目的命名,意思是"当前关卡阶段的状态";如果放到更通用的业务系统里,它也可以叫 StageState 或 FlowState。
重构前后对比图
#mermaid-svg-RzAav5wdo9hYIhDP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RzAav5wdo9hYIhDP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RzAav5wdo9hYIhDP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RzAav5wdo9hYIhDP .error-icon{fill:#552222;}#mermaid-svg-RzAav5wdo9hYIhDP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RzAav5wdo9hYIhDP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RzAav5wdo9hYIhDP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RzAav5wdo9hYIhDP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RzAav5wdo9hYIhDP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RzAav5wdo9hYIhDP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RzAav5wdo9hYIhDP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RzAav5wdo9hYIhDP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RzAav5wdo9hYIhDP .marker.cross{stroke:#333333;}#mermaid-svg-RzAav5wdo9hYIhDP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RzAav5wdo9hYIhDP p{margin:0;}#mermaid-svg-RzAav5wdo9hYIhDP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RzAav5wdo9hYIhDP .cluster-label text{fill:#333;}#mermaid-svg-RzAav5wdo9hYIhDP .cluster-label span{color:#333;}#mermaid-svg-RzAav5wdo9hYIhDP .cluster-label span p{background-color:transparent;}#mermaid-svg-RzAav5wdo9hYIhDP .label text,#mermaid-svg-RzAav5wdo9hYIhDP span{fill:#333;color:#333;}#mermaid-svg-RzAav5wdo9hYIhDP .node rect,#mermaid-svg-RzAav5wdo9hYIhDP .node circle,#mermaid-svg-RzAav5wdo9hYIhDP .node ellipse,#mermaid-svg-RzAav5wdo9hYIhDP .node polygon,#mermaid-svg-RzAav5wdo9hYIhDP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RzAav5wdo9hYIhDP .rough-node .label text,#mermaid-svg-RzAav5wdo9hYIhDP .node .label text,#mermaid-svg-RzAav5wdo9hYIhDP .image-shape .label,#mermaid-svg-RzAav5wdo9hYIhDP .icon-shape .label{text-anchor:middle;}#mermaid-svg-RzAav5wdo9hYIhDP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RzAav5wdo9hYIhDP .rough-node .label,#mermaid-svg-RzAav5wdo9hYIhDP .node .label,#mermaid-svg-RzAav5wdo9hYIhDP .image-shape .label,#mermaid-svg-RzAav5wdo9hYIhDP .icon-shape .label{text-align:center;}#mermaid-svg-RzAav5wdo9hYIhDP .node.clickable{cursor:pointer;}#mermaid-svg-RzAav5wdo9hYIhDP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RzAav5wdo9hYIhDP .arrowheadPath{fill:#333333;}#mermaid-svg-RzAav5wdo9hYIhDP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RzAav5wdo9hYIhDP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RzAav5wdo9hYIhDP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RzAav5wdo9hYIhDP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RzAav5wdo9hYIhDP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RzAav5wdo9hYIhDP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RzAav5wdo9hYIhDP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RzAav5wdo9hYIhDP .cluster text{fill:#333;}#mermaid-svg-RzAav5wdo9hYIhDP .cluster span{color:#333;}#mermaid-svg-RzAav5wdo9hYIhDP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-RzAav5wdo9hYIhDP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RzAav5wdo9hYIhDP rect.text{fill:none;stroke-width:0;}#mermaid-svg-RzAav5wdo9hYIhDP .icon-shape,#mermaid-svg-RzAav5wdo9hYIhDP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RzAav5wdo9hYIhDP .icon-shape p,#mermaid-svg-RzAav5wdo9hYIhDP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RzAav5wdo9hYIhDP .icon-shape .label rect,#mermaid-svg-RzAav5wdo9hYIhDP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RzAav5wdo9hYIhDP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RzAav5wdo9hYIhDP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RzAav5wdo9hYIhDP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 重构后:按职责聚合
重构前:字段平铺
职责边界变清楚
GameState
什么都承担
用户资源
hand / deck / playsLeft
阶段进度
round / ante / blindType
分数状态
score / targetScore
流程状态
gameStatus
GameState
顶层聚合入口
PlayerState
用户资源状态
StageState
阶段进度状态
gameStatus
整体流程状态
重构后,GameState 仍然存在,但它的职责变了。
它不再负责直接平铺所有字段,而是负责组织不同职责的子状态:
ts
type GameState = {
playerId: string;
playerState: PlayerState;
stageState: StageState;
gameStatus: "initialized" | "playing" | "finished";
};
其中 PlayerState 表示用户资源状态,比如手牌、牌堆、剩余次数:
ts
type PlayerState = {
deck: Card[];
hand: string[];
playsLeft: number;
discardsLeft: number;
handSize: number;
currentActionScore: number;
};
StageState 表示当前阶段进度状态,比如当前轮次、当前阶段类型、目标分和阶段累计分:
ts
type StageState = {
round: number;
ante: number;
stageType: "small" | "big" | "boss";
targetScore: number;
currentStageScore: number;
};
这样拆完之后,GameState 的职责会更清楚。
text
GameState
├─ playerState:用户资源状态
├─ stageState:阶段进度状态
└─ gameStatus:整体流程状态
它不是"所有字段都往里塞",而是负责聚合当前业务运行时状态。这样一来,状态之间的边界就清楚很多了。
四、为什么不是一开始就拆得很细
这里还有一个容易误解的地方。
既然最后还是要拆,那是不是一开始就应该设计成这样?
我现在的理解是:不一定。
在项目早期,功能还比较少,过早拆分反而可能让结构变得复杂。
比如一开始就设计:
text
PlayerState
StageState
RewardState
ShopState
EffectState
SettlementState
RuntimeState
看起来很完整,但实际很多系统还没出现。这时候拆得太细,反而会带来几个问题:我并不确定后面真的需要这些状态,很多结构只是预想出来的,代码层级变多但实际承载的逻辑很少,后面需求变化时也可能还要推翻。
所以状态拆分不是越早越好,也不是越细越好。
| 情况 | 是否需要立刻拆 | 原因 |
|---|---|---|
| 字段数量不多,且含义接近 | 不一定 | 过早拆分会增加结构复杂度 |
| 字段总是一起初始化、一起更新 | 可以暂时不拆 | 生命周期一致,放在一起问题不大 |
| 后续扩展方向还不明确 | 不建议强行拆 | 过早抽象可能会拆错方向 |
| 只是为了"看起来更架构" | 不建议 | 拆分应该解决真实问题 |
| 已经出现不同职责和生命周期 | 建议拆 | 继续平铺会越来越难维护 |
真正适合拆分的时机,是字段背后的职责边界已经开始出现。更准确地说:
当字段已经开始出现明显分组,并且不同分组有不同生命周期时,就应该考虑拆分。
也就是说,拆分不应该是为了"看起来像架构",而是为了解决真实的混乱。
五、什么时候应该拆分状态对象
我现在会从几个角度判断。
1. 字段是否已经出现明显分组
如果一个对象里有些字段总是一起出现、一起初始化、一起更新,就说明它们可能属于同一组状态。
例如:hand、deck、playsLeft、discardsLeft、handSize,这些字段明显和用户当前资源有关;而 round、ante、stageType、targetScore、currentStageScore,这些字段明显和阶段进度有关。
当这种分组越来越清楚时,就可以考虑拆。
2. 不同字段是否有不同生命周期
这是我觉得最关键的判断。
比如用户手牌和阶段进度,它们的生命周期不一定一样。用户进入新阶段时,手牌可能要重新发,出牌次数可能要重置,当前阶段分数可能要清零,但整体进度可能还要继续保留。
如果所有字段都平铺在一起,就很容易搞不清:
这次重置到底该重置哪些?
所以当不同字段有不同生命周期时,拆分就会很有价值。
3. 修改某类状态时,是否经常误伤其他状态
如果我修改用户资源状态时,总担心影响阶段状态;或者我重置阶段状态时,总担心把用户资源也清掉;这就说明状态边界已经不清晰了。
这个时候继续靠注释和记忆维护,就不太稳。
4. 新增字段时,是否总是"不知道放哪"
如果每次加新字段时,第一反应都是:
先放
GameState里吧。
那就要小心了。
因为这说明 GameState 已经变成默认兜底位置,而一个对象一旦变成兜底位置,就很容易继续膨胀。
#mermaid-svg-id6nBFx5WmvnBiGw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-id6nBFx5WmvnBiGw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-id6nBFx5WmvnBiGw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-id6nBFx5WmvnBiGw .error-icon{fill:#552222;}#mermaid-svg-id6nBFx5WmvnBiGw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-id6nBFx5WmvnBiGw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-id6nBFx5WmvnBiGw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-id6nBFx5WmvnBiGw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-id6nBFx5WmvnBiGw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-id6nBFx5WmvnBiGw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-id6nBFx5WmvnBiGw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-id6nBFx5WmvnBiGw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-id6nBFx5WmvnBiGw .marker.cross{stroke:#333333;}#mermaid-svg-id6nBFx5WmvnBiGw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-id6nBFx5WmvnBiGw p{margin:0;}#mermaid-svg-id6nBFx5WmvnBiGw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-id6nBFx5WmvnBiGw .cluster-label text{fill:#333;}#mermaid-svg-id6nBFx5WmvnBiGw .cluster-label span{color:#333;}#mermaid-svg-id6nBFx5WmvnBiGw .cluster-label span p{background-color:transparent;}#mermaid-svg-id6nBFx5WmvnBiGw .label text,#mermaid-svg-id6nBFx5WmvnBiGw span{fill:#333;color:#333;}#mermaid-svg-id6nBFx5WmvnBiGw .node rect,#mermaid-svg-id6nBFx5WmvnBiGw .node circle,#mermaid-svg-id6nBFx5WmvnBiGw .node ellipse,#mermaid-svg-id6nBFx5WmvnBiGw .node polygon,#mermaid-svg-id6nBFx5WmvnBiGw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-id6nBFx5WmvnBiGw .rough-node .label text,#mermaid-svg-id6nBFx5WmvnBiGw .node .label text,#mermaid-svg-id6nBFx5WmvnBiGw .image-shape .label,#mermaid-svg-id6nBFx5WmvnBiGw .icon-shape .label{text-anchor:middle;}#mermaid-svg-id6nBFx5WmvnBiGw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-id6nBFx5WmvnBiGw .rough-node .label,#mermaid-svg-id6nBFx5WmvnBiGw .node .label,#mermaid-svg-id6nBFx5WmvnBiGw .image-shape .label,#mermaid-svg-id6nBFx5WmvnBiGw .icon-shape .label{text-align:center;}#mermaid-svg-id6nBFx5WmvnBiGw .node.clickable{cursor:pointer;}#mermaid-svg-id6nBFx5WmvnBiGw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-id6nBFx5WmvnBiGw .arrowheadPath{fill:#333333;}#mermaid-svg-id6nBFx5WmvnBiGw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-id6nBFx5WmvnBiGw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-id6nBFx5WmvnBiGw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-id6nBFx5WmvnBiGw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-id6nBFx5WmvnBiGw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-id6nBFx5WmvnBiGw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-id6nBFx5WmvnBiGw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-id6nBFx5WmvnBiGw .cluster text{fill:#333;}#mermaid-svg-id6nBFx5WmvnBiGw .cluster span{color:#333;}#mermaid-svg-id6nBFx5WmvnBiGw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-id6nBFx5WmvnBiGw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-id6nBFx5WmvnBiGw rect.text{fill:none;stroke-width:0;}#mermaid-svg-id6nBFx5WmvnBiGw .icon-shape,#mermaid-svg-id6nBFx5WmvnBiGw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-id6nBFx5WmvnBiGw .icon-shape p,#mermaid-svg-id6nBFx5WmvnBiGw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-id6nBFx5WmvnBiGw .icon-shape .label rect,#mermaid-svg-id6nBFx5WmvnBiGw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-id6nBFx5WmvnBiGw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-id6nBFx5WmvnBiGw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-id6nBFx5WmvnBiGw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
否
是
否
是
字段开始变多
是否出现
明显分组
是否存在
不同生命周期
修改时是否
容易误伤
新字段是否
总往大对象塞
继续放一起
暂不拆分
考虑拆出子状态
优先拆分
避免垃圾桶化
可以简单总结成一张表:
| 判断问题 | 如果答案是"是" | 说明 |
|---|---|---|
| 字段是否已经出现明显分组? | 可以考虑拆分 | 说明字段背后已经有不同业务含义 |
| 不同字段是否有不同生命周期? | 优先考虑拆分 | 初始化、更新、清理规则可能已经不同 |
| 修改某类字段时是否担心误伤? | 应该考虑拆分 | 说明状态边界已经不清楚 |
| 新字段是否总是默认塞进大对象? | 应该警惕 | 大对象开始变成默认兜底位置 |
| 读对象时是否需要反复确认字段含义? | 可以考虑拆分 | 可读性和维护性已经下降 |
| 接口返回是否已经很难分层? | 可以考虑拆分 | 状态结构可能已经影响前后端协作 |
六、拆分状态,不只是为了好看
有时候重构状态结构,容易被误解成"代码洁癖"。
但我觉得不是。
状态拆分真正解决的,不是让代码看起来更规整,而是让后续初始化、更新、重置、返回和扩展都有更明确的边界。
1. 初始化更清楚
状态拆分后,最直观的变化不是代码行数变少,而是初始化时字段归属更清楚。
重构前,所有字段都平铺在 GameState 顶层:
ts
const gameState = {
playerId,
deck,
hand,
playsLeft,
discardsLeft,
handSize,
round,
ante,
blindType,
targetScore,
currentBlindScore,
gameStatus,
};
这种写法在字段少的时候问题不明显,但随着字段继续增加,读代码的人就需要自己判断:哪些字段属于用户资源,哪些字段属于阶段进度,哪些字段又是在表达整体流程状态。
重构后,可以把初始化结构改成按职责分组:
ts
const gameState = {
playerId,
playerState: {
deck,
hand,
playsLeft,
discardsLeft,
handSize,
currentActionScore,
},
stageState: {
round,
ante,
stageType,
targetScore,
currentStageScore,
},
gameStatus,
};
可以用一张图概括这个变化:
#mermaid-svg-TL0DQhvlIabFwcjj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-TL0DQhvlIabFwcjj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-TL0DQhvlIabFwcjj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-TL0DQhvlIabFwcjj .error-icon{fill:#552222;}#mermaid-svg-TL0DQhvlIabFwcjj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-TL0DQhvlIabFwcjj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-TL0DQhvlIabFwcjj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-TL0DQhvlIabFwcjj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-TL0DQhvlIabFwcjj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-TL0DQhvlIabFwcjj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-TL0DQhvlIabFwcjj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-TL0DQhvlIabFwcjj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-TL0DQhvlIabFwcjj .marker.cross{stroke:#333333;}#mermaid-svg-TL0DQhvlIabFwcjj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-TL0DQhvlIabFwcjj p{margin:0;}#mermaid-svg-TL0DQhvlIabFwcjj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-TL0DQhvlIabFwcjj .cluster-label text{fill:#333;}#mermaid-svg-TL0DQhvlIabFwcjj .cluster-label span{color:#333;}#mermaid-svg-TL0DQhvlIabFwcjj .cluster-label span p{background-color:transparent;}#mermaid-svg-TL0DQhvlIabFwcjj .label text,#mermaid-svg-TL0DQhvlIabFwcjj span{fill:#333;color:#333;}#mermaid-svg-TL0DQhvlIabFwcjj .node rect,#mermaid-svg-TL0DQhvlIabFwcjj .node circle,#mermaid-svg-TL0DQhvlIabFwcjj .node ellipse,#mermaid-svg-TL0DQhvlIabFwcjj .node polygon,#mermaid-svg-TL0DQhvlIabFwcjj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-TL0DQhvlIabFwcjj .rough-node .label text,#mermaid-svg-TL0DQhvlIabFwcjj .node .label text,#mermaid-svg-TL0DQhvlIabFwcjj .image-shape .label,#mermaid-svg-TL0DQhvlIabFwcjj .icon-shape .label{text-anchor:middle;}#mermaid-svg-TL0DQhvlIabFwcjj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-TL0DQhvlIabFwcjj .rough-node .label,#mermaid-svg-TL0DQhvlIabFwcjj .node .label,#mermaid-svg-TL0DQhvlIabFwcjj .image-shape .label,#mermaid-svg-TL0DQhvlIabFwcjj .icon-shape .label{text-align:center;}#mermaid-svg-TL0DQhvlIabFwcjj .node.clickable{cursor:pointer;}#mermaid-svg-TL0DQhvlIabFwcjj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-TL0DQhvlIabFwcjj .arrowheadPath{fill:#333333;}#mermaid-svg-TL0DQhvlIabFwcjj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-TL0DQhvlIabFwcjj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-TL0DQhvlIabFwcjj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TL0DQhvlIabFwcjj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-TL0DQhvlIabFwcjj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TL0DQhvlIabFwcjj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-TL0DQhvlIabFwcjj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-TL0DQhvlIabFwcjj .cluster text{fill:#333;}#mermaid-svg-TL0DQhvlIabFwcjj .cluster span{color:#333;}#mermaid-svg-TL0DQhvlIabFwcjj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-TL0DQhvlIabFwcjj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-TL0DQhvlIabFwcjj rect.text{fill:none;stroke-width:0;}#mermaid-svg-TL0DQhvlIabFwcjj .icon-shape,#mermaid-svg-TL0DQhvlIabFwcjj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TL0DQhvlIabFwcjj .icon-shape p,#mermaid-svg-TL0DQhvlIabFwcjj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-TL0DQhvlIabFwcjj .icon-shape .label rect,#mermaid-svg-TL0DQhvlIabFwcjj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TL0DQhvlIabFwcjj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-TL0DQhvlIabFwcjj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-TL0DQhvlIabFwcjj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 重构后:按职责分组
重构前:字段平铺
字段归属变清楚
GameState
用户资源字段
deck / hand / playsLeft
阶段进度字段
round / ante / blindType
分数目标字段
targetScore / currentBlindScore
整体流程字段
gameStatus
GameState
playerState
用户资源状态
stageState
阶段进度状态
gameStatus
整体流程状态
这样拆完之后,代码行数不一定更少,但结构表达更清楚。
读的人可以直接知道:
playerState里放的是用户资源状态stageState里放的是阶段进度状态gameStatus表示整体流程是否还能继续
所以初始化结构的变化,不只是"对象嵌套了一层"。
它真正解决的是:
初始化时就把字段归属表达出来,让后续更新、重置和返回都有更明确的边界。
2. 重置更安全
状态拆分后的第二个收益,是重置逻辑更安全。
比如进入新阶段时,可能只需要重置用户局内状态:
ts
playerState.hand = [];
playerState.playsLeft = INITIAL_PLAYS_LEFT;
playerState.discardsLeft = INITIAL_DISCARDS_LEFT;
playerState.currentActionScore = 0;
这些字段都集中在 playerState 里,所以读代码时会比较清楚:这里处理的是用户当前资源,而不是整个流程。
如果阶段状态也要变化,就单独处理 stageState:
ts
stageState.round += 1;
stageState.currentStageScore = 0;
stageState.targetScore = nextTargetScore;
这种拆分最大的好处是:重置某一类状态时,不需要在一堆顶层字段里到处找,也不容易顺手改到不该改的字段。
比如用户进入新阶段时,手牌和操作次数可能需要重置,但整体流程状态不一定要结束;当前阶段分数可能要清零,但当前 ante 或整体进度可能还要继续保留。如果这些字段全部平铺在 GameState 里,后续写重置逻辑时就更容易出错。
| 要处理的事情 | 更适合操作的状态 |
|---|---|
| 重置手牌、次数、当前操作分 | playerState |
| 推进阶段、刷新目标分 | stageState |
| 判断整体流程是否结束 | gameStatus |
所以状态拆分并不是让代码变短,而是让"该改哪里,不该改哪里"变得更清楚。
3. 返回结构也更容易分层
状态拆分后,接口返回给前端时,也可以自然分层。
json
{
"playerState": {
"hand": [],
"playsLeft": 3,
"discardsLeft": 2
},
"stageState": {
"round": 2,
"targetScore": 300,
"currentStageScore": 120
},
"gameStatus": "playing"
}
比起所有字段都平铺在最外层,这种结构对前端也更友好。
前端在渲染时可以更清楚地知道:用户区域用 playerState,阶段信息用 stageState,整体流程判断用 gameStatus。
这不是单纯为了"返回结构好看",而是让前后端对状态含义形成一致理解。如果返回结构是平铺的,前端需要自己猜哪些字段属于用户资源,哪些字段属于阶段进度,哪些字段只是本次操作结果;但如果后端已经按状态分层返回,前端就可以按模块消费数据。
如果从"设计动作"角度看,状态拆分带来的收益可以整理成这样:
| 设计动作 | 带来的收益 | 解决的问题 |
|---|---|---|
拆出 playerState |
用户资源集中维护 | 避免资源字段散在顶层 |
拆出 stageState |
阶段进度集中表达 | 避免流程字段和资源字段混在一起 |
保留 gameStatus |
整体流程状态更清楚 | 避免阶段结束和整体结束混用 |
| 返回结构按状态分组 | 前端更容易消费数据 | 避免前端从平铺字段里猜含义 |
| 按子状态写重置逻辑 | 生命周期更清楚 | 避免误清理或漏清理字段 |
所以拆分状态不是为了让代码"看起来更高级"。
它真正带来的变化是:
初始化、更新、重置、返回、扩展,都开始有了明确边界。
它不一定会让代码立刻变少,但会让后续每一次修改都更可控。
七、通用后端里也有类似问题
这个问题并不只存在于游戏后端。很多普通业务系统里,也很容易出现一个"大状态对象"。
| 业务场景 | 容易膨胀的大对象 | 容易混在一起的状态 | 更合适的拆分方向 |
|---|---|---|---|
| 订单系统 | Order / OrderState |
支付、配送、退款、发票、风控 | paymentInfo、deliveryInfo、refundInfo、riskInfo |
| 活动系统 | ActivityRuntimeState |
任务进度、奖励领取、优惠券发放、过期状态 | taskState、rewardState、couponState、lifecycleState |
| 审批系统 | ApprovalState |
当前节点、审批人、历史记录、通知状态 | nodeState、assigneeState、historyState、notifyState |
| 游戏后端 | GameState |
用户资源、阶段进度、结算结果、临时效果 | playerState、stageState、settlementState、effectState |
订单系统一开始可能只有 orderId、userId、amount、status,后面慢慢加 couponInfo、paymentInfo、deliveryInfo、refundInfo、invoiceInfo、riskInfo。如果全部平铺在 Order 里,很快也会变成一个大对象。
更合理的方式可能是:
text
Order
├─ paymentInfo
├─ deliveryInfo
├─ refundInfo
├─ invoiceInfo
└─ riskInfo
活动系统也类似。如果任务进度、奖励领取、优惠券发放、过期状态都混在一个 ActivityRuntimeState 里,后面排查问题就会很难。审批系统里,如果一个 ApprovalState 同时放申请信息、当前节点、审批人、审批历史、驳回原因、通知状态,后面也会越来越乱。
所以本质上,这类问题都可以归纳成一句话:
当一个对象同时承载太多不同生命周期的状态时,它就应该被拆分。
八、我现在对聚合状态的理解
现在回头看,我会觉得 GameState 这类对象更像是一个聚合入口,而不是字段仓库。
它应该回答的是:
当前这个业务实例,整体由哪些状态组成?
而不是:
当前这个业务实例里所有字段都是什么?
这两个问题不一样。
如果把 GameState 当字段仓库,那它会越来越大。
如果把它当聚合入口,它就会变成一种结构组织方式:
#mermaid-svg-Lg8sOjgYZQtWx4nK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Lg8sOjgYZQtWx4nK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Lg8sOjgYZQtWx4nK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Lg8sOjgYZQtWx4nK .error-icon{fill:#552222;}#mermaid-svg-Lg8sOjgYZQtWx4nK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Lg8sOjgYZQtWx4nK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Lg8sOjgYZQtWx4nK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Lg8sOjgYZQtWx4nK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Lg8sOjgYZQtWx4nK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Lg8sOjgYZQtWx4nK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Lg8sOjgYZQtWx4nK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Lg8sOjgYZQtWx4nK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Lg8sOjgYZQtWx4nK .marker.cross{stroke:#333333;}#mermaid-svg-Lg8sOjgYZQtWx4nK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Lg8sOjgYZQtWx4nK p{margin:0;}#mermaid-svg-Lg8sOjgYZQtWx4nK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Lg8sOjgYZQtWx4nK .cluster-label text{fill:#333;}#mermaid-svg-Lg8sOjgYZQtWx4nK .cluster-label span{color:#333;}#mermaid-svg-Lg8sOjgYZQtWx4nK .cluster-label span p{background-color:transparent;}#mermaid-svg-Lg8sOjgYZQtWx4nK .label text,#mermaid-svg-Lg8sOjgYZQtWx4nK span{fill:#333;color:#333;}#mermaid-svg-Lg8sOjgYZQtWx4nK .node rect,#mermaid-svg-Lg8sOjgYZQtWx4nK .node circle,#mermaid-svg-Lg8sOjgYZQtWx4nK .node ellipse,#mermaid-svg-Lg8sOjgYZQtWx4nK .node polygon,#mermaid-svg-Lg8sOjgYZQtWx4nK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Lg8sOjgYZQtWx4nK .rough-node .label text,#mermaid-svg-Lg8sOjgYZQtWx4nK .node .label text,#mermaid-svg-Lg8sOjgYZQtWx4nK .image-shape .label,#mermaid-svg-Lg8sOjgYZQtWx4nK .icon-shape .label{text-anchor:middle;}#mermaid-svg-Lg8sOjgYZQtWx4nK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Lg8sOjgYZQtWx4nK .rough-node .label,#mermaid-svg-Lg8sOjgYZQtWx4nK .node .label,#mermaid-svg-Lg8sOjgYZQtWx4nK .image-shape .label,#mermaid-svg-Lg8sOjgYZQtWx4nK .icon-shape .label{text-align:center;}#mermaid-svg-Lg8sOjgYZQtWx4nK .node.clickable{cursor:pointer;}#mermaid-svg-Lg8sOjgYZQtWx4nK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Lg8sOjgYZQtWx4nK .arrowheadPath{fill:#333333;}#mermaid-svg-Lg8sOjgYZQtWx4nK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Lg8sOjgYZQtWx4nK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Lg8sOjgYZQtWx4nK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Lg8sOjgYZQtWx4nK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Lg8sOjgYZQtWx4nK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Lg8sOjgYZQtWx4nK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Lg8sOjgYZQtWx4nK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Lg8sOjgYZQtWx4nK .cluster text{fill:#333;}#mermaid-svg-Lg8sOjgYZQtWx4nK .cluster span{color:#333;}#mermaid-svg-Lg8sOjgYZQtWx4nK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Lg8sOjgYZQtWx4nK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Lg8sOjgYZQtWx4nK rect.text{fill:none;stroke-width:0;}#mermaid-svg-Lg8sOjgYZQtWx4nK .icon-shape,#mermaid-svg-Lg8sOjgYZQtWx4nK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Lg8sOjgYZQtWx4nK .icon-shape p,#mermaid-svg-Lg8sOjgYZQtWx4nK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Lg8sOjgYZQtWx4nK .icon-shape .label rect,#mermaid-svg-Lg8sOjgYZQtWx4nK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Lg8sOjgYZQtWx4nK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Lg8sOjgYZQtWx4nK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Lg8sOjgYZQtWx4nK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 业务运行时状态
Aggregate State
资源状态
Resource State
流程状态
Flow State
结算状态
Settlement State
临时效果状态
Temporary Effect State
用户当前拥有什么
流程推进到了哪里
本次是否产生结算
哪些效果等待生效或清理
当然,不是每个系统一开始都要拆成这么多层。
但这个思路是有价值的:
顶层对象不是字段仓库,而是聚合入口。 它应该负责组织子状态,而不是吞掉所有字段。
这样系统后续扩展时,就不至于什么都塞到一个大对象里。
九、怎么判断这次拆分是有效的
状态拆分之后,还需要回头看一眼:这次拆分是不是真的降低了维护成本,而不是只是把对象拆得更碎。
我现在会用两个方法做简单判断。
1. 看新增状态时,会不会继续往顶层塞
如果后面要新增一个"临时增益效果",比如只持续几个阶段的加成状态。
在拆分前,我可能很容易直接在 GameState 顶层加一个字段:
ts
type GameState = {
// ... 其他字段
temporaryEffect: TemporaryEffect;
};
这种写法短期能跑,但它其实又回到了"新状态不知道放哪里,就先往顶层塞"的老问题。
拆分后,我会先问:
这个临时效果到底影响谁?它跟着用户走,跟着阶段走,还是跟着整个流程走?
- 如果它影响的是用户当前运行时能力,那可能应该放到
playerState或单独的effectState; - 如果它只影响当前阶段,那可能更适合放到
stageState; - 如果它会跨多个阶段生效,那就需要一个更清楚的生命周期状态。
所以拆分是否有效,可以看新增字段时,自己是不是能自然判断它应该属于哪个子状态。
2. 看修改某类状态时,影响范围是否更集中
另一个判断方式,是看修改影响范围是否变小。
比如现在要调整"用户手牌上限"的规则。如果所有字段都平铺在 GameState 里,我就需要到处确认哪些逻辑会读 handSize,哪些流程会重置它,哪些返回结构会带它。
但拆出 playerState 之后,这个问题会更集中。我会优先去看 PlayerState 初始化逻辑、用户操作后的更新逻辑、进入新阶段时的用户状态重置逻辑,以及返回给前端的 playerState 结构。
所以我觉得,一个状态拆分是否有效,可以看下面几个信号:
| 验证问题 | 如果答案是"是" | 说明 |
|---|---|---|
| 新增字段时,能否判断它属于哪个子状态 | 是 | 状态边界开始清楚 |
| 修改某类状态时,影响范围是否更集中 | 是 | 拆分降低了维护成本 |
| 初始化和重置是否更容易按模块处理 | 是 | 生命周期边界更清楚 |
| 返回结构是否更容易让前端理解 | 是 | 字段归属表达出来了 |
如果拆完之后,新增字段还是不知道放哪里,修改一个字段还是要全局搜索一大片,那这次拆分可能只是形式上拆了,边界并没有真正变清楚。
十、本篇小结
这一篇主要复盘了一个问题:
当一个状态对象里的字段越来越多时,如何避免它变成状态垃圾桶?
我的理解是:
不要等对象完全失控后才拆,也不要一开始就过度设计。应该在字段开始出现明显职责分组和生命周期差异时,及时拆出子状态。
可以把本篇结论整理成下面几条:
| 设计结论 | 对应含义 |
|---|---|
| 字段变多不是问题 | 真正的问题是字段归属不清 |
| 顶层状态负责聚合 | 不应该把所有字段都平铺到顶层 |
| 拆分依据来自业务职责 | 不要为了分层而分层 |
| 状态结构会影响接口设计 | 返回结构会自然跟着状态边界变化 |
如果用一句话总结:
顶层状态负责聚合,子状态负责表达具体业务含义。
这也是我从这次状态重构里得到的一个很重要的经验。
后端系统的复杂度,很多时候不是因为字段多,而是因为字段之间的关系没有被表达出来。当状态结构能表达业务边界时,后续的初始化、更新、重置、返回和扩展,都会更清楚一些。
下一篇会继续复盘:
多层生命周期设计:阶段结束不等于流程结束。
🃏 关于这个项目
本复盘专栏的内容,来自我正在实现的一个 Balatro 风格实时游戏后端项目。
主线系列 《实时游戏后端工程实践:从 Balatro 出发》 会记录项目从 0 到 1 的实现过程;而本专栏会从项目里抽出一些更通用的后端设计问题,把具体功能背后的设计取舍讲清楚。
如果你对状态设计、接口拆分、规则系统、奖励系统和游戏后端工程实践感兴趣,也可以顺着主线系列继续看。