资讯详情

context-mode 设计:用模式栈替代布尔值管理复杂上下文状态

📅 2026/10/31 5:27:31 | 华诺云谱 👁 阅读
context-mode 设计:用模式栈替代布尔值管理复杂上下文状态
1. context-mode 不是玄学一个模式栈解决的状态管理痛点最近半年我一直在重构一个内部后台系统权限、编辑状态、预览模式、移动端适配这些逻辑散落在几十个页面里。改到后面我发现一个非常明显的问题真正难缠的从来不是某个业务逻辑本身而是“当前到底处于什么场景”这件事没有被建模。你点开一个表单可能同时是“编辑状态”“管理员身份”“移动端布局”“只读字段可见”……每加一种状态代码里就多一个布尔值。isEditing、isPreview、isReadonly、isMobile、isAdmin这五个布尔值放在一起组合情况能翻出三十多种任何一个if判断漏掉一种组合就会出现“明明预览着却还能改数据”这种诡异bug。后来我参考编辑器里的模式设计思路把上下文状态改成了一种显式栈结构内部叫它 context-mode。这个名字纯粹是工作里叫顺口的但设计思想很有代表性不要再用散落的布尔值去表达“当前场景”而是把场景本身当作一等公民用一层一层可叠加、可嵌套、可退出的模式来管理。context-mode 的核心价值可以用一句话概括它把“判断”从业务代码里抽出来变成一种可观测、可顺藤摸瓜的状态轴。所有逻辑不再问“isA !isB 是否成立”而是问“我现在处于什么模式”。这套思路并不是什么全新发明状态机、中间件栈、vim 的编辑模式本质上都有类似味道。区别在于日常业务系统里很多状态并不是严格互斥的强行用状态机反而别扭。模式栈更接近真实世界的认知方式一个用户一边在“编辑模式”里操作一边带着“管理员”身份还叠加了“调试模式”这些状态可以同时存在互相影响缺一不可。适用人群也广前端、后端、做 CLI 工具的只要你的程序里出现过“因为某个上下文不同行为就应该不同”的场景都值得往下看。看完你能直接照着我这套代码改出一个自己的 context-mode 实现。2. 核心机制模式栈而不是布尔海2.1 为什么选栈而不是状态机最早我其实想过用状态机画了一版状态流转图发现根本画不下去。表单场景里“编辑中”可以被“预览中”叠加而不是替代“管理员模式”又能继续叠加在两者之上。状态机要求每个时间点只能有一个状态并且转移关系要提前定义好这套逻辑放进去后光状态转移条件就有十几个维护成本直接起飞。后来同事一句话点醒我这些叠加状态本质上更像是“图层”而不是“档位”。图层可以一层层往上盖也能一层层撤掉撤的顺序不能乱。这就是栈。栈的好处是天然带有生命周期。一个请求进入某个页面时压入模式请求结束时弹出模式顺序错了就会出问题但栈这种结构天生不会让你颠倒顺序因为你只能在顶部操作。第二个好处是可叠加。同一时刻栈里有多个模式互相之间只是通过优先级和名字来协调互动谁也不用吞掉谁。这比“一个系统只有一个当前状态”要灵活太多。所以我最终选择用栈而不是状态机。当然如果确实有一些严格互斥、流程固定的场景状态机还是更合适。最简单的方式是栈里同时支持“互斥模式组”声明同组的模式进栈时自动把旧的顶掉也够了。2.2 一个最小的 context-mode 实现我自己在 TypeScript 项目里写过一个精简版核心代码不长大约一百行就能跑起来。这里先把骨架贴出来方便你理解。export interface ModeT extends object {} { name: string; data?: T; priority?: number; } export class ContextModeStack { private stack: Mode[] []; enterT extends object(name: string, data?: T, priority 0): () void { const mode: ModeT { name, data, priority }; this.stack.push(mode); this.emit(change, mode.name, enter); return () this.exit(name); } exit(name: string): boolean { const idx this.stack.map((mode) mode.name).lastIndexOf(name); if (idx -1) return false; this.stack.splice(idx, 1); this.emit(change, name, exit); return true; } has(name: string): boolean { return this.stack.some((mode) mode.name name); } getT extends object(name: string): T | undefined { const modes this.stack.filter((mode) mode.name name); if (modes.length 0) return undefined; modes.sort((a, b) (b.priority ?? 0) - (a.priority ?? 0)); return modes[0].data as T | undefined; } dump(): string[] { return this.stack.map((mode) ${mode.name}#${mode.priority ?? 0}); } private emit(..._args: unknown[]) { // 这里可以接自己的事件总线或者直接调用 window.dispatchEvent } }这套实现的细节值得展开说一下。enter 方法返回一个退出函数这是一个非常关键的防呆设计。我要求所有调用方尽量写成下面这种一次性作用域的样子而不是在一处 enter、另一处 exit中间隔着大量业务代码。const leave ctx.enter(editing, { rowId: 123 }); try { // 业务逻辑 } finally { leave(); }因为只要把 enter 和 exit 拆开几十行就一定会出现“忘记退出”的现场。我统计过我们项目里一半以上的上下文相关 bug 都是只进不出后面讲到坑的时候再细说。get 方法处理的是同名多次压入的情况。比如两个模块都进入“admin”模式但一个带 force 权限一个不带这时候按优先级取最高那个。priority 默认是 0谁想覆盖谁就声明更高的数字规则透明。2.3 模式栈的合并规则模式栈有时候会同时存在很多层光靠一个 name 判断可能不够。比如同样是“readonly”模式在编辑弹窗里进入的和在审批详情页进入的虽然名字相同但关联数据完全不同。这时候就用到 data。ctx.enter(readonly, { reason: 审批中, allowEdit: false }); ctx.enter(readonly, { reason: 已归档, allowEdit: false, highlight: true }, 10);当业务代码调用ctx.get(readonly)时会拿到 priority 更高那层的数据也就是“已归档”。这类似于配置覆盖更高优先级给出更具体的表达。还有人问为什么不干脆给每个模式取唯一名字那样确实不用处理同名冲突但业务场景里同名很重要。用户看到的提示语可以都是“只读”可具体原因不同。我们做数据分析时想统计“当前处于只读模式的请求有多少”只要按 name 聚合一下就行名字不统一的话统计口径就碎了。所以模式名要稳定短暂的差异用 priority 和 data 来表达。3. 一个可以照着改的落地示例3.1 场景定义光说设计有点空我拿一个真实做过的内部项目举例。这是一个面向运维同事的发布工具命令行操作核心命令叫pub用来把前端资源推送到远端服务器。以前这个工具的代码到处都是这样的判断if [ $USER admin ] [ $DRY_RUN 1 ]; then # 只有 admin 才能在预演模式下发强制命令 fi运维同学用了一段时间后开始骂因为环境变量、配置文件、命令行参数混在一锅粥里有些上下文明明是矛盾的程序却照跑不误。比如既设置了DEBUG1又在配置文件里关闭了 debug 日志两处冲突只能等踩雷了才发现。用 context-mode 重写后我把上下文拆成四个模式来源项目模式根据当前工作目录自动识别如果目录下有.git且存在package.json就进入 project 模式目标路径自动指向当前项目环境模式如果环境变量里设置DEBUG1进入 debug 模式运行模式命令行带--dry-run进入 dryRun 模式带--force进入 force 模式身份模式当前登录用户属于管理员组进入 admin 模式。每一种模式由不同的模块在启动时进入栈内最终pub命令的执行逻辑里只关心统一的栈查询不再分散判断。3.2 关键实现伪代码我来写一段简化版的逻辑去掉具体执行细节把 context 怎么流入业务展示清楚。import { ContextModeStack } from ./context-mode; const ctx new ContextModeStack(); // 初始化阶段 if (fs.existsSync(.git)) { ctx.enter(project, { root: process.cwd() }); } if (process.env.DEBUG 1) { ctx.enter(debug, { verbose: true }, 1); } if (argv.includes(--dry-run)) { ctx.enter(dryRun, {}, 5); } if (isAdmin(process.getuid())) { ctx.enter(admin, { allowForce: true }); } // 执行发布 function publish(target: string) { if (ctx.has(dryRun)) { console.log([dry-run] 将要推送到 ${target}); return; } if (ctx.has(project)) { const root ctx.get{ root: string }(project)!.root; target path.join(root, dist); } if (ctx.has(debug)) { console.log([debug] target${target}); } if (argv.includes(--force) !ctx.has(admin)) { throw new Error(需要 admin 模式才能使用 --force); } // 真正执行推送 }这段代码最大的变化是业务层不再问“用户是不是 admin 且同时没有处于 dryRun 且还在项目目录”而是把几个模式分别问清楚。每种判断单独看都很好理解合起来也容易推理。比如用户发现--dry-run没生效只要ctx.dump()打一下立刻知道 dryRun 模式有没有进来排查问题从“翻配置文件找环境变量”变成了“看当前模式栈快照”。3.3 实测下来的几个变化这套改造上线后最直观的不是代码量变少而是 bug 定位变快了。以前报“发布不了文件”运维要贴一堆环境变量给我现在直接让我查“模式栈里有没有异常层”一眼就能看到是不是不小心带入了另一个项目的 project 模式。另一个变化是权限判断不再重复。以前“只有 admin 可以强推”这个逻辑每个命令脚本里都写了一遍改动一次要同步五六个文件。现在统一在入口层做if (argv.includes(--force) !ctx.has(admin)) { throw new Error(需要 admin 模式才能使用 --force); }权限逻辑收敛到一个地方规则变更时只摸一个文件这比所有脚本各自维护一份布尔判断舒服太多。4. 这四种坑我几乎每次都遇到4.1 只进不出的模式栈最大的坑就是 enter 之后忘了 exit。表现很迷惑第一次调用某个功能正常第二次调用时界面多出一些不该有的限制或者反过来失去了一些权限。举一个典型例子用户打开一个客户详情页页面进入“客户上下文”模式离开页面时应该退出。但代码里有一个定时器在页面关闭后还在运行尝试访问上下文中某个数据顺手又把上下文栈重建了一遍。结果下一次打开详情页栈里出现了两层“客户上下文”行为直接错乱。我现在基本强推一条规矩谁 enter谁负责在同一个函数作用域内拿到退出函数并且 finally 退出。不允许把 enter 和 exit 分散在两个函数里除非你能给退出函数一个可信的注册点。// 推荐的做法 export async function openCustomer(rowId: string) { const leave ctx.enter(customer, { rowId }); try { await fetchDetail(rowId); } finally { leave(); } }为了防呆我后来还给 ContextModeStack 加了一个 maxDepth 参数超过 20 层直接抛异常。栈在这种场景下已经指望不上了基本可以断定有人忘了退出越早暴露越好。4.2 异步逻辑串了上下文这个是并发场景下的经典问题。我们早期把 context-mode 做成了全局单例请求 A 进入一个模式还没来得及退出请求 B 进来了于是一下子两个请求共享同一份模式栈后一个请求看到的模式里混着前一个请求的状态。表现特别像随机 bug并发量一高就出现权限异常单请求测试怎么都复现不了。解决思路很简单但要注意具体方案。前端单页应用里如果只有一个释放流程全局单例勉强可用后端一旦涉及异步并发必须给每个独立执行单元分配独立的 context 实例。Node.js 里可以直接用async_hooks提供的AsyncLocalStorage来做import { AsyncLocalStorage } from async_hooks; const als new AsyncLocalStorageContextModeStack(); export function runWithContextT(fn: () T): T { return als.run(new ContextModeStack(), fn); } export function currentCtx(): ContextModeStack { const ctx als.getStore(); if (!ctx) throw new Error(当前执行单元没有初始化上下文); return ctx; }这样每个请求一开始就创建独立的模式栈请求结束自动销毁异步回调链上的所有查询都落在同一个栈上互相不污染。4.3 模式优先级被轻视优先级这个参数看着简单实际用起来最容易翻车。两个插件都往栈里推了同名模式但各自对 data 的改动不一样最后取到哪个完全取决于谁先 enter。如果没定义优先级的合并规则那行为就是隐式依赖“初始化顺序”排在前面的插件会莫名被后面的覆盖。我在这块踩过真实的坑一个在线文档系统里阅读模式有“权限只读”和“历史版本只读”两种来源都叫 readonly。历史版本那层 priority 是 10权限那层默认 0按本意应该是“历史版本只读”优先但由于初始化顺序问题权限层后进栈并且 sorted 之后优先级更高直接把历史版本判断盖过去了导致历史版本打开时还能编辑差点出事故。排查下来其实是 priority 设计太隐晦没人知道哪层应该更高。我后来的处理方式是把优先级的语义写进模式定义表统一在常量文件里管理禁止调用方顺手填 magic number。比如export const MODE_PRIORITY { permission: 0, readonlyHistory: 10, debug: 100, };这样所有同名模式的优先级之争一目了然。即便有人想临时调也知道自己是在突破默认约定。4.4 把模式栈当银弹过度设计并不是所有状态都需要模式栈。如果一个系统里所谓上下文最多只有一个且不存在嵌套和叠加那用一个布尔标记就足够了。硬上模式栈反而让简单的状态判断多了一层抽象新人还要先理解“模式栈”才能改代码。我见过最夸张的案例是有人把一个只表达“是否启用短信通知”的开关也做成了一个 context-mode 模式理由是要“保持架构统一”。结果打开某个页面时 push 一个smsEnabled离开时 pop中间稍有不慎就把这个模式和登录态混在一起短信开关影响到了账号权限判断。这完全是把简单问题复杂化。判断要不要用 context-mode 的标准很简单如果状态“最多只有两种取值且一旦进入不会叠加别的状态”用布尔如果“状态有多个维度可以叠加、嵌套、且不同维度之间会互相影响”才值得用模式栈。实际上我觉得大多数后端服务里请求上下文用户、租户、语言、设备天然就是多维叠加的很适合 context-mode。但如果有多个租户跑到同一个内存对象里又没做隔离那模式栈解决不了架构问题只能掩盖问题这点要分清楚。我整理了一张快速排查表方便你对照现场表现问题现象可能根因处理建议功能第一次正常第二次行为异常enter 后忘记 exit用 finally 包裹或查 maxDepth并发请求权限错乱全局单例上下文被多个执行体共享改用 AsyncLocalStorage 隔离同名模式 data 总是不符合预期优先级无统一约定模式优先级集中在常量表代码里到处是 ctx.has() 判断比布尔还乱过度设计切的模式太细考虑合并或回归普通状态管理线上调试不知道当前处于什么模式dump 没做或者日志没打出来在请求入口打印 dump 快照5. 如果现在让我重新写一次 context-mode我会在第一版就加入两个现在后补才追悔莫及的东西。一个是模式名的强类型约束。直接用字符串写 mode name 特别自由但也特别危险。模块 A 写readonly模块 B 拼成了read-only两个模式既不同名又肉眼难以分辨栈里会出现“幽灵模式”。现在我用as const定义一份全局模式枚举所有 enter 和 has 的类型签名都指向这个枚举编译期就把拼写错误拦下来。export const MODE { EDIT: editing, PREVIEW: preview, READONLY: readonly, ADMIN: admin, DEBUG: debug, } as const; export type ModeName (typeof MODE)[keyof typeof MODE];另一个是可视化调试。我以前觉得只要代码逻辑正确上下文有没有被正确压栈不重要。直到有一次线上环境出了问题用户操作被拦截偏日志系统没记录模式栈我愣是花了两个多小时查配置和权限。后来痛定思痛把dump()接进了三个地方请求入口打印完整的模式栈快照每个高权限操作执行前打印关键模式是否存在异常捕捉时把当前栈信息塞进错误上报。这样一旦出问题打开日志就能看到类似下面的输出[ctx] enter: project#0 payload{root:/app/demo} [ctx] enter: debug#1 payload{verbose:true} [ctx] enter: admin#0 payload{allowForce:true} [ctx] hook check: dryRun false [ctx] dump after exec: [project#0,debug#1,admin#0]有了这些信息我只看着 log 就能回答这个问题“当时用户进入的是哪种模式有哪些模式叠加着哪个模式的优先级更高”这比反复看代码猜状态要高效得多。再到后来我甚至给 mode 名取别名的时候也留了一手dump 里除了栈顺序还会把每个模式的进入时间打出来。模式压栈超过十秒还没退出就自动标记一个 stale 警告。这类静默的错误源头越早暴露越省心。我也越来越倾向于把 context 不单看作状态而是一种可追溯的执行环境。用户、请求、页面都是在一个明确画出来的“场景幕布”里运行的。enter 是拉开一幕exit 是闭灯走人而幕布上到底有哪几层什么时候谁在上面整个过程都应该是白的而不是黑的。这一点比任何花哨的实现都重要。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑