
做了十年产品研发我越来越觉得“好用”这个词的底层逻辑其实特别朴素软件要在对的时机把对的能力摆到用户面前。context-mode 就是用来解决这个问题的核心设计思路之一。这个词现在很多场合都能见到翻译成中文是“上下文模式”在编辑器、设计工具、AI 对话产品、甚至系统级交互里反复出现它的本质是让界面行为和可用操作跟随用户当前的场景动态变化。我刚开始接触这个概念时总觉得它不过是一堆交互细节的集合直到在真实项目里查了不计其数的状态流转问题又在不同产品里观察它被实现得高下立判才意识到这是一套值得当成基础架构来设计的机制。这篇文章我想把这个概念彻底拆开讲清楚它到底解决什么问题在不同产品里有哪些典型写法再带着你用 TypeScript 从零实现一个最小可用的上下文模式引擎最后把我在项目中踩过的坑和你分享一下。适合正在做前端、客户端或者在做 AI 相关应用需要处理“让系统懂场景”这个问题的工程师。1. 先弄清楚 context-mode 是什么以及它到底在解决什么问题1.1 上下文不等于状态它是一组可观测的场景要素很多第一次接触 context-mode 的人会把它和“状态”混在一起。其实这是两件不同的事。状态描述的是系统“现在处于什么形态”比如登录态、加载态、编辑态而上下文描述的是用户“正处在什么场景里”它通常由多个可观测的要素组成比如鼠标落点、选中内容、输入焦点、历史操作、设备姿态、时间特征等。举一个最直白的例子在文档编辑器里当用户选中一段文字时界面如果自动浮出加粗、变色、引用等操作按钮这个行为就是基于上下文触发的。这里的“选中了文字”本身不是一个业务状态而是一个可以被系统捕捉的上下文要素。真正的状态在这个上下文被识别之后才会改变比如编辑器从“默认模式”切到“选中模式”。这就引出了第一层理解context-mode 的系统必须先把“上下文”收集成一个可以判断的快照再根据快照决定行为。快照越完整识别的准确度越高但快照越复杂判断成本和误触概率也会上升。所以在设计时第一件事不是写规则而是先定义哪些要素值得被纳入判断范围。1.2 模式决定能干什么状态决定是什么“模式”这个词有时候很唬人其实可以简单理解为一组行为集合的标签。编辑器切到“选择模式”意味着当前可用的快捷键、菜单、操作按钮、甚至剪切板行为都属于选择这个集合。而“状态”回答的是数据层面的问题比如当前选择的是哪一段文本、光标在哪个位置。这套区分在实际落地时非常有用。因为如果上下文模式被当成全局状态直接写入业务数据就会引发灾难性的连锁变化——一个 UI 层面的临时判断把存储层的数据结构、接口传参、缓存逻辑全部带着改一遍。正确的做法应该是让模式处于一个独立的交互层通过被动的“上下文快照 规则引擎”来判断和切换业务数据层只响应最终需要持久化的结果。我在实际项目里见过的最典型反例就是把“当前是否处于某种模式”这个布尔值放进全局 Redux store还让多个组件直接依赖它做数据请求。结果每次鼠标移动都有可能触发一次 context 重算整个页面被拖得极慢。后来我重构时才明白模式应该是一个可以被快速计算、随时重建的瞬态它不是业务状态的源头而是业务状态的解释层。1.3 为什么这个词在今天尤其值得认真对待context-mode 不是新造的概念它早就在交互设计领域存在类似“上下文感知”的研究成果甚至可以回溯到九十年代的普适计算。但为什么最近它频繁出现在各种技术讨论中核心原因是产品正在从“以功能为中心”转向“以任务为中心”。传统软件把功能铺在菜单和工具栏里用户需要记住功能在哪里而现代产品希望用户专注于任务本身系统自动把相关功能推送到触手可及的位置。加上大模型带来的自然语言交互能力“系统理解用户当前在干嘛”变得比以前更可行也更受期待。如今一个聊天式 AI 产品如果没有 context-mode 对对话场景、当前文件、实时勾选内容做感知它的回答会显得非常“脱节”。说直接一点context-mode 已经从一种“加分体验”变成了决定产品是否聪明的关键机制。它决定的是系统在用户发出指令之前已经帮他准备好了多少隐性信息。2. 产品里常见的 context-mode 设计范式三种典型写法理解概念之后最好再对照真实产品看看实现层面的不同选择。我梳理了一下市面上的 context-mode 设计基本可以分成三类显式模式、隐式模式、混合模式。它们各有适用场景也各有代价。2.1 显式模式用户自己掌控开关显式模式是最朴素也最容易理解的写法。系统不会主动判断场景而是把“当前处于什么模式”的决策权交给用户。用户可以通过快捷键、菜单切换、或者一个视觉开关手动进入某种模式。比如很多绘图软件里的“框选模式”“拖拽模式”“标尺模式”本质都是一组手动开关。又比如一些代码编辑器的“Vim 模式”切换动作完全由用户触发。显式模式的好处是行为可预期用户知道什么时候会发生什么不容易误判坏处是用户需要记忆和主动操作学习成本和操作负担都高。如果一个功能面向的是专业用户且操作频率高、模式之间差异巨大用显式模式通常不会错。它的设计核心落在“如何让用户知道当前在哪个模式”以及“如何快速切换”上。我见过不少产品在这块翻车不是因为识别不准而是模式指示器做得太隐蔽用户切进去之后找不着北。2.2 隐式模式系统主动识别并自动切换隐式模式是更智能也更难做的一种。系统通过监听用户的输入、位置、选择范围、操作历史等信息自动推导出“用户现在大概想干什么”然后悄悄把界面调整到对应的上下文模式。典型代表就是现在很多图片处理软件里的“选中物体后自动弹出编辑面板”以及聊天工具里“正在输入时自动调整输入区域”。用户不需要做任何额外操作系统的行为变化非常自然甚至让人感觉不到“模式”的存在。这种设计的极限是让产品像有一个贴身助理永远比用户快半步。但隐式模式的风险也很直接误判。一旦系统猜测错误用户会感到被打扰甚至产生“这软件怎么不听我话”的挫败感。加上自动切换本身需要监听大量事件稍不注意就会产生性能损耗和状态错乱。所以做隐式模式时宁可少触发也不要乱触发尽量做到“只在置信度足够高时才行动”。2.3 混合模式自动判断加用户干预现实中真正成熟的产品大多是混合模式。系统先基于上下文自动给出一个默认的模式建议同时保留用户手动覆盖的能力并且能记住用户的覆盖偏好。最有代表性的一个场景是智能输入法里的中英文切换。系统会根据当前输入框的类型、内容语境、历史习惯自动切换中英文但如果用户手动切过几次系统会短暂学习并调整判断策略。这种“自动为主、手动作矫正”的循环让模式既保持省心又保有可控制性。混合模式的设计难点在于“覆盖后的记忆规则”。比如用户在系统自动进入专注模式后连续三次手动退出系统是应该永久禁用自动进入还是只在本次会话内禁用这里几乎没有标准答案需要根据业务特点去定义遗忘曲线和信任上限。我比较推荐的做法是覆盖操作只对当前上下文快照生效不上升到全局避免一次误操作导致产品以后永远不推荐该模式。范式触发方式优点代价典型案例显式模式用户手动可预期、精确学习成本高Vim 模式、绘图工具的模式开关隐式模式系统自动省心、自然误判风险选中文字弹工具栏、智能输入法混合模式自动 手动覆盖两者兼顾规则复杂智能输入法、IDE 智能建议3. 从零手写一个轻量 context-mode 引擎理论讲太多不如动手写一遍。这一节我带你实现一个极简但结构完整的 context-mode 引擎。它不依赖任何重型框架核心代码用 TypeScript 编写任何前端或客户端项目都能借鉴。在正式开始之前说明一下这个实现是基于我在前端项目“交互增强”场景下总结出的一套常见写法不同业务可以在此基础上扩展但你如果拿去做生产环境建议再加上完整的埋点和回归测试。3.1 先定义上下文快照引擎的输入长什么样引擎第一步是汇总所有可观测要素生成一份上下文快照ContextSnapshot。快照应该是一个纯数据结构不包含任何业务方法。这样做的目的是让后续的规则判断变成纯函数便于测试和隔离。export type ContextSnapshot { /** 当前聚焦元素类型 */ focusType: input | textarea | button | plain | none; /** 当前选中的文本长度方便编辑类场景判断 */ selectedTextLength: number; /** 鼠标最近一次所在的位置跨边界时可用于判断停留区域 */ pointerArea: string; /** 用户离开当前窗口的时长单位毫秒 */ idleMs: number; /** 当前是否处于某操作的连续过程中例如拖拽 */ interacting: boolean; /** 最近一次请求的模式偏好覆盖可为空 */ manualOverride?: string; };实际项目里快照可能还包含传感器数据、页面可见性、网络状态、甚至用户角色。不过核心原则是一样的快照只负责描述“发生了什么”不负责表达“应该怎么办”。我建议把快照的生成集中在一个地方比如一个createContextSnapshot()函数这样方便统一修剪字段也方便在调试时打印完整上下文。不要在每个组件里各自拼装一份快照否则后续加字段的时候你会痛苦地发现每个组件都要改一遍。3.2 设计规则声明把模式映射拆成可读可维护的规则有了快照接下来要定义“什么上下文对应什么模式”。我这里的做法是把规则声明成对象数组每条规则包含四个部分模式名、优先级、匹配函数、以及进出模式的回调钩子。用数组而不是 if-else是为了方便扩展和调整优先级。export type ContextModeRule { name: string; /** 数字越大优先级越高冲突时胜出 */ priority: number; match: (snapshot: ContextSnapshot) boolean; onEnter?: (snapshot: ContextSnapshot) void; onExit?: (snapshot: ContextSnapshot) void; };定义几组演示规则当用户聚焦输入框且没有选词时进入“输入模式”当检测到选中文本长度大于 0 时进入“编辑模式”当用户闲置超过 5 分钟则进入“休眠模式”。export const contextRuleSet: ContextModeRule[] [ { name: idle, priority: 10, match: (s) s.idleMs 300_000, onEnter: async () { // 常见做法降低轮询频率、暂停非关键任务 console.log(enter idle mode); }, }, { name: editing, priority: 60, match: (s) s.selectedTextLength 0, onEnter: () { // 拉起文本编辑工具条 console.log(enter editing mode); }, }, { name: input, priority: 50, match: (s) s.focusType input || s.focusType textarea, onEnter: () { // 调出快捷输入辅助 console.log(enter input mode); }, }, { name: default, priority: 0, match: () true, onEnter: () { console.log(enter default mode); }, }, ];注意规则数组的排序并不重要引擎在判断时会主动按照优先级数字选择。这里的数字设计我用了 10、50、60、0 这一段主要是为了留下扩展位避免场景复杂后到处补大规模的数字。3.3 规则引擎与模式切换如何做到不震荡、不冲突把规则定义好之后核心就是引擎的 resolve 逻辑。引擎遍历规则找到匹配且优先级最高的那一条如果与当前模式不同就执行退出和进入的钩子并更新当前模式。还需要额外处理“重复命中同一模式”的情形避免每次事件都触发无意义的钩子。export class ContextModeEngine { private currentMode: string | null null; private rules: ContextModeRule[] []; private _lastSnapshot: ContextSnapshot | null null; constructor(rules: ContextModeRule[]) { this.rules rules; } evaluate(snapshot: ContextSnapshot) { this._lastSnapshot snapshot; const matched this.rules .filter((rule) rule.match(snapshot)) .sort((a, b) b.priority - a.priority)[0]; if (!matched) return; if (this.currentMode ! matched.name) { const prevRule this.rules.find((r) r.name this.currentMode); prevRule?.onExit?.(snapshot); matched.onEnter?.(snapshot); this.currentMode matched.name; // 这里可以将新模式派发给轻量状态管理层或者事件总线 console.log(mode switched to:, this.currentMode); } } get mode() { return this.currentMode; } get lastSnapshot() { return this._lastSnapshot; } }这里最关键的一个细节是不要每次事件都重新创建引擎也不要让引擎直接修改业务 Store。它应该是一个交互层的纯计算器每次传入新快照它只负责输出“该切到哪个模式”并且只在模式变化时才通知外部。这样能大幅减少后续调试和性能排查的复杂度。我在这个版本里没有引入“模式冷却时间”。如果要处理连续切换的震荡问题你可以在evaluate里加一个lastSwitchAt时间戳要求两次切换间隔必须大于某个阈值或者在match阶段让规则自己判断上下文是否足够稳定。这个属于业务细节我更建议放在规则里而不是塞进引擎否则引擎会变得越来越不可预测。3.4 事件监听与防抖让引擎和真实交互流接上有了引擎接下来需要把它挂到真实事件流上。这里的核心工作有两个收集快照、以合理频率触发 evaluate。所有事件类型都会先汇总成一个“低速但稳定”的快照流再交给引擎判断。import { debounce } from lodash-es; const engine new ContextModeEngine(contextRuleSet); function createSnapshotFromEvent(e: Event): ContextSnapshot { const target e.target as HTMLElement | null; const selection window.getSelection(); const now Date.now(); return { focusType: target?.tagName INPUT ? input : target?.tagName TEXTAREA ? textarea : target?.tagName BUTTON ? button : plain, selectedTextLength: selection?.toString().length ?? 0, pointerArea: computePointerArea(e), idleMs: now - lastActivityTs, interacting: isDragging, manualOverride: undefined, }; } const emitSnapshot (snapshot: ContextSnapshot) { engine.evaluate(snapshot); lastActivityTs Date.now(); }; // 高频事件用 throttle 避免性能问题 const throttledEvaluate throttle(emitSnapshot, 120); // 低频事件直接评估 window.addEventListener(mouseup, () { throttledEvaluate(createSnapshotFromEvent(event)); }); window.addEventListener(keyup, () { throttledEvaluate(createSnapshotFromEvent(event)); }); window.addEventListener(focusout, () { emitSnapshot(createSnapshotFromEvent(event)); });关于防抖我踩过不少坑。用throttle还是debounce取决于你关心的是“持续响应”还是“最终稳定”。比如鼠标移动时想让工具条实时跟随应该用throttle但如果只是判断用户是否停留在某区域用debounce更合适。不要所有事件都统一用一种策略要根据上下文要素的变化速度来区分。还有一点createSnapshotFromEvent里我用了全局变量lastActivityTs和isDragging这在演示中没问题。真实项目里建议把这些状态也纳入快照或者用一个独立的 ActivityCenter 来统一维护避免 demo 代码越写越乱。3.5 接入大模型做自动上下文识别更高层级的“感知”上面实现的是基于规则的隐式模式适合判断条件相对明确的场景。但如果上下文非常复杂比如需要判断用户输入的一句话是否在表达“我想生成代码”或“我想总结这篇文章”传统规则往往不够。这时很多产品会选择让大模型参与语义层面的上下文识别。基本思路是把当前界面状态、用户最近操作、以及候选模式列表拼成一段结构化提示词让模型输出应该进入什么模式再回到引擎执行切换。这个过程可以离线批处理也可以做在线预测。下面的代码示意了一个最简的接入方式实际使用时需要根据你自己的模型服务接口进行调整async function classifyModeWithLLM(snapshot: ContextSnapshot): Promisestring { const prompt 你是一个交互模式分类器。 当前候选模式editing, input, searching, analyzing, default 当前上下文 ${JSON.stringify(snapshot, null, 2)} 请只返回一个模式名不要输出额外解释。 ; const response await fetch(/api/classify-context, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt }), }); const data await response.json(); return data.mode ?? default; }把大模型接进来之后引擎本身不需要大改。你只需要新增一个“高优先级规则”当模型输出某个模式时让该规则临时接管。同时建议把模型的置信度一起返回低于阈值的就不切换宁可用默认模式。不过要提醒一点模型识别有延迟不适合用来处理毫秒级交互。常见做法是结合两套系统——用本地规则做即时反馈用模型做更智能的异步重判断。比如用户选中一段文字先本地规则立刻进入编辑模式同时异步让模型判断用户是否其实想“翻译”如果是再平滑切换成翻译模式。3.6 把模式变化暴露给 UI最小订阅机制设计最后一步需要让界面能够订阅模式变化。我不想把代码写得过于工程化但至少应该提供一个轻量的订阅器或者一个 React Hook。这里我给一个简化的 Hook 示例import { useSyncExternalStore } from react; let listeners: (() void)[] []; let currentMode default; const engine new ContextModeEngine(contextRuleSet); const originEvaluate engine.evaluate.bind(engine); engine.evaluate (snapshot) { originEvaluate(snapshot); if (currentMode ! engine.mode) { currentMode engine.mode!; listeners.forEach((l) l()); } }; function subscribe(cb: () void) { listeners.push(cb); return () { listeners listeners.filter((l) l ! cb); }; } export function useContextMode() { return useSyncExternalStore(subscribe, () currentMode); }Hook 写好后组件里只需要一行const mode useContextMode();然后根据 mode 做条件渲染。由于引擎只会在模式真正变化时通知订阅者UI 不会跟着鼠标事件频繁重渲染性能基本可控。这里我强烈建议不要用全局事件总线去广播所有上下文变化事件只广播“模式切换事件”。很多同学把每次快照更新都广播出去结果组件为了监听一条事件不得不做各种浅比较性能烂不说心智负担也重。记住UI 关心的是个位数的模式不是无限多的上下文细节。4. 实战中高频踩坑与排查技巧4.1 上下文误判宁可用“置信度”来推迟消费误判是上下文模式最先会遇到的问题。我见过一个真实的搜索框案例用户只是习惯性地双击输入框里的某个词系统却自动弹出了“高级筛选面板”用户不得不手动关掉一来一回气得不行。后来团队加了条件双击后必须停留超过 200ms 且页面无滚动才触发。看起来苛刻一点但误判率大幅下降。我的建议是为高破坏性的模式切换设置置信度门槛。门槛可以是时间阈值、重复命中次数、或者多个上下文要素同时满足。触发的原则是“可以先不响应但不要错误响应”。上下文模式一旦让用户觉得“烦”信任就建立不起来了。4.2 模式震荡连续快速切换让界面闪烁模式震荡指的是用户在一个边界上移动时系统在两种模式之间来回切换表现为界面闪烁、菜单反复弹出收起。这个问题的根源通常是规则之间没有互斥或者两个模式的匹配条件在事件流中交替满足。要解决它至少做两件事。一是让规则具备“切换冷却时间”从当前模式切到新模式后短时间内不允许再次切换回来。二是给规则加上“迟滞”逻辑进入某个模式需要满足更高的阈值退出时则要跌破更低的阈值。这类似硬件里的施密特触发器原理能有效消除边界抖动。我在自己的项目里就吃过这个亏光标在图片和正文之间移动时工具栏疯狂切换编辑和图片模式页面明明没卡视觉上却像抽风一样。后来给编辑模式加了“进入需要选中 3 个字符以上退出需要选中 0 个字符”的迟滞规则问题立刻消失。4.3 模式叠加导致规则冲突优先级不是唯一方案多个上下文要素会同时成立。用户可能在输入框里选中了一段文字那么“输入模式”和“编辑模式”都满足条件。这时如果只按优先级硬选一个可能会丢失另一个模式的合理诉求。我的实际经验是用“复合模式”代替“单模式”是一种更好的扩展方向。也就是允许一个快照同时激活多个互不冲突的模式而冲突只存在于那些会互相覆盖 UI 行为的模式之间。比如“输入模式”和“编辑模式”可以共存UI 可以同时展示输入辅助和文本编辑工具条但“阅读模式”和“编辑模式”就不适合共存因为它们会争抢主视图的空间。实现上你可以把engine.mode改成Setstring规则匹配后按优先级批量加入激活集合再用“排斥表”处理互斥关系。代码会复杂一些但用户体验会顺畅很多。4.4 排查技巧给引擎加一条“现场回放”在开发调试阶段我强烈建议为引擎增加日志回放能力。具体做法是把每个快照、每次规则匹配结果、最终模式切换动作按顺序记录到一个环形缓冲区里。一旦测试或者真实用户反馈“刚才界面出了奇怪问题”可以直接导出最近几百条记录定位是哪个快照引发了误切换。打印日志时不要只打印 mode 切换结果也要打印快照的关键字段。否则你只知道“切过去了”不知道“为什么切过去”。我自己常用格式是时间戳、触发源、快照摘要、匹配到的规则、优先级、最终模式。这个习惯帮我省了无数个排查晚上。4.5 自动化测试的两个稳定边界给 context-mode 写测试比普通业务逻辑更有挑战因为它依赖事件流和时间。这里有两个比较稳定的测试思路。第一用快照驱动引擎测试。直接构造不同的 ContextSnapshot断言引擎输出正确模式不必模拟真实事件。这是最可靠、也是覆盖度最高的测试方式。第二用固定的虚拟事件序列测试振荡问题。把 mouseup、keyup、focusout 这些事件按时间顺序排好灌入 throttledEvaluate 的 mock 版本然后断言模式切换次数是否在合理范围。这个测试能有效避免未来有人改规则时重新引入震荡。5. 再做深一点的几个方向以及我现在的习惯做法5.1 把模式本身也做成可解释的一个容易被忽略的细节是用户有时候需要知道“系统为什么进入了这个模式”。尤其是 AI 参与的上下文识别用户看到界面变了心里会犯嘀咕。我现在做项目时会在模式切换事件里附带一个 reason 字段比如reason: selected_text_length3或者reason: llm:translate_request。在调试面板、用户反馈工具、或者客服后台里这些信息能让研发和用户快速对齐。很多智能产品让人觉得“玄学”就是因为缺少这一层可解释性。5.2 context-mode 与大模型结合时的常见进阶玩法上下文模式这套机制做完之后其实是一个很好的“状态容器”它不只可以控 UI。比如它可以把当前模式作为上下文注入到大模型请求里让模型回答更贴合场景也可以在记录用户行为埋点时把模式作为标签打到每一条埋点数据上。这套沉淀下来之后你后续做用户画像、做智能推荐都会有更丰富的基础数据。我个人的习惯做法是把“模式迁移”也当成产品事件来埋点。用户从阅读模式切换到编辑模式这本身就是一个信号说明用户正在从“浏览”变成“行动”。长期积累下来的模式迁移数据往往比单点的点击日志更能反映用户的真实意图。5.3 最后分享一点实操层面的体会前面讲的规则、引擎、代码实现都是从多次项目教训里提炼出来的。但比代码更重要的其实是设计心态。不要一开始就想把模式做得非常智能也不要让系统猜得太激进。我现在的准则很简单上下文模式的每一个自动切换动作都要能在回答“用户为什么会觉得这个行为合理”时给出一个不绕的解释。如果暂时解释不了宁可先把这个模式开关放成显式操作等积累了足够数据后再逐步自动化。很多产品翻车不是因为技术上做不到自动识别而是因为在用户还没有建立起心智模型前就强行塞给他一套“自作聪明”的行为。给用户一点时间也给你自己一点时间。