1. 项目全貌:这个 “context-mode” 到底在解决什么问题1.1 初见 context-mode:一段让所有写业务逻辑的人眼熟的场景如果问我最近一年多最值得沉淀下来的技术经验是什么,我会毫不犹豫地说:一套叫 “context-mode” 的上下文模式管理机制。它不是什么高深框架,也不是某个大厂的开源神器,而是在写业务代码时反复被同一个问题折磨之后,自己动手攒出来的一套通用做法。先说一个高频场景。接手过一个内部系统,页面上一堆按钮,有的要在“编辑态”下才能点,有的要在“预览态”下隐藏,还有的要根据“移动端还是桌面端”显示不同的文案。最初的实现是每个组件里都自己判断当前是什么状态,到处 if (mode === 'edit')、if (device === 'mobile')。在只有三五个页面的时候还好,一旦页面多起来,需求开始叠加,状态越传越乱:有人把 mode 存在内存变量里,有人从 URL 参数里读一个残缺的副本,还有人干脆在组件内部再套一层状态去推算当前模式。结果就是,你在这个页面里改了模式,刷新一下它不生效;换到另一个页面,它又莫名其妙地带着上一个页面的“记忆”跑过来了。这种问题有一个共同的名字:上下文混乱。而 context-mode 要做的,就是把这些散落的“当前处于什么模式、什么环境、什么状态”统一收拢成一个显式的上下文对象,再通过一套定义好的切换规则,让系统里所有行为都围绕这个上下文来响应。项目名里的 “mode” 不是某一个具体功能,而是一种结构化的状态组织方式。这篇内容适合谁看呢?我总结下来大概是三类人:第一类是写业务逻辑的同学,天天和各种状态判断较劲,想把代码从 if-else 泥潭里捞出来;第二类是搞编辑器、IDE 插件、低代码平台这类“天然多模式”场景的开发者,context-mode 几乎是刚需;第三类是研究 AI 应用开发的同学,因为现在各种 Agent、提示词管理本质上也在处理“上下文窗口”的问题,这套思路完全可以平移过去用。1.2 如果没有 context-mode,问题具体长什么样很多人一开始觉得“上下文”是个虚词,不就是一个变量吗?但真正做复杂系统的人都知道,上下文问题是把双刃剑:不做管理,系统会进入混沌;做了过度设计,又会变成看不懂的抽象层。我选择从“问题倒推”来聊,是因为只有先看清楚这一堆实际痛点,才能理解后面那套机制每一处设计的意义。痛点一:行为漂移。同一段代码,在“用户设置页”里和“用户管理后台”里执行结果不一样,因为全局的一些状态被不同模块偷偷改了。你查了半天逻辑没发现错误,最后发现是某个第三方组件在初始化时顺手改了一个全局开关。这种问题最阴,因为代码静态看起来是对的,动态跑起来是错的。痛点二:职责扩散。要不要显示某个按钮、要不要执行某段逻辑,这些决策散落在组件层、服务层、路由层,各层都在重复判断。改一个需求,要同时改十个地方,漏掉一个就是线上事故。痛点三:排查困难。线上报了一个问题,本地死活复现不了,因为本地没有线上那套“用户角色 + 设备类型 + 实验分组 + 页面来源”的上下文组合。这三个痛点的根源其实都是同一个:上下文信息没有被显式建模。context-mode 的核心思想就是三步:第一,把上下文建模成一等公民的数据结构;第二,通过 enter/exit 这样的生命周期操作来切换它;第三,业务代码只依赖“当前上下文是什么”来做决策,而不是靠散布各处的条件判断。所以,与其说这是一个具体项目,不如说这是一个应对“状态熵增”的模式工具箱。我相信看完这篇的人,哪怕不照抄代码,也能在自己的项目里找到对应的落地方式。2. 核心设计与建模细节:为什么 context-mode 这么设计2.1 上下文的四个基本属性:类型、作用域、优先级、生命周期在我自己做的那套 context-mode 里,每一个上下文对象本质上就是一个不可变的普通数据对象,但我会强制它包含四类元信息,这四类元信息决定了它能不能被系统正确理解。第一个属性是类型(type)。上下文不能是散装的键值对,必须有一个类型名字,比如 editor-mode、device-env、user-scope。类型的作用是给后续的注册表做分类,让不同的行为模块能按类型订阅自己关心那部分上下文,而不是把所有上下文都揉成一锅粥。比如用 TypeScript 描述的话,每个上下文是 { type: string; state: Record<string, unknown> },type 是身份证,state 是具体内容。第二个属性是作用域(scope)。这是最容易被人忽略的。同一个 context-mode 系统里,至少要区分全局、会话、局部三个层级。全局上下文比如“系统当前处于维护模式”;会话上下文比如“当前用户正处于调试工作台”;局部上下文比如“当前弹窗处于编辑状态”。局部上下文不应该污染全局,全局上下文也不应该被局部操作随意改写。我在设计里规定了:切换到局部上下文时,默认继承当前全局,但只覆盖本地差异;离开局部上下文时,全局原样返回。这个继承与覆盖的规则,是从函数作用域链借来的思路,非常好用。第三个属性是优先级(priority)。当多个上下文同时生效时,冲突不可避免。比如全局是“编辑模式”,当前局部页面又是“演示模式”,那按钮按哪个来?我的规则是:优先级数值大者胜出,局部上下文默认优先级高于全局,但也可以通过显式配置去改变。这个设计看起来很朴素,但在实际代码里省掉了大量“到底听谁的”的争议。顺便说一句,真正常被低估的是显式声明优先级的过程——把冲突摆到台面上来,比在代码里用一堆条件表达式掩盖冲突要健康得多。第四个属性是生命周期(lifetime)。进入条件、离开条件、是否允许嵌套、是否支持外部注入,这些都要在上下文类型定义时写清楚。比如“调试模式”允许全局挂起,允许嵌套;“支付保护模式”则不允许嵌套,必须等当前上下文退出后才能进入新的。这看起来像约束,但实际上是把不可能提前发现的设计漏洞挡在了编译期之前。2.2 从“单一模式变量”到“模式栈”的取舍初版 context-mode 我只支持一个“当前模式”,后来很快就遇到打脸场景:用户在全屏演示模式里,又临时调出了批注工具,这两个模式叠加在一起,如果只能用单个值表示,就只能造出一堆组合值:'presentation', 'annotation', 'presentation-annotation'。组合爆炸只是一方面,更麻烦的是组合值的消费方必须理解所有组合,耦合爆炸。于是我把“当前模式”改成了“模式栈”。每次进入新模式就是 push,退出时就是 pop,系统在任意时刻看到的是整条栈快照。业务代码询问的是栈里是否存在某个模式,而不是某个单独值。这个改动让 context-mode 真正支撑起了嵌套场景。模式栈的典型例子就是编辑器:处于 vim 模式还开着 diff 视图,diff 视图里又临时进入命令面板,这套嵌套如果不用栈表示,后面根本维护不动。当然,栈不是银弹。单一模式变量胜在简单,栈胜在表达力强。我的建议是:如果你的业务状态之间天然互斥,用枚举单值没问题;一旦出现“同时存在”的需求,果断换栈。切换的时机要早,不要等到组合状态到处都是再重构。2.3 上下文数据从哪里来:显式传入 vs 隐式传播设计好上下文的数据结构之后,下一个问题是:这些数据怎么流转到需要它的代码里去?两类做法各有拥趸。显式传入是指谁能拿到上下文、能拿到哪些字段,都在调用链路上写明白。优点是清晰,缺点是侵入性强,中间层函数即使完全不需要上下文,也得作为参数把它带下去,代码变得啰嗦。隐式传播则相反,中间层完全不需要感知上下文的存在,由底层框架在背后把它塞到合适的地方。比如很多语言自带的 context 机制、或者异步局部存储,就是干这个的。我的实践结论是:对 context-mode 来说,没有绝对优劣,只有边界取舍。在核心调用路径上,多花点参数把上下文传下去是值得的,因为调试时能顺着参数溯源;在旁路逻辑里,比如日志埋点、指标采集、通用工具函数,隐式传播效率更高。我自己的实现里两种都支持:业务代码可以选择显式调用 getContext(),底层的工具模块则通过默认上下文自动感知。关键是不要在一种机制里混着来,否则一旦上下文对不上,排查成本指数级上升。3. 实操过程与核心环节实现:五步搭一套可用的 context-mode3.1 第一步:定义 Context 类型和模式注册表接下来是硬核环节。我挑一个最典型的落地场景来讲:给一个“多模式编辑器”项目装配 context-mode。场景很简单,它有编辑模式、演示模式、专注模式三种,支持嵌套,比如在演示模式下开启专注模式。用 TypeScript 演示,因为它的类型系统能帮我们把上下文约束前置。先定义上下文的基础结构:interface ContextModeContext { readonly type: string; readonly state: Record<string, unknown>; readonly priority: number; readonly createdAt: number; } type ContextModeStack = readonly ContextModeContext[];state 里放业务相关字段,priority 决定冲突胜出规则,createdAt 用来做调试排序。注意这里我把字段全部设成 readonly,后面会专门解释不可变为什么重要。接着是模式注册表。每个模式在进入系统前必须注册,注册内容包括模式名、默认状态、进入钩子、退出钩子、以及允许嵌套的规则。注册表的存在价值是让“模式”成为项目里的显式概念,而不是散落在各处的字符串常量。我用一个 Map 来管理:type ContextModeDefinition = { name: string; defaultState?: Record<string, unknown>; onEnter?: (ctx: ContextModeContext) => void; onExit?: (ctx: ContextModeContext) => void; allowNested?: boolean; }; const registry = new Map<string, ContextModeDefinition>(); export function registerMode(def: ContextModeDefinition) { if (registry.has(def.name)) { throw new Error(`[context-mode] duplicated mode: ${def.name}`); } registry.set(def.name, def); }注册表刚搭好的时候看不出威力,但等你的项目里出现几十个模式之后,你会感谢这个登记本:一眼就能看到所有模式定义,新成员接手也不会瞎猜某个模式是干什么的。3.2 第二步:实现 Manager 的核心方法 enter/exit/peek/applyManager 是 context-mode 的心脏,对外暴露的接口并不多,我锁定了四个方法:enter 进入新模式、exit 退出栈顶模式、peek 查看当前生效上下文、apply 把当前上下文应用到目标行为上。class ContextModeManager { private stack: ContextModeStack = []; enter(name: string, state?: Record<string, unknown>): ContextModeContext { const def = registry.get(name); if (!def) throw new Error(`[context-mode] mode not registered: ${name}`); if (!def.allowNested && this.stack.length > 0) { throw new Error(`[context-mode] mode "${name}" does not allow nesting`); } const ctx: ContextModeContext = { type: name, state: { ...def.defaultState, ...state }, priority: this.stack.length + 1, createdAt: Date.now(), }; this.stack = [...this.stack, ctx]; def.onEnter?.(ctx); return ctx; } exit(): ContextModeContext | undefined { const exiting = this.stack.at(-1); if (!exiting) return undefined; this.stack = this.stack.slice(0, -1); registry.get(exiting.type)?.onExit?.(exiting); return exiting; } peek(): ContextModeContext | undefined { return this.stack.at(-1); } isActive(name: string): boolean { return this.stack.some((c) => c.type === name); } apply<T>(fn: (ctx: ContextModeContext) => T): T { const ctx = this.peek(); if (!ctx) return fn(this.emptyContext()); return fn(ctx); } }看到这里肯定会有人问:为什么 exit 不按名称退出,只退栈顶?这是我踩坑之后的刻意设计。上下文嵌套天然是栈结构,先进后出,如果允许按名称随机退出,很容易跳出一半,状态就乱了。真遇到需要重置的场景,直接用 reset() 清空整条栈,比精细操作安全得多。这段代码里还有两个细节要特别提示。一是每次 push 栈都是创建新数组而不是原地 push,这保证了历史快照不可篡改;二是 enter 时对 state 做了拷贝({ ...def.defaultState, ...state }),避免外部传入的对象被后续代码意外改坏。3.3 第三步:让业务代码与模式彻底解耦Manager 有了,但直接让业务代码调用 manager.isActive('presentation') 显然还不够优雅。context-mode 想解决的问题是“行为跟随上下文”,所以我再加一层:行为映射。所谓行为映射,就是把业务动作注册成与模式名关联的处理器。比如“全屏演示模式下按快捷键 F,应该唤起讲者备注”,而不是“判断当前是不是演示模式,再决定要不要唤起”。注册方式如下:type BehaviorHandler = (ctx: ContextModeContext, payload?: unknown) => void; class BehaviorMap { private handlers = new Map<string, BehaviorHandler[]>(); on(modeName: string, handler: BehaviorHandler) { const list = this.handlers.get(modeName) ?? []; list.push(handler); this.handlers.set(modeName, list); } handle(modeNames: string[], payload?: unknown) { modeNames.forEach((name) => { this.handlers.get(name)?.forEach((h) => h(this.currentCtx(), payload)); }); } private currentCtx() { return manager.peek() ?? { type: 'unknown', state: {}, priority: 0, createdAt: 0 }; } }你可能会疑惑:这和直接在事件回调里判断模式有什么区别?区别在于,注册到 BehaviorMap 里的行为是模块化、可插拔的。新增一个模式时,不需要去改原来那一坨事件处理逻辑,只需要往 BehaviorMap 里挂新的 handler。这其实就是策略模式的上下文版,我实测下来,对需求变动非常友好。3.4 第四步:完整示例,一个多模式编辑器的最小实现理论说了一堆,直接看一个能跑通的最小示例。假设编辑器有三种模式,分别注册:registerMode({ name: 'edit', defaultState: { readOnly: false }, allowNested: false }); registerMode({ name: 'presentation', defaultState: { showToolbar: false, fullscreen: true }, onEnter: () => console.log('[ctx] fullscreen presentation on'), onExit: () => console.log('[ctx] presentation closed'), }); registerMode({ name: 'focus', defaultState: { dimOtherPanes: true }, allowNested: true, });然后初始化 manager、进入演示模式,再在演示模式里进入专注模式:const mgr = new ContextModeManager(); mgr.enter('edit'); mgr.enter('presentation'); mgr.enter('focus'); console.log(mgr.isActive('presentation')); // true console.log(mgr.isActive('focus')); // true console.log(mgr.peek()?.type); // focus mgr.apply((ctx) => { if (ctx.type === 'focus') { console.log('[render] dim other panes, toolbar hidden'); } }); mgr.exit(); // 退出 focus,回到 presentation mgr.exit(); // 退出 presentation,回到 edit这个例子虽然简单,却完整展示了我最看重的三个能力:嵌套、按需查询、退出还原。如果换成传统的单个状态字段,根本无法同时表达“presentation 和 focus 同时在栈里”这件事,除非把所有组合穷举一遍。而栈结构不需要。3.5 第五步:给 context-mode 补上测试与验证很多自己写的小工具都不写测试,但这个建议还是补上。因为 context-mode 处于业务核心位置,一旦它坏了,整个系统的行为都会乱。我写的测试一般覆盖这几个场景:测试场景输入预期结果基础进入与退出enter('edit') 后 exit()peek 返回 undefined,onExit 被调用嵌套模式enter('presentation') 再 enter('focus')isActive('presentation') 仍为 true不允许嵌套的拦截已存在任意模式时 enter('edit')抛出运行时错误状态隔离外部对象被修改后再次 peek栈内上下文 state 不受影响优先级冲突同时查询全局字段与局部覆盖局部上下文字段胜出单测价值不只是防回归,它还把每个模式定义的“契约”固化了。比如某个模式明确说不允许嵌套,以后谁想改这个规则,测试就是最后的守卫。4. 常见问题与排查技巧实录4.1 上下文莫名丢失,翻遍调用链都找不到赋值这是 context-mode 上线之后遇到最多的一个问题,尤其在使用隐式传播的时候。症状是:进入某个模式后,切换到另一个页面,再切回来,发现上下文状态变成初始值了。第一反应是 manager 被重新 new 了,一查果然如此。因为我们把 manager 实例化在了组件内部,路由切换时旧组件销毁新组件创建,对象当然重建。排查方法和修复都很典型:先确认 singleton 是否被重置,二看有没有路由懒加载导致模块被重复加载。修复很简单,把 manager 挂在全局单例容器里,或者放进依赖注入容器。但这里更接近真相的原因,其实是你在隐式传播机制里混用了函数作用域,上下文对象没有跟随业务对象的生命线走。我最后的结论:任何用隐式传播的地方,都要明确“谁能看到这个上下文”,看到就是看到,看不到就是看不到,不能用猜的。4.2 嵌套太深导致模式栈膨胀,甚至发生递归调用模式栈的容量不给上限的话,早晚会在极端操作下爆掉。比如用户在演示模式里连续在控制台手动调用了五十次 enter('focus'),然后又有一个逻辑监听每次 onEnter 时又触发一次 enter 新模式——这就是典型的递归式生产者。最直观的异常就是调用栈溢出或者内存持续上涨。给 enter 增加最大层级限制是最直接的防护。后来我又加了一条更友好的策略:同类型上下文只允许存在一层,重复进入时不重复 push,而是更新已有那一层的状态。这有点像函数防抖的思路。对于大部分业务场景,“同一模式重复进入”根本没意义,此时栈里保留多条同类型记录只会给判断逻辑埋雷。我强烈建议在实现时默认开启“同类型去重”。4.3 异步环境下上下文串台,数据串到别的请求里了如果你只写前端同步业务,大概率不会遇到这个问题;一旦开始写 Node 服务端,或者前端里大量使用 Promise 并发时,上下文串台就是头号杀手。举例:A 请求进入“专属定价”模式,B 请求进入“普通用户”模式,结果 A 请求里执行某个异步回调时,读到的是 B 的上下文。原因在于我的 manager 是全局单例,而异步回调的上下文环境没有跟着 Promise 链走。解决办法有几种:一是每次异步操作前显式传入上下文快照,二是使用语言自带的异步局部存储能力。我在 Node 侧用的是异步存储方案,前端侧则偏向显式传参,因为前端并发通常没那么高,显式传参反而更可控。如果你非要在前端搞隐式传播,那就必须非常小心并发函数执行顺序,每一步都确认自己在哪一段上下文里。4.4 上下文对象被外部逻辑修改,幽灵模式层出不穷一开始我的 ContextModeContext 不是只读的,state 里的字段可以被任意代码直接赋值。后果非常刺激:某段代码悄悄把 readOnly 从 false 改成了 true,编辑器在演示模式下莫名只读,排查了两天后发现是某个不相关的初始化模块改的。这个问题的根源不是上下文状态不该变,而是“变”这个动作没有走 manager 的通道。我给 context-mode 上了两道锁。第一道是 TypeScript 层面的 readonly 标记;第二道是运行时通过 Object.freeze 防止篡改。如果真的需要更新上下文内某个字段,请通过 updateCurrentState() 这样的方法,由 manager 统一生成新对象替换栈顶,而不是原地改状态。改了之后,所有消费方拿到的都是新快照,旧的快照还能用于日志追踪,调试体验直接上了一个台阶。4.5 问题速查表现象可能原因排查手段上下文切换后没有生效manager 实例被重建检查实例来源,全局单例持久化模式栈无限增长崩溃缺最大深度限制设置栈容量上限,同类型去重异步回调读到错误模式全局单例在并发环境被共享用异步局部存储或显式传上下文快照模式状态被悄悄改掉上下文对象可写使用只读不可变对象,所有变更走 manager两个模式都“活”着时行为不一致优先级规则没有明确检查 priority 数值,统一决策入口5. 怎么把 context-mode 的价值发挥到最大5.1 编辑器与 IDE 插件的上下文感知编辑器插件是 context-mode 最顺畅的落地场景。比如写一个 VS Code 插件时,命令面板里会有大量命令,但很多命令只有在特定条件下才有意义。以前的做法是在 contribute 配置里写一堆 when 条件,维护起来很琐碎。用 context-mode 的思路,就是把“当前语言是 Python”“当前文档处于未保存状态”“当前选中了代码区域”这些都看作上下文状态,命令的可用性完全由上下文状态推导。这样做的好处是,你想让命令变成可用时,只需要改变上下文,而不是改命令定义。我自己做一个内部代码生成插件时就用了这套思路,给插件客户端的命令注册加了一层 context-mode 包装。效果最直观的改进是:新增一个命令时,不再需要同时修改三处 when 判断,只注册一次行为即可,状态判断由运行时统一处理。调试的时候还能手动在命令面板里模拟上下文,比如模拟“当前只读文件”的场景验证按钮禁用逻辑,这比改代码刷新再复现要省太多时间。5.2 AI 辅助开发:把上下文窗口变成显式管理现在写 AI 应用的人越来越多,而且稍微做过点 Agent 的人都会发现,提示词工程里最难的不是写提示词本身,而是管理“当前对话的上下文到底有哪些信息”。一个理想的 Agent 交互,应该是根据当前用户所处的模式,动态组装上下文信息:在写代码模式下,把当前文件、项目结构、最近改动打包进提示词;在问答思辨模式下,则不塞代码片段,只给结构化的领域知识。这其实就是 context-mode 在提示词管理层的应用。我把这套思想移植到一个 AI 编码助手的内部架构里,定义了一组上下文模式:code-task、debug-session、design-doc。每次切换模式时,系统自动选择需要注入提示词的上下文信息,而不是把一堆令牌全部无脑塞进去。实测下来,回答相关性提高了,因为大模型接收的上下文信息更聚焦了,令牌消耗也跟着降了下来。如果你也做 AI 应用开发,强烈建议把上下文窗口管理当作系统工程,而不是每次临时拼字符串。5.3 测试与线上故障排查:给问题现场留套上下文快照排查问题时最痛苦的是信息不对称:代码跑在别人电脑上或者线上集群里,本地完全不知道那个现场发生了什么。context-mode 天然给了我们一个解决方案:在上下文每次切换时记录快照,形成一个“时间线”。我后来在 context-mode 里加了一个 auditor 模块,作用非常简单:每次 enter、exit、update 时,往环形缓冲里塞一条记录,包含时间戳、操作类型、上下文对象、以及执行操作时的调用栈摘要。出问题时,不需要用户复述“我点了什么”,只需要把最近 N 条上下文事件导出来,基本就能还原现场。这个方法已经被我推广到团队里解决疑难 Bug,省下来的排查时间相当可观。5.4 从“模式切换”到“模式语言”这套东西再往前走一步,就是把我最初只服务编辑器的那套模型泛化成更通用的“业务策略语言”。比如低代码平台的表单设计器,可以定义一组可视化配置节点,每个节点其实就是“进入某种上下文并应用某种行为”。从 context-mode 的角度看,低代码编辑器也只是一个上下文大栈:你在哪个模块、选中了什么组件、处于什么视图,都影响工具面板展示什么内容。这种模式选择机制也可以被非开发者使用。比如运维团队想要临时把某个接口置于“只读保护模式”,他们可以在配置中心里直接点一个开关,底层的行为在 context-mode 注册表里已经预置好,不需要改任何代码。把事情从“写 if-else 改逻辑”变成“配置模式切换”,是我认为这套思路最兴奋的地方。我个人在实际项目里的体会是:context-mode 真正值钱的不是那几十行核心代码,而是它逼着你去想清楚“系统现在处于什么状态、谁在修改状态、谁在消费状态”这三个问题。很多看起来复杂无比的功能,拆到底就是上下文管理做得好。如果让我提一条最实用的建议,那就是:别等代码彻底乱掉才开始搭它。一旦你在项目里闻到“第三个 mode 判断”的味道,就可以考虑把 context-mode 引进来了。早引入的维护成本最低,晚引入的改造代价几乎等于重写。后续我还在计划把它扩展成一套可以配置化定义模式语言的轻量框架,把入口注册、行为绑定和审计日志全部收敛起来,到时候再回来写一篇更完整的实战记录。
1. 项目全貌:这个 “context-mode” 到底在解决什么问题1.1 初见 context-mode:一段让所有写业务逻辑的人眼熟的场景如果问我最近一年多最值得沉淀下来的技术经验是什么,我会毫不犹豫地说:一套叫 “context-mode” 的上下文模式管理机制。它不是什么高深框架,也不是某个大厂的开源神器,而是在写业务代码时反复被同一个问题折磨之后,自己动手攒出来的一套通用做法。先说一个高频场景。接手过一个内部系统,页面上一堆按钮,有的要在“编辑态”下才能点,有的要在“预览态”下隐藏,还有的要根据“移动端还是桌面端”显示不同的文案。最初的实现是每个组件里都自己判断当前是什么状态,到处 if (mode === 'edit')、if (device === 'mobile')。在只有三五个页面的时候还好,一旦页面多起来,需求开始叠加,状态越传越乱:有人把 mode 存在内存变量里,有人从 URL 参数里读一个残缺的副本,还有人干脆在组件内部再套一层状态去推算当前模式。结果就是,你在这个页面里改了模式,刷新一下它不生效;换到另一个页面,它又莫名其妙地带着上一个页面的“记忆”跑过来了。这种问题有一个共同的名字:上下文混乱。而 context-mode 要做的,就是把这些散落的“当前处于什么模式、什么环境、什么状态”统一收拢成一个显式的上下文对象,再通过一套定义好的切换规则,让系统里所有行为都围绕这个上下文来响应。项目名里的 “mode” 不是某一个具体功能,而是一种结构化的状态组织方式。这篇内容适合谁看呢?我总结下来大概是三类人:第一类是写业务逻辑的同学,天天和各种状态判断较劲,想把代码从 if-else 泥潭里捞出来;第二类是搞编辑器、IDE 插件、低代码平台这类“天然多模式”场景的开发者,context-mode 几乎是刚需;第三类是研究 AI 应用开发的同学,因为现在各种 Agent、提示词管理本质上也在处理“上下文窗口”的问题,这套思路完全可以平移过去用。1.2 如果没有 context-mode,问题具体长什么样很多人一开始觉得“上下文”是个虚词,不就是一个变量吗?但真正做复杂系统的人都知道,上下文问题是把双刃剑:不做管理,系统会进入混沌;做了过度设计,又会变成看不懂的抽象层。我选择从“问题倒推”来聊,是因为只有先看清楚这一堆实际痛点,才能理解后面那套机制每一处设计的意义。痛点一:行为漂移。同一段代码,在“用户设置页”里和“用户管理后台”里执行结果不一样,因为全局的一些状态被不同模块偷偷改了。你查了半天逻辑没发现错误,最后发现是某个第三方组件在初始化时顺手改了一个全局开关。这种问题最阴,因为代码静态看起来是对的,动态跑起来是错的。痛点二:职责扩散。要不要显示某个按钮、要不要执行某段逻辑,这些决策散落在组件层、服务层、路由层,各层都在重复判断。改一个需求,要同时改十个地方,漏掉一个就是线上事故。痛点三:排查困难。线上报了一个问题,本地死活复现不了,因为本地没有线上那套“用户角色 + 设备类型 + 实验分组 + 页面来源”的上下文组合。这三个痛点的根源其实都是同一个:上下文信息没有被显式建模。context-mode 的核心思想就是三步:第一,把上下文建模成一等公民的数据结构;第二,通过 enter/exit 这样的生命周期操作来切换它;第三,业务代码只依赖“当前上下文是什么”来做决策,而不是靠散布各处的条件判断。所以,与其说这是一个具体项目,不如说这是一个应对“状态熵增”的模式工具箱。我相信看完这篇的人,哪怕不照抄代码,也能在自己的项目里找到对应的落地方式。2. 核心设计与建模细节:为什么 context-mode 这么设计2.1 上下文的四个基本属性:类型、作用域、优先级、生命周期在我自己做的那套 context-mode 里,每一个上下文对象本质上就是一个不可变的普通数据对象,但我会强制它包含四类元信息,这四类元信息决定了它能不能被系统正确理解。第一个属性是类型(type)。上下文不能是散装的键值对,必须有一个类型名字,比如 editor-mode、device-env、user-scope。类型的作用是给后续的注册表做分类,让不同的行为模块能按类型订阅自己关心那部分上下文,而不是把所有上下文都揉成一锅粥。比如用 TypeScript 描述的话,每个上下文是 { type: string; state: Record<string, unknown> },type 是身份证,state 是具体内容。第二个属性是作用域(scope)。这是最容易被人忽略的。同一个 context-mode 系统里,至少要区分全局、会话、局部三个层级。全局上下文比如“系统当前处于维护模式”;会话上下文比如“当前用户正处于调试工作台”;局部上下文比如“当前弹窗处于编辑状态”。局部上下文不应该污染全局,全局上下文也不应该被局部操作随意改写。我在设计里规定了:切换到局部上下文时,默认继承当前全局,但只覆盖本地差异;离开局部上下文时,全局原样返回。这个继承与覆盖的规则,是从函数作用域链借来的思路,非常好用。第三个属性是优先级(priority)。当多个上下文同时生效时,冲突不可避免。比如全局是“编辑模式”,当前局部页面又是“演示模式”,那按钮按哪个来?我的规则是:优先级数值大者胜出,局部上下文默认优先级高于全局,但也可以通过显式配置去改变。这个设计看起来很朴素,但在实际代码里省掉了大量“到底听谁的”的争议。顺便说一句,真正常被低估的是显式声明优先级的过程——把冲突摆到台面上来,比在代码里用一堆条件表达式掩盖冲突要健康得多。第四个属性是生命周期(lifetime)。进入条件、离开条件、是否允许嵌套、是否支持外部注入,这些都要在上下文类型定义时写清楚。比如“调试模式”允许全局挂起,允许嵌套;“支付保护模式”则不允许嵌套,必须等当前上下文退出后才能进入新的。这看起来像约束,但实际上是把不可能提前发现的设计漏洞挡在了编译期之前。2.2 从“单一模式变量”到“模式栈”的取舍初版 context-mode 我只支持一个“当前模式”,后来很快就遇到打脸场景:用户在全屏演示模式里,又临时调出了批注工具,这两个模式叠加在一起,如果只能用单个值表示,就只能造出一堆组合值:'presentation', 'annotation', 'presentation-annotation'。组合爆炸只是一方面,更麻烦的是组合值的消费方必须理解所有组合,耦合爆炸。于是我把“当前模式”改成了“模式栈”。每次进入新模式就是 push,退出时就是 pop,系统在任意时刻看到的是整条栈快照。业务代码询问的是栈里是否存在某个模式,而不是某个单独值。这个改动让 context-mode 真正支撑起了嵌套场景。模式栈的典型例子就是编辑器:处于 vim 模式还开着 diff 视图,diff 视图里又临时进入命令面板,这套嵌套如果不用栈表示,后面根本维护不动。当然,栈不是银弹。单一模式变量胜在简单,栈胜在表达力强。我的建议是:如果你的业务状态之间天然互斥,用枚举单值没问题;一旦出现“同时存在”的需求,果断换栈。切换的时机要早,不要等到组合状态到处都是再重构。2.3 上下文数据从哪里来:显式传入 vs 隐式传播设计好上下文的数据结构之后,下一个问题是:这些数据怎么流转到需要它的代码里去?两类做法各有拥趸。显式传入是指谁能拿到上下文、能拿到哪些字段,都在调用链路上写明白。优点是清晰,缺点是侵入性强,中间层函数即使完全不需要上下文,也得作为参数把它带下去,代码变得啰嗦。隐式传播则相反,中间层完全不需要感知上下文的存在,由底层框架在背后把它塞到合适的地方。比如很多语言自带的 context 机制、或者异步局部存储,就是干这个的。我的实践结论是:对 context-mode 来说,没有绝对优劣,只有边界取舍。在核心调用路径上,多花点参数把上下文传下去是值得的,因为调试时能顺着参数溯源;在旁路逻辑里,比如日志埋点、指标采集、通用工具函数,隐式传播效率更高。我自己的实现里两种都支持:业务代码可以选择显式调用 getContext(),底层的工具模块则通过默认上下文自动感知。关键是不要在一种机制里混着来,否则一旦上下文对不上,排查成本指数级上升。3. 实操过程与核心环节实现:五步搭一套可用的 context-mode3.1 第一步:定义 Context 类型和模式注册表接下来是硬核环节。我挑一个最典型的落地场景来讲:给一个“多模式编辑器”项目装配 context-mode。场景很简单,它有编辑模式、演示模式、专注模式三种,支持嵌套,比如在演示模式下开启专注模式。用 TypeScript 演示,因为它的类型系统能帮我们把上下文约束前置。先定义上下文的基础结构:interface ContextModeContext { readonly type: string; readonly state: Record<string, unknown>; readonly priority: number; readonly createdAt: number; } type ContextModeStack = readonly ContextModeContext[];state 里放业务相关字段,priority 决定冲突胜出规则,createdAt 用来做调试排序。注意这里我把字段全部设成 readonly,后面会专门解释不可变为什么重要。接着是模式注册表。每个模式在进入系统前必须注册,注册内容包括模式名、默认状态、进入钩子、退出钩子、以及允许嵌套的规则。注册表的存在价值是让“模式”成为项目里的显式概念,而不是散落在各处的字符串常量。我用一个 Map 来管理:type ContextModeDefinition = { name: string; defaultState?: Record<string, unknown>; onEnter?: (ctx: ContextModeContext) => void; onExit?: (ctx: ContextModeContext) => void; allowNested?: boolean; }; const registry = new Map<string, ContextModeDefinition>(); export function registerMode(def: ContextModeDefinition) { if (registry.has(def.name)) { throw new Error(`[context-mode] duplicated mode: ${def.name}`); } registry.set(def.name, def); }注册表刚搭好的时候看不出威力,但等你的项目里出现几十个模式之后,你会感谢这个登记本:一眼就能看到所有模式定义,新成员接手也不会瞎猜某个模式是干什么的。3.2 第二步:实现 Manager 的核心方法 enter/exit/peek/applyManager 是 context-mode 的心脏,对外暴露的接口并不多,我锁定了四个方法:enter 进入新模式、exit 退出栈顶模式、peek 查看当前生效上下文、apply 把当前上下文应用到目标行为上。class ContextModeManager { private stack: ContextModeStack = []; enter(name: string, state?: Record<string, unknown>): ContextModeContext { const def = registry.get(name); if (!def) throw new Error(`[context-mode] mode not registered: ${name}`); if (!def.allowNested && this.stack.length > 0) { throw new Error(`[context-mode] mode "${name}" does not allow nesting`); } const ctx: ContextModeContext = { type: name, state: { ...def.defaultState, ...state }, priority: this.stack.length + 1, createdAt: Date.now(), }; this.stack = [...this.stack, ctx]; def.onEnter?.(ctx); return ctx; } exit(): ContextModeContext | undefined { const exiting = this.stack.at(-1); if (!exiting) return undefined; this.stack = this.stack.slice(0, -1); registry.get(exiting.type)?.onExit?.(exiting); return exiting; } peek(): ContextModeContext | undefined { return this.stack.at(-1); } isActive(name: string): boolean { return this.stack.some((c) => c.type === name); } apply<T>(fn: (ctx: ContextModeContext) => T): T { const ctx = this.peek(); if (!ctx) return fn(this.emptyContext()); return fn(ctx); } }看到这里肯定会有人问:为什么 exit 不按名称退出,只退栈顶?这是我踩坑之后的刻意设计。上下文嵌套天然是栈结构,先进后出,如果允许按名称随机退出,很容易跳出一半,状态就乱了。真遇到需要重置的场景,直接用 reset() 清空整条栈,比精细操作安全得多。这段代码里还有两个细节要特别提示。一是每次 push 栈都是创建新数组而不是原地 push,这保证了历史快照不可篡改;二是 enter 时对 state 做了拷贝({ ...def.defaultState, ...state }),避免外部传入的对象被后续代码意外改坏。3.3 第三步:让业务代码与模式彻底解耦Manager 有了,但直接让业务代码调用 manager.isActive('presentation') 显然还不够优雅。context-mode 想解决的问题是“行为跟随上下文”,所以我再加一层:行为映射。所谓行为映射,就是把业务动作注册成与模式名关联的处理器。比如“全屏演示模式下按快捷键 F,应该唤起讲者备注”,而不是“判断当前是不是演示模式,再决定要不要唤起”。注册方式如下:type BehaviorHandler = (ctx: ContextModeContext, payload?: unknown) => void; class BehaviorMap { private handlers = new Map<string, BehaviorHandler[]>(); on(modeName: string, handler: BehaviorHandler) { const list = this.handlers.get(modeName) ?? []; list.push(handler); this.handlers.set(modeName, list); } handle(modeNames: string[], payload?: unknown) { modeNames.forEach((name) => { this.handlers.get(name)?.forEach((h) => h(this.currentCtx(), payload)); }); } private currentCtx() { return manager.peek() ?? { type: 'unknown', state: {}, priority: 0, createdAt: 0 }; } }你可能会疑惑:这和直接在事件回调里判断模式有什么区别?区别在于,注册到 BehaviorMap 里的行为是模块化、可插拔的。新增一个模式时,不需要去改原来那一坨事件处理逻辑,只需要往 BehaviorMap 里挂新的 handler。这其实就是策略模式的上下文版,我实测下来,对需求变动非常友好。3.4 第四步:完整示例,一个多模式编辑器的最小实现理论说了一堆,直接看一个能跑通的最小示例。假设编辑器有三种模式,分别注册:registerMode({ name: 'edit', defaultState: { readOnly: false }, allowNested: false }); registerMode({ name: 'presentation', defaultState: { showToolbar: false, fullscreen: true }, onEnter: () => console.log('[ctx] fullscreen presentation on'), onExit: () => console.log('[ctx] presentation closed'), }); registerMode({ name: 'focus', defaultState: { dimOtherPanes: true }, allowNested: true, });然后初始化 manager、进入演示模式,再在演示模式里进入专注模式:const mgr = new ContextModeManager(); mgr.enter('edit'); mgr.enter('presentation'); mgr.enter('focus'); console.log(mgr.isActive('presentation')); // true console.log(mgr.isActive('focus')); // true console.log(mgr.peek()?.type); // focus mgr.apply((ctx) => { if (ctx.type === 'focus') { console.log('[render] dim other panes, toolbar hidden'); } }); mgr.exit(); // 退出 focus,回到 presentation mgr.exit(); // 退出 presentation,回到 edit这个例子虽然简单,却完整展示了我最看重的三个能力:嵌套、按需查询、退出还原。如果换成传统的单个状态字段,根本无法同时表达“presentation 和 focus 同时在栈里”这件事,除非把所有组合穷举一遍。而栈结构不需要。3.5 第五步:给 context-mode 补上测试与验证很多自己写的小工具都不写测试,但这个建议还是补上。因为 context-mode 处于业务核心位置,一旦它坏了,整个系统的行为都会乱。我写的测试一般覆盖这几个场景:测试场景输入预期结果基础进入与退出enter('edit') 后 exit()peek 返回 undefined,onExit 被调用嵌套模式enter('presentation') 再 enter('focus')isActive('presentation') 仍为 true不允许嵌套的拦截已存在任意模式时 enter('edit')抛出运行时错误状态隔离外部对象被修改后再次 peek栈内上下文 state 不受影响优先级冲突同时查询全局字段与局部覆盖局部上下文字段胜出单测价值不只是防回归,它还把每个模式定义的“契约”固化了。比如某个模式明确说不允许嵌套,以后谁想改这个规则,测试就是最后的守卫。4. 常见问题与排查技巧实录4.1 上下文莫名丢失,翻遍调用链都找不到赋值这是 context-mode 上线之后遇到最多的一个问题,尤其在使用隐式传播的时候。症状是:进入某个模式后,切换到另一个页面,再切回来,发现上下文状态变成初始值了。第一反应是 manager 被重新 new 了,一查果然如此。因为我们把 manager 实例化在了组件内部,路由切换时旧组件销毁新组件创建,对象当然重建。排查方法和修复都很典型:先确认 singleton 是否被重置,二看有没有路由懒加载导致模块被重复加载。修复很简单,把 manager 挂在全局单例容器里,或者放进依赖注入容器。但这里更接近真相的原因,其实是你在隐式传播机制里混用了函数作用域,上下文对象没有跟随业务对象的生命线走。我最后的结论:任何用隐式传播的地方,都要明确“谁能看到这个上下文”,看到就是看到,看不到就是看不到,不能用猜的。4.2 嵌套太深导致模式栈膨胀,甚至发生递归调用模式栈的容量不给上限的话,早晚会在极端操作下爆掉。比如用户在演示模式里连续在控制台手动调用了五十次 enter('focus'),然后又有一个逻辑监听每次 onEnter 时又触发一次 enter 新模式——这就是典型的递归式生产者。最直观的异常就是调用栈溢出或者内存持续上涨。给 enter 增加最大层级限制是最直接的防护。后来我又加了一条更友好的策略:同类型上下文只允许存在一层,重复进入时不重复 push,而是更新已有那一层的状态。这有点像函数防抖的思路。对于大部分业务场景,“同一模式重复进入”根本没意义,此时栈里保留多条同类型记录只会给判断逻辑埋雷。我强烈建议在实现时默认开启“同类型去重”。4.3 异步环境下上下文串台,数据串到别的请求里了如果你只写前端同步业务,大概率不会遇到这个问题;一旦开始写 Node 服务端,或者前端里大量使用 Promise 并发时,上下文串台就是头号杀手。举例:A 请求进入“专属定价”模式,B 请求进入“普通用户”模式,结果 A 请求里执行某个异步回调时,读到的是 B 的上下文。原因在于我的 manager 是全局单例,而异步回调的上下文环境没有跟着 Promise 链走。解决办法有几种:一是每次异步操作前显式传入上下文快照,二是使用语言自带的异步局部存储能力。我在 Node 侧用的是异步存储方案,前端侧则偏向显式传参,因为前端并发通常没那么高,显式传参反而更可控。如果你非要在前端搞隐式传播,那就必须非常小心并发函数执行顺序,每一步都确认自己在哪一段上下文里。4.4 上下文对象被外部逻辑修改,幽灵模式层出不穷一开始我的 ContextModeContext 不是只读的,state 里的字段可以被任意代码直接赋值。后果非常刺激:某段代码悄悄把 readOnly 从 false 改成了 true,编辑器在演示模式下莫名只读,排查了两天后发现是某个不相关的初始化模块改的。这个问题的根源不是上下文状态不该变,而是“变”这个动作没有走 manager 的通道。我给 context-mode 上了两道锁。第一道是 TypeScript 层面的 readonly 标记;第二道是运行时通过 Object.freeze 防止篡改。如果真的需要更新上下文内某个字段,请通过 updateCurrentState() 这样的方法,由 manager 统一生成新对象替换栈顶,而不是原地改状态。改了之后,所有消费方拿到的都是新快照,旧的快照还能用于日志追踪,调试体验直接上了一个台阶。4.5 问题速查表现象可能原因排查手段上下文切换后没有生效manager 实例被重建检查实例来源,全局单例持久化模式栈无限增长崩溃缺最大深度限制设置栈容量上限,同类型去重异步回调读到错误模式全局单例在并发环境被共享用异步局部存储或显式传上下文快照模式状态被悄悄改掉上下文对象可写使用只读不可变对象,所有变更走 manager两个模式都“活”着时行为不一致优先级规则没有明确检查 priority 数值,统一决策入口5. 怎么把 context-mode 的价值发挥到最大5.1 编辑器与 IDE 插件的上下文感知编辑器插件是 context-mode 最顺畅的落地场景。比如写一个 VS Code 插件时,命令面板里会有大量命令,但很多命令只有在特定条件下才有意义。以前的做法是在 contribute 配置里写一堆 when 条件,维护起来很琐碎。用 context-mode 的思路,就是把“当前语言是 Python”“当前文档处于未保存状态”“当前选中了代码区域”这些都看作上下文状态,命令的可用性完全由上下文状态推导。这样做的好处是,你想让命令变成可用时,只需要改变上下文,而不是改命令定义。我自己做一个内部代码生成插件时就用了这套思路,给插件客户端的命令注册加了一层 context-mode 包装。效果最直观的改进是:新增一个命令时,不再需要同时修改三处 when 判断,只注册一次行为即可,状态判断由运行时统一处理。调试的时候还能手动在命令面板里模拟上下文,比如模拟“当前只读文件”的场景验证按钮禁用逻辑,这比改代码刷新再复现要省太多时间。5.2 AI 辅助开发:把上下文窗口变成显式管理现在写 AI 应用的人越来越多,而且稍微做过点 Agent 的人都会发现,提示词工程里最难的不是写提示词本身,而是管理“当前对话的上下文到底有哪些信息”。一个理想的 Agent 交互,应该是根据当前用户所处的模式,动态组装上下文信息:在写代码模式下,把当前文件、项目结构、最近改动打包进提示词;在问答思辨模式下,则不塞代码片段,只给结构化的领域知识。这其实就是 context-mode 在提示词管理层的应用。我把这套思想移植到一个 AI 编码助手的内部架构里,定义了一组上下文模式:code-task、debug-session、design-doc。每次切换模式时,系统自动选择需要注入提示词的上下文信息,而不是把一堆令牌全部无脑塞进去。实测下来,回答相关性提高了,因为大模型接收的上下文信息更聚焦了,令牌消耗也跟着降了下来。如果你也做 AI 应用开发,强烈建议把上下文窗口管理当作系统工程,而不是每次临时拼字符串。5.3 测试与线上故障排查:给问题现场留套上下文快照排查问题时最痛苦的是信息不对称:代码跑在别人电脑上或者线上集群里,本地完全不知道那个现场发生了什么。context-mode 天然给了我们一个解决方案:在上下文每次切换时记录快照,形成一个“时间线”。我后来在 context-mode 里加了一个 auditor 模块,作用非常简单:每次 enter、exit、update 时,往环形缓冲里塞一条记录,包含时间戳、操作类型、上下文对象、以及执行操作时的调用栈摘要。出问题时,不需要用户复述“我点了什么”,只需要把最近 N 条上下文事件导出来,基本就能还原现场。这个方法已经被我推广到团队里解决疑难 Bug,省下来的排查时间相当可观。5.4 从“模式切换”到“模式语言”这套东西再往前走一步,就是把我最初只服务编辑器的那套模型泛化成更通用的“业务策略语言”。比如低代码平台的表单设计器,可以定义一组可视化配置节点,每个节点其实就是“进入某种上下文并应用某种行为”。从 context-mode 的角度看,低代码编辑器也只是一个上下文大栈:你在哪个模块、选中了什么组件、处于什么视图,都影响工具面板展示什么内容。这种模式选择机制也可以被非开发者使用。比如运维团队想要临时把某个接口置于“只读保护模式”,他们可以在配置中心里直接点一个开关,底层的行为在 context-mode 注册表里已经预置好,不需要改任何代码。把事情从“写 if-else 改逻辑”变成“配置模式切换”,是我认为这套思路最兴奋的地方。我个人在实际项目里的体会是:context-mode 真正值钱的不是那几十行核心代码,而是它逼着你去想清楚“系统现在处于什么状态、谁在修改状态、谁在消费状态”这三个问题。很多看起来复杂无比的功能,拆到底就是上下文管理做得好。如果让我提一条最实用的建议,那就是:别等代码彻底乱掉才开始搭它。一旦你在项目里闻到“第三个 mode 判断”的味道,就可以考虑把 context-mode 引进来了。早引入的维护成本最低,晚引入的改造代价几乎等于重写。后续我还在计划把它扩展成一套可以配置化定义模式语言的轻量框架,把入口注册、行为绑定和审计日志全部收敛起来,到时候再回来写一篇更完整的实战记录。