1. 从"context-mode"这个命名说起:它到底指什么
第一次看到"context-mode"这个词,很多人会下意识地把它当成某个具体框架的配置项,或者某个库里的枚举值。但如果你在工程一线待过几年,就会发现这个词其实是一个跨领域的设计概念,而不是某一个产品的专属名词。它描述的是一种"让系统根据当前所处的上下文环境,自动切换行为模式"的机制。换句话说,同一个入口、同一套代码,在不同的上下文里跑出不同的结果,而这个"不同"是被显式定义、可预期、可测试的。
我在实际项目里接触过至少三种形态的 context-mode。第一种是对话系统里的上下文模式,比如一个助手在"闲聊模式"和"任务模式"下对同一句话的响应策略完全不同;第二种是渲染或执行引擎里的上下文模式,比如同一段逻辑在"编辑态"和"只读态"下走不同的分支;第三种是数据管道里的上下文模式,比如批处理上下文和流式上下文对同一条记录的处理顺序、容错策略都不一样。这三种形态表面上看八竿子打不着,但底层思路是一致的:把"当前处于什么场景"这件事,从散落各处的 if-else 里抽出来,变成一个一等公民。
为什么这件事值得单独拿出来讲?因为绝大多数项目在早期都是靠if (isEditMode)这种布尔开关硬扛的。一开始只有两三个模式,代码还能看;等到模式变成五六个,而且模式之间还有组合关系(比如"编辑态 + 离线态 + 草稿态"),布尔开关就会爆炸成几十个分支,改一处崩三处。context-mode 这套思路的价值,就是把这个爆炸扼杀在摇篮里。
这篇文章适合谁看?如果你正在设计一个需要"多场景复用同一套核心逻辑"的系统,或者你已经被满屏的isXxx标志位折磨过,那接下来的内容应该能帮你理清一条可落地的路径。我会从概念拆解讲到结构设计,再讲到实测中的坑,尽量把"为什么这么设计"讲透,而不是只丢一堆代码。
提示:本文讨论的 context-mode 是一个通用的架构思路,不绑定任何特定语言或框架。示例代码用 TypeScript 和 Python 混着写,重点是表达结构,不是让你照抄语法。
2. 为什么布尔开关一定会失控:一个真实的演化过程
2.1 从两个模式到六个模式,代码是怎么烂掉的
我先还原一个我亲历的演化过程,这样你能直观感受到问题是怎么积累的。最开始,我们的编辑器组件只需要区分"预览"和"编辑"两种状态,代码大概长这样:
function renderContent(data: Content, isEditMode: boolean) { if (isEditMode) { return renderEditable(data); } return renderReadonly(data); }这段代码没有任何问题,清晰、直接、好测试。两周后,产品提了个需求:编辑态下要区分"新建"和"修改",因为新建时某些字段是必填的,修改时是只读的。于是加了一个isCreateMode:
function renderContent(data: Content, isEditMode: boolean, isCreateMode: boolean) { if (isEditMode && isCreateMode) { return renderEditableWithRequired(data); } if (isEditMode && !isCreateMode) { return renderEditableWithLocked(data); } return renderReadonly(data); }到这里还勉强能忍。再往后,又来了"批量编辑"和"模板套用"两个场景,每个场景对字段的可编辑性、校验规则、提交行为都有细微差别。布尔参数从两个变成四个,分支从两条变成十六条。最要命的是,这十六个分支里有相当一部分是永远不会出现的组合,但代码里没有地方表达"这个组合非法",只能靠开发者脑子里记。
2.2 布尔开关的三个致命缺陷
我把这段经历里暴露出来的问题归纳成三条,这三条基本能解释为什么布尔开关方案注定要重构。
第一,组合爆炸不可控。n 个布尔参数理论上有 2 的 n 次方种组合,但真正合法的可能只有 n 种。布尔方案没有任何机制帮你排除非法组合,非法状态一旦被构造出来,行为就是未定义的。
第二,行为分散在调用链各处。判断"当前是不是编辑态"这件事,可能出现在渲染层、校验层、提交层、日志层。每一层都自己判断一遍,一旦判断逻辑有细微差异(比如某层忘了考虑"只读的编辑态"),就会出现层与层之间行为不一致的诡异 bug。
第三,无法表达"模式之间的转换规则"。从"新建"切到"修改"时,哪些字段要保留、哪些要清空、哪些要重新校验,这些规则在布尔方案里无处安放,只能散落在各个事件处理函数里。
2.3 context-mode 的核心承诺
context-mode 这套思路要解决的就是上面三个问题,它给出的承诺很明确:
- 模式是有限且显式枚举的,非法组合在类型层面就被排除;
- 模式决定行为,行为集中在模式定义里,而不是散落在调用链;
- 模式之间的转换有明确的规则,转换时该做什么、不该做什么,写在一处。
理解了这三个承诺,后面的结构设计就顺理成章了。接下来我讲具体怎么落地。
3. 把"上下文"建模成显式对象:结构设计的四个决策点
3.1 决策一:模式用枚举还是用标签组合
这是第一个要拍板的问题。有两种主流做法:一种是互斥枚举,比如mode: 'create' | 'edit' | 'readonly' | 'batch';另一种是正交标签,比如flags: { editable: true, draft: false, offline: true }。
我的经验是:如果模式之间存在明确的优先级和互斥关系,用枚举;如果维度之间真正正交、可以自由组合,用标签。但绝大多数业务场景其实是"伪正交"——看起来可以自由组合,实际上只有少数几个组合是合法的。这种情况下,用枚举把合法组合穷举出来,反而比用标签更安全。
举个例子,"离线"和"编辑"看起来正交,但"离线 + 新建"这个组合在业务上可能根本不允许(离线时不能新建,只能改本地草稿)。如果你用标签,就得额外写校验逻辑去拦截非法组合;如果你用枚举,直接不定义这个枚举值就行了。
// 推荐:互斥枚举,合法组合一目了然 type EditorMode = | 'create' // 新建,字段必填 | 'edit' // 修改,部分字段锁定 | 'readonly' // 只读 | 'batch' // 批量编辑 | 'draft'; // 草稿,离线可写3.2 决策二:模式定义里放什么
模式定义不是只放一个名字,它应该是一个行为契约。我通常会在模式定义里放四类东西:
| 类别 | 作用 | 示例 |
|---|---|---|
| 能力声明 | 这个模式下允许做什么 | canSubmit、canDelete |
| 字段策略 | 字段的可编辑性、校验规则 | fieldPolicy |
| 生命周期钩子 | 进入/退出模式时执行什么 | onEnter、onExit |
| 转换规则 | 能切换到哪些模式、切换时做什么 | transitions |
把这四类东西集中在一个对象里,好处是新增一个模式时,你只需要在一个地方补全所有信息,而不是去五个文件里各改一处。这是 context-mode 相比布尔开关最直接的收益。
interface ModeDefinition { name: EditorMode; capabilities: { canSubmit: boolean; canDelete: boolean; canUndo: boolean; }; fieldPolicy: (field: Field) => FieldPolicy; onEnter?: (ctx: Context) => void; onExit?: (ctx: Context) => void; transitions: Partial<Record<EditorMode, TransitionRule>>; }3.3 决策三:上下文对象和模式对象分离
这里有个容易踩的坑:很多人会把"当前模式"和"当前上下文数据"混在一个大对象里。我建议分开。模式是相对稳定的(就那么几个),上下文数据是频繁变化的(用户输入、网络状态、草稿内容)。混在一起会导致模式定义里混入大量易变数据,难以复用。
我的做法是:Context对象持有mode引用和data,模式定义本身是无状态的单例,所有依赖运行时数据的行为都通过参数传入。
interface Context { mode: ModeDefinition; data: Record<string, unknown>; history: EditorMode[]; // 模式切换历史,用于回退 } // 模式定义是无状态的,可以全局复用 const createMode: ModeDefinition = { /* ... */ }; const editMode: ModeDefinition = { /* ... */ };3.4 决策四:转换规则写在哪
转换规则我强烈建议写在源模式的定义里,而不是写在一个中央的转换表里。原因是:当你新增一个模式时,你希望所有和它相关的信息都在它自己的定义附近,而不是散落在别处。写在源模式里,新增模式时只需要改自己,符合"高内聚"。
const editMode: ModeDefinition = { name: 'edit', // ... transitions: { readonly: { guard: (ctx) => !ctx.data.hasUnsavedChanges, action: (ctx) => { ctx.data.lastEditTime = Date.now(); } }, draft: { guard: () => true, action: (ctx) => { saveDraft(ctx.data); } } } };guard决定能不能切,action决定切的时候做什么。这两个分开,测试起来非常方便——你可以单独测 guard 的真假,也可以单独测 action 的副作用。
4. 一次完整的落地:从需求到可运行的最小实现
4.1 需求梳理:先别急着写代码
假设我们要做一个"文档编辑器",需求是这样的:
- 支持新建、编辑、只读、草稿四种模式;
- 新建模式下标题必填,编辑模式下标题可改但作者字段锁定;
- 只读模式下所有字段不可改,但可以复制;
- 草稿模式可以离线保存,联网后自动尝试提交;
- 从编辑切到只读时,如果有未保存修改,要先弹确认。
我拿到这个需求的第一件事不是写代码,而是画一张模式转换图(在纸上画,不用工具)。把四个模式画成四个圈,把允许的转换画成箭头,每个箭头上标注 guard 和 action。这张图一旦画出来,代码结构基本就定了。
4.2 定义模式与字段策略
字段策略我用一个函数表达,输入字段名,输出这个字段在当前模式下的策略。这样比给每个字段写一堆布尔标志要灵活得多。
type FieldPolicy = { editable: boolean; required: boolean; visible: boolean; }; function makeFieldPolicy(mode: EditorMode): (field: string) => FieldPolicy { const policies: Record<EditorMode, Record<string, FieldPolicy>> = { create: { title: { editable: true, required: true, visible: true }, author: { editable: true, required: true, visible: true }, content: { editable: true, required: false, visible: true } }, edit: { title: { editable: true, required: false, visible: true }, author: { editable: false, required: false, visible: true }, content: { editable: true, required: false, visible: true } }, readonly: { title: { editable: false, required: false, visible: true }, author: { editable: false, required: false, visible: true }, content: { editable: false, required: false, visible: true } }, draft: { title: { editable: true, required: false, visible: true }, author: { editable: true, required: false, visible: true }, content: { editable: true, required: false, visible: true } } }; return (field) => policies[mode][field] ?? { editable: false, required: false, visible: false }; }注意最后那个??兜底:未定义的字段默认不可编辑、不可见。这是"默认拒绝"原则,比"默认允许"安全得多。我见过太多 bug 是因为新增字段时忘了在某个模式里配置,结果在只读模式下居然能改。
4.3 实现模式切换与守卫
切换逻辑的核心是:先查 guard,guard 通过再执行 action,最后更新 context。顺序不能乱,否则会出现"action 执行了但 guard 没过"的脏状态。
function switchMode(ctx: Context, target: EditorMode): boolean { const rule = ctx.mode.transitions[target]; if (!rule) { console.warn(`不允许从 ${ctx.mode.name} 切换到 ${target}`); return false; } if (rule.guard && !rule.guard(ctx)) { return false; } ctx.mode.onExit?.(ctx); rule.action?.(ctx); ctx.history.push(ctx.mode.name); ctx.mode = MODE_REGISTRY[target]; ctx.mode.onEnter?.(ctx); return true; }这段代码里有几个细节值得说。第一,onExit在action之前执行,因为 action 可能依赖退出前的状态。第二,history记录的是切换前的模式,方便实现"撤销切换"。第三,切换失败时返回false而不是抛异常,因为"切换被 guard 拦截"是正常业务流,不是错误。
4.4 把校验和提交也纳入模式
很多人做到模式切换就停了,其实校验和提交也应该受模式驱动。比如新建模式下校验规则更严,草稿模式下校验可以放宽(允许保存不完整内容)。
function validate(ctx: Context): ValidationResult { const policy = makeFieldPolicy(ctx.mode.name); const errors: string[] = []; for (const [field, value] of Object.entries(ctx.data)) { const p = policy(field); if (p.required && !value) { errors.push(`${field} 是必填项`); } } return { valid: errors.length === 0, errors }; }这样校验逻辑就完全跟着模式走了,新增模式时只要补字段策略,校验自动适配,不需要改校验函数本身。
5. 实测中暴露的五个坑与应对
5.1 坑一:模式切换的副作用顺序导致状态不一致
我遇到过一个 bug:从编辑切到只读时,onExit里清空了临时数据,但action里又要读这些临时数据做持久化,结果持久化的是空数据。根因是副作用的执行顺序没有明确约定。
我的应对是:在模式定义里显式区分"清理型副作用"和"持久化型副作用",清理永远在持久化之后。或者更简单粗暴——所有持久化逻辑放在 action 里,onExit 只做纯清理,且清理的数据必须是 action 不再需要的。这个约定写进团队规范后,这类 bug 基本绝迹。
5.2 坑二:guard 里做了有副作用的操作
guard 顾名思义应该是"纯判断",但有人会在 guard 里顺手改状态,比如"如果没保存就先自动保存再返回 true"。这会导致 guard 被调用多次时副作用重复执行。我的做法是:guard 必须是纯函数,任何写操作都放 action。如果确实需要"先保存再判断",那就拆成两步:先调 action 保存,再调 guard 判断。
5.3 坑三:模式定义对象被意外共享导致污染
因为模式定义是单例,如果某个 action 不小心改了模式定义对象上的属性(比如往transitions里动态加了一条),就会污染全局。我在代码评审里专门盯这一点:模式定义对象一律Object.freeze,运行时数据只能存在Context.data里。
const editMode = Object.freeze({ name: 'edit', // ... });5.4 坑四:历史栈无限增长
history数组如果只 push 不清理,长时间运行会吃内存。我的做法是设一个上限(比如 50),超过就丢弃最旧的。另外,如果某个模式切换是"临时性"的(比如弹窗预览),不应该进历史栈,需要在切换时传一个transient: true标志。
5.5 坑五:测试时难以构造特定模式
因为模式切换有 guard,测试时想直接构造"处于草稿模式且离线"的状态很麻烦。我的应对是提供一个测试专用的构造器,绕过 guard 直接设置模式,但只在测试环境暴露。
// 仅测试环境可用 export function __testSetMode(ctx: Context, mode: EditorMode) { if (process.env.NODE_ENV !== 'test') { throw new Error('仅测试环境可用'); } ctx.mode = MODE_REGISTRY[mode]; }这个函数在正式代码里永远不会被调用,但能让测试用例直接跳到目标状态,省去一堆前置操作。
6. 什么时候不该用 context-mode
讲了这么多好处,我得泼盆冷水:context-mode 不是银弹,有些场景用了反而更糟。
如果你的系统只有两个模式,而且未来大概率不会增加,那用布尔开关就够了,引入模式定义、转换规则、守卫这一套是过度设计。我见过一个只有"登录/未登录"两种状态的模块,硬套 context-mode,结果多出两百行样板代码,维护成本反而上升。
另一个不适合的场景是模式之间高度动态、无法预先枚举的情况。比如某些规则引擎,模式是由用户配置动态生成的,这时候用枚举就不现实,得用更灵活的规则表方案。context-mode 的前提是"模式有限且可枚举",这个前提不成立时,它就不适用。
还有一个判断标准:如果模式之间的差异主要体现在数据上,而不是行为上,那也不该用 context-mode。比如同一个表单,只是字段默认值不同,那用配置数据就够了,不需要为每种默认值定义一个模式。context-mode 解决的是"行为差异",不是"数据差异"。
7. 我在多个项目里沉淀下来的几条经验
第一条经验是先画转换图再写代码。我现在的习惯是,只要需求里出现"在某种情况下要变成另一种行为",就先在纸上画圈和箭头。图画不出来,说明需求本身没想清楚,这时候写代码就是浪费。图画出来了,代码结构基本就是图的直译。
第二条经验是模式命名要用业务语言,不要用技术语言。create、edit、readonly是业务语言,modeA、modeB、state1是技术语言。用业务语言命名的好处是,产品经理看代码也能看懂,沟通成本大幅下降。我甚至会把模式定义文件直接给产品看,让他们确认"这个模式下确实不能删除"。
第三条经验是给每个模式写一个"行为清单"测试。不是测单个函数,而是测"在这个模式下,做这十件事分别是什么结果"。这个清单测试写起来很快,但能覆盖绝大多数回归问题。每次新增模式,复制一份清单改改就行。
第四条经验是模式切换要可观测。我在switchMode里加了一行日志,记录从哪个模式切到哪个模式、guard 结果、耗时。上线后通过日志发现了好几个"用户实际走的路径和我们预想的不一样"的情况,这些发现直接指导了后续的模式设计优化。
最后一条,也是我觉得最重要的一条:不要为了用模式而用模式。每次引入 context-mode 之前,我都会问自己一个问题——"如果我用布尔开关,半年后会不会后悔?"如果答案是"会",那就用;如果答案是"不确定",那就先用布尔开关,等真的痛了再重构。过早抽象和过度抽象一样有害,这个度得靠项目经验来把握。
这套思路我在编辑器、工作流引擎、对话系统三个不同类型的项目里都用过,每次的具体实现都不一样,但"显式枚举模式、集中定义行为、明确转换规则"这三条内核是共通的。如果你正在被散落各处的状态判断折磨,不妨试试把它抽成一个显式的 context-mode,大概率能让代码清爽不少。