☰
context-mode 设计:用模式栈替代布尔值管理复杂上下文状态
2026/10/31 5:27:15 网站建设 项目流程

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 Mode<T extends object = {}> { name: string; data?: T; priority?: number; } export class ContextModeStack { private stack: Mode[] = []; enter<T extends object>(name: string, data?: T, priority = 0): () => void { const mode: Mode<T> = { 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); } get<T 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

运维同学用了一段时间后开始骂,因为环境变量、配置文件、命令行参数混在一锅粥里,有些上下文明明是矛盾的,程序却照跑不误。比如既设置了DEBUG=1,又在配置文件里关闭了 debug 日志,两处冲突只能等踩雷了才发现。

用 context-mode 重写后,我把上下文拆成四个模式来源:

  • 项目模式:根据当前工作目录自动识别,如果目录下有.git且存在package.json,就进入 project 模式,目标路径自动指向当前项目;
  • 环境模式:如果环境变量里设置DEBUG=1,进入 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 AsyncLocalStorage<ContextModeStack>(); export function runWithContext<T>(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 是闭灯走人,而幕布上到底有哪几层,什么时候谁在上面,整个过程都应该是白的,而不是黑的。

这一点,比任何花哨的实现都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询