ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Context-Mode实战:用模式与上下文终结状态爆炸

Context-Mode实战:用模式与上下文终结状态爆炸

1. 这个 Context-Mode 到底在治什么病

去年年底我接手一个内部后台系统的维护。问题不算复杂,改右侧详情面板的时候,左侧列表的选中状态经常被带跑,隔三差五就有人报“数据看着看着变成了另一条”。我连续排了两天,发现根因不是某个组件写错了,而是整个系统对“现在到底在干什么”这件事,从来就没有一个统一答案。有人用 isEditing 判断是否可编辑,有人用 isPreview 判断是否预览,有人用 isSelected 判断是否选中,十几个布尔变量互相打架,改一个就会踩到另一个。

后来我参考 Vim 那种模式设计,把交互拆成一薄层 Context 和一套 Mode,问题才算真正消停。这套整理思路,我叫它 Context-Mode。Context 描述“正在操作什么”,Mode 描述“当前处于哪种操作语义”,两者组合起来其实是一句完整的话:在某个模式下,对某个对象,执行某个动作。

举文本编辑器的例子最直接。当前光标停在哪一段,是 Context;你处于“编辑模式”“只读模式”还是“选择模式”,是 Mode。同样按一下方向键,只读模式里是滚动页面,编辑模式里是移动光标,选择模式里是扩展选区。同一个输入、同一个对象,因为 Mode 不同,语义完全不同。系统里所有交互都该遵守这个规则,而不是每个组件自己猜。

这篇文章不给你推销框架。它聊的是思路:项目里状态太多、交互太散、切换太随意的时候,怎么用 Context-Mode 重新梳理。正在维护老后台、做富文本编辑器或复杂表格,以及被一堆布尔 flag 逼疯的前端开发,都适合看下去。我举的例子大多来自前端,但这套东西在后端权限、工作流引擎、自动化脚本里同样成立。

1.1 Context 与 Mode:一个是宾语,一个是谓语

Context 和 Mode 最怕混为一谈,所以我想先建立一个直觉:Context 是句子里的宾语,Mode 是谓语。

Context 回答“我在对谁操作”。它包含当前激活的资源、选中的条目、焦点所在的区域、光标位置、当前路由这些信息。比如打开了一个文档,选了三行评论,现在焦点在编辑器里,这就是一份完整的 Context。

Mode 回答“我现在用哪套规则操作”。它是有限集合里的一个枚举值,比如浏览、编辑、多选、命令面板。模式决定了同一个动作投递到哪里、会触发什么结果。工具栏的删除按钮、右键菜单的删除按钮、键盘上的 Delete 键,在“多选模式”下都应该删除选中集合;在“编辑模式”下就应该删除当前文档;在“浏览模式”下可能什么都不该发生。

这个区分非常重要。Context 变了,Mode 不一定跟着变;Mode 变了,Context 可能原地不动。Vim 的设计已经证明了这种组合的威力:你先决定要操作哪一行(Context 定位),再决定用哪套命令语义(Mode),两条信息合起来,一套快捷键能表达几十种动作。

有些系统会犯的毛病,是把这两层真正混成一个状态对象。比如 context 里带上 isEditing、isPreview、isSelected,等于每个组件都在维护自己的小世界。它们互相套娃、互相覆盖、互相冲突,还几乎没有可枚举的合法组合。Context-Mode 的第一步,就是把这两层彻底拆开看待。

1.2 为什么六个布尔值会造出六十四个修罗场

“状态爆炸”这个词听起来很抽象,落到代码里非常具体。假设你的系统需要表达互斥的几种状态,你用布尔值来表达:

  • isEditing = true,isPreview = false,isMultiSelect = false,isDragging = false... 光这四个布尔值就有 16 种组合,但业务上合法的大概只有三种。麻烦的是,组件 A 可能把 isPreview 和 isDragging 组合当作“拖动预览状态”,组件 B 却认为这个组合非法,直接渲染出空白。没有人定义全量组合的语义,最后就是谁先读谁有理。

组合效率也会随着布尔值数量指数膨胀。六个布尔值是 64 种组合,十个布尔值是 1024 种组合。真实业务里还没有校验器能告诉你哪个组合是孤儿。我自己维护的那个老后台,删除按钮为什么经常失准?因为有三个地方同时维护“当前选中的 ID 是谁”,列表组件存一份,右键菜单存一份,详情面板又存一份。三份数据只要有一份没有同步,就会出现“我明明点的是行 A,删除的却是行 B”。

Context-Mode 想对付的就是这个乱象:Context 只有一份全局副本,Mode 只有一个全局枚举值。组件之间不再互相传布尔值,而是读取同一个上下文快照。模式切换是显式动作,状态集合只有合法组合,非法状态从一开始就走不进系统。

2. 单靠一堆 if 判断,永远修不干净这类问题

我最早也想过用补丁来解决。某个地方逻辑不对,就在那里加一个 if;快捷键冲突,就再加一个条件。结果一个月后,代码里全是“如果处在编辑状态且不是预览状态且选中数量大于一”这种长串判断,谁改谁头大。

2.1 一个真实后台系统的乱象现场

拿我那个文档管理系统举例。页面分成三块:左侧是文档列表,中间是编辑器,右侧是评论和批注面板。原始代码大概长这样:列表行有 onClick,详情面板有 onFocus,编辑器有 onKeyDown,右键菜单有 onContextMenu。每个事件各自维护自己的状态副本,相互之间只靠传来传去的回调通知。

我发现三个最典型的失控点。第一个是删除操作不明确——它不知道自己是应该删“当前选中的行”还是“当前打开的文档”。第二个是全选快捷键会打破内部焦点——用户选中列表想按 Ctrl+C 复制,结果编辑器抢先拿到这个组合键,把刚选中的内容又清掉了。第三个是右键菜单永远忠于“它自己在哪一格”,而不是“用户整个人当前在关注什么”,在评论侧栏点右键,菜单里居然还出现“新建文档”这种完全无关的选项。

这些问题的共同点,是每个控件都尝试独立解释“上下文”。它们看到的是局部信息,却要做出全局判断。无论你在哪里加 if,都不可能穷尽所有控件组合。

2.2 引入 Context-Mode 之后,变化在哪里

我重新梳理的做法,是让系统只有一个“当前操作语义”的来源。

先定 Context。每次路由变化、选中变化、焦点变化,都通过一个统一的 dispatch 动作更新全局 Context 快照。再定 Mode。点击列表行进入浏览模式,双击进入编辑模式,按下 Ctrl+A 进入多选模式,按 Esc 回退到上一模式。所有 UI 入口都走同一套切换函数,不再允许组件私下改状态。

改完之后,删除按钮的语义立刻清晰了。它不再自己考虑“该删谁”,而是先看 Mode:多选模式下,取 Context.selectedIds;编辑模式下,取 Context.resourceId;浏览模式下干脆提示用户“当前没有可删除对象”。这个判断不是散落在每个按钮里,而是收拢成一个 act() 函数。哪个按钮想删除,只管调用 act({ type: 'delete' }) 就好。

从代码量看,我这个后台系统最终大约少了 20% 的胶水代码。但我觉得更关键的是“新增功能不再踩雷”:想给某个模式加一个动作,只需要改一条分派表和对应模式的路由,不用翻遍几十个组件看哪里会收到未知消息。

3. 一个最小可跑的 Context-Mode 实现

这一节我会给你一套可以直接照着改的代码骨架。它用 TypeScript + React 写,但核心逻辑是状态机,搬到 Vue 或者原生 JS 一样可以。我故意不引入任何复杂中间件,保证你在本地 30 分钟内能跑起来。

3.1 定义 Mode 与 Context 的类型

先定义 Mode。记住,Mode 是有限集合,不是自由字符串。每一个枚举值都对应一套合法的操作语义。

export type Mode = | 'browse' // 浏览:只读、滚动、选中查看 | 'edit' // 编辑:可输入、可修改当前资源 | 'multiSelect' // 多选:批量操作 | 'commandMenu'; // 命令面板:临时切换层

再定义 Context。Context 要装的是“当前正在操作谁”,不应该塞任何布尔状态进去。

export interface Context { resourceId: string | null; resourceType: 'document' | 'folder' | 'comment' | null; selectedIds: string[]; focusZone: 'list' | 'editor' | 'commentPanel' | null; route: string; revision: number; // 每次上下文变化都自增,用于丢弃过期回调 }

把 revision 放进来是我踩坑之后总结出来的。后端接口还在返回,用户却已经切走了,如果此时把响应写回 context,会覆盖掉用户刚选中的新数据。revision 提供的是一个廉价的时间戳:异步回调回来时,如果发现外部 revision 已经变了,说明这份响应属于旧世界,直接丢弃。这是 Context-Mode 方案里不能省的一个字段。

核心状态就是这两层合并:

export interface CtxState { context: Context; mode: Mode; modeStack: Mode[]; // 记录临时模式之前的模式,Esc 时回退 }

3.2 用 Reducer 收敛所有切换逻辑

所有对 Context 和 Mode 的修改,都应该收敛到一个 reducer 里。你可以理解成所有改状态的入口都走一扇门,不允许有人从窗户跳进来。

export type CtxAction = | { type: 'SET_CONTEXT'; patch: Partial<Context> } | { type: 'SET_MODE'; mode: Mode } | { type: 'PUSH_MODE'; mode: Mode } | { type: 'POP_MODE' } | { type: 'CLEAR_SELECTION' }; export function ctxReducer(state: CtxState, action: CtxAction): CtxState { switch (action.type) { case 'SET_CONTEXT': return { ...state, context: { ...state.context, ...action.patch, revision: state.context.revision + 1, }, }; case 'SET_MODE': // 直接替换模式,清空模式栈 return { ...state, mode: action.mode, modeStack: [], }; case 'PUSH_MODE': // 进入临时模式,比如从编辑状态打开命令面板 return { ...state, modeStack: [...state.modeStack, state.mode], mode: action.mode, }; case 'POP_MODE': { const prev = state.modeStack[state.modeStack.length - 1] ?? 'browse'; return { ...state, mode: prev, modeStack: state.modeStack.slice(0, -1), }; } case 'CLEAR_SELECTION': return { ...state, context: { ...state.context, selectedIds: [], revision: state.context.revision + 1, }, }; } }

有几个细节请你特别留意。PUSH_MODE 和 SET_MODE 的区别是:PUSH 会把当前模式压入栈,方便 Esc 回退;SET 是直接转向,不回退。右键菜单、命令面板这种临时覆盖层,用 PUSH;用户明确切换工作场景,比如从列表进入预览,用 SET。

3.3 把用户操作统一成 Intent

Mode 和 Context 都已经收敛了,最后一步是操作。我不想让组件直接 dispatch 底层动作,而是希望组件只表达“我想干什么”,比如“删除当前目标”“切换选中项”“发布当前文档”。具体的模式分派逻辑放在一个 act() 方法里。

export type UserIntent = | { type: 'delete' } | { type: 'openResource'; id: string; resourceType: Context['resourceType'] } | { type: 'select'; id: string } | { type: 'save' }; export function act(state: CtxState, intent: UserIntent): CtxState { const { mode, context } = state; switch (intent.type) { case 'delete': if (mode === 'multiSelect') { // 批量删除选中项 return confirmAndDelete(context.selectedIds) ? { ...state, context: { ...context, selectedIds: [], revision: context.revision + 1 } } : state; } if (mode === 'edit' && context.resourceType === 'document') { // 删除当前文档 return deleteDocument(context.resourceId); } // 浏览模式或没有目标时,不做破坏性操作 notify('当前没有可删除的对象'); return state; case 'openResource': return { ...context, resourceId: intent.id, resourceType: intent.resourceType, }; case 'save': if (mode === 'edit') { return doSave(context.resourceId); } return state; } }

这段只是示意,真实项目里你可以把删除逻辑拆到 service 层。重要的是组件层从此非常干净:Toolbar 的删除按钮只管调用act(state, { type: 'delete' }),键盘 Delete 也是同一个调用。如果未来要多选模式下追加“移动”操作,只要在 act 里增加一个 branch,所有入口立刻生效,不用一遍一遍加 if。

3.4 组件侧只读状态与快捷键注册

组件侧我建议提供两个 hook:useCtxState()返回 { context, mode } 的只读快照;useCtxDispatch()返回 dispatch 和 act 的封装。任何组件都不能直接改 context,只能通过 reducer 动作。这样好处是回退、调试、持久化都简单——你把所有变更集中到一个 reducer,就是天然的可视化日志。

快捷键注册可以考虑做一个“模式路由表”。每个快捷键都声明自己属于哪个模式:

const shortcuts: ShortcutEntry[] = [ { key: 'ArrowDown', modes: ['browse'], handler: () => scrollList() }, { key: 'ArrowDown', modes: ['edit'], handler: () => moveCursor() }, { key: 'Mod+a', modes: ['browse', 'edit'], handler: () => startMultiSelect() }, { key: 'Escape', modes: ['multiSelect', 'commandMenu'], handler: () => popMode() }, ];

事件监听器拿到 keydown 后,先看当前 mode 是否在数组里,命中才执行。这样你在编辑模式下按方向键绝不会触发滚动的逻辑,也不会因为某个 UI 库悄悄加了 preventDefault 导致快捷键失效。我强烈建议你这样做而不是到处addEventListener,因为只要每次新快捷键只出现在一个表里,冲突排查就是查表的事。

3.5 一张对照表:布尔状态方案与 Context-Mode

为了让你更直观看到差异,我把两个方案做了一组对比:

维度布尔状态方案Context-Mode
合法状态隐含,没人知道全量组合模式枚举显式定义
上下文来源每个组件各存一份全局单一快照
模式切换组件内部 setState统一 act/dispatch
快捷键冲突按注册顺序,后写先死按模式路由查表
异步回写容易覆盖新数据revision 机制丢弃旧响应
测试难度需要模拟无数组合每个 mode+context 一组用例
新增操作要排查所有布尔分支只改 act 分派表

这张表基本是我重构完一个老系统后积累的真实感受。布尔方案写起来短,但它把复杂性藏进了组合里;Context-Mode 前期多花半天建模,后期省下的维护时间远超这个投入。

4. 排查实录:Context-Mode 的五个常见坑

理论看起来顺畅,实操处处有坑。这一节我把自己踩过的坑和观察到的同组翻车现场整理一下,希望能帮你少走弯路。

4.1 把“保存中”当成了模式

我最开始设计模式时会写mode: 'editingSaving'。听起来很合理,编辑保存中,禁止再改。但保存其实是后端请求的系统状态,不是用户在“干什么”。用户此刻依然处于编辑意图,只是系统正在忙。

如果我让 Mode 携带“保存中”语义,界面就要在轮询、超时、重试时动不动切换 Mode。一旦请求失败,Mode 会变来变去,用户甚至看到工具栏跳动。正确的做法是:Mode 只表达意图,异步状态放到另一个 field 里,比如remoteState: 'idle' | 'saving' | 'saved' | 'error'。Act 函数可以同时修改 mode 和 remoteState,但两者不要混成一个枚举。

4.2 全局快捷键和输入框抢焦点

Context-Mode 的快捷键路由解决了“模式冲突”,但没解决“输入框架 vs 全局快捷键”的冲突。比如用户正在编辑评论,按Ctrl+A,你的浏览器默认行为是全选评论文字。如果你的全局监听器先拿到这个事件,立刻把系统切到 multiSelect 模式,评论里的文字全选就失效了。

我的处理方法是区分“焦点区域”。如果 focusZone 是 'editor' 或 'commentPanel',就优先放行浏览器原生输入行为,只有按下明确的命令键才拦截。可以给快捷键表加一列ignoredIfEditable: true,监听器先判断焦点是否在可输入元素里,是就跳过。这个规则不必写死到组件,放到模式路由表配置里即可。

4.3 异步回调写回了旧 Context

这是最隐蔽的坑。场景是这样的:用户先选中一个文档,系统发起读取详情请求;请求还没回来,用户又选中了另一个文档;第一个请求回来,直接setContext({ resourceId: response.id }),把第二个文档覆盖掉了。页面看起来就是“我明明点的是 B,显示的还是 A”。

我在 Context 里加 revision 字段后,每个异步请求都拍一个快照。响应回来先比对 revision 是否一致,不一致就丢弃。代码永远都是这种姿势:

const snapshotRevision = getCtx().context.revision; fetchDetail(id).then((data) => { if (getCtx().context.revision !== snapshotRevision) return; commit(data); });

这套思路可以用一个自增数字,也可以用 token,核心就是别允许晚到的响应覆盖新的现实。

4.4 模式栈越压越深

PUSH_MODE 很顺手,于是开发者喜欢任何弹层都 push 一层模式。打开命令面板 push,打开右键菜单 push,打开搜索面板又 push。用户按了几下 Esc,模式栈里全是“浏览”“编辑”的交替记录,逻辑最后连自己都迷糊。

我现在给自己立了一条约束:只有真正阻塞当前操作的临时覆盖层才用 PUSH,比如模态框、命令面板;非阻塞的悬浮提示、工具栏 hover 状态,不应该出现在全局 Mode 里。Mode 只需要反映“操作场景”,不该当表现层的仓库。模式太多时先问一句:这个状态真的影响下一轮用户输入的语义吗?

4.5 测试用例漏掉了“模式组合”

Context-Mode 最大的优点是测试方便:每个合法的 mode × context 组合都可以列成一张表。但如果你偷懒,只测兜底状态“browse + 无选中”,那么多选模式的批量操作永远测不到。

我实际用的测试框架是给 reducer 写纯函数测试。先构造一个 state,dispatch 一个 action,再断言结果。每个模式至少覆盖三组 context:空、单条选中、多条选中。这样跑一遍,比之前用 UI 自动化点按钮更稳。纯函数测试速度很快,CI 里放几十个用例毫无压力。

4.6 兼容旧逻辑时的过渡技巧

老系统不可能一天改完。我建议渐进式推进:先从右键菜单这种“局部且高频”的地方入手,把它的逻辑改成直接调用 act;再把全局快捷键接进来;最后清理组件里散落的布尔状态。过渡期间可以保留旧的布尔字段,但让它们从getCtxState()派生,而不是各组件自己维护。这样至少保证两个来源只有一个真相,旧组件慢慢删就够了。

5. Context-Mode 还能延伸到哪里

看到这里,你可能觉得这只是一个前端状态管理的花招。其实它的底层逻辑是“把上下文与操作语义解耦”,这套东西能延伸到很多场景。

5.1 富文本编辑器和设计工具

Notion 的/命令菜单、Figma 的移动工具栏、飞书文档的评论模式,本质都在用 Context-Mode。当前选中的文本、光标所在的 block、被选中的图层是 Context;当前是“编辑文字”还是“框选对象”是 Mode。只要模式分得清,同一个快捷键既能移动光标、也能移动对象、还能缩放宽高。

很多编辑器用户都知道 Vim 的核心设计就是“模式”。Vim 的 normal、insert、visual、command-line 四模式,加上当前光标位置、寄存器内容这些 Context,力量强大到几十年没被淘汰。Web 编辑器完全可以借鉴这套思路,而且实现起来并不难,你只要把 dispatch 动作替换成 vim 的命令解析即可。

5.2 权限系统和多角色后台

权限判断特别适合用 Mode 来表达。同样一个“删除按钮”,管理员模式下可见可用,普通编辑模式下可见但禁用,访客模式下直接不渲染。Context 决定“要删哪个项目”,Mode 决定“当前用户以什么角色在操作”。这比在每个按钮里写if (user.roles.includes('admin') && item.ownerId === user.id)清晰得多。

我后来就把老后台的“角色切换”实现成了 SET_MODE。切换角色时只改 Mode,Context 里的资源 ID 和选中项全部保留。用户切到管理员,原本选中的文档还在,只是可操作范围变了。交互上没有“刚切换角色就丢选中焦”的诡异体验。

5.3 工作流引擎和自动化脚本

工作流引擎每个节点都接收一份“当前案件上下文”,同时整个流程处于“审核中”“退回”“已完成”等模式。Context-Mode 把节点定义写成纯函数:输入 context 和 mode,输出新 context 和 mode 转移。这样每一步都能被测试、被回放、被审计。

自动化脚本同理。你的脚本可能在不同环境下要“调试模式”“静默模式”“交互确认模式”。把这些模式作为入参传给同一个执行函数,而不是在脚本里散落几十个if debug,这会让脚本好维护非常多。

5.4 Prompt 工程里的 Context-Mode

我做 agent 实验时,发现这概念也可以解释最近很流行的“角色模式”提示词。对模型来说,Context 是当前的对话记录、上传的资料、用户的最后一句话;Mode 是“你是分析师”“你是代码审查员”“你是校对员”这类显式指令。把 Mode 写在系统提示里,模型的行为风格就不同。

如果你经常发现同一个 prompt 时而给长文总结、时而给代码示例、时而给情绪化回复,那很可能就是 Mode 没设置清楚。可以在提示词开头固定写一句MODE: analysis之类的指令,让模型知道该用哪套操作语义,然后根据用户输入逐渐更新 Context。这就是 LLM 版的 Context-Mode:Context 描述材料,Mode 描述任务类型,两者合起来才是一次完整的要求。

不过我要提醒一句:prompt 里的 Mode 不能替代对上下文的管理。如果你把两段互相矛盾的材料都塞进 Context,模型再切一百个模式也救不回来。先把材料整理清楚,再选一个明确的 Mode,输出才会稳定。

5.5 把 Mode 当作一种“产品语言”

Context-Mode 不只是代码组织方式,它还能变成产品和 UI 沟通的语言。以前设计师说“这里要高亮”,前端理解可能五花八门。现在大家能统一说“在 multiSelect 模式下,列表行允许高亮;在 browse 模式下,只有 hover 才高亮”。产品、设计、研发共同引用同一个模式词表,沟通效率会好很多。

我甚至在项目里维护了一页“模式字典”,把每个模式允许的动作和禁止的动作写清楚。新同事进来不需要翻完业务代码,就能快速理解为什么按钮在某种情况下不可点。这种文档虽然不产生直接收益,但能把“隐性知识”变成显性资产,长期价值非常大。

6. 长期使用后的一些体会

文章最后我不想做什么总结,就说几个自己最有感觉的点。

Context-Mode 不是银弹。如果项目状态简单、交互少,直接用布尔可能更省事。但只要你发现“组合状态”开始失控,比如同一个按钮在不同模块里行为不一致、快捷键互相掐架、删除总删错对象,那早点引入这套模型是真的能止疼。

我个人目前的习惯是:接到新项目或者大模块重构,先别急着写组件。我会花半天画一张表——左边是 Context 字段,右边是 Mode 枚举,中间是“哪个入口会产生哪个切换”。这张表画完,代码只是把它翻译一遍的事。很多“一时不知道怎么做”的设计难题,在表上就已经消解了。

代码层面有一点我反复强调也要再提一遍:revision 机制和模式栈一定要从第一天就放进去。它们看起来像额外复杂度,但没有它们,异步回调覆盖、多层弹层回退这两个坑迟早会找上你。到时候再补,改动成本比现在高得多。

最后一个让我意外的是,这套思路不止让我代码更稳,还让团队讨论更聚焦了。大家不再争论“这个按钮不该显示”,而是直接问“当前 mode 是什么、context 里有没有目标”。问题回到数据与规则本身,人情世故少了很多。

如果你手头正被状态爆炸折磨,建议你从最小的右键菜单开始,试着把 Context-Mode 灌进去。跑通一个场景后,你会回来把这篇文章的代码骨架复制到你的真实项目里的。

返回列表