1. 从context-mode这个词说起它到底指什么第一次看到context-mode这个标题很多人会下意识地把它当成某个具体框架里的配置项或者某个库的开关参数。但如果你在工程一线待过几年就会发现这个词其实指向一个更底层、更普遍的问题同一段逻辑在不同上下文里应该表现出不同的行为而上下文本身需要被显式建模而不是靠一堆 if-else 硬编码。我最早接触这个概念是在处理一个多端渲染的项目时。同一份数据在列表页要精简展示在详情页要完整展开在导出场景要转成另一种结构。最初的做法是写三个函数各自处理各自的逻辑。结果需求一变三个地方都要改改漏一个就出线上问题。后来我们把当前处于什么场景抽象成一个显式的上下文对象让同一套核心逻辑根据上下文模式走不同分支维护成本直接降了一个数量级。这就是 context-mode 最朴素的价值。所以这篇内容不是讲某个特定库的 API而是讲如何在自己的项目里落地上下文模式这套思路。它适合谁看如果你正在被同一功能多场景复用折磨如果你写的代码里到处是if (isDetailPage)这种判断如果你想让核心逻辑和场景耦合解耦那这篇就是写给你的。哪怕你只是刚入门的开发者只要理解函数接收参数、根据参数走不同分支这件事就能看懂后面的内容。需要先明确一点context-mode 不是银弹。它解决的是行为随场景变化这一类问题不解决性能问题也不解决架构分层问题。把它用在对的地方收益很大用错地方就是过度设计。后面我会详细讲清楚它的适用边界。2. 为什么上下文值得被单独建模2.1 散落的场景判断是怎么变成技术债的先看一个几乎每个人都写过的代码形态function renderData(data, scene) { if (scene list) { return data.map(item ({ id: item.id, title: item.title })); } else if (scene detail) { return { ...data, extra: data.raw }; } else if (scene export) { return JSON.stringify(data); } }这段代码本身没错甚至在小项目里是最省事的写法。问题出在它被复制粘贴到第五个文件之后。每个文件里的scene判断条件略有差异有的用字符串有的用数字枚举有的干脆传了个布尔值。等到要新增一个场景时你得全局搜索所有scene 的地方一个个改。这就是典型的隐式上下文带来的技术债。context-mode 的核心主张是把当前处于什么模式这件事从散落的判断里抽出来变成一个显式的、有类型的、集中管理的对象。这个对象我们通常叫它 context而它携带的模式信息就是 mode。2.2 显式上下文带来的三个直接收益第一可测试性。当上下文是一个独立对象时你可以针对每种 mode 单独写测试用例传入构造好的 context断言输出。不需要启动整个页面也不需要 mock 一堆全局状态。第二可扩展性。新增一个场景只需要在 mode 的枚举里加一项然后在对应的处理分支里补上逻辑。所有依赖 context 的地方自动感知不需要全局搜索。第三可读性。读代码的人看到context.mode Mode.Detail立刻明白这里的行为和详情场景绑定。而看到if (flag)时他得去翻 flag 是从哪传进来的、什么时候为真。显式命名本身就是最好的文档。2.3 一个生活化的类比把 context-mode 想象成一家餐厅的服务流程。同一道菜核心逻辑堂食要摆盘上桌外卖要装盒密封宴席要分餐到小碟。厨师做菜的手艺核心逻辑不变变的是出餐模式。如果每个服务员都自己记一套什么场合怎么装迟早出错。正确做法是订单上明确标注出餐模式后厨按模式走标准流程。这个订单上的模式标注就是 context-mode 里的 context。3. 落地 context-mode 的四种典型实现路径3.1 枚举加策略映射最直白也最稳的方案这是我最推荐新手起步的方案。核心就两步定义 mode 枚举建立 mode 到处理函数的映射表。const Mode Object.freeze({ List: list, Detail: detail, Export: export, }); const handlers { [Mode.List]: (data) data.map(i ({ id: i.id, title: i.title })), [Mode.Detail]: (data) ({ ...data, extra: data.raw }), [Mode.Export]: (data) JSON.stringify(data), }; function process(data, context) { const handler handlers[context.mode]; if (!handler) throw new Error(未知模式: ${context.mode}); return handler(data); }为什么推荐这个方案因为它把分支判断变成了查表。查表的好处是新增模式时只加一行映射不动主流程测试时可以单独测每个 handler映射表本身就是一份完整的模式清单一眼看全。注意映射表的 key 一定要用枚举常量而不是裸字符串。裸字符串拼错一个字母运行时才报错而枚举在定义处就能被静态检查工具发现。3.2 类继承加多态适合逻辑复杂的场景当每种 mode 的处理逻辑都很重、各自需要维护内部状态时策略映射就不够用了。这时候用类继承更合适。class BaseContext { process(data) { throw new Error(子类必须实现 process); } } class ListContext extends BaseContext { process(data) { return data.map(i ({ id: i.id, title: i.title })); } } class DetailContext extends BaseContext { constructor(options) { super(); this.options options; } process(data) { return { ...data, extra: data.raw, ...this.options }; } }多态的好处是每个 mode 可以有自己的构造函数参数、自己的辅助方法。缺点是类层级一旦深了追踪一个方法到底走的哪个实现会比较费劲。我的经验是mode 数量少于 5 个、逻辑简单用映射表mode 逻辑复杂、需要独立状态用类继承。不要为了看起来高级而强行上继承。3.3 函数式组合把上下文当作可拼接的管道如果你的处理逻辑是一系列可叠加的转换那函数式组合会很优雅。每个 mode 对应一组中间件context 决定这组中间件怎么拼。const middlewares { list: [pickFields([id, title])], detail: [pickFields([id, title, raw]), expandRaw()], export: [pickFields([id, title, raw]), toJSON()], }; function compose(mws) { return (data) mws.reduce((acc, mw) mw(acc), data); } function process(data, context) { return compose(middlewares[context.mode])(data); }这个方案的精髓在于中间件是可复用的积木。pickFields在三个 mode 里都用到了只写一次。新增 mode 时往往只是换一组积木的排列组合。这在数据处理管道类的项目里特别好用。3.4 配置驱动把模式差异外置成数据最后一种也是最灵活的把每种 mode 的差异写成配置代码只负责读配置执行。const modeConfig { list: { fields: [id, title], transform: array }, detail: { fields: [id, title, raw], transform: object }, export: { fields: [id, title, raw], transform: json }, };配置驱动的好处是改行为不用改代码改配置就行。适合那种模式经常变、但变化规律固定的场景。坏处是配置本身可能变得很复杂最后变成用 JSON 写代码可读性反而下降。我的建议是配置只放数据层面的差异字段、格式、阈值逻辑层面的差异还是交给代码。下面这张表可以帮你快速选型方案适合场景新增模式成本主要风险枚举加映射逻辑简单、模式少加一行映射映射遗漏类继承多态逻辑复杂、需状态加一个子类层级过深函数式组合转换管道类加一组中间件中间件顺序错配置驱动模式频繁变加一段配置配置膨胀4. 上下文对象本身该怎么设计4.1 只放决策所需的信息别当垃圾桶我见过最糟糕的 context 设计是把整个应用状态都塞进去用户信息、路由参数、主题色、语言、甚至购物车。结果每个 handler 都能访问一切依赖关系彻底失控。正确的做法是context 只放决定当前行为分支所必需的信息。如果某个字段不影响分支选择也不影响处理结果它就不该出现在 context 里。判断标准很简单把这个字段删掉处理逻辑会不会变不会变就删。4.2 mode 的粒度太粗和太细都是坑mode 粒度是个需要反复权衡的点。太粗比如只有展示和导出两种那展示场景内部的列表和详情差异还是得靠额外判断等于没抽象干净。太细比如列表第一页列表第二页都算不同 mode那 mode 数量爆炸维护成本反而更高。我的经验法则是一个 mode 对应一种输出契约。如果两种场景的输出结构完全一致只是数据不同那它们是同一个 mode。如果输出结构不同那就是不同 mode。按这个标准列表和详情输出结构不同是两个 mode列表第一页和第二页输出结构相同是同一个 mode。4.3 上下文的不可变性context 一旦创建就不应该在处理过程中被修改。原因有两个一是并发场景下共享可变对象会出竞态问题二是调试时你希望这个 context 从进到出始终代表同一个场景。function createContext(mode, options {}) { return Object.freeze({ mode, ...options }); }用Object.freeze冻结或者用不可变数据结构。如果确实需要在处理过程中传递额外状态用单独的 accumulator 对象别污染 context。5. 实战中踩过的坑与排查链路5.1 坑一mode 判断散落在 handler 内部现象明明已经按 mode 分了 handler但每个 handler 内部还有if (context.mode ...)的判断。根因抽象做了一半。外层按 mode 分发了内层又按 mode 判断说明分发粒度不对或者有些差异没被识别出来。排查过程我当时的做法是把所有 handler 内部的 mode 判断全部打印出来列成清单。发现有三处判断其实是在处理同一个 mode 下的子差异。于是把这三处子差异提升为独立的 mode分发层重新划分内层判断就消失了。修复方案如果一个 handler 内部出现 mode 判断先问自己这个判断是不是意味着这里其实是两个 mode如果是拆开如果不是说明这个判断依赖的是别的维度比如数据量大小那就该用别的 context 字段而不是 mode。5.2 坑二默认分支悄悄吞掉错误现象新增了一个 mode但忘了在映射表里加对应 handler结果代码走了默认分支返回了错误的数据线上过了两天才发现。根因默认分支default case太宽容。很多实现里会写handlers[context.mode] || defaultHandler这个||就是隐患。修复方案永远不要给 mode 分发写默认分支。找不到 handler 就直接抛错让问题在开发阶段暴露。function process(data, context) { const handler handlers[context.mode]; if (!handler) { throw new Error(未注册的模式: ${context.mode}请检查映射表); } return handler(data); }这个改动看起来很小但它把静默错误变成了显式崩溃排查成本从两天降到两分钟。5.3 坑三context 在异步链路里被意外修改现象一个请求进来context 是 detail 模式但处理到一半变成了 list 模式输出结构错乱。根因某个中间件或回调里直接改了 context 对象。异步链路里对象被多处引用改一处影响全局。排查链路这种问题最难查因为现象和原因隔了好几层。我的做法是在 context 创建时打一个唯一 id在每个处理节点打印id mode。跑一遍就能看到 mode 在哪个节点变的然后去那个节点找修改点。修复方案context 创建后立即冻结。如果确实需要传递可变状态用独立的、每个请求独有的 accumulator并且明确它的生命周期。5.4 坑四mode 命名和业务术语脱节现象代码里 mode 叫typeA、typeB新人接手完全看不懂对应什么业务场景改错了好几次。根因命名时图省事用了无意义的代号。修复方案mode 命名直接用业务术语List、Detail、Export这种一看就懂。如果业务术语有歧义在枚举定义处加注释说明每个 mode 对应的具体场景。命名这件事多花五分钟省下的是后面无数次的沟通成本。6. 把 context-mode 用对地方适用与不适用6.1 这些场景用它收益最大多端渲染同一份数据在移动端、桌面端、打印端展示结构不同。这是 context-mode 的经典战场。数据导出同一份数据导出成 CSV、JSON、Excel字段和格式各异。用 mode 区分导出格式核心取数逻辑只写一遍。权限视图同一份资源管理员看到全部字段普通用户看到脱敏字段。mode 对应角色处理逻辑按 mode 走。测试替身测试环境用 mock 数据生产环境用真实数据但处理流程一致。mode 区分数据源流程代码不变。6.2 这些场景别硬套纯粹的性能优化如果两个分支只是算法不同、输出一样那是策略模式或者简单条件判断的活不需要引入 context 概念。一次性的临时逻辑如果某个场景只出现一次永远不会复用那直接写 if 就行。为一次性逻辑建 mode是过度设计。状态机如果模式之间会互相转换、有明确的转换规则那你要的是状态机不是 context-mode。两者容易混淆但状态机关注的是状态迁移context-mode 关注的是同一逻辑的不同表现。6.3 一个判断是否该引入的简单测试问自己三个问题第一同一段核心逻辑是否要在多个场景下复用第二这些场景的差异是否稳定、可枚举第三新增场景的频率是否高于修改核心逻辑的频率三个都是是那就引入 context-mode。有一个是否就先别急用最朴素的写法等痛点真的出现了再重构。7. 几个让代码更耐用的实操细节7.1 给 mode 加类型约束如果项目用 TypeScript把 mode 定义成联合类型编译器会帮你检查所有分支是否覆盖。type Mode list | detail | export; const handlers: RecordMode, (data: any) any { list: (data) data.map((i: any) ({ id: i.id, title: i.title })), detail: (data) ({ ...data, extra: data.raw }), export: (data) JSON.stringify(data), };RecordMode, ...这个类型标注会强制你为每个 mode 都提供 handler漏一个就编译不过。这比运行时抛错又早了一步。7.2 把 mode 清单集中在一处不要在代码各处散落 mode 字符串。所有 mode 定义集中在一个文件里其他文件从这里导入。这样新增 mode 时改一处全局生效也方便做全局搜索。7.3 为每个 mode 写一个最小测试不需要写很复杂的测试每个 mode 一个输入固定数据、断言输出结构的用例就够。这些测试的价值不在于覆盖率而在于当有人改了核心逻辑时能立刻发现哪个 mode 的输出被影响了。7.4 日志里带上 mode处理入口处打一行日志处理开始modedetail, dataIdxxx。线上出问题时这一行日志能帮你快速定位是哪个 mode 出的问题省去大量猜测。7.5 定期清理不再使用的 modemode 和业务场景绑定业务场景下线了对应的 mode 就该删。留着的死 mode 会误导后来的人也会让映射表越来越臃肿。我一般每个季度过一遍 mode 清单确认每个都还有活跃调用方。8. 从 context-mode 延伸出去的思考context-mode 这套思路本质上是在回答一个更宏大的问题如何让变化的部分和不变的部分各归其位。变化的是场景不变的是核心逻辑。把场景显式建模成 context核心逻辑就获得了稳定性和复用性。这个思路可以继续往外延伸。比如在配置系统里context 可以是当前环境开发、测试、生产mode 对应不同的配置加载策略。在权限系统里context 可以是当前请求者mode 对应不同的鉴权级别。在渲染系统里context 可以是当前设备能力mode 对应不同的渲染降级方案。我在实际项目里最深的体会是引入 context-mode 的时机比怎么实现它更重要。太早引入是为不存在的需求买单太晚引入是让技术债滚成雪球。判断时机的方法前面说过就是那三个问题。当三个答案都是是的时候别犹豫动手重构。重构的过程可能有点痛但痛过一次之后后面每次加场景都是轻松的。最后分享一个小技巧如果你不确定某个 mode 该不该独立出来先把它当成一个独立的 mode 写一版跑通之后再看它和其他 mode 的代码重合度。如果重合度超过七成说明它可能不该独立如果重合度低于三成说明独立得对。这个先拆再合的方法比在脑子里空想靠谱得多。
阅读完成 · 觉得有帮助?