
1. 从“context-mode”这个词说起它到底指什么第一次看到“context-mode”这个标题很多人会愣一下——它不像“XX管理系统”或“XX爬虫”那样一眼能看出用途。我最初接触这个词是在做对话系统上下文管理的时候当时团队里有人提出“给模型加一个 context-mode 开关”意思是让系统在不同场景下切换对上下文的理解方式。后来我发现这个词在不同圈子里含义差别挺大做前端路由的人会想到“上下文模式匹配”做状态管理的人会想到“上下文作用域”做AI应用的人则会想到“对话上下文的组织策略”。所以这篇内容我打算把“context-mode”当作一个上下文模式管理的通用设计思路来拆解。它不是一个具体的库或框架而是一类问题的统称当你的系统需要同时处理多种上下文来源、多种上下文生命周期、多种上下文优先级时怎么用一种可切换、可配置、可观测的方式把它们管起来。适合谁看如果你正在做聊天机器人、IDE插件、低代码平台、或者任何需要“记住当前状态并据此决策”的系统这篇内容应该能给你一些可直接抄作业的思路。我先把结论放在前面context-mode 的核心价值不在于“多了一个模式”而在于把隐式的上下文假设显式化。大多数系统在早期都是“全局一个上下文”所有逻辑都默认读同一份状态等到场景变多代码里就会到处出现if (isChat) {...} else if (isEdit) {...}这种分支。context-mode 要解决的就是把这种分支收敛成可配置的模式定义让上下文的选择变成数据而不是代码。提示如果你现在的系统里已经有超过三处“根据当前场景读不同状态”的判断那基本就是 context-mode 该出场的时候了。2. 为什么需要上下文模式三个真实场景的痛点2.1 场景一同一个后端服务要同时服务多种前端我之前参与过一个内部工具平台后端是一套 API前端却有三种形态Web 管理台、IDE 插件、命令行工具。最开始后端只按“用户ID”来组织上下文结果 IDE 插件需要“当前打开的文件 光标位置”命令行需要“当前工作目录 最近命令”Web 管理台需要“当前选中的项目 分页状态”。这三套上下文混在一起后缓存键冲突、权限判断错乱、日志里根本看不出请求来自哪种形态。当时的做法是在请求头里加一个X-Client-Type然后在业务代码里到处写if (clientType ide)。代码能跑但每次加一种前端就要改十几处。后来我们把它重构成 context-mode每种前端对应一个模式定义模式里声明“这个模式需要哪些上下文字段”“这些字段从哪来”“默认值是什么”“优先级如何”。业务代码只读统一的上下文对象不再关心来源。改动量从“十几处”降到“一个模式配置文件”。2.2 场景二对话系统里“短期记忆”和“长期记忆”的冲突做对话系统的人一定遇到过这个问题用户上一句说“帮我查一下北京的天气”这一句说“那上海呢”。系统需要把“北京”替换成“上海”同时保留“查天气”这个意图。但如果用户中间隔了十轮对话再问“那上海呢”你就不能简单替换了因为“那”指代的东西可能已经变了。这就是典型的上下文模式问题短期上下文最近几轮和长期上下文用户画像、历史偏好需要不同的读取策略。有的系统用滑动窗口有的用摘要压缩有的用向量检索。context-mode 的思路是把这些策略定义成模式比如short-term模式只读最近 N 轮long-term模式读用户画像加检索结果hybrid模式两者都读但给不同权重。运行时根据对话阶段切换模式而不是把所有策略硬编码在一个函数里。2.3 场景三低代码平台里“设计态”和“运行态”的上下文隔离低代码平台有个经典难题同一个组件在设计器里需要显示“可拖拽、可配置”的上下文在运行时需要显示“真实数据、真实交互”的上下文。如果两套上下文混用就会出现“设计器里点了按钮真的触发了业务逻辑”这种事故。我们当时的解法是给每个组件实例挂一个context-mode标记design模式下事件处理器被替换成空操作数据源被替换成 mock 数据runtime模式下才走真实逻辑。这个标记贯穿渲染、事件、数据三层任何一层读到design就短路。听起来简单但如果没有统一的模式定义每个组件各自实现一套判断维护成本会指数级上升。这三个场景的共同点是上下文不是单一的而是多维的不是静态的而是随场景切换的。context-mode 就是把这种“多维 可切换”抽象出来的设计模式。3. 拆解 context-mode 的核心结构四个必须定义的要素3.1 模式标识与模式注册表任何 context-mode 实现的第一步都是给模式起名字并注册。名字要稳定、可枚举不要用魔法字符串散落在代码里。我习惯用一个常量对象或枚举来管理// 模式标识定义 const ContextMode { DESIGN: design, RUNTIME: runtime, PREVIEW: preview, DEBUG: debug, } as const; type ContextModeType typeof ContextMode[keyof typeof ContextMode];然后有一个注册表把模式和它的上下文提供者绑定起来// 模式注册表每个模式声明自己的上下文来源 const modeRegistry: RecordContextModeType, ContextProvider[] { [ContextMode.DESIGN]: [designTimeProvider, mockDataProvider], [ContextMode.RUNTIME]: [runtimeDataProvider, userSessionProvider], [ContextMode.PREVIEW]: [runtimeDataProvider, mockDataProvider], [ContextMode.DEBUG]: [runtimeDataProvider, debugLogProvider], };这样做的好处是新增模式只需要加一行注册不需要改业务逻辑。模式之间的差异全部体现在“用哪些 provider”上。注意模式标识不要用数字0/1/2因为数字在日志和序列化时不可读排查问题时会很痛苦。用字符串哪怕长一点。3.2 上下文提供者的优先级与合并策略多个 provider 同时存在时必然有字段冲突。比如runtimeDataProvider提供了userIdmockDataProvider也提供了userId以谁为准这就需要定义优先级和合并策略。我的经验是分三层覆盖override、合并merge、回退fallback。覆盖用于“这个模式必须用这个值”合并用于“对象字段互补”回退用于“没有就取默认”。在注册表里可以这样声明interface ContextProvider { name: string; priority: number; // 数字越大优先级越高 strategy: override | merge | fallback; provide: () Recordstring, unknown; }合并时的顺序按 priority 从低到高执行高优先级的 provider 后执行自然覆盖低优先级的值。如果是merge策略则做浅合并或深合并取决于你的上下文复杂度。我一般建议默认用 override只有明确知道字段不冲突时才用 merge因为 merge 在嵌套对象上容易产生意料之外的结果。3.3 上下文的生命周期与失效时机上下文不是永久有效的。用户切换了项目、关闭了文件、退出了登录对应的上下文就应该失效。context-mode 需要定义每个模式的失效条件。常见做法是给上下文加一个scope字段声明它的作用域作用域失效时机典型用途request请求结束单次 API 调用的临时状态session会话结束用户登录态、对话历史component组件卸载组件内部状态global应用重启全局配置、主题在模式定义里声明 scope 后框架层可以在对应时机自动清理避免内存泄漏和脏读。我踩过的一个坑是把对话历史放在globalscope 里结果多个用户会话互相污染。后来改成sessionscope并在会话结束时显式清理问题才解决。3.4 模式切换的触发机制模式不会自己切换需要有触发点。触发点分三类显式触发调用方主动设置比如setContextMode(debug)。条件触发满足某个条件自动切换比如“检测到开发环境变量则切到 debug”。嵌套触发在某个模式内部临时切换到另一个模式执行完再切回来。嵌套触发最容易出问题。我建议用一个栈来管理模式切换class ContextModeManager { private stack: ContextModeType[] [runtime]; push(mode: ContextModeType) { this.stack.push(mode); } pop() { if (this.stack.length 1) { this.stack.pop(); } } current(): ContextModeType { return this.stack[this.stack.length - 1]; } }这样即使嵌套多层也能正确回到上一层模式。如果没有栈用单个变量记录当前模式嵌套切换后就会丢失原始模式。4. 落地实现从零搭一个可用的 context-mode 层4.1 目录结构与模块划分我习惯把 context-mode 相关代码单独放一个目录不要和业务逻辑混在一起src/ context-mode/ modes.ts // 模式标识定义 registry.ts // 模式注册表 providers/ // 各上下文提供者 design.ts runtime.ts mock.ts manager.ts // 模式管理器切换、栈、当前模式 resolver.ts // 上下文解析与合并 hooks.ts // 对外暴露的读取接口这样划分的好处是modes.ts和registry.ts是配置层providers/是数据层manager.ts和resolver.ts是逻辑层hooks.ts是接口层。任何一层要改影响范围可控。4.2 上下文解析器的实现细节解析器的职责是根据当前模式找到对应的 provider 列表按优先级执行合并结果。核心逻辑大概是这样function resolveContext(mode: ContextModeType): Recordstring, unknown { const providers modeRegistry[mode] || []; const sorted [...providers].sort((a, b) a.priority - b.priority); let result: Recordstring, unknown {}; for (const provider of sorted) { const value provider.provide(); if (provider.strategy override) { result { ...result, ...value }; } else if (provider.strategy merge) { result deepMerge(result, value); } else if (provider.strategy fallback) { result { ...value, ...result }; // 已有值优先 } } return result; }这里有个性能细节如果 provider 的provide()是异步的比如要读数据库就不能用同步循环。我的做法是先把所有 provider 的 provide 调用收集成 Promise 数组用Promise.all并发执行再按优先级合并。并发执行能把解析耗时从“累加”降到“取最大值”。提示如果某个 provider 的 provide 很慢考虑加缓存。缓存键可以用“模式 作用域 关键参数”组合避免不同模式之间互相污染。4.3 对外接口设计让业务代码无感业务代码不应该关心模式是怎么解析的它只需要读上下文。所以对外暴露的接口要极简// 读取当前上下文中的某个字段 function useContextT(key: string): T | undefined { const mode manager.current(); const ctx resolveContext(mode); return ctx[key] as T; } // 在指定模式下执行一段逻辑 function withContextModeT(mode: ContextModeType, fn: () T): T { manager.push(mode); try { return fn(); } finally { manager.pop(); } }withContextMode这个接口特别有用。比如在设计器里渲染一个组件时可以这样写withContextMode(design, () { renderComponent(component); });渲染过程中任何useContext调用都会读到 design 模式的上下文渲染结束后自动恢复。业务代码里看不到任何if (mode design)的判断。4.4 调试与可观测性模式切换的日志埋点context-mode 最大的风险是“模式切换了但没人知道”。所以我强烈建议在 manager 的 push/pop 里加日志push(mode: ContextModeType) { const prev this.current(); this.stack.push(mode); logger.debug([context-mode] ${prev} - ${mode}, { stack: [...this.stack], trigger: new Error().stack, // 记录调用栈方便定位谁切换的 }); }new Error().stack这招在排查“谁把模式切错了”时非常管用。我遇到过一个问题某个组件在渲染时意外把模式切到了 debug导致线上用户看到了调试信息。靠日志里的调用栈五分钟就定位到了那个组件的生命周期钩子。另外可以在开发环境加一个浮层实时显示当前模式和上下文快照。这个浮层不用很精致一个固定定位的 div 就行但对调试效率的提升是巨大的。5. 踩坑记录我在 context-mode 上翻过的三次车5.1 第一次翻车模式切换没有恢复导致上下文泄漏早期实现里我用的是单个变量记录当前模式没有用栈。结果在一个嵌套场景里外层是 runtime内层临时切到 preview内层结束后把模式设回了 runtime——看起来没问题。但后来加了一个“preview 里再嵌套 debug”的场景debug 结束后设回 previewpreview 结束后设回 runtime逻辑上是对的。问题出在异常路径如果内层抛异常没有 finally 恢复模式就永远停在 preview 了。修复方案就是前面说的栈 try/finally。这个坑让我明白任何“临时切换”都必须用栈并且必须在 finally 里恢复。不要相信“这段代码不会抛异常”。5.2 第二次翻车provider 之间的循环依赖有一次我让runtimeProvider依赖sessionProvider提供的userId而sessionProvider又依赖runtimeProvider提供的tenantId。结果解析时死循环了。当时没有加依赖检测直接栈溢出。后来我在注册表里加了依赖声明解析前先做拓扑排序检测到环就报错interface ContextProvider { name: string; dependsOn?: string[]; // ... }拓扑排序的代码不复杂但能避免很多隐蔽问题。如果你的 provider 之间有依赖关系一定要显式声明不要靠“执行顺序碰巧对”来工作。5.3 第三次翻车模式粒度过细配置爆炸有一段时间我追求“每个场景一个模式”结果模式数量膨胀到二十多个。每个模式都要维护 provider 列表改一个公共字段要改十几个模式。后来我做了合并把差异小的模式合并成一个模式加参数。比如design-web、design-mobile、design-desktop三个模式其实只有viewport字段不同完全可以合并成design模式加一个viewport参数。模式应该是“上下文来源的组合”而不是“所有差异的枚举”。判断标准很简单如果两个模式的 provider 列表完全相同只是某个字段值不同那它们应该是同一个模式加参数。6. 进阶玩法把 context-mode 和状态管理、依赖注入结合6.1 与状态管理库的边界划分很多人会问context-mode 和 Redux、Zustand 这些状态管理库有什么区别我的理解是状态管理管的是“数据怎么存、怎么改”context-mode 管的是“当前该读哪份数据”。两者是互补的。实际项目中我通常让状态管理库持有所有数据context-mode 只负责“选择器”——根据当前模式决定从 store 的哪个 slice 读数据。这样状态管理库不需要知道模式的存在context-mode 也不需要关心数据怎么更新。// context-mode 的 provider 从 store 读数据 const runtimeDataProvider: ContextProvider { name: runtime-data, priority: 10, strategy: override, provide: () ({ userId: store.getState().session.userId, projectId: store.getState().project.currentId, }), };6.2 与依赖注入容器的配合如果你的项目用了依赖注入DI可以把 context-mode 的 manager 注册成单例provider 注册成多实例。DI 容器负责生命周期context-mode 负责选择逻辑。这样测试时可以轻松替换 provider不需要改模式定义。我试过在 NestJS 项目里这样做把ContextModeManager注册为Scope.REQUEST每个请求一个实例请求内的模式切换互不影响。配合拦截器在请求开始时根据路由设置初始模式整个链路非常干净。6.3 模式继承减少重复配置当模式数量增多时可以用继承来减少重复。比如debug模式继承runtime模式只额外加一个debugLogProviderconst modeRegistry { runtime: [runtimeDataProvider, sessionProvider], debug: [...modeRegistry.runtime, debugLogProvider], };这样runtime的 provider 变更会自动同步到debug。但要注意继承层级不要超过两层否则“这个模式到底有哪些 provider”会变得难以追踪。7. 一些实测有效的经验与检查清单7.1 模式命名的三条规则第一用场景名而不是技术名。design比no-event好因为前者表达意图后者表达实现。第二用正向命名而不是否定命名。runtime比non-design好因为否定命名在模式增多时会混乱。第三模式名要能独立理解不要用mode1、mode2这种。7.2 上线前的自检清单所有模式是否都有明确的 provider 列表没有“空模式”每个 provider 的 priority 是否唯一重复的 priority 会导致合并顺序不确定。模式切换是否都在 try/finally 里有没有可能异常导致模式不恢复上下文里是否有可能为 undefined 的字段业务代码是否做了空值处理日志里是否能看出“当前模式是什么、谁切换的、切换前是什么”生产环境是否关闭了 debug 模式的入口有没有可能被外部触发7.3 性能上的一个实测数据在一个中等规模的前端项目里约 200 个组件引入 context-mode 后首屏渲染耗时增加了约 3%——主要来自上下文解析。后来加了缓存按模式缓存解析结果模式不变就不重新解析耗时回落到 1% 以内。所以缓存是必须的尤其是 provider 的 provide 有计算成本时。缓存失效的时机要跟模式切换绑定push/pop 时清空缓存。不要用时间过期因为模式切换是事件驱动的时间过期会导致读到旧数据。7.4 一个容易被忽略的细节服务端渲染如果你的应用要做服务端渲染SSRcontext-mode 的 manager 不能是模块级单例因为服务端会同时处理多个请求。必须每个请求创建一个 manager 实例请求结束后销毁。这个坑我在一个 Next.js 项目里踩过开发环境正常一上生产就出现“A 用户的上下文泄漏到 B 用户”。排查了半天才发现是 manager 被模块级缓存了。修复方式是用 React 的 Context 或请求作用域的 DI 来持有 manager。在 Next.js 里可以用AsyncLocalStorage来隔离请求上下文效果很好。8. 最后分享几个小技巧第一个技巧给模式加一个description字段写清楚“这个模式什么时候用”。半年后回来看代码你会感谢自己。第二个技巧在模式注册表旁边放一个测试文件每个模式至少测一次“解析出的上下文包含预期字段”。模式配置是数据数据最容易在重构时被改错。第三个技巧如果团队里有人不熟悉 context-mode不要一上来就讲架构先给他看一个“切换模式后行为不同”的最小 demo让他直观感受到价值再讲实现。这个方向后续还可以扩展的地方把模式定义从代码里抽出来放到配置文件或数据库让非开发人员也能调整加一个模式的可视化编辑器拖拽 provider 就能生成模式把模式切换和埋点结合分析用户实际在哪些模式下停留最久。这些都是我在实际项目里想过但还没完全落地的方向有兴趣的可以试试。