后端复盘(3):聚合状态拆分设计,如何避免一个对象变成状态垃圾桶

  • 本文来自《后端系统设计复盘:从游戏项目到通用后端》专栏。
  • 文章内容源于我正在实现的一个 Balatro 风格实时游戏后端项目,但本文不会重点讲某个具体游戏功能怎么写,而是抽出一个更通用的后端设计问题:当一个状态对象里的字段越来越多时,什么时候应该继续放在一起,什么时候应该拆成更清晰的子状态。
  • 这个问题不只存在于游戏后端,在订单系统、活动系统、审批系统、任务系统里也很常见。只要一个系统里存在"运行时状态",就很容易遇到类似问题:一开始只是几个字段,后面慢慢变成一个什么都能放的大对象。

这篇文章想表达的核心观点是:

  • 状态对象字段变多本身不一定是问题,真正的问题是字段背后的业务归属、生命周期和更新边界开始变得不清楚。
  • 状态拆分不是为了让代码看起来更复杂,而是为了让系统结构表达清楚:哪些状态应该一起变化,哪些状态应该分开维护。
  • 顶层状态不应该变成字段仓库,而应该作为聚合入口,组织不同职责的子状态。

文章目录

一、这个问题是怎么出现的

  • 下面我会用一个游戏运行时状态 GameState 举例,但这里讨论的不是游戏特有问题,而是很多后端系统都会遇到的"聚合状态膨胀"问题。
  • 如果换成普通业务系统,GameState 可以类比成 OrderStateActivityRuntimeStateApprovalState。它们本质上都在描述:当前这个业务实例运行到了什么状态。

为了让这篇文章可以独立阅读,先解释几个后面会反复出现的词:

名词 在本文里的含义 普通业务里的类比
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";
};

如果只看早期字段,它们好像都只是"当前状态"。但随着后面继续加入阶段、进度、目标分、当前阶段得分、临时效果、奖励结果这些字段,问题就开始变得明显。

字段类型 示例字段 表面上看 后面会暴露的问题
用户资源字段 handdeckplaysLeft 只是记录用户当前资源 需要跟随用户操作频繁变化
流程进度字段 roundanteblindType 只是记录当前进度 会影响下一阶段如何推进
目标判断字段 targetScorecurrentBlindScore 只是用于判断是否达标 会影响阶段是否结束
整体状态字段 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 表示整体流程状态。它们都不是无意义字段,也不是随便乱加的字段。

但问题在于,这些字段放在一起后,背后的职责已经不一样了。

字段类型 示例字段 背后含义
用户资源状态 handdeckplaysLeft 用户当前拥有什么、还能做什么
阶段进度状态 roundanteblindType 当前流程推进到了哪里
分数结算状态 targetScorecurrentBlindScore 当前阶段目标和累计结果
整体流程状态 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 可以理解成更贴近当前游戏项目的命名,意思是"当前关卡阶段的状态";如果放到更通用的业务系统里,它也可以叫 StageStateFlowState

重构前后对比图

#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. 字段是否已经出现明显分组

如果一个对象里有些字段总是一起出现、一起初始化、一起更新,就说明它们可能属于同一组状态。

例如:handdeckplaysLeftdiscardsLefthandSize,这些字段明显和用户当前资源有关;而 roundantestageTypetargetScorecurrentStageScore,这些字段明显和阶段进度有关。

当这种分组越来越清楚时,就可以考虑拆。

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 支付、配送、退款、发票、风控 paymentInfodeliveryInforefundInforiskInfo
活动系统 ActivityRuntimeState 任务进度、奖励领取、优惠券发放、过期状态 taskStaterewardStatecouponStatelifecycleState
审批系统 ApprovalState 当前节点、审批人、历史记录、通知状态 nodeStateassigneeStatehistoryStatenotifyState
游戏后端 GameState 用户资源、阶段进度、结算结果、临时效果 playerStatestageStatesettlementStateeffectState

订单系统一开始可能只有 orderIduserIdamountstatus,后面慢慢加 couponInfopaymentInfodeliveryInforefundInfoinvoiceInforiskInfo。如果全部平铺在 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 的实现过程;而本专栏会从项目里抽出一些更通用的后端设计问题,把具体功能背后的设计取舍讲清楚。

如果你对状态设计、接口拆分、规则系统、奖励系统和游戏后端工程实践感兴趣,也可以顺着主线系列继续看。