命令模式专栏
场景加载那条主链、传送的每个阶段,都跑在它上面。 这套命令框架本身不吃业务,只解决一件事:把一堆异步步骤按顺序推进,并给出一个可 await 的最终结果。 它只有六个文件,看懂调用方怎么进来、六个件的依赖方向,就掌握了整条链的骨架。 本专栏先讲框架本身(六文件 + 关系总图),再落到两个真实案例: 场景加载的 loadwp 命令与传送的阶段命令。
第 1–9 节讲框架本身,第 10 节给出两个案例的入口,案例页分别拆解「怎么用框架把一条真实业务链跑起来」。
1. 六个文件各是什么
| 文件 | 角色 | 一句话职责 |
|---|---|---|
| CommandExecutor.ts | 执行器(逻辑中心) | 持有命令队列与状态机,把命令逐条推进,最后产出五种结果之一 |
| CommandBase.ts | 命令基类 | 一条命令的抽象:Execute() 被调用,用 Succeed/Fail/Bail/Fatal 回传 |
| CommandSystem.ts | 调度器(单例) | 统一 Tick,用时间切片驱动当前所有运行中(Running)的 executor |
| CommandContextBase.ts | 上下文基类 | 一次执行里共享的数据 / 结果载体,作为泛型 T 贯穿 executor 与命令 |
| CommandPromise.ts | 结果承诺 | 让调用方能 await 的 Promise;执行结束时 SetResult 兑现 |
| CommandBudgetGroup.ts | 预算组枚举 | 声明有哪些限流组,决定命令进哪个时间切片通道 |
2. 关系总图
先看全貌。三个横带分别是调用方 / 执行器与调度 / 命令与上下文; 中间那条带里,CommandExecutor<T> 是唯一的焦点。
2.1 六条主干怎么读
- ① 造链 + 启动:调用方
new CommandExecutor<T>(),Init后再await Execute()。 - ② 调度:
CommandSystem是全局单例,每帧按时间切片把运行中的 executor 推进一把(双向:Executor 把运行队列挂到 System,System 回过来 Tick 它)。 - ③ 结算:执行结束时 Executor 调
CommandPromise.SetResult(result)(双向:Executor 持有 Promise,Promise 回过来兑现给 Flow)。 - ④ 队列驱动:Executor 从队列里
PopCommandAndExecute,把命令一条条驱动起来。 - ⑤ 结果回传:命令调
Succeed/Fail/Bail/Fatal,经CommandSuccess/CommandFail/...回到 Executor。(这条画成蓝色,因为它是「控制权回流」的关键。) - ⑥ 通道 / 预算:命令的
ThrottleControlGroupName与CommandBudgetGroup对齐后,决定它进 System 的哪个时间切片通道。
另外三条紫色虚线是「泛型 T」与「await 结果」:CommandContextBase 同时被
CommandExecutor<T> 和 CommandBase<T> 约束(同一条 T),
而 CommandPromise 兑现的结果最终回到调用方的 await 处。
3. 调用方怎么用
// 场景加载链的入口(简化)
const exec = new CommandExecutor<SceneLoadingCmdContext>();
exec.Init(context, expireTime, callbacks...); // 绑定 context 与各类回调
exec.AddInitCommand(rootCmd); // 构链期:InitCommand 在此展开子命令
const result = await exec.Execute(); // 交 CommandSystem 分片驱动
// result: ECommandExecutorResult(Success / Bail / Fail / Timeout / Fatal)
await exec.Execute() 等价于「等整条链跑完」。
Executor 在 FinishExecute 里调 CommandPromise.SetResult,
此时 await 才 resolve——所以调用方拿到的永远是终态结果,不会拿到中间态。
4. 一次执行的完整生命周期
None;命令队列为空context、过期时间与 execute / result 回调;状态转 InitOnAddInitCommand 展开子命令,depth + 1CommandSystem.AppendRunningExecutor(this),返回 CommandPromise;状态转 ExecutingTimeSliceChannelsCommandSuccess / CommandFail / CommandBail / CommandFatalCommandPromise.SetResult(result) → CommandSystem.RemoveRunningExecutor(this)CommandSuccess 只置一个 TimeSliceSyncStepDone = true 就返回,
真正的「取下一命令」由调度循环在预算内继续推进。所以同步命令在分片下也可能跨帧。
5. 五种结果 & 七个命令状态
两个枚举别混:executor 结果是「整条链的终态」,命令状态是「一条命令的当下态」。
| ECommandExecutorResult | 含义 |
|---|---|
| Success | 全链正常跑完 |
| Bail | 中途退出:命令主动 Bail() 触发,会回滚已执行命令(见 §6) |
| Fail | 失败:命令 Fail(),触发回滚 |
| Timeout | 超时:到期时间到(默认行为见下) |
| Fatal | 严重错误:无法继续,按异常处理 |
| ECommandState(7 个) | 含义 |
|---|---|
| Idle | 已创建、未执行 |
| Executing | 正在执行 |
| Succeeded | 成功 |
| Failed | 失败 |
| Bailed | 主动中止(不是「跳过」,见 §6) |
| Disposed | 已销毁 |
| Fatal | 严重错误态 |
DoExecutorTimeout() 在默认配置(TriggerCallbackOnTimeout = false)下
只记一条日志,不改状态、不结算,为的是兼容既有调用方。
所以「有没有超时兜底」实际取决于命令自己有没有设超时——这正是场景加载链「卡 Loading」的结构性原因。
6. Bail 到底干了什么:终止整条链,不是跳过本条
这是最容易读错的一处。CommandBase.Bail() 的源码注释写得很清楚:
「会当即退出所有的命令执行流程,不算做失败」。它的实际路径是:
// CommandBase.ts
Bail(bailReason) → State = ECommandState.Bailed → ExecutorInternal!.CommandBail()
// CommandExecutor.ts
CommandBail() → DoExecutorBail()
DoExecutorBail():
if (StateInternal >= Success) return; // 已结束就不再处理
StateInternal = Bail;
RollbackExecutedCommands(); // 反向回滚已执行的命令
FinishExecute(ECommandExecutorResult.Bail);
Bail() 会结束整个 executor 并触发回滚,语义是「不继续了」。
真正「跳过某一步、链条继续」的命令,调用的其实是 Succeed()——
例如场景加载里的 SceneWaitWpStreamingCmd、SceneLoadingMoveCmd,
条件不满足时都直接 Succeed() 跳过。
所以排查「某步好像没执行」时:看到 Succeeded 才是「被条件跳过、链继续」,
看到 Bailed 是「整条链被中止了」。这一点在
场景加载命令链 · 2.1 状态里有同款澄清。
7. TimeSlice 分片驱动
如果每条命令都同步跑完,一个重帧就能把画面卡住。框架的解法是预算化的时间切片。
- 用
RegisterTimeSliceChannel(channelName, budgetMsNormal, budgetMsLoading = 200)注册通道;默认通道预算是 2ms(正常)/ 200ms(Loading)。 CommandSystem.TickByTimeSlice每帧按IsLoading选预算,先处理默认通道,再遍历TimeSliceChannels。- 每帧推进条数受
OneFrameMaxExecuteNum限制(默认 1)。 TickOneTimeSliceQueue用队首Front取命令而非立刻Pop——暂停的 Executor 留在队首,下一帧继续。
PauseExecution() 在非时间切片环境下会退化成 Succeed()(直接成功、不等下一帧)。
靠它分批处理的逻辑,在退化路径下会只处理第一批就往下走且不报错。
8. 泛型 T 与 CommandContextBase
CommandContextBase 是一次执行的共享数据 / 结果载体。
它作为泛型 T 同时约束执行器与命令:
export class CommandExecutor<T extends CommandContextBase> { ... }
export abstract class CommandBase<T extends CommandContextBase> { ... }
好处是:命令通过 context 读写共享状态时有完整类型提示,且不同链路(场景加载、传送、重连)
各有自己的 Context 子类,互不污染。Context.Dispose() 会在全部命令执行完后被调用并触发 OnDispose() 钩子。
9. CommandBudgetGroup 与限流
不是所有命令都需要限流,但大批量、单条轻、总量重的命令(实体初始化、UI 复制项)必须。 框架用「预算组」来表达这件事。
export const enum ECommandBudgetGroup {
EntityComponentInit, // 实体组件初始化
EntityComponentStart, // 实体组件 Start
SceneInteractionShow, // 场景交互显示
UiCopyItem, // UI 复制项
}
- 命令用
ThrottleControlGroupName(默认取类名)声明自己属于哪个通道。 CanBeThrottled()默认false——只有同步重度逻辑才应开启。Cost默认NoneCost = 0:0 走无限制的 Round1,>0 才受预算限制(Round2)。CommandSystem.InitAllBudgetGroups()目前只给EntityComponentInit/EntityComponentStart注册了SetBudgetLimiter;SceneInteractionShow/UiCopyItem是走独立通道注册的。
ECommandBudgetGroup 加枚举值;② 在 CommandSystem.InitAllBudgetGroups 加对应的 SetBudgetLimiter。
漏掉第二步,分组名存在但不会被限流。
10. 两个案例:框架怎么落到业务链
框架只会「按序推进 + 给终态结果」,真正的复杂度在调用方怎么把业务步骤拼成一条链。 两个案例分别代表两种拼法:一个是构链期一次性展开(loadwp),一个是阶段化 + 轮次隔离(传送)。
| 案例 | 载体 | 看点 | 入口 |
|---|---|---|---|
| 案例一 · loadwp | SceneLoadingWpMapCmd | 作为根命令,在 OnAddInitCommand 里构链期一次性展开整条场景加载主链;OnExecute 只 Succeed() 让位 |
案例一 → |
| 案例二 · 传送 | Teleport/Cmd/* | 阶段命令基类约定四出口;用轮次令牌隔离迟到异步;简化分段用 CommandExecutor 现场编排 |
案例二 → |
11. 面试怎么讲
六个文件的中心是
CommandExecutor:它持有队列和状态机;CommandSystem 负责调度它;
CommandBase 是被它驱动的命令;CommandPromise 兑现结果给调用方;
CommandContextBase 作为泛型 T 让执行器与命令共享同一份数据;CommandBudgetGroup 决定命令进哪个限流通道。
CommandSystem 是唯一入口,集中做「本帧总共能花多少预算、按通道分配、先默认后专用」,
executor 只管把自己的运行队列登记进去(AppendRunningExecutor)。这是把限流做成全局资源而不是各自为政。
Bail 与 Fail 都会终止整条链并回滚已执行命令,区别在于语义与结果码:
Fail 是「出错了」,结果是 Fail(关键节点还会 FailCritical 升级为客户端异常);
Bail 是「不继续了,但不算失败」,结果是 Bail。两者都不是「跳过本条继续下一条」。要跳过一步、链继续,命令应调
Succeed()——
场景加载链里那些「进来就判断条件」的命令就是这么写的;传送里的 Skip() 则是把「让位」语义映射到 Bail(见案例二)。
DoExecutorTimeout 默认只打日志)。
一条等待型命令(如等 WP 流送)如果自己不设超时,就会永远停在 Executing,
整条链不结算、await 不 resolve、Loading 界面不消。排查路径:看进度上报卡在第几步 → 找到那条命令 → 看它有没有自带超时。详见 场景加载命令链。