说实话,我第一次听到“context-mode”这个项目代号时,第一反应是:这不就是做个“模式切换”嘛,菜单里放两个选项,点一下换套皮肤,完事。真正动手之后我才发现自己想得太简单了。当需求方说“我们希望在同一个页面里,不同使用场景下展示完全不同的信息结构和交互逻辑”,并且要求这套机制可以随时扩展新场景、不会把代码越改越乱的时候,“context-mode”就不再是一个开关,而是一整套需要认真设计的上下文管理机制了。
这篇文章就来复盘一下我做“context-mode”的完整过程,包括需求拆解、方案选型、核心数据结构设计、切换链路的实现细节,以及我在实际开发中踩过的几个坑。如果你正在做一个功能比较多、用户角色和使用场景都相对复杂的应用,或者你的代码里已经出现了一堆“if (mode === 'xxx')”的判断,那这篇文章应该能给你一些可落地的思路。
1. 为什么需要 context-mode:从一个“要命的需求”说起
1.1 一个真实需求引发的思考
事情起源于某工具类应用改版。当时团队接到的需求是:应用有两种使用方式。一种面向追求效率的老用户,页面要紧凑、操作路径要短、信息密度要高;另一种面向刚上手的新用户,界面要留白、引导要重、功能要藏得少一点,宁可多一步操作也要让用户知道下一步该干什么。
看起来像不像“做个设置项就行”?但问题在于,这两种模式不只是换皮,它们会影响默认配置、功能可用性、接口请求参数、消息推送策略,甚至连底部导航的入口都不一样。如果靠业务代码里到处写“如果是A模式就怎样,如果是B模式就怎样”,那这个项目基本就宣告失控了。
我当时做了一件事:把所有涉及到“模式差异”的地方全部列出来。结果拉了整整三页文档,从UI展示到数据请求,从权限判断到埋点上报,几乎每一层都有分支。这时候结论就很清楚了:需要一个统一的东西来承载“模式”这个概念,让所有模块都能独立感知、并被统一管理。它就是“context-mode”。
1.2 context-mode 到底是什么,不是什么
先把我理解的“context-mode”定义清楚。
它是一个应用级的上下文管理机制。所谓“上下文”,指的是当前用户处于哪种使用场景,以及这个场景下整套配置、行为、界面的集合。“context-mode”要做的,就是维护当前上下文的身份、状态和切换过程,并把上下文信息注入到应用的各个层面。
它不是一个简单的全局变量。全局变量只解决“取值”问题,不解决“变更通知”“副作用清理”“持久化恢复”这些问题。比如你在A模式时启动了一个轮询,切到B模式后轮询还跑着,就会造成资源浪费甚至数据混乱。全局变量管不了这个。
它也不是一个UI主题系统。主题只换颜色和样式,而上下文模式要动的是整个信息架构和行为逻辑。主题是“看起来不一样”,context-mode 是“用起来不一样”。
你可以把它理解成一个房间里的“场景开关”:按一下“影院模式”,灯关掉、窗帘拉上、投影仪自动打开;再按一下“阅读模式”,灯光变亮、窗帘拉开、幕布收起。真正值钱的不是开关本身,而是开关背后那一整套协同联动机制。context-mode 就是应用里的那个场景开关。
1.3 设计目标:让每一种模式都成为一等公民
定目标这件事,听起来虚,但非常有价值。我给自己定了三条硬性原则,后面所有设计都围着它们转。
第一条,新增模式必须“零侵入”。意思是,以后加一种新场景时,已有的代码逻辑尽量不要改动。新场景是一个插件,而不是对现有系统的补丁。
第二条,切换过程必须“可追踪”。谁发起了切换,从哪个模式切到哪个模式,切换后哪些配置生效了,每一步都要有日志可查。不然线上出了问题,排查会非常痛苦。
第三条,状态必须“可恢复”。应用被杀死、刷新、或者是用户主动退出再进入,当前模式不能被忘记,也不能被恢复错。最怕的就是持久化里面存了一个老版本的模式ID,新版启动时直接读崩了。
有了这三条原则,后面的技术选型就有了判断依据。接下来我详细说说方案选型的过程。
2. 方案选型:状态管理、切换调度与数据模型
2.1 常见实现方案横向对比
在正式开始写代码之前,我把几种常见方案都摆在桌面上比较了一番。注意,我这里说的“方案”指的是用什么技术形态来组织“上下文模式”,不是指某个具体框架,因为这套思路在React、Vue、小程序甚至原生App里都能套用。
第一种最粗暴:全局单例加枚举判断。定义一个enum Mode { A, B },然后用一个单例对象存当前值,页面里到处读。优点是实现快,五分钟就能跑起来;缺点是没有任何约束,你永远不知道哪里改了它、哪里需要响应它,而且一旦页面忘记更新,界面和状态就脱节了。
第二种常见方案:放状态管理库里,严格区分不同模式的配置和能力;上下文切换的版本兼容和恢复机制要单独处理,详见下面的持久化部分。
第三种是状态机。把“模式”定义成状态机里的状态,切换动作定义成迁移事件。它强在约束和可预测性:哪些迁移合法、哪些不合法,一开始就定义死。缺点是需要额外引入状态机的概念,团队需要学习成本,而且很多约束在简单场景下其实是过度设计。
我最终采用的是“模式注册表 + 切换调度器”的组合方案,核心思路是:把“有哪些模式”和“模式怎么切”分开治理,前者做成可注册的元信息集合,后者做成有顺序有副作用的调度器。下面是具体设计意图。
2.2 最终架构:模式注册表 + 切换调度器
先解释一下为什么不用状态机而用注册表加调度器。我的判断依据是:context-mode 这套机制里,状态迁移的约束其实没有那么多,更多时候只需要保证“从当前模式切到目标模式,必须经过统一的入口”,至于迁配合关系,注册表加调度器的设计比状态机轻量。
注册表的职责是收集所有模式的定义。每种模式是一个元信息对象,包含它的标识、名称、进入和离开时应该触发的副作用函数、默认配置等。这样做的好处是:模式的定义集中在一个地方,以后要加新模式,只需要往注册表里加一条记录,业务代码不用翻个底朝天。
调度器的职责是处理切换请求。它不关心模式里面具体是什么逻辑,只负责三件事:校验目标模式是否合法、调用旧模式的离开钩子、调用新模式的进入钩子、通知所有监听者。这个设计保证了“没有任何模块能绕过调度器直接修改当前模式”,从源头上杜绝了状态被乱写的问题。
架构确定之后,每个部分做什么、交互关系如何,我心里也就清楚了:业务模块通过注册表的元信息了解模式能做什么,通过调度器发出切换请求,通过监听机制感知模式变化,通过读取接口拿到当前模式。接下来就是落代码,先解决数据结构。
2.3 核心数据结构与类型定义
数据结构决定了这套机制的上限,所以我先在TypeScript里把类型定义写扎实。我不会在这里贴完整项目代码,只讲最容易出错、也最核心的几个片段。
首先是模式枚举和模式元信息:
// 模式标识,新增模式时在这里扩展 export type AppMode = 'quick' | 'immersive'; // 模式元信息:描述一种模式是什么、进入和离开时要做什么 export interface ModeMeta { key: AppMode; label: string; description?: string; // 默认配置,业务模块可以按需读取 defaultConfig: Record<string, unknown>; // 进入模式时要执行的副作用 onEnter?: (ctx: ModeContext) => Promise<void>; // 离开模式时要执行的副作用 onLeave?: (ctx: ModeContext) => Promise<void>; // 标志位:是否允许用户主动切换,有些模式可能是强制生效的 userSwitchable?: boolean; }然后是上下文对象。它记录一次有效切换的全过程信息,从哪来、到哪去、什么时候切的、为什么切。这个对象会在整个切换链路里传递,是排查问题的关键线索。
export interface ModeContext { current: AppMode; previous: AppMode | null; config: Record<string, unknown>; switchedAt: number; reason?: 'user_action' | 'auto_detect' | 'restore' | 'external'; }接下来是注册表实现。我用了Map来存放模式元信息,提供了注册和获取两个方法。注意一点:注册表不允许重复注册,一旦发现同一个key被注册两次,直接抛错。这个保护非常重要,因为后期模式多了以后,很容易在合并代码时出现同名覆盖问题。
const modeRegistry = new Map<AppMode, ModeMeta>(); export function registerMode(meta: ModeMeta): void { if (modeRegistry.has(meta.key)) { throw new Error(`context-mode: duplicate mode key "${meta.key}"`); } modeRegistry.set(meta.key, meta); } export function getModeMeta(key: AppMode): ModeMeta { const meta = modeRegistry.get(key); if (!meta) { throw new Error(`context-mode: unknown mode "${key}"`); } return meta; } export function getRegisteredModes(): AppMode[] { return Array.from(modeRegistry.keys()); }注册表本身不存“当前模式”,它只负责回答“有哪些模式、长什么样”。当前模式的状态存哪、怎么变更,那是调度器的工作。这种职责分离一开始会觉得绕,等业务复杂起来你会发现,它保证了状态变更永远走同一个入口,再也没出现过“某个页面直接劫持模式”的情况。
3. 核心实现:一次模式切换的完整生命周期
3.1 切换链路拆解:从触发到落地
有了注册表和数据模型,接下来就是实现切换逻辑。一次完整的模式切换,我把它拆成了五个阶段:触发、校验、离开、切换、进入。
触发阶段就是用户点了某个按钮,或者系统检测到了某个条件,调用调度器的switchMode()接口。这个时候先不急着改状态,先进入校验阶段。
校验阶段要做两件事。第一,判断目标模式和当前模式是否相同,相同就无操作返回;第二,确认这个模式确实已经在注册表里注册过,没注册就直接抛错,避免后续拿到空的元信息。
离开阶段调用当前模式的onLeave钩子。这个钩子用来清理当前模式产生的东西,比如定时器、轮询、临时缓存、事件监听。要注意onLeave可能抛异常,我的处理是:即使旧模式清理失败,也要保证新模式能切换成功,只在日志里记录异常。原因很实际:某个清理函数出了问题不能成为整个应用卡死的理由。
切换阶段把当前模式指针对准新值、更新上下文信息、给所有监听者发通知。这一步是最核心的状态变更,一旦发生,所有关心模式的模块都会立刻收到新的模式名。
进入阶段调用新模式的onEnter钩子。这个钩子用来做新模式需要的事情,比如重新注册快捷键、初始化默认配置、发送一次启动埋点等。注意onEnter和onLeave都应该是幂等的,意思是执行两次和执行一次结果一样,不然重复切换会导致副作用叠加。
我把这五步写进调度器之后,还用了一个简单的队列做并发处理:如果用户连续快速触发多次切换,我不希望最后的结果取决于异步事件的完成顺序,而是严格按请求顺序执行。这个细节后面在性能小节详细讲。
3.2 让所有模块都能感知当前模式:上下文读取与注入
调度器维护了状态,但状态如果只躺在调度器里,那业务代码还是拿不到。实际工程里,各个页面、组件、服务函数都需要读到当前模式,甚至在模式变化时重新渲染。
我的做法是提供两类访问能力:一类是“即读即取”,适合在事件处理函数、接口请求函数里同步拿当前模式;另一类是“订阅监听”,适合组件和长驻服务,它们需要响应模式变化。
先看第一类实现:
let currentMode: AppMode = 'quick'; export function getCurrentMode(): AppMode { return currentMode; }再看订阅实现:
type ModeListener = (mode: AppMode, context: ModeContext) => void; const listeners = new Set<ModeListener>(); export function subscribeMode(listener: ModeListener): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } function notifyModeChange(mode: AppMode, context: ModeContext): void { listeners.forEach((listener) => { try { listener(mode, context); } catch (err) { console.error('context-mode: listener threw', err); } }); }这里有个细节我要单独强调:我再三强调清理。
在React里,我会封装一个useMode()钩子,把订阅和取消订阅的生命周期对齐。这个钩子不能有bug,否则模式一切换,整个应用可能因为监听器泄漏而卡顿。
import { useEffect, useState } from 'react'; export function useMode(): AppMode { const [mode, setMode] = useState<AppMode>(getCurrentMode()); useEffect(() => { const unsubscribe = subscribeMode((next) => { setMode(next); }); return unsubscribe; }, []); return mode; }业务组件里这样用:const mode = useMode();,模式切了,组件自动重新渲染。但在事件回调里,就不要用这个钩子返回值,而是用getCurrentMode(),因为闭包里的状态可能是旧的。这个坑我后面单独讲。
3.3 持久化恢复与版本兼容
模式切完不能白切,下一次启动要能记住。持久化本身不难,难在兼容和脏数据处理。
我的策略是用独立的存储键,比如ctx-mode.v1,读出来的数据要经过三道检查。第一道是JSON格式检查,解析失败直接丢弃,走默认模式。第二道是模式合法性检查,如果存的是一个未知的模式ID,说明这是旧版本数据,或者是被外部改坏的,同样丢弃。第三道是版本兼容检查,结构里带一个版本号,不同版本可以单独处理。
const STORAGE_KEY = 'ctx-mode.v1'; function persistMode(mode: AppMode): void { try { const payload = JSON.stringify({ mode, version: 1, savedAt: Date.now(), }); localStorage.setItem(STORAGE_KEY, payload); } catch (err) { console.error('context-mode: persist failed', err); } } function readStoredMode(): AppMode | null { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; try { const data = JSON.parse(raw); if (typeof data.mode !== 'string') return null; if (!modeRegistry.has(data.mode as AppMode)) return null; return data.mode as AppMode; } catch { return null; } }写操作也要防微杜渐:不需要每次读都解析一遍存储,应用启动时读一次放进内存,后续直接走内存,写的时候再异步写到本地。一个小优化是写之前先判断值是否和当前内存值一致,一致就跳过写入,减少无意义的I/O。
这个“恢复”阶段同样要作为一次模式切换来走,因为新模式进入可能需要执行钩子、启动轮询、调整配置。我给它传的reason是'restore',这样日志里可以区分是用户主动切的还是系统恢复的。
4. 性能优化与体验细节:切换不仅要稳,还要顺
4.1 高频切换下的防抖、合并与优先级
模式切换不像按钮点击那么高频,但在某些场景下,确实会出现连续切换的情况。比如用户上手第一天,连续试了五六种模式;或者某个自动化流程在几秒内反复触发模式变化。
这时候如果每次切换都立刻执行onLeave和onEnter,效率会很难看,因为这两个钩子往往涉及异步请求和DOM操作。而且顺序如果没控制好,还会出现“启动了两个轮询、清理了错误的界面”这种谁都不想遇到的局面。
我的处理方案是在调度器前面加一个串行队列,保证每次切换都等上一次完全结束。具体到实现上,每次switchMode调用都会先进入队列,前一个异步流程结束后再消费下一个请求。这样即使一秒内触发了十次切换,也会一个一个按顺序执行,而不会交错污染。
防抖策略也要分场景:用户手动点击切换,我不做防抖,保证响应及时;但如果是自动化流程连续触发同一方向切换,我会加一个几百毫秒的合并窗口,只保留最后一次。这个合并窗口需要额外参数控制,不能全局一刀切,否则用户手动快速切换会觉得“卡了一下”。
4.2 模式切换与动画、定时器、异步任务的关系
模式切换往往会引发布局变化,比如从“紧凑工具模式”切到“沉浸阅读模式”,左边栏收起来了,顶部导航变了。这些变化如果直接瞬间切换,用户会感到突兀;如果加动画,又涉及到一个复杂问题:动画进行中模式再次变化怎么办。
我给自己的原则是:状态优先,动画跟随。先改模式状态,再根据新状态去计算动画路径。绝对不要反过来,为了让动画顺畅而推迟模式状态的落定。因为模式状态是所有业务的基础,它一旦变慢,整个系统的响应都会变慢。
定时器的处理在onLeave钩子里做统一清理。我给每个模式都约定了一个规矩:凡是这个模式启动的轮询、超时任务、全局事件监听,必须在这个模式的onLeave里主动关闭。这个问题不解决,你会发现每次切完模式,旧的定时器还在偷偷跑,轻则浪费资源,重则出现“双模式同时生效”的幽灵状态。
4.3 无障碍与多端适配:别让模式切换变成“盲区”
模式切换对普通用户来说是视觉和交互变化,但对无障碍用户来说,可能只是屏幕阅读器里突然多了一句“模式已切换”。
我在实现里加了一个能力:切换完成后,向无障碍服务发送一个任务型提示,告诉用户当前处于什么模式,以及这个模式最显著的变化是什么。例如“已切换到紧凑模式,左侧导航已收起”。这个提示要简洁,不能把整个配置宣读一遍。
多端适配方面,我要提一个特别容易漏的点:Web端和移动端对存储、后台恢复的处理机制差异。Web端刷新和返回是高频场景,而移动端还得考虑应用被系统回收后重新启动的情况。我写的逻辑是:不管是刷新还是重新启动,统一走启动恢复流程,从存储里读当前模式,然后走一次完整的恢复切换。这样你的模式状态在两个端上的表现才一致。
5. 常见问题与排查技巧实录
5.1 问题速查表:症状、原因与解法
这里把我实际遇到的和身边同行碰到过的典型问题整理成一个速查表,方便你以后排查时直接对照。
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| 切换模式后页面没有变化 | 订阅没有绑定,或绑定了但没有取消后重新订阅 | 检查useMode()是否在组件顶层调用,检查订阅是否被覆盖 |
| 模式切换后旧的定时器还在跑 | onLeave钩子没有清理定时器 | 规范钩子写法:所有副作用必须在onLeave里反注册 |
| 事件回调读到的模式是旧的 | 闭包捕获了旧值 | 事件回调里改用getCurrentMode(),不要依赖组件状态 |
| 本地存储里有未知模式ID | 旧版本数据残留 | 读取时校验合法性,不合法就丢弃走默认模式 |
| 连续切换后界面错乱 | 异步切换没有串行化,后一次覆盖了前一次的副作用 | 调度器加串行队列 |
| 新增模式后老代码报错 | 某处代码假设只有旧的两个模式 | getModeMeta增加显式抛错,强制所有调用点显式处理 |
5.2 三个典型的线上踩坑案例
第一个坑是数据丢失。我们有一次灰度上线后,收到反馈“用户从沉浸模式切回快速模式,他刚填的表单数据没了”。排查之后发现,离开沉浸模式时,onLeave里顺手清了表单缓存,但当时表单还没保存。这个问题的根子在于:onLeave只负责清理与本模式强相关的资源,而用户尚未提交的业务草稿必须归业务层管理,不能因为“切模式”这种系统动作就顺手清掉。从那以后,我要求所有模式钩子里禁止主动删除用户业务数据。
第二个坑是异步回调拿到了过期模式。有同事写了个下拉刷新,在网络回调里判断“如果当前是沉浸模式就做A,否则做B”。但用户在下拉过程中切了模式,等网络回来时,判断用的闭包值已经过时,导致执行了错误的逻辑。这种情况我后来统一改成:异步回调里一律用getCurrentMode()现取现用,不用闭包快照。
第三个坑是持久化脏写。因为我们当时在灰度版本里调整过模式ID的命名,线上同时跑着新旧两版,旧版用户切换模式后会把旧ID写入存储,新版读到不认识的老 ID,导致回退到默认模式,用户觉得“设置好的模式被打回原形了”。解决办法就是读取时做合法性校验,同时新版本一旦存活,优先写入新版本号,让旧版本不再覆盖。
5.3 排查利器:模式埋点与快照比对
最后分享一个我用了之后效率大增的排查工具组合。
第一,把模式切换写入埋点日志。每次切换记录:发起时间、原因、旧模式、新模式、切换耗时、onLeave/onEnter是否异常。这样线上反馈问题时,拉出时间轴就能还原用户意图,不用靠猜。
第二,做一次“模式快照”。在每个关键页面里,把当前模式、页面上关键配置、模式相关时间戳一起存下来,出问题时直达现场。我在调试面板里只会保留最近几份快照,不长期占用空间。
第三,给调试加一个强制切换入口。不在应用界面上暴露,但启动时带上特殊参数就能拉起一个工具箱,可以任意切换模式并输出当前注册表和配置全貌。这个功能平时不开启,直到排查现场时才救人一命。
6. 后续还能怎么扩展:从模式到场景的升级路径
如果你觉得上面的实现思路还比较“正经”,那我可以再展开一个我后来很推崇的方向:从“模式”升级到“场景”。
一个模式只回答“现在处于什么形态”,但一个用户的使用行为往往是“谁在什么场景下做什么”。比如“晚上在家用平板刷内容”是一个场景,它可能同时涉及“沉浸模式”、“夜间主题”、“个人账号配置”、“勿扰开关”等多个维度。后续扩展的核心思路是,把模式从单一维度改成多标签的组合维度,在切换调度器这一层引入“场景编排”的能力。
这个方向并不难,难的是在设计之初不要把“模式”和“具体业务”耦合死,给配置项留好扩展点。我之所以强调注册表和元信息的重要性,就是因为它天然支持后续把“模式”演变成“场景组合”。
当然,这已经是后话。绝大多数项目把上下文管理做到我上面说的程度,就已经非常够用了。
做 context-mode 这段时间,我个人最大的体会是:这类横切关注点的功能,真正的难点永远不在写代码,而在于一开始能不能把“上下文”这条边界划清楚。它像一根贯穿整个项目的绳子,系得好,后面每个功能都能顺着找到自己的位置;系得乱,后面每个需求都会往里再缠一道。
最后再分享一个很实在的小技巧:把模式名称写进所有日志和埋点的公共字段里。不管是网络错误、页面性能还是用户反馈,日志里多带一个 mode 字段,排查效率能翻好几倍。这个技巧花不了十分钟,但长期收益非常大,强烈建议你在做类似功能时第一时间加上。