1. 从"context-mode"这个命名说起:它到底在解决什么问题
第一次看到"context-mode"这个词,很多人会下意识地把它归到某个具体框架的配置项里,比如某个状态管理库的开关、某个编辑器插件的模式切换。但如果你把最近围绕这个词的讨论翻一翻,会发现它更像是一个被反复提及的设计思路,而不是某一个产品的专属名词。它指向的核心问题非常朴素:同一套逻辑,在不同上下文里应该表现出不同的行为,而切换这种行为的成本要尽可能低。
我在实际项目里踩过太多次"上下文错乱"的坑。举个最典型的例子:一个后台管理系统,同一个数据表格组件,在"我的待办"页面需要支持行内快速编辑,在"历史归档"页面则必须只读,在"审批流"页面又要支持批量勾选加批量操作。早期我的做法是给组件传一堆布尔开关——editable、selectable、batchable、readonly,结果组件内部变成了一棵巨大的条件判断树,每加一个页面就要回去改组件源码,改到最后没人敢动那个文件。
后来我换了个思路:不传开关,传模式。把"这个表格当前处于什么上下文"抽象成一个 mode 对象,组件只认 mode,不认具体页面。这就是 context-mode 这个思路最直观的价值——把散落在各处的条件判断,收敛成一个可枚举、可测试、可扩展的上下文描述。
所以这篇内容适合谁看?如果你正在维护一个"到处是 if-else 开关"的组件或服务,如果你正在设计一套需要适配多种业务场景的通用能力,如果你对"配置驱动"和"策略模式"之间的关系还没完全理清,那接下来的内容应该能帮你省下不少返工时间。我会从命名背后的设计动机讲起,一路拆到落地时的目录结构、状态流转、边界处理和实测踩坑,尽量把"为什么这么设计"讲透,而不是只丢一段代码让你抄。
提示:context-mode 不是某个官方标准术语,不同团队对它的叫法可能不同,有人叫 scene、有人叫 profile、有人叫 variant。名字不重要,重要的是它背后那套"用上下文描述替代布尔开关"的取舍逻辑。
2. 为什么"传开关"会越写越乱:三种典型失控现场
在正式聊 context-mode 怎么落地之前,我想先把"不用它会怎样"讲清楚。因为很多人对模式化设计的抵触,来源于没真正体会过开关式设计的痛。下面这三种失控现场,我几乎在每个中型以上项目里都见过至少一种。
2.1 开关组合爆炸:2 的 N 次方种状态没人测得完
假设一个组件有 5 个布尔开关:editable、selectable、sortable、filterable、exportable。理论上它存在 2 的 5 次方,也就是 32 种状态组合。但真正有意义的组合可能只有 4 到 5 种,剩下的全是"理论上能出现、实际上不该出现"的非法状态。
问题在于,代码层面没有任何机制阻止别人传出一个非法组合。某天一个新同事为了赶需求,传了editable=true加readonly=true,组件内部两个分支同时命中,页面直接白屏。你回头去查,发现没有任何文档说明这两个开关互斥。这就是开关式设计最要命的地方:它把"哪些组合合法"这个知识,留在了每个人的脑子里,而不是写在代码里。
context-mode 的做法是把这些合法组合显式枚举出来。比如定义view、edit、batch、archive四个模式,每个模式内部固定了各个能力的取值。组件只接受这四个模式之一,传别的直接报错。非法状态从"运行时才暴露"变成"编译期或启动期就拦住"。
2.2 条件判断渗透:业务逻辑被开关切得七零八落
第二个失控现场更隐蔽。开关传进组件后,组件内部会写大量if (editable) { ... } else { ... }。一开始还好,但随着需求迭代,这些判断会像藤蔓一样爬满整个文件。
我见过一个 800 行的表格组件,里面散落着 40 多处开关判断。最夸张的一段是:根据editable和batchable的组合,决定表头复选框的三种渲染方式。这段逻辑藏在渲染函数中间,没有任何注释,改一次要通读上下文两百行。这种代码的维护成本不是线性的,是指数级的——因为每加一个开关,所有已有判断都要重新审视一遍是否受影响。
context-mode 的思路是把这些判断前置到模式定义阶段。模式定义里写清楚"edit 模式下表头复选框不渲染、batch 模式下渲染且默认全选、view 模式下渲染但禁用",组件内部只做一次switch (mode)的分发,分发之后各分支互不干扰。判断没有消失,但它们被集中到了可管理的地方。
2.3 跨页面复用困难:改一个页面怕崩另一个页面
第三个现场是复用问题。当开关散落在各个调用方时,你根本不知道某个开关被哪些页面传过什么值。想重构一个开关的语义,得全局搜索所有调用点,一个个确认影响范围。
我经历过一次惨痛的重构:想把editable拆成editable和editableFields两个更细的开关,结果搜出来 60 多个调用点,分布在 20 多个文件里,其中还有动态拼接的。最后花了整整两天,改完还得全量回归测试。如果当初用的是模式,重构只需要改模式定义那一处,调用方传的还是mode="edit",内部怎么拆是组件自己的事。
这三种现场指向同一个结论:开关式设计把复杂度推给了调用方和组件内部,而模式化设计把复杂度收敛到了模式定义这一层。复杂度没有凭空消失,但它从"散落各处、难以追踪"变成了"集中一处、可审阅可测试"。
3. context-mode 的落地骨架:从模式定义到状态流转
理解了动机,接下来是落地。我不会给你一个"万能模板",因为不同技术栈、不同业务场景的实现差异很大。但有一套骨架是通用的,我把它拆成四层:模式枚举层、能力映射层、状态流转层、消费层。下面逐层讲。
3.1 模式枚举层:用有限集合锁死合法状态
第一层最简单也最重要:把模式定义成一个有限集合。在 TypeScript 里就是一个联合类型,在 Java 里就是一个枚举,在 Python 里可以用Enum或者常量集合。
// 模式枚举:只有这四个是合法的 type TableMode = 'view' | 'edit' | 'batch' | 'archive'; // 能力描述:每个模式对应一组固定的能力取值 interface TableCapability { editable: boolean; selectable: boolean; sortable: boolean; filterable: boolean; exportable: boolean; batchActions: string[]; } const MODE_MAP: Record<TableMode, TableCapability> = { view: { editable: false, selectable: false, sortable: true, filterable: true, exportable: true, batchActions: [], }, edit: { editable: true, selectable: false, sortable: false, filterable: false, exportable: false, batchActions: [], }, batch: { editable: false, selectable: true, sortable: true, filterable: true, exportable: false, batchActions: ['approve', 'reject', 'export'], }, archive: { editable: false, selectable: false, sortable: true, filterable: true, exportable: true, batchActions: [], }, };这段代码的关键不在于写法,而在于它把"哪些组合合法"这件事写进了代码。view模式下editable永远是false,不存在"传错"的可能。调用方只需要传mode="view",剩下的它不用管。
注意:模式的数量要克制。我见过有人一口气定义了 15 个模式,结果模式之间的差异小到需要对照表才能分清。经验值是单组件 3 到 6 个模式比较合适,超过 8 个就该考虑是不是抽象层级出了问题,可能需要拆成两组正交的模式。
3.2 能力映射层:让组件只认能力,不认模式
第二层是很多实现容易忽略的:组件内部不应该直接判断模式,而应该判断能力。模式到能力的映射在组件外部完成,组件内部只消费能力对象。
为什么这么设计?因为这样组件就与具体业务解耦了。今天你有view、edit、batch、archive四个模式,明天业务变了,加一个audit模式,只要在MODE_MAP里加一项,组件代码一行不用改。反过来,如果组件内部写的是if (mode === 'edit'),那每加一个模式都要回去改组件。
function DataTable({ mode, data }: { mode: TableMode; data: Row[] }) { // 组件内部只认能力,不认模式 const cap = MODE_MAP[mode]; return ( <div> {cap.selectable && <HeaderCheckbox />} {cap.filterable && <FilterBar />} <TableBody data={data} editable={cap.editable} sortable={cap.sortable} /> {cap.batchActions.length > 0 && ( <BatchToolbar actions={cap.batchActions} /> )} </div> ); }这里有个细节值得说:batchActions我用了字符串数组而不是布尔值。因为批量操作往往不止一种,用数组可以精确描述"这个模式下允许哪些操作",比一个笼统的batchable布尔值信息量大得多。能力描述要尽量具体,越具体,组件内部的条件判断越少。
3.3 状态流转层:模式之间能不能切换,谁说了算
第三层是模式化设计里最容易出 bug 的地方:模式切换。如果模式是静态的,那一切好办。但现实中经常需要动态切换,比如用户点了"编辑"按钮,从view切到edit;编辑完点"取消",又要切回view。
这时候问题来了:是不是任意两个模式之间都能随便切?显然不是。archive模式下的数据是只读的历史快照,你不可能从archive直接切到edit。所以需要一层流转规则。
// 定义合法的模式流转:key 是当前模式,value 是允许切换到的目标模式 const MODE_TRANSITIONS: Record<TableMode, TableMode[]> = { view: ['edit', 'batch'], edit: ['view'], // 编辑完只能回 view,不能直接去 batch batch: ['view'], // 批量操作完回 view archive: [], // 归档模式是终态,不允许切换 }; function canTransition(from: TableMode, to: TableMode): boolean { return MODE_TRANSITIONS[from].includes(to); }有了这层规则,切换模式时先校验,非法切换直接拒绝并给出提示。这比"切过去之后发现状态不对再回滚"要可靠得多。我在一个审批系统里就是靠这层规则拦住了一个 bug:用户从归档列表点进详情,详情页复用了编辑组件,结果因为没做流转校验,直接进了编辑态,差点让用户改到历史数据。
提示:流转规则最好和模式定义放在同一个文件里,方便对照维护。我习惯把
MODE_MAP和MODE_TRANSITIONS放在modes.ts里,任何模式相关的改动都只动这一个文件。
3.4 消费层:调用方怎么写才最省心
最后一层是调用方。理想状态下,调用方应该只需要关心"我现在处于什么业务场景",然后选一个模式,不需要关心模式内部有哪些能力。
// 待办页面:需要编辑 <DataTable mode="edit" data={todoList} /> // 归档页面:只读 <DataTable mode="archive" data={archivedList} /> // 审批页面:需要批量操作 <DataTable mode="batch" data={pendingList} />对比一下开关式写法:
// 开关式:调用方要自己拼出一组合法的开关 <DataTable editable={true} selectable={false} sortable={false} filterable={false} exportable={false} data={todoList} />哪个更省心一目了然。而且开关式写法里,调用方一旦漏传某个开关,就会落到默认值上,可能产生意料之外的行为。模式化写法里,模式是必填的,漏传直接报错,把"忘记配置"从运行时问题变成了启动时问题。
4. 实测中绕不开的四个坑:从命名冲突到性能陷阱
骨架讲完了,但真正落地时,坑都在细节里。下面这四个是我和团队在实际项目里踩过的,每一个都值得单独拎出来说。
4.1 坑一:模式名和业务状态名撞车
第一个坑很隐蔽。我们一开始把模式命名为draft、published、archived,因为业务上数据确实有这三个状态。结果写着写着发现,模式描述的是"界面能力",而业务状态描述的是"数据生命周期",两者根本不是一回事。
比如一条已发布的数据,在列表页可能是view模式(只读),在详情页可能是edit模式(可编辑),在批量页可能是batch模式。如果模式名直接用了业务状态名,就会出现"已发布的数据为什么能编辑"这种逻辑上的困惑。
后来我们把模式名改成了纯能力导向的view、edit、batch、archive,业务状态单独用一个字段传。模式名要描述"能做什么",不要描述"数据是什么"。这个区分想清楚了,后面很多纠结都会消失。
4.2 坑二:模式定义散落多处,改一处漏一处
第二个坑是工程管理问题。项目初期只有前端用模式,定义放在前端。后来后端接口也需要根据模式返回不同字段,于是后端也定义了一份。再后来移动端也接进来了,又定义了一份。三份定义慢慢就不同步了。
解决方式有两个方向:要么把模式定义抽成独立的共享包(monorepo 里很常见),前后端都依赖它;要么把模式定义放在后端,前端通过接口拉取。我们最后选了前者,因为模式本质上是"契约",契约应该独立于任何一端存在。
// packages/shared-modes/src/index.ts export type TableMode = 'view' | 'edit' | 'batch' | 'archive'; export const MODE_MAP = { /* ... */ }; export const MODE_TRANSITIONS = { /* ... */ };前端import { MODE_MAP } from '@shared/modes',后端同样引用。改一处,两端同步。这个投入在项目早期看起来有点重,但等到第三个端接入时,收益就非常明显了。
4.3 坑三:把模式当全局状态,导致组件间互相污染
第三个坑是状态管理层面的。我们有个页面同时渲染了两个表格,一个view模式,一个edit模式。结果发现编辑表格的操作会影响查看表格的显示。排查了半天,发现是模式被存进了全局 store,两个表格共用了同一个模式值。
模式应该是组件实例级别的局部状态,不是全局状态。除非你的业务确实是"整个页面统一切换模式",否则不要把模式往全局 store 里塞。如果确实需要页面级统一模式,那也应该用 Context 或者 provide/inject 这类作用域机制,而不是全局单例。
// 错误:全局单例,所有表格共享 const globalMode = ref<TableMode>('view'); // 正确:每个表格实例独立 function useTableMode(initial: TableMode) { const mode = ref<TableMode>(initial); const switchMode = (to: TableMode) => { if (canTransition(mode.value, to)) { mode.value = to; } }; return { mode, switchMode }; }4.4 坑四:能力对象每次渲染都重建,引发无谓重渲染
第四个坑是性能问题,比较隐蔽。MODE_MAP[mode]每次渲染都会返回同一个对象引用(因为MODE_MAP是常量),这本身没问题。但如果你在组件内部基于模式动态计算能力,比如const cap = { ...MODE_MAP[mode], extra: someState },那每次渲染都会生成新对象,如果这个对象被传给了子组件,就会导致子组件无谓重渲染。
解决方式是把能力对象缓存起来,用useMemo或者类似的记忆化手段,依赖只写mode。
const cap = useMemo(() => MODE_MAP[mode], [mode]);因为MODE_MAP[mode]本身返回的就是稳定引用,这个useMemo其实可以省略。但如果你确实需要基于模式做二次计算,那这个缓存就不能省。性能优化要建立在测量之上,不要一上来就到处加useMemo,先确认这里真的是瓶颈。
5. 模式化设计的边界:什么时候不该用 context-mode
讲了这么多好处,我得泼盆冷水:context-mode 不是银弹,有些场景用了反而更糟。这部分内容在很多教程里是缺失的,但我觉得比"怎么用"更重要。
5.1 只有两种状态且互斥时,布尔值就够了
如果一个组件只有"可编辑"和"只读"两种状态,且永远互斥,那你直接传一个readonly布尔值就行,没必要上模式。模式化的价值在于管理"多个能力的组合",当组合只有一种时,模式就是过度设计。
我见过有人为了"架构统一",给一个纯展示的文本组件也套了模式,定义了normal和highlight两个模式,结果代码量翻了三倍,可读性反而下降。统一是手段,不是目的。该简单的地方就让它简单。
5.2 模式之间差异过大时,应该拆组件而不是加模式
如果两个模式的渲染结构差异超过 70%,比如一个模式渲染表格,另一个模式渲染卡片流,那它们本质上就是两个组件,硬塞进一个组件用模式区分,只会让组件内部变成两套逻辑的缝合怪。
判断标准很简单:如果switch (mode)的每个分支都长得完全不一样,那说明该拆了。模式适合的是"骨架相同、能力不同"的场景,不是"骨架都不同"的场景。
5.3 模式数量会随业务无限增长时,考虑配置化
如果业务方今天要加一个模式,明天又要加一个,模式数量眼看要突破两位数,那说明你的模式定义方式可能太"硬"了。这时候可以考虑把模式定义从代码里挪到配置文件甚至数据库里,让业务方自己配。
但这里有个权衡:配置化会牺牲类型安全。代码里定义的模式,编译器能帮你检查;配置里定义的模式,只能运行时校验。我的建议是,模式数量在 10 个以内时,坚持代码定义;超过 10 个且还在增长时,再考虑配置化,同时补上运行时校验。
| 场景特征 | 推荐方案 | 理由 |
|---|---|---|
| 单一布尔状态,互斥 | 布尔 prop | 模式化是过度设计 |
| 3-6 个能力组合 | context-mode | 收益最明显的区间 |
| 渲染结构差异大于 70% | 拆组件 | 模式无法弥合结构差异 |
| 模式数量持续增长超过 10 个 | 配置化 + 运行时校验 | 代码定义维护成本过高 |
6. 一个完整的最小可运行示例:审批列表的三种模式
光讲理论不够,我把上面所有内容串成一个完整示例。场景是审批列表,有三种模式:view(只读查看)、edit(编辑草稿)、batch(批量审批)。这个示例可以直接跑,你可以照着改成自己的业务。
6.1 模式定义文件
// modes.ts export type ApprovalMode = 'view' | 'edit' | 'batch'; export interface ApprovalCapability { editable: boolean; selectable: boolean; batchActions: string[]; showHistory: boolean; } export const APPROVAL_MODE_MAP: Record<ApprovalMode, ApprovalCapability> = { view: { editable: false, selectable: false, batchActions: [], showHistory: true, }, edit: { editable: true, selectable: false, batchActions: [], showHistory: false, }, batch: { editable: false, selectable: true, batchActions: ['approve', 'reject'], showHistory: false, }, }; export const APPROVAL_TRANSITIONS: Record<ApprovalMode, ApprovalMode[]> = { view: ['edit', 'batch'], edit: ['view'], batch: ['view'], }; export function canSwitchApprovalMode( from: ApprovalMode, to: ApprovalMode ): boolean { return APPROVAL_TRANSITIONS[from].includes(to); }6.2 组件消费
// ApprovalList.tsx import { useState, useMemo } from 'react'; import { ApprovalMode, APPROVAL_MODE_MAP, canSwitchApprovalMode, } from './modes'; export function ApprovalList({ initialMode = 'view' as ApprovalMode }) { const [mode, setMode] = useState<ApprovalMode>(initialMode); const cap = useMemo(() => APPROVAL_MODE_MAP[mode], [mode]); const handleSwitch = (to: ApprovalMode) => { if (canSwitchApprovalMode(mode, to)) { setMode(to); } else { console.warn(`不允许从 ${mode} 切换到 ${to}`); } }; return ( <div> <div className="toolbar"> <button onClick={() => handleSwitch('view')}>查看</button> <button onClick={() => handleSwitch('edit')}>编辑</button> <button onClick={() => handleSwitch('batch')}>批量</button> </div> {cap.selectable && <BatchToolbar actions={cap.batchActions} />} <Table editable={cap.editable} selectable={cap.selectable} /> {cap.showHistory && <HistoryPanel />} </div> ); }6.3 实测效果与几个细节说明
跑起来之后,几个行为值得注意。第一,从edit点"批量"按钮,控制台会打印"不允许从 edit 切换到 batch",界面不动。这就是流转规则在起作用。第二,batch模式下BatchToolbar才渲染,view和edit模式下它根本不存在于 DOM 里,而不是渲染了再隐藏。"不渲染"比"渲染后隐藏"更干净,既省性能,也避免隐藏元素被误操作。
第三,showHistory这个能力只在view模式下为true。这个设计是有业务含义的:查看历史记录是只读场景的需求,编辑和批量场景下不需要。能力定义要带业务语义,不要为了"对称"而硬凑。我见过有人给每个模式都定义全部能力字段,哪怕某些字段在所有模式下都是同一个值,这种"对称"没有价值,反而增加维护负担。
7. 从表格到更广的场景:context-mode 思路的迁移
最后聊聊这套思路能迁移到哪些地方。因为 context-mode 本质上不是"表格组件的技巧",而是一种用上下文描述替代条件开关的通用设计方法。
7.1 表单场景:从"字段开关"到"表单模式"
表单是开关的重灾区。一个用户表单,注册场景要填手机号,登录场景不要,资料编辑场景手机号只读,管理后台场景手机号可改。用开关写就是phoneEditable、phoneRequired、phoneVisible三个布尔值。用模式写就是register、login、profile、admin四个模式,每个模式固定了各字段的状态。
好处是一样的:字段状态从"散落在调用方"变成"集中在模式定义",新增场景只加模式不改字段逻辑。
7.2 接口场景:从"参数开关"到"响应模式"
后端接口也适用。同一个/api/orders接口,列表页要精简字段,详情页要全量字段,导出要包含扩展字段。用开关写就是?detail=true&export=true,参数组合一多就乱。用模式写就是?mode=list、?mode=detail、?mode=export,后端根据模式决定返回哪些字段。
这里有个额外好处:模式可以作为接口契约的一部分写进文档,前端一看就知道有哪些模式、每个模式返回什么,比一堆布尔参数清晰得多。
7.3 权限场景:模式与角色的关系
有人会问,模式是不是就是角色?不完全是。角色是"谁在用",模式是"在什么场景下用"。同一个角色在不同场景下可能对应不同模式。比如管理员在查看自己的待办时是view模式,在管理他人数据时是batch模式。
所以模式判断通常需要结合角色和场景两个维度。我的做法是先用角色过滤出可用模式集合,再根据当前场景从集合里选一个。这样既不会越权,也不会出现"有权限但界面不对"的尴尬。
7.4 迁移时的渐进式策略
如果你手上已经有一个开关满天飞的老组件,不建议一次性重写。渐进式迁移更稳。我的做法是:先加一层模式适配器,把现有开关组合映射成模式,组件内部暂时不动。等所有调用方都改成传模式之后,再逐步清理组件内部的开关判断。这样每一步都可回滚,风险可控。
// 过渡期的适配器:把老开关翻译成模式 function legacyToMode(props: { editable?: boolean; selectable?: boolean; }): ApprovalMode { if (props.selectable) return 'batch'; if (props.editable) return 'edit'; return 'view'; }这个适配器在迁移期间是桥,迁移完成后就可以删掉。不要为了"优雅"而跳过过渡期,老代码的调用方可能比你想象的多。
我在最近一个项目里用这套渐进式策略迁移了一个有 40 多个调用点的老组件,全程没有一次线上事故。核心经验就一条:先让新旧两种写法并存,确认新模式覆盖了所有场景之后,再删旧写法。急着一步到位,往往会在某个没覆盖到的边缘场景上翻车。