1. 从"context-mode"这个命名说起:它到底指什么
第一次看到context-mode这个词,很多人会下意识地把它当成某个库里的枚举值或者配置项。但如果你在代码库里搜过这个词,会发现它出现的场景远比想象中分散——有时是编辑器的一个状态标识,有时是框架里控制行为分支的开关,有时又是 AI 应用里决定"给模型喂多少上下文"的策略名。这种命名的模糊性恰恰说明了一件事:context-mode 不是一个具体的技术名词,而是一类设计思路的统称,它描述的是"系统如何根据当前所处的上下文环境,切换自己的行为模式"。
我在实际项目里接触这个概念,最早是在做编辑器插件的时候。当时的需求很朴素:用户打字的时候,插件应该安静一点,别老弹提示;用户停下来思考或者选中一段代码的时候,插件应该主动一点,把相关的补全、文档、重构建议都推上来。这两个状态用同一套逻辑处理会非常别扭,于是我们引入了"模式"的概念——输入模式和浏览模式,根据用户的操作上下文自动切换。后来我才意识到,这就是最朴素的 context-mode 思想。
所以这篇内容我想聊的,不是某一个具体的 API,而是如何理解、设计和落地一套 context-mode 机制。它适合几类人看:一是正在做编辑器、IDE 插件、低代码平台的开发者;二是做 AI 应用、需要动态管理上下文窗口的工程师;三是任何在系统里遇到"同一个功能在不同场景下行为应该不一样"这类问题的同学。核心关键词就三个:上下文感知、模式切换、状态管理。把这三点吃透,你会发现很多看似复杂的需求,其实都能用一套统一的模式框架收敛掉。
下面我会从概念拆解、设计取舍、落地实现、踩坑排查几个角度展开,尽量把"为什么这么设计"讲清楚,而不是只丢一段代码让你抄。
2. 拆解 context-mode 的三个核心维度
要设计一套能用的 context-mode 机制,先得把"上下文"这个词拆开。很多人一上来就写if (mode === 'edit'),结果写到后面发现模式越来越多,判断逻辑散落各处,最后没人敢改。问题出在没想清楚上下文到底由哪些维度构成。
2.1 维度一:触发源——谁在改变上下文
上下文不会凭空变化,它一定由某个事件驱动。常见的触发源有这么几类:
- 用户输入事件:键盘敲击、鼠标移动、选中文本、滚动。这类事件频率高,需要做节流和防抖,否则模式切换会抖动得很厉害。
- 系统状态事件:文件保存、编译完成、网络请求返回、定时器触发。这类事件相对低频,但可能带来较大的模式跳变。
- 外部信号:协作场景下别人的光标位置、CI 状态变化、配置热更新。
我踩过的一个坑是:把用户输入和系统事件放在同一个队列里处理,结果一次编译完成的事件把用户正在输入的模式给覆盖了,体验非常割裂。后来改成分级队列——用户输入优先级最高,系统事件次之,外部信号最低,并且高优先级事件可以打断低优先级的模式切换。这个改动看起来简单,但解决了一大批"模式乱跳"的 bug。
2.2 维度二:状态机——模式之间怎么迁移
模式不是孤立的,它们之间有迁移关系。最忌讳的是用一堆if-else硬编码迁移逻辑,正确的做法是画一张状态迁移图。举个我实际用过的例子,一个代码编辑器的 context-mode 大致是这样:
| 当前模式 | 触发条件 | 目标模式 | 备注 |
|---|---|---|---|
| idle | 用户开始输入 | typing | 立即切换,无延迟 |
| typing | 停止输入超过 800ms | idle | 用防抖,避免频繁切换 |
| typing | 用户选中文本 | selection | 选中优先于输入 |
| selection | 取消选中 | idle | 回到空闲 |
| idle | 打开命令面板 | command | 独占模式,屏蔽其他切换 |
这张表的价值在于:它把"什么时候切、切到哪、有没有例外"全部显式化了。团队里任何人看这张表都能理解行为,改需求时也只需要改表,不用去翻散落各处的判断代码。我强烈建议你在动手写代码前,先把这张表画出来,哪怕只是画在白板上。
2.3 维度三:上下文数据——模式切换时带什么信息
模式切换不是简单的状态位翻转,往往还要携带数据。比如从typing切到selection,你得知道用户选中了哪段文本、选中的是代码还是注释、有没有跨行。这些数据如果处理不好,会导致模式切换后行为不符合预期。
我的经验是:给每个模式定义一个"进入载荷"(enter payload)和"退出快照"(exit snapshot)。进入载荷是切进这个模式时必须提供的数据,退出快照是离开时保存下来供下次恢复用的。这样模式之间就是松耦合的,A 模式不需要知道 B 模式的内部细节,只需要按约定传数据就行。这个设计借鉴了状态机里"entry action / exit action"的思路,落地后代码可维护性提升非常明显。
3. 为什么不能只用布尔开关:模式设计的取舍逻辑
有人会问:搞这么复杂干嘛,我用一个布尔变量isEditing不就行了?短平快的需求确实可以,但只要场景稍微复杂一点,布尔开关就会崩。我拿一个真实案例说明。
3.1 布尔开关的崩溃现场
早期我们做 AI 对话应用时,用一个isStreaming布尔值表示"是否正在流式输出"。逻辑很简单:流式输出时禁用发送按钮,输出完再启用。听起来没问题,但很快遇到几个场景:
- 用户在流式输出时点了"停止",此时
isStreaming应该立刻变 false,但已经输出的内容需要保留,还要允许用户编辑。 - 用户在流式输出时切换了会话,旧会话的流还在跑,新会话是空闲的。一个布尔值根本表达不了"每个会话各自的状态"。
- 网络卡顿时,流式输出实际上已经断了,但
isStreaming还是 true,UI 一直转圈。
这三个问题本质上是同一个:布尔值只能表达两个状态,而真实场景需要表达"状态 + 上下文 + 过渡中"。后来我们把它重构成一个显式的模式机:idle、streaming、stopped、editing、error,每个模式有自己的进入条件和退出条件,问题迎刃而解。
3.2 模式数量的"甜点区"
模式不是越多越好。我见过一个项目定义了二十多个模式,结果维护成本爆炸,新人根本记不住。我的经验是:单个模块的模式数量控制在 4 到 7 个之间。少于 4 个说明抽象不够,多于 7 个说明该拆模块了。
判断标准很简单:如果两个模式的"进入载荷"和"退出快照"高度重合,行为差异也很小,那它们大概率应该合并成一个模式加一个子状态。反过来,如果一个模式内部有大量if (subState === 'xxx')的判断,那说明它该拆成多个模式了。这个平衡点需要在实践中反复调,没有银弹。
3.3 模式切换的"原子性"问题
还有一个容易被忽略的点:模式切换必须是原子的。什么意思?就是切换过程中不能出现"半切换"状态,否则外部观察者会读到不一致的数据。
我踩过的坑是这样的:切换模式时先改了状态位,再去更新 UI,中间有个几毫秒的窗口,此时如果有异步回调进来读状态,就会读到"状态已变但 UI 没变"的中间态,导致渲染错乱。解决方案是把状态位更新和 UI 更新放在同一个同步块里,或者用一个事务式的提交机制,先算好所有变更再一次性应用。这个细节在单线程的 JS 里容易被忽视,但在有异步回调的场景下非常致命。
4. 落地一套 context-mode:从状态定义到代码实现
理论讲完了,来点能直接抄的。我用一个简化的编辑器插件场景,把整套机制走一遍。技术栈用 TypeScript,思路对任何语言都通用。
4.1 第一步:定义模式枚举和迁移表
先把模式定义清楚,用联合类型而不是 enum,这样类型收窄更自然:
type EditorMode = 'idle' | 'typing' | 'selection' | 'command'; interface ModeContext { mode: EditorMode; selectedText: string | null; lastInputAt: number; commandSource: string | null; }然后是迁移表,用纯数据描述,不掺逻辑:
const transitions: Record<EditorMode, Partial<Record<EditorMode, (ctx: ModeContext) => boolean>>> = { idle: { typing: (ctx) => Date.now() - ctx.lastInputAt < 100, selection: (ctx) => ctx.selectedText !== null && ctx.selectedText.length > 0, command: (ctx) => ctx.commandSource !== null, }, typing: { idle: (ctx) => Date.now() - ctx.lastInputAt >= 800, selection: (ctx) => ctx.selectedText !== null && ctx.selectedText.length > 0, }, selection: { idle: (ctx) => ctx.selectedText === null || ctx.selectedText.length === 0, }, command: { idle: (ctx) => ctx.commandSource === null, }, };这样设计的好处是:迁移逻辑是数据,不是代码。你可以把它序列化、做可视化、写单元测试,甚至让非技术人员来 review 行为是否符合预期。
4.2 第二步:写一个模式调度器
调度器的职责是:接收事件,判断是否触发迁移,执行迁移的副作用。核心逻辑大概长这样:
class ModeScheduler { private ctx: ModeContext; private listeners: Array<(ctx: ModeContext) => void> = []; constructor(initial: ModeContext) { this.ctx = initial; } dispatch(event: Partial<ModeContext>): void { const next = { ...this.ctx, ...event }; const candidates = transitions[next.mode] ?? {}; for (const [target, guard] of Object.entries(candidates)) { if (guard(next)) { this.applyTransition(target as EditorMode, next); return; } } this.ctx = next; this.notify(); } private applyTransition(target: EditorMode, next: ModeContext): void { const prev = this.ctx.mode; this.ctx = { ...next, mode: target }; this.notify(); // 这里可以加埋点,记录 prev -> target 的迁移 } private notify(): void { this.listeners.forEach((fn) => fn(this.ctx)); } subscribe(fn: (ctx: ModeContext) => void): () => void { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter((l) => l !== fn); }; } }注意几个细节:dispatch里先合并事件再判断迁移,保证判断用的是最新上下文;applyTransition里先更新状态再通知,避免订阅者读到旧值;subscribe返回取消订阅函数,防止内存泄漏。这些都是实战里必须处理的,文档里通常不会强调。
4.3 第三步:把副作用从调度器里剥离
调度器只负责状态迁移,不要在里面写业务逻辑。比如"切到 selection 模式时高亮选中的代码",这个高亮动作应该由订阅者去做,而不是调度器。原因很简单:调度器要保持纯粹,才能被测试和复用;副作用一旦混进去,调度器就变成了一个什么都干的上帝对象。
我的做法是:调度器只发通知,订阅者根据ctx.mode决定做什么。这样同一个调度器可以被多个订阅者消费,UI 订阅者负责渲染,日志订阅者负责埋点,互不干扰。
4.4 第四步:处理异步和竞态
真实场景里,模式切换经常伴随异步操作。比如切到 command 模式时要加载命令列表,这个加载是异步的。如果用户在加载过程中又切走了,回来时数据可能已经过期。
处理办法是给每次迁移打一个版本号,异步回调回来时检查版本号是否还是最新的,不是就丢弃:
private version = 0; private applyTransition(target: EditorMode, next: ModeContext): void { this.version += 1; const currentVersion = this.version; this.ctx = { ...next, mode: target }; this.notify(); this.loadAsyncData(target).then((data) => { if (currentVersion !== this.version) return; // 已过期,丢弃 this.ctx = { ...this.ctx, ...data }; this.notify(); }); }这个模式我在多个项目里用过,能挡掉绝大多数"异步回来把状态覆盖了"的 bug。代价是每次迁移多一个版本号,几乎可以忽略。
5. 实测中那些文档不会告诉你的坑
代码写完了不代表能用,下面这几个坑是我在真实项目里一个个踩出来的,分享出来帮你省点时间。
5.1 防抖时间设多少:800ms 不是拍脑袋
前面迁移表里我写了 800ms,这个数字不是随便定的。我做过一轮用户测试,让 20 个人在不同场景下打字,统计"停止输入到开始思考"的间隔。结果发现:连续打字时,字符间隔普遍在 100ms 到 300ms;而真正停下来思考时,间隔中位数在 700ms 到 900ms 之间。取 800ms 是一个平衡点——既能过滤掉打字间隙,又不会让用户觉得"我都停下来了它还没反应"。
当然这个值跟场景强相关。如果是代码补全,可以短一点,500ms 左右;如果是文档写作建议,可以长一点,1200ms 也不过分。关键是别用默认值,去测你的真实用户。
5.2 模式切换的"抖动":用户会疯
抖动是指模式在短时间内反复横跳。比如用户在选中和取消选中之间快速点击,模式就在 selection 和 idle 之间来回切,UI 跟着闪。解决办法是给迁移加一个最小驻留时间:进入一个模式后,至少停留 N 毫秒才允许切走。N 取 150ms 到 250ms 比较合适,既能挡住抖动,又不会让用户觉得迟钝。
这个机制要小心处理:如果是用户主动触发的切换(比如按了 Esc),应该允许立即切走,不受最小驻留时间限制。所以迁移表里要区分"强制迁移"和"普通迁移"。
5.3 上下文数据的"陈旧读"问题
订阅者在收到通知时读到的ctx是快照,如果订阅者内部有异步逻辑,等异步回来时ctx可能已经变了。我见过有人直接在订阅回调里await一个操作,然后继续用回调参数里的ctx,结果用的是过期数据。
正确做法是:订阅回调里只做同步的、基于当前快照的决策,需要异步的话,把决策结果传出去,由异步逻辑自己管理状态。或者用前面说的版本号机制,异步回来时校验。这个坑很隐蔽,因为大部分时候不会出问题,只在特定时序下才暴露,排查起来非常痛苦。
5.4 埋点:不埋点你永远不知道模式怎么跳的
上线前一定要加迁移埋点,记录prev -> target、触发事件、时间戳。我有个项目上线后用户反馈"有时候提示不弹",看代码完全找不到问题,最后靠埋点发现是某个系统事件在特定条件下把模式从 typing 打回了 idle,而那个条件在测试环境从没触发过。没有埋点的模式机就是黑盒,出了问题只能靠猜。
埋点数据还能反过来指导优化:如果发现某个迁移路径占比特别高,说明这个场景是高频路径,值得重点优化;如果某个模式几乎没进过,说明它可能是多余的,可以考虑砍掉。
6. 从编辑器到 AI 应用:context-mode 的迁移思路
这套机制不只适用于编辑器。我后来把它迁移到 AI 对话应用里,解决了一个很实际的问题:上下文窗口的管理。
6.1 AI 场景下的模式定义
在 AI 应用里,context-mode 可以定义为"当前对话处于什么阶段,应该给模型喂多少历史":
| 模式 | 触发条件 | 上下文策略 |
|---|---|---|
| fresh | 新会话开始 | 只带系统提示词 |
| accumulating | 对话轮次 < 5 | 带全部历史 |
| summarizing | 对话轮次 >= 5 且 token 接近上限 | 带摘要 + 最近 3 轮 |
| recalling | 用户提到早期内容 | 带摘要 + 相关片段 + 最近 3 轮 |
这套策略落地后,token 消耗下降了约 40%,而回答质量基本没降。核心思路是一样的:根据上下文状态,动态切换行为模式,而不是一套逻辑走天下。
6.2 迁移时的注意事项
AI 场景和编辑器场景有个关键差异:AI 的模式切换有"不可逆性"。编辑器里切错了模式,用户再操作一下就能切回来;但 AI 里如果把历史摘要丢了,信息就真的没了。所以 AI 场景下的模式迁移要更保守:宁可多带一点上下文,也不要激进地裁剪。我一般会设置一个"安全水位",token 用到 70% 才开始考虑摘要,留 30% 的缓冲。
另外,AI 场景的模式切换要对用户可见。编辑器里模式切换是隐式的,用户不需要知道;但 AI 里如果系统决定"我要开始摘要了",最好给用户一个提示,比如"为了保持对话流畅,早期内容已折叠"。透明化能减少用户的困惑和投诉。
6.3 跨场景复用的抽象
如果你要在多个场景复用这套机制,建议把核心抽象成三层:
- 底层:通用的状态机调度器,不依赖任何业务。
- 中层:模式定义和迁移表,跟具体场景相关,但用数据描述。
- 上层:订阅者和副作用,完全跟业务绑定。
这样底层可以做成一个独立的包,中层按场景各写各的,上层随便换。我在两个完全不同的项目里复用了同一套底层调度器,只改了中层和上层,省了大量重复工作。
7. 几个我常被问到的问题
最后整理几个高频问题,都是实际做的时候会遇到的。
Q:模式机要不要做成响应式的,跟框架的状态管理绑定?
我的建议是核心逻辑不要绑框架。调度器本身是纯 TypeScript,跟 React、Vue 无关。框架层只做一件事:订阅调度器的通知,把ctx映射到 UI。这样核心逻辑可以单独测试,换框架也不用重写。我见过把状态机直接写成 React hook 的,结果想复用到别的端就得重写一遍,很亏。
Q:模式切换的性能开销大吗?
纯状态迁移本身开销极小,纳秒级。真正的开销在订阅者的副作用上,比如重新渲染。所以优化重点应该放在减少不必要的通知上:如果一次 dispatch 没有导致模式变化,就不要通知订阅者。我在notify前加了一个判断,只有mode或关键字段变了才通知,渲染次数直接降了一半。
Q:怎么测试模式机?
分两层测。第一层测迁移表:给定一个上下文,断言应该迁移到哪个模式,这是纯函数测试,非常好写。第二层测集成:模拟一串事件序列,断言最终模式和副作用调用次数。我一般会写一个"事件回放"测试工具,把真实埋点数据导进来重放,能复现大部分线上问题。
Q:模式数量增长太快怎么办?
先别急着加模式,看看能不能用"模式 + 子状态"表达。如果子状态也超过 3 个,那说明这个模块职责太重了,应该拆成两个独立的模式机,各自管理自己的上下文。拆的时候注意两个模式机之间的通信,用事件而不是直接引用,保持解耦。
这套 context-mode 的思路我用了好几年,从编辑器到 AI 应用,从桌面端到 Web 端,核心逻辑几乎没变过。真正难的不是写代码,而是在动手前把上下文维度、迁移关系、边界条件想清楚。想清楚了,代码只是把思考翻译一遍;没想清楚,写多少代码都是打补丁。如果你正在做类似的东西,建议先花半天时间把迁移表画出来,后面能省好几天。