前阵子我们内部起了一个项目代号,就叫context-mode。起因挺狼狈:凌晨两点,线上订单详情页被用户反馈"串号"——A 用户看到的是 B 用户的审批状态,第一反应是接口缓存问题,排查到最后才发现,根源是两组业务状态被塞进了同一个全局区域,页面切换时程序只更新了局部字段,"当前用户""当前订单"这些上下文被留在了原地。那次故障之后,我花了将近两周时间,把项目里的状态传递方式整体翻了一遍,最终沉淀出一套可以复用的设计思路。这篇文章就把这次实践完整拆开讲:从 Context 到底是什么,到 React 里怎么落地,再到后来做 AI 应用时发现同样一套思想可以迁移到 LLM 的上下文管理上。如果你正在被"状态散乱、上下文丢失、组件越改越耦合"这类问题困扰,这篇应该能帮你找到系统的解法。
1. 为什么要专门为"上下文"做一个模式
1.1 从一次凌晨故障说起
先还原一下当时出问题的现场。我们的中后台项目用的是 React + TypeScript,页面结构并不复杂:订单列表页、订单详情页、审批页,加上一个全局用户栏。早期为了赶进度,开发同学图省事,把"当前用户信息"和"当前正在查看的订单"都挂到了一个全局的 store 对象里,取数据的时候直接从里面拿。
平时单用户演示没问题,一旦有人快速切换账号、或者从详情页返回再进入另一条订单,问题就来了。因为这个全局 store 里存的是"上一次操作留下的状态",而新页面读数据时只覆盖了部分字段,两笔订单的字段就会混在一起。我们在修复时发现,最麻烦的不是改代码,而是找出"到底有哪些模块在隐式依赖这一份状态"。这就像办公室共用一个白板,A 组画了进度图,B 组来了没擦直接往上写日程,C 组路过看见一半,所有人都在猜白板现在代表什么。
这次事故让我明确了一个判断:系统里每个模块需要的"背景信息",必须有一种显式的、结构化的传递方式,而不是靠某一个全局变量来承担。这就是context-mode项目的起点——把"上下文"当作一个正式的设计概念,而不是顺手写的全局数据。
1.2 ContextMode 的两张面孔:组件上下文与 AI 上下文
做前端的人看到 Context,第一反应是 React Context API;但如果你最近接触过 LLM 应用开发,又会发现另一个高频词——上下文(Context)窗口、RAG 上下文注入、多轮对话上下文管理。我后来意识到,这两个场景底层是同一个问题:一个执行单元(组件、函数、大模型)在完成工作时,需要知道"当前处在什么环境、有哪些约束、之前发生了什么"。
组件需要上下文来避免把 props 一层层手动传下去,大模型需要上下文来避免"失忆"和答非所问。React 的方案是 Provider/Consumer,AI 应用的方案是 Prompt 组装和记忆压缩,但它们的目标一致——让执行单元用最少的显式参数,获得最完整的背景信息。
所以这篇文章不只是讲 React Context API 的用法,而是围绕context-mode展开一套完整的实践:怎么分层设计上下文、怎么管理上下文生命周期、怎么避免性能陷阱,以及同一套思想怎么平移到 LLM 应用的上下文工程里。
2. 上下文分层:把 Context 从"全局杂物间"变成"分级仓库"
2.1 为什么不能让一个 Context 包打天下
很多团队用 Context 的方式很粗暴:在入口组件包一个<AppContext.Provider>,把用户信息、主题色、接口地址、路由信息、甚至某个弹窗是否打开全部塞进去。代码写起来确实省事,但后果在项目中期集中爆发。
首先是更新收敛问题。任何一处调用setAppContext,整个 app 里所有消费这个 Context 的组件都会收到通知,哪怕它只关心主题色,也会因为"用户信息变了"而被连带重渲染。React 里 Context 的传播机制是"值变了就通知所有 consumer",它不像 Redux 那样有精细的 selector 能力,靠<Context.Provider value={...}>的一次性赋值,很难做到精准更新。
其次是职责混淆。用户信息是跨会话长期存在的,订单状态是页面级别的短期数据,弹窗开关是组件内部的瞬时状态。把它们混在一个 Context 里,让"生命周期"的管理无从下手——页面卸载时应该清理哪些?切换账号时要重置哪些?答案全都含糊。
我的结论是:Context 不是不能用来做全局状态,而是不能只做一个"大杂烩" Context。正确思路是分层,把不同生命周期的数据放进不同的 Provider,让各自管理各自的范围。
2.2 三层上下文模型的划分
在context-mode项目里,我把上下文分为三层,分别命名为领域上下文、会话上下文、环境上下文。这三层的划分依据是"作用域 + 更新频率 + 生命周期"。
| 层级 | 典型内容 | 生命周期 | 更新频率 | Provider 放置位置 |
|---|---|---|---|---|
| 环境上下文 | 主题、语言、接口基地址、功能开关 | 应用级,几乎不变 | 极低 | 应用根节点 |
| 会话上下文 | 当前用户、登录态、权限标识 | 登录会话期间 | 低 | 登录态校验之后 |
| 领域上下文 | 当前订单、当前项目、当前正在编辑的文档 | 页面或业务域内 | 高 | 页面路由或业务模块入口 |
环境上下文是最稳定的,它描述的是"这个应用运行在什么条件下"。会话上下文跟随用户登录状态,切换账号时必须整体重建。领域上下文是业务操作的核心背景,比如你在订单详情页看的是哪条订单、在审批页审的是哪一步——这类数据变化最频繁,也最容易串号。
这个分层带来的直接好处是:每一层 Context 都可以独立重建、独立测试。切换账号时只需要重建会话上下文,领域上下文跟着页面走;页面跳转时领域上下文重建,不会去动环境和会话层。故障排查时也能顺着层级快速定位——是环境配置错了,还是会话过期了,还是领域数据没刷新,一划分就清楚。
2.3 作用域与生命周期管理的对应关系
分层设计之后,Provider 的嵌套关系也要跟着层级走,而不是全部堆在根部。我们的做法是:
- 环境上下文放在
App.tsx的根节点,保证整个应用都能读到主题和开关配置。 - 会话上下文放在登录校验路由内,只有已登录区域才需要它,登录页不消费用户信息。
- 领域上下文放在各自业务域的入口组件里,比如订单模块的布局组件内部。
这样每个 Provider 的"管辖区"跟它的生命周期天然对齐。订单 Context 会随着订单路由卸载而被销毁,里面的"当前订单"数据自然清空,下次进入再重新加载,想串号都难。
这里有个操作细节值得强调:每一层 Provider 的 value 都要有明确的构建和销毁时机,不要在一个 Provider 内部去操作其他层的数据。比如订单 Provider 里可以读会话上下文里的用户 ID,但不要直接改写用户信息,跨层写数据会把生命周期搞混。
3. React 侧落地:从 createContext 到业务闭环
3.1 真实业务域:订单上下文的抽象过程
分层之后,最先改造的是当时出事的订单模块。当时业务需求是:列表页点进某条订单,详情页、审批页、操作日志页都要共享"当前订单 ID、订单状态、买家信息、卖家信息、当前审批人"这些数据,而且列表页操作后详情页要同步刷新。
如果不用 Context,最直接的方案是 props 一层层传。从列表页传到详情页,详情页再传给审批组件、日志组件,中间可能经过四五层无关组件,每传一层都要在类型上补充新字段,维护成本很高。如果用全局 store 又回到之前串号的老路。
我最后采取的方式是:为订单域建一个独立的OrderContext,粒度只覆盖订单模块,不掺入用户、主题这些全局数据。流程分成三步:定义值结构、实现 Provider、写自定义 Hook 供消费方使用。
3.2 Provider 的实现与稳定引用
先定义订单上下文的值结构。这里有一个经验:Context value 的每个字段都应该有明确的语义和来源,能不用 setter 就别直接暴露 setter。我们倾向于把"加载订单""刷新订单""清空订单"这些操作封装成方法,让业务组件语义化地调用,而不是把底层 state 的 setState 裸奔出去。
// order-context.ts import { createContext, useContext } from 'react'; export interface OrderBrief { orderId: string; status: 'pending' | 'approved' | 'rejected'; buyerName: string; sellerName: string; currentApprover?: string; } interface OrderContextValue { order: OrderBrief | null; loading: boolean; error: Error | null; loadOrder: (orderId: string) => Promise<void>; refreshOrder: () => Promise<void>; resetOrder: () => void; } export const OrderContext = createContext<OrderContextValue | null>(null);Provider 部分的实现要特别注意 value 的稳定性。React 官方有个容易被忽略的细节:Context value 一旦换成新的对象引用,所有 consumer 都会重新渲染。如果 Provider 内部每次 render 都现场拼一个新对象,即使数据没变化,子组件也会全部跟着刷新。所以我们用useMemo把这几个操作函数和状态值打包成稳定引用。
// order-provider.tsx import { ReactNode, useCallback, useMemo, useState } from 'react'; import { OrderContext, OrderBrief } from './order-context'; export function OrderProvider({ children }: { children: ReactNode }) { const [order, setOrder] = useState<OrderBrief | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const loadOrder = useCallback(async (orderId: string) => { setLoading(true); setError(null); try { // 实际开发在这里调用接口,并做字段映射 const data = await fetchOrder(orderId); setOrder(data); } catch (e) { setError(e instanceof Error ? e : new Error('加载订单失败')); } finally { setLoading(false); } }, []); const refreshOrder = useCallback(async () => { if (order?.orderId) { await loadOrder(order.orderId); } }, [order?.orderId, loadOrder]); const resetOrder = useCallback(() => { setOrder(null); setError(null); setLoading(false); }, []); const value = useMemo<OrderContextValue>( () => ({ order, loading, error, loadOrder, refreshOrder, resetOrder }), [order, loading, error, loadOrder, refreshOrder, resetOrder] ); return <OrderContext.Provider value={value}>{children}</OrderContext.Provider>; }注意useMemo的依赖数组里把order、loading、error都列进去了,也就是说数据真正变化时 value 才会更新。这个写法看起来繁琐,但能避免大量无意义重渲染。踩过几次坑之后我的体会是:写 Provider 时想清楚"什么变了才需要通知下游",比之后在 Profiler 里排查渲染树要省力得多。
3.3 自定义 Hook:把复杂性关在门内
Provider 写好了,消费端也不能直接用useContext(OrderContext)裸奔。因为在还没有任何 Provider 包裹的组件里调用它会拿到null,写ctx.order会直接抛错。我们封装了一个自定义 Hook,把空值保护和错误提示统一处理掉。
// use-order.ts import { useContext } from 'react'; import { OrderContext } from './order-context'; export function useOrder() { const ctx = useContext(OrderContext); if (!ctx) { throw new Error('useOrder 必须在 <OrderProvider> 内部使用'); } return ctx; }这个 Hook 的意义不只是省一次判空。它相当于给订单上下文的消费者一个"接口约定":只要在 OrderProvider 范围内,组件不需要关心数据怎么来、怎么更新,直接拿到订单对象和操作函数即可。列表页选择订单时调用loadOrder(orderId),详情页挂载时读order,审批通过后调refreshOrder(),数据流转一目了然。
实际改造后的效果是:订单模块里删掉了大量手动传递的 props 和组件内部的"临时状态同步逻辑",组件树之间的依赖变成了一条清晰的 Provider 链路。链路内组件只管消费,链路外组件完全感知不到订单数据的存在,串号问题从设计上就被杜绝了。
4. LLM 应用中的 Context Mode:窗口、记忆与注入
4.1 上下文窗口不是无限内存
前端项目告一段落后,我刚好在帮一个团队做 AI 应用的技术方案,意外发现context-mode的思路在 LLM 场景下同样成立。
做 AI 应用的同学应该都遇到过这个矛盾:模型能记住的信息是有限的,但对话一长,早期内容要么被截断,要么被压缩得面目全非。调用商用的对话模型时,上下文越长,单次调用的成本也越高,响应速度还会变慢。于是"上下文管理"就成了 AI 工程里的核心问题——怎么在有限窗口里塞进最有价值的信息,而不是把所有历史原封不动倒进去。
这跟前面提到的"环境上下文、会话上下文、领域上下文"分层思想有很强的对应关系:系统提示(system prompt)描述的是模型运行的环境规则,属于环境级;当前对话的完整历史属于会话级;用户本次提问所涉及的具体资料片段属于领域级。把不同生命周期的上下文分开管理,分别决定"放进来、压缩了放进来、还是不放进来了",这才是 Context Mode 的本质。
4.2 四种上下文管理策略的取舍
我整理了一下目前工程上常见的上下文管理手段,大致可分成四类,各有各的适用场景:
| 策略 | 做法 | 成本 | 风险 |
|---|---|---|---|
| 直接截断 | 超出窗口的部分丢弃,只保留最近几轮 | 最低 | 丢关键信息,模型可能忘记重要前提 |
| 摘要压缩 | 把早期对话用模型压缩成摘要,用摘要代替原文 | 中等,要额外调用模型 | 摘要会丢失细节,且自身也是 token |
| 结构化提炼 | 边聊边抽取"用户偏好、关键实体、待办事项"等字段 | 较高,需要设计抽取规则 | 抽取质量影响后续理解 |
| RAG 检索注入 | 在外部知识库中检索与当前问题相关的片段,拼进上下文 | 中高,需要搭建向量检索 | 检索不准会引入噪音,反而干扰模型 |
这四类策略对应的是不同的上下文生命周期。对话进行中,每一轮的原始消息属于高更新频率的短期上下文;从对话里提炼出的用户偏好属于跨会话的长期上下文;外部知识库片段则是随问题变化的领域上下文。把它们混在一个数组里暴力拼接,前面提到"大杂烩 Context"的毛病就全部出现了。
4.3 组装 Prompt 时如何编排上下文
实际写代码的时候,我们会封装一个"上下文组装器",专门负责决定哪些内容进 Prompt、哪些内容不进。它的逻辑可以理解成一个环形缓冲加分级压缩:短期对话历史保留最近几轮原文,更早的合并成摘要;系统指令作为环境上下文固定在最前面;用户当前问题触发外部知识检索,把相关片段插进对话历史之前。
// build-context.ts interface ContextBundle { system: string; compressedHistory: string; relevantDocs: string[]; recentTurns: string[]; } export function buildPrompt(userInput: string, bundle: ContextBundle): string { return [ bundle.system, '=====相关资料=====', bundle.relevantDocs.join('\n'), '=====历史摘要=====', bundle.compressedHistory, '=====近期对话=====', bundle.recentTurns.join('\n'), `用户:${userInput}`, ].filter(Boolean).join('\n'); }这个架构的价值在于:每一段上下文的变化都有独立触发条件。用户只问了新问题,就只更新recentTurns和relevantDocs,不用重算历史摘要;只有历史积累到一定轮数才触发压缩流程。这就好比 React 里分层后的多个 Context,各自在自己的生命周期内更新,不会牵一发动全身。
做完 AI 项目再回头看,两个领域解决的是同一个问题,只是表现载体不同:前端的 Context 是组件树上的 Provider,AI 的 Context 是 Prompt 里的结构化段落。掌握底层的分层和生命周期思想,技术栈迁移时就能快速把经验带过去。
5. 性能与调试:Context 重构最容易翻车的地方
5.1 重渲染风暴:问题的定位过程
Context 的模式写起来清爽,但落地后第一波问题往往出现在性能上。我们做订单模块改造时,上线第二天就收到反馈:订单详情页每次加载都要卡顿小半秒,比改造前还慢。
打开 React DevTools 的 Profiler 录制操作,一眼就看到了问题——详情页里几乎所有组件都被标成了红色,重渲染范围远超预期。进一步看 props 变化,发现原因很典型:我们在订单 Provider 内部把"加载中的 loading 状态"和"订单数据"放在同一个 Context value 里,接口请求一开始setLoading(true),整个 value 对象就变成新的引用,所有消费方集体重渲染;然后接口返回setOrder(data),又是一次全量重渲染。一次加载触发两轮风暴。
5.2 拆分、缓存与按序消费
根因清楚了,解决方向很明确:让 Context value 的粒度匹配消费方的关注点。我拆成了两个思路来改。
第一是拆分粒度。把"订单数据本身"和"加载状态"拆成两个 Context,或者用单独的状态管理来承载高频变化的 loading 态。消费方里只有真正需要展示 loading 的按钮和骨架屏才订阅加载态,页面主体只关心订单数据。这样一来,setLoading(true)不再是全局广播,只有少数组件响应。
第二是稳定引用。排查时发现一个隐藏 bug:我们有个详情组件用了memo,但仍然跟着无关 Context 一起重渲染。原因是 Provider 的 value 里塞了一个每次 render 都会新建的临时对象——{ data: order, actions },结构相同但引用不同。改成useMemo之后,memo 组件的性能优化才真正生效。
我整理了一张排查表,后续再遇到类似问题直接按表排查:
| 症状 | 常见根因 | 解法 |
|---|---|---|
| 一次更新触发大量渲染 | Context value 引用总在变 | useMemo包裹 value,依赖项尽量少 |
| 只关心 loading 也重渲染数据 | loading 和数据在同一个 Context | 拆分 Context,按消费频率分层 |
| 所有子组件全部重渲染 | Provider 包在组件树过高的位置 | 下沉 Provider,缩小作用范围 |
| memo 组件失效 | 传入了不稳定函数或对象 | 用useCallback稳定函数引用 |
5.3 调试 Context 的实用技巧
调试 Context 比调试 props 要难,因为数据流是隐式的,不像 props 直接写在组件调用处。这里分享三个我用着顺手的小技巧。
第一个是给 Context 加日志装饰。在自定义 Hook 里临时打印ctx的值变化,开发环境能看到每一次触发更新的来源,定位"谁改了上下文"非常方便。上生产前把这段日志删掉就好。
第二个是在 Provider 边界设断点。怀疑某个区域上下文异常时,直接在useMemo计算 value 的那行打断点,看依赖数组里的每一项是什么时候变的。很多"玄学 bug"都是依赖项里混入了每次 render 都重新创建的数组或函数导致的。
第三个技巧是"上下文越矩检测"。我们在开发环境写过一个 HOC 包装器,在 Context Provider 外套一层代理,如果发现某个组件订阅的 Context 数量和它的实际位置不匹配(比如很底层的小组件竟然订阅了领域级 Context),就打印警告。这能逼着团队把上下文消费控制在一个合理的层级范围内,避免上下文用成新的全局变量。
6. 什么场景才值得启用 Context Mode
6.1 Context 与状态管理库的分工
经常有人问:既然有 Zustand、Redux 这些状态管理库,为什么还要用 Context?我的看法是,这两者解决的问题有重叠,但侧重点不同。
Context 的本质是依赖注入,它擅长的是"把背景信息从高处传给深层的组件",解决 props 层层透传的问题。状态管理库则擅长"跨组件共享可更新的状态,并精细控制响应范围"。实践中可以组合使用:稳定的、低频更新的背景信息用 Context 传递;高频的、需要精细 selector 的共享数据交给状态管理库。
| 对比维度 | Context | 状态管理库 |
|---|---|---|
| 主要作用 | 依赖注入、背景传递 | 共享状态、跨模块通信 |
| 学习成本 | 低,原生 API | 中高,需理解数据流范式 |
| 细粒度更新 | 差,值变则全通知 | 好,支持 selector 精确订阅 |
| 异步逻辑 | 需自己封装 | 多数内置或生态成熟 |
| 适用场景 | 传递主题、用户、业务域背景 | 复杂跨模块交互、高性能列表 |
context-mode项目最终没有推翻状态管理库,而是把"业务域上下文"这类生命周期明确、更新频率适中的数据用 Context 承接了下来,让状态管理库专心处理它擅长的部分。方案之间不是替代关系,而是分工关系。
6.2 适用的信号与不适用的场景
经过这一轮实践,我总结出几个"该启用 Context Mode"的信号:
- 同一个业务数据被三层以上组件传递,中间隔着无关组件。
- 页面或业务域有明确的生命周期,进入时需要加载背景数据,退出时需要重置。
- 多个模块共享同一份业务状态,且希望状态变化对所有消费方同步可见。
- 团队希望减少 props 透传带来的类型维护成本。
反过来,有些场景强上 Context 反而是麻烦。比如简单的父子级联组件,两三层 props 就能传完,硬加 Context 只是增加阅读负担;再比如高频实时数据(每秒推送的行情、游标位置),用 Context 触发全量通知会很吃力,外部 store 或状态管理库更合适。
6.3 我踩过之后沉淀的判断标准
做完整套改造,我给自己定了一条判断标准,遇到"要不要引入 Context"这个问题时先想三件事:粒度是否够小、作用范围是否够窄、生命周期是否清楚。粒度小,就不会牵出一堆无关组件重渲染;范围窄,就不会变成新的全局变量;生命周期清楚,就不会出现上下文残留和串号。
当初凌晨那次故障,本质上就是这三个标准全都没满足。后来把订单上下文收进独立 Provider,它只服务订单域,数据随路由卸载而销毁,周期结束就清理干净,问题自然消失。回头看,context-mode不是一个复杂的架构,它更像一组朴素的原则:显式传递背景信息,分层管理生命周期,让每个模块都知道自己处在什么状态下、该按什么规则办事。
我个人现在再做类似设计时,会把这套思路也带进新项目里,包括 AI 应用中 Prompt 的组装,也会下意识问一句:这段上下文该放在哪个层级,它的生命周期有多久,更新时会影响哪些下游。想清楚这三个问题,大部分上下文混乱的坑就都能提前绕开了。