2026/10/29 6:13:10

context-mode 工程实践:多环境上下文切换与依赖注入设计

context-mode 工程实践:多环境上下文切换与依赖注入设计 1. 从“context-mode”说起一个被低估的工程概念第一次看到“context-mode”这个词很多人会下意识地把它归到某个具体框架的配置项里比如某个前端状态管理库的上下文模式或者某个后端框架的请求上下文切换。但如果你在工程一线待过几年就会发现这个词背后藏着一类非常通用的设计问题同一套逻辑在不同上下文环境下应该表现出不同的行为模式而调用方不需要关心切换过程。这就是 context-mode 的核心价值。它不是一个具体的库也不是某个语言独有的语法糖而是一种架构思路——把“当前处于什么场景”这件事抽象成一个可切换、可传递、可组合的模式让业务代码从一堆 if-else 里解放出来。举个最直白的例子你写了一个数据读取函数在服务端渲染时它应该直接查数据库在客户端 hydration 时它应该读 window 上挂载的初始状态在单元测试时它应该返回 mock 数据。三种场景三套行为但对外暴露的接口完全一致。这就是 context-mode 要解决的问题。这篇文章适合谁看如果你正在维护一个多端复用的代码库或者你的项目里开始出现大量“根据环境判断走哪条分支”的逻辑又或者你单纯对“上下文切换”这类设计模式感兴趣那接下来的内容应该能给你一些可以直接抄作业的思路。我会从设计动机、核心实现、实操步骤、踩坑记录几个维度展开尽量把每个决策背后的“为什么”讲清楚。2. 为什么需要 context-mode从三个真实痛点说起2.1 痛点一环境判断逻辑散落各处我接手过一个项目代码里到处是这种写法if (typeof window ! undefined) { // 客户端逻辑 } else { // 服务端逻辑 }一开始只有三四处后来越加越多最后统计下来有四十多个文件里都有类似判断。问题不在于判断本身而在于判断逻辑和业务逻辑耦合在一起。每次新增一个运行环境比如从 CSR 扩展到 SSR 再扩展到边缘函数就要全局搜索所有环境判断点逐个修改。这种维护成本是指数级上升的。context-mode 的思路是把“当前是什么环境”这件事收敛到一个地方业务代码只面向一个统一的上下文对象编程。环境切换时只需要替换上下文对象的实现业务代码一行不用改。2.2 痛点二测试时 mock 成本高单元测试里最烦的事情之一就是被测函数依赖了全局状态或环境变量。比如一个函数内部直接调用了localStorage.getItem你在 Node 环境跑测试就会报错。常规做法是在测试文件顶部挂一个全局 mock但这样做有几个问题mock 的作用域是全局的测试之间会互相污染mock 的粒度太粗没法针对单个用例定制行为mock 和真实实现的差异可能掩盖 bug。context-mode 把这类外部依赖抽象成上下文的一部分。测试时传入一个测试专用的上下文对象里面所有外部依赖都是可控的。这样每个测试用例可以独立构造自己的上下文互不干扰而且 mock 的行为边界非常清晰。2.3 痛点三多端复用时逻辑分叉难管理同一套业务逻辑要在 Web、移动端、桌面端复用这是很多团队都会遇到的场景。不同端的差异可能体现在存储方式、网络请求、UI 渲染等多个层面。如果没有统一的上下文抽象代码里就会出现大量平台判断if (platform web) { storage new WebStorage() } else if (platform mobile) { storage new MobileStorage() }这种写法的问题在于每新增一个平台就要在所有分叉点加分支。而 context-mode 的做法是定义一个平台无关的上下文接口每个平台提供自己的实现业务代码只依赖接口。新增平台时只需要新增一个上下文实现业务代码零改动。3. context-mode 的核心设计接口、实现与切换机制3.1 上下文接口的定义原则设计上下文接口时最关键的原则是最小完备性接口只暴露业务代码真正需要的能力不多不少。接口太大会导致每个实现类都要写一堆空方法接口太小又会导致业务代码频繁需要向下转型获取具体实现的能力。我通常的做法是先写业务代码把业务代码里所有对外部环境的依赖点列出来然后归纳成接口方法。比如一个典型的数据处理模块可能依赖这些能力读取某个 key 的值写入某个 key 的值发起网络请求获取当前时间戳记录日志这五个能力就可以构成一个上下文接口。注意这里没有“判断当前是哪个平台”这样的方法因为业务代码不应该关心平台只应该关心能力。interface Context { get(key: string): Promiseunknown set(key: string, value: unknown): Promisevoid request(options: RequestOptions): PromiseResponse now(): number log(level: string, message: string): void }3.2 多套实现的组织方式接口定义好之后每个运行环境提供一套实现。这里有个经验实现类不要继承用组合。因为不同环境的差异可能只在某几个方法上如果每个实现都从头写一遍所有方法会有大量重复代码。更好的做法是提供一个基础实现把通用逻辑放进去然后每个环境实现只覆盖有差异的方法。比如内存上下文可以作为基础实现Web 上下文继承它并覆盖存储相关方法测试上下文再继承 Web 上下文并覆盖网络请求方法。class MemoryContext implements Context { protected store new Mapstring, unknown() async get(key: string) { return this.store.get(key) } async set(key: string, value: unknown) { this.store.set(key, value) } async request(options: RequestOptions) { // 默认实现抛错强制子类覆盖 throw new Error(request not implemented) } now() { return Date.now() } log(level: string, message: string) { console.log([${level}] ${message}) } }3.3 切换机制显式传递还是隐式注入上下文对象怎么传递给业务代码这是 context-mode 设计里最容易产生分歧的地方。两种主流方案显式传递每个需要上下文的函数都接收一个 context 参数。优点是依赖关系清晰测试时直接传参即可缺点是函数签名会变长调用链深的时候要层层透传。隐式注入通过某种全局机制比如 AsyncLocalStorage 或依赖注入容器让业务代码在任意位置获取当前上下文。优点是调用方无感知代码简洁缺点是依赖关系不透明调试时不容易追踪上下文来源。我的建议是混合使用在模块边界用显式传递模块内部用隐式注入。具体来说入口函数接收 context 参数然后把它设置到当前执行流的隐式存储里内部函数直接从隐式存储读取。这样既保证了模块间的依赖清晰又避免了内部层层透传的繁琐。import { AsyncLocalStorage } from async_hooks const contextStorage new AsyncLocalStorageContext() export function withContextT(ctx: Context, fn: () T): T { return contextStorage.run(ctx, fn) } export function useContext(): Context { const ctx contextStorage.getStore() if (!ctx) { throw new Error(No context available. Did you forget to call withContext?) } return ctx }4. 实操从零搭建一个 context-mode 体系4.1 第一步梳理依赖点定义接口不要一上来就写代码。先花时间把现有代码里所有对外部环境的依赖点找出来。我通常用 grep 搜索这些关键词window、document、localStorage、process.env、fetch、Date.now、console。每找到一个就记录下它被用来做什么。搜完之后把功能相同或相近的依赖点合并。比如localStorage.getItem和sessionStorage.getItem可以合并成get方法通过不同的上下文实现来区分存储介质。合并的原则是业务代码不需要知道数据存在哪里只需要知道能存能取。接口定义完成后写一份接口文档明确每个方法的语义、参数、返回值、异常情况。这份文档是后续所有实现的契约也是团队协作的基础。4.2 第二步实现基础上下文与各环境上下文基础上下文MemoryContext是所有实现的起点。它用内存数据结构模拟所有能力适合在测试和本地开发时使用。然后针对每个目标环境实现具体的上下文环境存储实现网络实现时间实现日志实现Node文件系统或 Redishttp 模块Date.nowwinston浏览器localStoragefetchDate.nowconsole测试内存 Mapmock 函数可控时钟静默或收集边缘函数KV 存储fetchDate.now结构化日志每个实现类只需要覆盖有差异的方法通用逻辑继承自基础上下文。这样新增一个环境时工作量通常只有几十行代码。4.3 第三步接入业务代码替换硬编码判断这一步是最需要耐心的。不要试图一次性替换所有环境判断而是按模块逐步迁移。每迁移一个模块就跑一遍该模块的测试确保行为一致。迁移的典型模式是// 迁移前 function getUser(id) { if (typeof window ! undefined) { return fetch(/api/user/${id}).then(r r.json()) } else { return db.query(SELECT * FROM users WHERE id ?, [id]) } } // 迁移后 function getUser(id, ctx) { return ctx.request({ url: /api/user/${id} }).then(r r.json()) }注意迁移后的函数签名多了一个 ctx 参数。如果调用链很深可以用前面提到的 withContext 在入口处注入内部函数通过 useContext 获取。4.4 第四步建立上下文切换的边界上下文切换应该发生在系统的边界处而不是散落在业务逻辑中间。典型的边界包括HTTP 请求入口根据请求头或路由决定使用哪个上下文定时任务入口根据任务配置决定上下文测试用例入口每个用例构造自己的上下文CLI 命令入口根据命令行参数决定上下文在这些边界处用 withContext 包裹整个执行流程。业务代码内部不再出现任何上下文切换逻辑。// HTTP 入口示例 app.use(async (req, res, next) { const ctx createContextForRequest(req) await withContext(ctx, async () { await handleRequest(req, res) }) })5. 踩坑记录那些文档里不会写的问题5.1 异步上下文丢失用 AsyncLocalStorage 做隐式注入时最容易踩的坑是在异步边界丢失上下文。比如在 setTimeout 回调里调用 useContext可能拿不到值。原因是 AsyncLocalStorage 依赖异步资源的传播机制某些第三方库或原生模块可能没有正确传播。解决办法有两个一是在异步边界显式重新注入上下文二是避免在不可控的异步回调里依赖隐式上下文。我通常的做法是在进入第三方库之前把需要的数据从上下文里取出来以参数形式传进去而不是让第三方库的回调去读上下文。5.2 上下文对象的可变性上下文对象应该是不可变的或者至少是只读的。我见过有人在上下文里放了一个可变的配置对象结果不同模块修改配置后互相影响排查了半天才发现是上下文被污染了。正确的做法是上下文对象创建后冻结所有需要变更的状态通过方法调用来管理而不是直接修改属性。如果确实需要动态配置把配置放在一个独立的、有明确生命周期管理的对象里上下文只持有它的引用。5.3 测试上下文的过度 mock测试上下文的目标是让测试可控而不是让测试脱离真实。我见过测试上下文把网络请求 mock 成永远返回成功结果线上出现网络错误时完全没有处理逻辑。测试上下文应该模拟真实场景的各种情况包括成功、失败、超时、异常数据。建议在测试上下文里提供一个“场景配置”机制让每个测试用例可以声明自己期望的场景测试上下文根据场景返回对应的模拟结果。const ctx new TestContext({ requestScenarios: { /api/user/1: { status: 200, body: { id: 1, name: Alice } }, /api/user/2: { status: 404, body: { error: Not found } }, /api/user/3: { delay: 5000, status: 200, body: {} } } })5.4 上下文粒度过粗或过细上下文粒度过粗意味着一个上下文对象承载了太多不相关的能力导致实现类臃肿测试时难以构造。粒度过细意味着业务代码需要同时持有多个上下文对象调用变得繁琐。我的经验法则是一个上下文对应一个明确的运行环境边界。比如“服务端请求处理”是一个边界“客户端渲染”是另一个边界“后台任务执行”是第三个边界。每个边界内的业务代码共享同一个上下文跨边界的调用通过显式参数传递。6. 常见问题速查与排查技巧6.1 上下文获取失败现象调用 useContext 时抛出 “No context available”。排查步骤确认入口处是否调用了 withContext确认当前代码是否在异步回调中且该回调是否在 withContext 的作用域内创建检查是否使用了不支持异步上下文传播的第三方库在 withContext 内部打日志确认上下文确实被设置了解决如果是异步边界问题在边界处重新用 withContext 包裹如果是第三方库问题改为显式传参。6.2 上下文行为不符合预期现象业务代码拿到的上下文实现和预期不符。排查步骤打印上下文的构造函数名或类型标识检查入口处的上下文创建逻辑确认判断条件是否正确检查是否有多个 withContext 嵌套内层覆盖了外层解决在上下文对象上加一个type字段方便调试时识别。嵌套场景下明确哪一层应该生效。6.3 性能问题现象引入 context-mode 后接口响应变慢。排查步骤检查上下文创建是否过于频繁比如每个请求都创建新上下文而不是复用检查上下文方法是否有不必要的序列化或深拷贝检查 AsyncLocalStorage 的使用是否导致内存泄漏解决上下文对象尽量轻量只放引用不放数据频繁创建的场景考虑对象池AsyncLocalStorage 在长生命周期对象中谨慎使用。问题类型典型现象首选排查方向推荐解决方式获取失败抛 No context 错误入口是否注入补 withContext行为不符实现类不对创建逻辑判断加 type 标识性能下降响应变慢创建频率与开销复用或池化内存增长内存持续上升异步存储泄漏限制作用域6.4 一个容易被忽略的细节上下文的序列化如果上下文需要在进程间传递比如从主进程传到 worker就需要考虑序列化问题。方法不能序列化所以传递的应该是上下文的状态而不是上下文对象本身。接收方根据状态重建上下文。我通常的做法是给上下文加一个serialize()方法返回一个纯数据对象然后提供一个静态的deserialize()方法从数据对象重建上下文。这样跨进程传递时只传数据不传行为。7. 进阶玩法上下文组合与中间件7.1 上下文组合有时候一个业务场景需要同时具备多种上下文的能力。比如一个函数既需要读存储又需要发网络请求还需要记录日志。如果每种能力都是一个独立的上下文函数签名就会变得很长。解决方案是上下文组合定义一个组合上下文把多个单一职责的上下文合并成一个。组合上下文本身也实现 Context 接口内部把方法调用委托给对应的子上下文。class CompositeContext implements Context { constructor( private storage: StorageContext, private network: NetworkContext, private logger: LoggerContext ) {} get(key: string) { return this.storage.get(key) } set(key: string, value: unknown) { return this.storage.set(key, value) } request(options: RequestOptions) { return this.network.request(options) } now() { return Date.now() } log(level: string, message: string) { this.logger.log(level, message) } }这样业务代码只需要一个上下文参数内部能力通过组合获得。测试时可以只替换需要 mock 的子上下文其他用真实实现。7.2 上下文中间件借鉴 Web 框架的中间件思想可以在上下文方法调用前后插入通用逻辑。比如在 request 前后记录耗时和状态码在 get/set 前后做数据校验在 log 前后做敏感信息脱敏实现方式是用装饰器模式包裹上下文对象function withMetrics(ctx: Context): Context { return { ...ctx, async request(options: RequestOptions) { const start Date.now() try { const result await ctx.request(options) metrics.record(request, Date.now() - start, success) return result } catch (error) { metrics.record(request, Date.now() - start, error) throw error } } } }这种模式的好处是通用逻辑和业务逻辑彻底分离而且可以按需组合。一个上下文可以同时被多个中间件包裹每个中间件只关心自己那一层。7.3 上下文与依赖注入的边界context-mode 和依赖注入DI有重叠但侧重点不同。DI 关注的是“对象如何获取它的依赖”context-mode 关注的是“同一套逻辑在不同环境下如何表现不同行为”。两者可以结合使用用 DI 容器管理上下文的创建和生命周期用 context-mode 管理业务代码对环境的依赖。我的建议是如果项目已经有成熟的 DI 体系把上下文作为一种特殊的依赖注册进去如果项目没有 DI不要为了 context-mode 强行引入 DI 容器用简单的工厂函数创建上下文就够了。8. 我在实际项目中的几点体会context-mode 这套东西我前后在三个项目里落地过有成功的经验也有失败的教训。最大的体会是不要为了抽象而抽象。如果一个项目只有一两种运行环境环境判断逻辑总共就十几处那直接写 if-else 完全没问题引入 context-mode 反而增加复杂度。真正适合引入 context-mode 的信号是环境判断逻辑超过二十处或者新增环境的频率很高或者测试时 mock 成本已经影响到开发效率。这时候引入 context-mode收益才能覆盖成本。另一个体会是上下文接口的稳定性比实现的质量更重要。接口一旦定下来所有实现都要遵循。如果接口频繁变动所有实现都要跟着改维护成本会很高。所以定义接口时宁可多花时间讨论也不要草率定稿。最后分享一个小技巧给上下文对象加一个debugInfo()方法返回当前上下文的类型、创建时间、关键配置等信息。排查问题时在日志里打印这个信息能省很多猜测的时间。这个方法在测试上下文里尤其有用可以清楚地看到当前用例使用的是哪套 mock 配置。这套模式后续还可以往插件化方向扩展把上下文实现做成可注册的插件运行时根据配置动态加载。这样新增环境时连代码都不用改只需要提供一个插件包。不过这是另一个话题了等有实际需求时再展开。