context-mode:上下文与模式解耦的工程实践
1. 从“context-mode”这个词说起它到底在解决什么问题第一次看到“context-mode”这个标题很多人会下意识地把它当成某个框架里的配置项或者某个编辑器插件的开关。但如果你真的在工程一线待过几年就会意识到这个词背后藏着一个非常具体、非常普遍的痛点同一个系统在不同上下文里需要表现出完全不同的行为模式而绝大多数项目在早期根本没有为这件事做设计。举个最直观的例子。一个后台管理系统管理员登录进去看到的是全量数据、所有操作按钮、完整的审计日志而普通用户登录进去看到的只是自己那一亩三分地的数据按钮少了一半敏感字段被脱敏。这两套逻辑如果写在一个组件里用一堆if (role admin)去判断代码会迅速膨胀成一团乱麻。更麻烦的是当业务方说“我们还要加一个‘审计员’角色他能看所有数据但不能改”的时候你会发现原来那些散落各处的判断条件根本改不动。“context-mode”要解决的就是这类问题。它的核心思路是把“当前处于什么上下文”和“在这个上下文下应该表现成什么样”这两件事拆开。上下文本身是一个独立的状态而模式是对这个状态的一种响应策略。听起来有点像设计模式里的“策略模式”加“状态模式”的混合体但它在实际落地时比这两个经典模式更贴近业务。我见过太多项目在这件事上翻车。有的是在组件内部硬编码判断有的是用全局变量到处传还有的是靠路由参数隐式传递上下文结果刷新页面就丢状态。这些做法在项目初期都能跑通但一旦业务复杂度上来维护成本会指数级上升。所以当我看到“context-mode”这个标题时第一反应是这应该是一个关于“如何优雅地管理上下文与模式映射关系”的实践总结。这篇文章适合谁看如果你正在做多角色、多租户、多场景切换类的系统或者你已经被满屏的条件判断折磨过那接下来的内容应该能帮你理清一条可落地的路径。如果你只是好奇这个词是什么意思那也可以把它当成一次关于“上下文驱动设计”的入门拆解。2. 上下文与模式为什么必须解耦一个反直觉的结论2.1 把上下文塞进组件是大多数人的第一反应假设你有一个数据表格组件需要根据当前用户角色决定显示哪些列。最直觉的做法是在组件内部接收一个role参数然后写一堆条件渲染逻辑。代码大概长这样function DataTable({ role, data }) { const columns [ { key: name, label: 名称, visible: true }, { key: email, label: 邮箱, visible: role admin }, { key: salary, label: 薪资, visible: role admin }, { key: department, label: 部门, visible: true }, ]; // ...渲染逻辑 }这个写法在只有两三个角色的时候完全没问题甚至可以说是最省事的。但它有一个隐藏的代价组件的可复用性被上下文绑死了。如果另一个页面也需要表格但列的定义完全不同你要么复制一份改改要么给这个组件加更多参数最后它变成一个什么都能干但什么都不精的“上帝组件”。更严重的问题是测试。你想单独测试表格的渲染逻辑就必须构造一个role参数而这个参数的值域是跟业务角色绑定的。业务加一个角色测试用例就得跟着改。这种耦合在项目规模小的时候不明显一旦角色数量超过五个维护成本就会让人崩溃。2.2 解耦之后上下文变成一种“可注入的环境”正确的做法是把“当前是什么上下文”和“在这个上下文下组件长什么样”分开。上下文本身只负责回答一个问题现在是谁、在什么场景下、处于什么状态。而模式负责回答在这个上下文下各个模块应该表现出什么行为。用一个更工程化的说法上下文是一个运行时环境对象模式是一组针对该环境的配置策略。组件不再关心role是什么它只关心“我当前应该显示哪些列、哪些操作可用”。这些信息由模式配置提供而模式配置又由上下文决定。这样做的好处是显而易见的。首先组件变成了纯粹的展示层它只接收“显示什么”的指令不关心指令从哪来。其次模式配置可以独立于组件存在甚至可以用 JSON 或数据库来管理运营人员改配置就能调整界面不需要改代码。最后测试变得极其简单你只需要给组件传入一个固定的模式配置就能验证渲染结果完全不需要构造业务角色。2.3 一个常见的误解上下文等于全局状态很多人会把“上下文”和“全局状态”混为一谈。全局状态是“整个应用共享的数据”而上下文是“当前这个操作环境所依赖的信息”。两者有交集但不等价。比如用户的登录态是全局状态但“当前正在编辑哪个文档”是上下文。如果把所有东西都塞进全局状态应用会变得难以预测如果把所有东西都当成上下文又会过度设计。我的经验是上下文应该是那些“切换之后会导致界面或行为发生显著变化”的信息。角色切换、租户切换、编辑/查看模式切换这些都属于上下文。而像“侧边栏是否折叠”这种纯 UI 状态虽然也可以放在上下文里但没必要跟模式绑定单独管理更合适。3. 模式映射的几种落地方式从硬编码到配置化3.1 硬编码映射最快但最脆最原始的方式是在代码里写一个映射表const MODE_MAP { admin: { columns: [name, email, salary, department], editable: true }, user: { columns: [name, department], editable: false }, auditor: { columns: [name, email, department], editable: false }, };这种方式的好处是直观、类型安全如果用 TypeScript 的话而且性能最好。但它的缺点也很明显每次调整模式都要改代码、重新构建、重新部署。如果模式的变化频率很低比如一个内部系统半年才调一次那这种方式完全可以接受。但如果业务方希望随时调整硬编码就会变成瓶颈。我个人的判断标准是如果模式配置的变更需要走代码发布流程那就说明它应该被抽出去。反之如果变更频率极低且变更成本可以接受硬编码反而是最稳妥的选择因为它没有运行时解析的开销也不会有配置格式错误的风险。3.2 配置文件映射灵活性与复杂度的平衡把映射表抽成 JSON 或 YAML 文件是很多团队会选择的中间方案。配置文件可以放在代码仓库里也可以放在独立的配置服务中。它的好处是修改不需要改代码逻辑只需要改数据。但随之而来的是几个新问题配置的格式校验、版本管理、以及“配置写错了怎么办”。我踩过的一个坑是配置文件里的字段名和代码里的字段名不一致导致某个模式下某个列死活显示不出来。排查了半天才发现是拼写问题。后来我们加了一层 schema 校验用 JSON Schema 或者 Zod 这类工具在启动时校验配置问题才被根治。所以如果你选择配置文件方案一定要在应用启动阶段做一次全量校验把配置错误暴露在启动时而不是运行时。另一个经验是配置文件最好按模式拆分成多个文件而不是一个大文件。比如modes/admin.json、modes/user.json。这样在代码审查时diff 更清晰也更容易定位是哪个模式出了问题。3.3 动态配置服务适合多租户场景如果你的系统是多租户的每个租户可能都有自己的模式定义那配置文件就不够用了。这时候需要一个动态配置服务把模式配置存在数据库或配置中心里运行时按租户 ID 拉取。这种方案的复杂度最高但灵活性也最强。它需要解决几个关键问题配置的缓存策略不能每次请求都查库、配置的版本控制回滚能力、以及配置的权限管理谁能改哪个租户的配置。我在一个多租户项目里用过这种方案当时的做法是配置存在数据库里应用启动时全量加载到内存同时监听配置变更事件做增量更新。这样既保证了读取性能又保证了变更的实时性。提示动态配置服务一定要有降级策略。如果配置服务挂了应用应该回退到一套默认模式而不是直接报错白屏。3.4 三种方式的对比与选型建议方式灵活性实现复杂度适用场景主要风险硬编码映射低低模式固定、变更频率极低变更需发版配置文件映射中中模式偶尔调整、单租户配置格式错误动态配置服务高高多租户、模式频繁调整服务依赖、缓存一致性选型的时候不要一上来就追求最灵活的方案。我见过不少项目明明只有两个角色却搞了一套动态配置服务结果维护成本比业务代码还高。从硬编码开始等真正遇到变更瓶颈时再往上迁移是更务实的路径。4. 上下文切换时的状态一致性最容易翻车的地方4.1 切换上下文不是简单换个变量假设你的应用支持用户在“管理员视图”和“普通用户视图”之间切换。最直觉的做法是改一个全局变量然后触发重新渲染。但实际做起来你会发现一堆问题当前打开的弹窗要不要关表单里填了一半的数据要不要保留滚动位置要不要重置路由要不要变这些问题没有标准答案但有一个原则上下文切换应该被视为一次“软重启”而不是一次“局部更新”。也就是说切换之后应用应该回到一个干净的初始状态而不是带着上一个上下文的残留状态继续跑。我在一个项目里吃过亏用户从管理员切到普通用户后之前打开的编辑弹窗还留在页面上结果普通用户点了一下保存触发了一个他没有权限的接口虽然后端有权限校验没造成数据问题但前端报了一堆错用户体验极差。后来我们的做法是上下文切换时强制关闭所有弹窗、清空所有表单、重置所有分页状态。虽然粗暴但有效。4.2 用“上下文版本号”解决异步竞态另一个隐蔽的坑是异步请求的竞态。假设用户在管理员视图下发起了一个数据请求请求还没回来用户就切换到了普通用户视图。这时候请求回来了数据被写入了状态但当前上下文已经变了导致普通用户视图里显示了管理员的数据。这个问题的根源是异步回调不知道自己的上下文已经过期了。解决方案是给每个上下文分配一个版本号或唯一 ID请求发出时带上当前版本号回调回来时比对版本号如果不一致就丢弃结果。let contextVersion 0; function switchContext(newContext) { contextVersion; // ...其他切换逻辑 } async function fetchData() { const version contextVersion; const result await api.getData(); if (version ! contextVersion) { return; // 上下文已切换丢弃结果 } // ...写入状态 }这个模式看起来简单但能避免大量诡异的数据错乱问题。我建议把它作为上下文切换的标准配套措施而不是等到出了问题再补。4.3 上下文持久化刷新页面后状态不能丢如果上下文只存在内存里用户刷新页面后就会丢失回到默认状态。对于单页应用来说这通常是不能接受的。解决方案是把上下文的关键信息同步到 URL 参数或本地存储里。URL 参数的好处是可以分享链接别人打开同样的链接会进入同样的上下文。本地存储的好处是不污染 URL适合那些不需要分享的场景。我的建议是如果上下文切换会影响用户看到的内容就放 URL如果只是个人偏好就放本地存储。比如“当前租户”应该放 URL因为不同租户看到的数据不同分享链接时应该带上租户信息。“侧边栏是否折叠”就放本地存储因为这是个人偏好不需要分享。5. 模式配置的粒度控制太粗和太细都是灾难5.1 模式粒度太粗一个模式管所有页面有些团队的做法是定义几个全局模式比如“管理员模式”“用户模式”然后所有页面都根据这个模式来决定行为。这种做法在页面数量少的时候没问题但页面一多你会发现不同页面对模式的需求差异很大。比如管理员在“用户管理”页面需要编辑权限但在“数据看板”页面只需要查看权限。如果只有一个全局模式就没法表达这种差异。5.2 模式粒度太细每个组件一个模式另一个极端是每个组件都定义自己的模式配置。这样做虽然灵活但配置量会爆炸。一个中等规模的应用可能有几十个组件每个组件都维护一套模式映射改一个角色就要改几十个地方维护成本极高。5.3 分层模式全局模式加局部覆盖比较务实的做法是分层全局定义一套基础模式描述通用的行为然后在页面级别或模块级别做局部覆盖描述该页面特有的行为。这样大部分通用逻辑只需要定义一次特殊逻辑在局部处理。比如全局模式定义“管理员可以编辑”但“数据看板”页面覆盖为“管理员只读”。这种分层结构既避免了重复定义又保留了灵活性。实现上可以用一个简单的合并函数把全局配置和局部配置做深度合并。function resolveMode(globalMode, localOverride) { return { ...globalMode, ...localOverride, permissions: { ...globalMode.permissions, ...(localOverride?.permissions || {}), }, }; }注意深度合并的时候要小心数组。数组通常是替换而不是合并因为合并数组很难定义清楚语义。如果确实需要合并数组建议用对象代替数组或者显式定义合并策略。6. 实测中遇到的几个典型问题与处理方式6.1 模式配置的默认值缺失导致白屏这是最常见的问题。某个模式下缺少了某个必需的配置项组件读取时拿到undefined直接报错白屏。解决方案有两个一是在配置加载时做完整性校验缺字段就报错二是在组件读取配置时提供默认值。我的做法是两者结合启动时校验必填字段运行时对可选字段提供默认值。这样既能尽早发现配置错误又能保证即使配置不完整应用也不会直接崩溃。6.2 上下文切换后缓存未失效如果应用里用了缓存比如 React Query 或 SWR上下文切换后缓存不会自动失效导致新上下文读到了旧上下文的数据。解决方案是在上下文切换时主动清除或标记缓存失效。function switchContext(newContext) { queryClient.clear(); // 清除所有缓存 // ...其他切换逻辑 }如果不想全量清除也可以给缓存 key 加上上下文标识这样不同上下文的缓存天然隔离。比如[users, tenantId, list]切换租户后 key 变了自然就读不到旧缓存了。6.3 模式配置的继承关系混乱当模式数量多了之后模式之间可能会有继承关系。比如“高级管理员”继承“管理员”只是多了几个权限。如果继承关系设计得不好会出现循环继承或者覆盖顺序不明确的问题。我的建议是模式继承只允许单继承且继承深度不超过两层。如果需要更复杂的组合用“混入”而不是“继承”。混入的顺序要明确定义比如后面的覆盖前面的。这样规则简单不容易出错。7. 一些可以复用的设计原则7.1 上下文只描述“是谁”不描述“能做什么”上下文应该保持纯粹只包含身份和环境信息比如用户 ID、角色、租户 ID、当前场景。不要把权限判断逻辑塞进上下文里。权限判断是模式层的事上下文只提供判断依据。7.2 模式配置尽量声明式避免命令式模式配置应该是“描述结果”而不是“描述过程”。比如配置“这个列可见”而不是配置“调用 showColumn 函数”。声明式配置更容易校验、更容易序列化、也更容易被非技术人员理解。7.3 为模式切换提供明确的入口和出口上下文切换不应该散落在代码各处而应该有一个统一的入口函数。这个函数负责版本号递增、缓存清理、状态重置、持久化更新。出口则是指切换完成后的回调用于执行一些副作用比如埋点上报。7.4 模式配置要有版本支持回滚如果模式配置是动态下发的一定要有版本号。出问题的时候可以快速回滚到上一个版本。我见过因为没有版本控制配置改错之后只能靠人工逐条改回来的案例耗时耗力。8. 写在最后一些个人体会“context-mode”这个模式看起来简单但真正落地时涉及的细节非常多。我从最早在组件里写if-else到后来抽配置文件再到做动态配置服务每一步都是被业务逼出来的。如果让我给一个建议那就是不要为了设计而设计但要在第一次感到“改不动”的时候果断重构。另外上下文切换的状态一致性是最容易被忽视的环节。很多人把注意力放在模式配置的灵活性上却忘了切换本身可能带来的数据错乱。我的经验是切换逻辑的测试用例应该覆盖切换时是否有未完成的请求、是否有未保存的表单、是否有打开的弹窗、缓存是否失效。把这几个场景都测一遍基本就能覆盖大部分坑。还有一个小心得模式配置的命名要统一。我见过有的地方叫mode有的地方叫role有的地方叫view最后维护的时候自己都搞混。建议在项目初期就定好术语全局统一减少沟通成本。这个方向后续还可以扩展的地方很多比如把模式配置和 feature flag 系统打通或者用 JSON Schema 做配置的自动补全和校验。但那是另一个话题了有机会再展开聊。