1. 从"context-mode"说起:一个被低估的工程概念
第一次看到"context-mode"这个词,很多人会下意识地把它归到某个具体框架的API文档里,觉得无非是某个函数的一个参数选项。但如果你在工程一线待过几年,尤其是在做系统设计、状态管理、AI应用编排或者复杂前端交互的时候,你会发现"上下文模式"其实是一个贯穿始终的底层设计哲学。它不是一个库,也不是一个工具,而是一种组织代码、管理状态、隔离逻辑的思维方式。
我最早系统性地接触这个概念,是在处理一个多轮对话系统的状态管理问题。当时系统里同时存在用户会话状态、页面路由状态、异步请求状态和权限校验状态,四套状态互相耦合,改一处崩三处。后来我们把所有状态按照"上下文"重新划分,每一类上下文有独立的生命周期、独立的读写规则、独立的失效策略,整个系统才变得可维护。那次经历让我意识到,context-mode的核心价值不在于某个具体实现,而在于它提供了一种"分而治之"的边界划分方法。
这篇文章适合谁看?如果你正在做以下任何一件事,那这篇内容对你会有直接帮助:正在设计一个需要管理多种状态的前端应用;正在搭建多轮对话或Agent系统;正在处理多环境配置切换;或者你只是单纯觉得代码里到处传参、到处判断环境、到处写if-else很痛苦。我会从设计思路、核心机制、实操落地、问题排查四个维度,把context-mode这个东西讲透,并且给出可以直接抄作业的方案。
需要提前说明的是,context-mode并不是某个特定产品的专有名词,不同技术栈下它的具体形态不同。我下面讲的内容,是基于我在多个项目中反复验证过的通用实践,结合常见的工程场景做的合理演绎。你完全可以根据自己的技术栈做适配。
2. 内容整体设计与思路拆解
2.1 为什么需要"上下文模式"而不是"全局状态"
很多项目一开始都是全局状态一把梭。一个全局store,什么数据都往里塞,组件需要什么就取什么。小项目这样干没问题,一旦项目规模上去,问题就来了。全局状态最大的毛病是"没有边界"——任何模块都可以读写任何数据,你根本不知道一个状态被谁改了、什么时候改的、改了之后会影响谁。
上下文模式的核心思路是:把状态按照"使用场景"而不是"数据类型"来划分。比如用户信息,在登录场景下是一个上下文,在个人主页展示场景下是另一个上下文,在权限校验场景下又是另一个上下文。这三个上下文可能共享同一份底层数据,但它们的读写规则、生命周期、失效条件完全不同。
我举个具体的例子。假设你在做一个电商App,用户从浏览商品到下单支付,中间会经历商品详情页、购物车、订单确认、支付四个页面。如果用全局状态,你需要维护一个巨大的orderState,里面包含商品列表、优惠券、地址、支付方式、发票信息等等。任何一个页面修改了这个对象,其他页面都得重新计算。而用上下文模式,你可以拆成四个独立的上下文:商品上下文(只读,来自服务端)、购物车上下文(可读写,本地持久化)、订单上下文(可读写,会话级)、支付上下文(只写,一次性)。每个上下文有自己的边界,互不干扰。
这种拆分带来的好处是显而易见的。首先是可测试性,每个上下文可以独立测试,不需要mock整个全局状态。其次是可维护性,修改购物车逻辑不会影响支付逻辑。最后是性能,每个上下文可以独立做缓存和失效策略,不需要全量更新。
2.2 上下文模式与其他状态管理方案的对比
市面上常见的状态管理方案大致有这么几类:全局单例store(如Redux、Vuex)、组件内状态(如React的useState)、服务端状态缓存(如React Query、SWR)、以及上下文模式。它们之间不是替代关系,而是互补关系。
| 方案 | 适用场景 | 边界清晰度 | 生命周期管理 | 跨组件共享 |
|---|---|---|---|---|
| 全局单例store | 应用级共享状态 | 低 | 手动管理 | 强 |
| 组件内状态 | 局部UI状态 | 高 | 自动跟随组件 | 弱 |
| 服务端状态缓存 | 异步数据获取 | 中 | 自动缓存失效 | 中 |
| 上下文模式 | 场景化状态隔离 | 高 | 按上下文定义 | 按需共享 |
上下文模式的独特之处在于,它既不像全局store那样无边无际,也不像组件状态那样难以共享。它通过"上下文提供者"和"上下文消费者"的配对,实现了按需共享和边界隔离。你可以把它理解成一种"有作用域的状态管理"。
在实际项目中,我通常会把上下文模式和服务端状态缓存结合使用。服务端状态缓存负责数据的获取和同步,上下文模式负责数据的场景化组织和读写控制。这样既避免了重复请求,又保证了状态边界清晰。
2.3 上下文模式的核心设计原则
设计上下文模式时,我遵循三条原则,这三条原则是我踩了无数坑之后总结出来的。
第一条原则:一个上下文只服务一个场景。不要试图做一个"万能上下文",那只会变成另一个全局状态。每个上下文应该对应一个明确的用户场景或业务流程。比如"登录上下文"只负责登录相关的状态,"主题上下文"只负责主题切换相关的状态。
第二条原则:上下文的读写接口要显式定义。不要直接把原始状态暴露出去,而是提供明确的读方法和写方法。这样做的好处是,你可以在写方法里加入校验、日志、副作用触发等逻辑,而且当状态结构变化时,只需要改上下文内部实现,外部调用方不受影响。
第三条原则:上下文的生命周期要明确。有些上下文是应用级的,从启动到关闭一直存在;有些是会话级的,用户登出就销毁;有些是页面级的,离开页面就清理。明确生命周期可以避免内存泄漏和状态污染。
注意:上下文模式不是银弹。如果你的应用只有两三个页面,状态之间几乎没有耦合,那用全局状态或者组件状态就够了。引入上下文模式反而会增加复杂度。判断标准很简单:当你发现状态之间的耦合关系已经无法用简单的父子传递或全局store来管理时,就是引入上下文模式的时机。
3. 核心细节解析与实操要点
3.1 上下文的定义与创建
定义一个上下文,本质上是在定义一个"状态容器"的规格。这个规格包含三部分:状态结构、读写接口、生命周期钩子。
以JavaScript/TypeScript生态为例,一个典型的上下文定义长这样:
interface CartContext { // 状态结构 state: { items: CartItem[]; totalPrice: number; couponCode: string | null; }; // 读接口 getItems: () => CartItem[]; getTotalPrice: () => number; // 写接口 addItem: (item: CartItem) => void; removeItem: (itemId: string) => void; applyCoupon: (code: string) => Promise<boolean>; // 生命周期 reset: () => void; }这个定义里,state是私有的,外部只能通过getItems和getTotalPrice来读取。addItem和removeItem是同步写操作,applyCoupon是异步写操作,因为它需要校验优惠券有效性。reset用于在用户登出或切换账号时清空上下文。
创建上下文时,我建议使用工厂函数而不是直接导出单例。工厂函数的好处是,你可以在测试时创建多个独立实例,互不干扰。而且工厂函数可以接收初始化参数,比如从服务端预取的数据。
function createCartContext(initialItems: CartItem[] = []): CartContext { let state = { items: initialItems, totalPrice: calculateTotal(initialItems), couponCode: null, }; return { getItems: () => [...state.items], getTotalPrice: () => state.totalPrice, addItem: (item) => { state.items.push(item); state.totalPrice = calculateTotal(state.items); }, removeItem: (itemId) => { state.items = state.items.filter(i => i.id !== itemId); state.totalPrice = calculateTotal(state.items); }, applyCoupon: async (code) => { const valid = await validateCoupon(code); if (valid) { state.couponCode = code; state.totalPrice = calculateTotal(state.items, code); } return valid; }, reset: () => { state = { items: [], totalPrice: 0, couponCode: null }; }, }; }注意getItems返回的是数组的浅拷贝,这是为了防止外部直接修改内部状态。这个细节很多人会忽略,但它是保证上下文边界不被破坏的关键。
3.2 上下文的注入与消费
定义好上下文之后,下一步是把它注入到需要它的组件或模块中。注入方式取决于你的技术栈。在React里,通常用Context Provider;在Vue里,用provide/inject;在纯JavaScript模块里,可以用依赖注入容器。
不管用什么方式,核心原则是一样的:消费者不应该关心上下文是怎么创建的,只应该关心怎么使用它。这意味着上下文的创建逻辑应该集中在应用入口或模块初始化阶段,而不是散落在各个消费点。
我见过一种反模式,就是在组件内部直接创建上下文实例。这样做的问题是,每次组件重新渲染都会创建一个新实例,状态完全丢失。正确的做法是在应用顶层创建上下文实例,然后通过Provider向下传递。
// 应用入口 const cartContext = createCartContext(); function App() { return ( <CartProvider value={cartContext}> <Router> <Routes> <Route path="/cart" element={<CartPage />} /> <Route path="/checkout" element={<CheckoutPage />} /> </Routes> </Router> </CartProvider> ); } // 消费点 function CartPage() { const cart = useCartContext(); const items = cart.getItems(); // ... }这种模式下,CartPage和CheckoutPage共享同一个cartContext实例,状态自然同步。而且因为上下文实例是在App外部创建的,它不会因为App重新渲染而重建。
3.3 上下文的组合与嵌套
实际项目中,一个页面往往需要同时使用多个上下文。比如订单确认页需要购物车上下文、地址上下文、支付上下文。这时候就涉及上下文的组合问题。
最简单的组合方式是嵌套Provider。但嵌套层数多了之后,代码会变得很难看,而且性能也会受影响,因为每一层Provider都会触发一次React的context查找。
我的做法是创建一个"上下文聚合器",把多个上下文合并成一个复合上下文。这样只需要一层Provider,消费点也可以一次性拿到所有需要的上下文。
interface AppContexts { cart: CartContext; address: AddressContext; payment: PaymentContext; } function createAppContexts(): AppContexts { return { cart: createCartContext(), address: createAddressContext(), payment: createPaymentContext(), }; } const AppContext = React.createContext<AppContexts | null>(null); function useAppContexts(): AppContexts { const ctx = React.useContext(AppContext); if (!ctx) throw new Error('AppContexts not provided'); return ctx; }这种聚合方式的好处是,消费点只需要调用一次useAppContexts就能拿到所有上下文。而且当某个上下文内部状态变化时,只有真正使用了该上下文的组件会重新渲染,其他组件不受影响。
提示:聚合上下文时要注意,不要把不相关的上下文强行合并。比如购物车上下文和主题上下文就没有必要放在一起。聚合的原则是"同一业务场景下经常一起使用的上下文"。
3.4 上下文的持久化与恢复
很多上下文需要持久化,比如购物车、用户偏好、草稿内容。持久化的时机和策略直接影响用户体验。
我的经验是,持久化策略应该由上下文自己决定,而不是由消费点决定。上下文内部可以在写操作之后自动触发持久化,也可以在生命周期钩子里做批量持久化。消费点不需要关心数据存到了哪里、什么时候存的。
function createCartContext(initialItems: CartItem[] = []): CartContext { // 从localStorage恢复 const saved = localStorage.getItem('cart'); let state = saved ? JSON.parse(saved) : { items: initialItems, totalPrice: 0, couponCode: null }; function persist() { localStorage.setItem('cart', JSON.stringify(state)); } return { // ... addItem: (item) => { state.items.push(item); state.totalPrice = calculateTotal(state.items); persist(); // 写操作后自动持久化 }, // ... }; }这里有个细节需要注意:持久化的数据要做版本管理。因为随着业务迭代,状态结构可能会变化,旧版本的数据直接反序列化可能会出错。我的做法是在持久化数据里加一个version字段,恢复时检查版本号,不匹配就丢弃或迁移。
const PERSIST_VERSION = 2; function persist() { localStorage.setItem('cart', JSON.stringify({ version: PERSIST_VERSION, data: state, })); } function restore() { const raw = localStorage.getItem('cart'); if (!raw) return null; const parsed = JSON.parse(raw); if (parsed.version !== PERSIST_VERSION) { localStorage.removeItem('cart'); return null; } return parsed.data; }这个版本管理机制看起来简单,但在实际项目中能避免很多"用户升级后数据错乱"的问题。我踩过一次坑,就是因为没有做版本管理,结果状态结构变更后,老用户恢复出来的数据缺少字段,导致页面白屏。
4. 实操过程与核心环节实现
4.1 从零搭建一个上下文模式的状态层
下面我以一个多步骤表单为例,完整走一遍上下文模式的搭建过程。这个表单有三个步骤:基本信息、地址信息、确认提交。每个步骤有自己的状态,步骤之间需要共享部分数据。
第一步,定义每个步骤的上下文规格。
// 基本信息上下文 interface BasicInfoContext { state: { name: string; email: string; phone: string }; setName: (v: string) => void; setEmail: (v: string) => void; setPhone: (v: string) => void; validate: () => { valid: boolean; errors: string[] }; } // 地址上下文 interface AddressContext { state: { province: string; city: string; detail: string }; setProvince: (v: string) => void; setCity: (v: string) => void; setDetail: (v: string) => void; validate: () => { valid: boolean; errors: string[] }; } // 表单聚合上下文 interface FormContext { basicInfo: BasicInfoContext; address: AddressContext; currentStep: number; goNext: () => boolean; goPrev: () => void; submit: () => Promise<boolean>; }第二步,实现各个上下文的工厂函数。这里以基本信息上下文为例。
function createBasicInfoContext(): BasicInfoContext { let state = { name: '', email: '', phone: '' }; return { get state() { return { ...state }; }, setName: (v) => { state.name = v; }, setEmail: (v) => { state.email = v; }, setPhone: (v) => { state.phone = v; }, validate: () => { const errors: string[] = []; if (!state.name.trim()) errors.push('姓名不能为空'); if (!/^[^@]+@[^@]+\.[^@]+$/.test(state.email)) errors.push('邮箱格式不正确'); if (!/^\d{11}$/.test(state.phone)) errors.push('手机号格式不正确'); return { valid: errors.length === 0, errors }; }, }; }第三步,实现表单聚合上下文,负责步骤切换和最终提交。
function createFormContext(): FormContext { const basicInfo = createBasicInfoContext(); const address = createAddressContext(); let currentStep = 0; return { basicInfo, address, get currentStep() { return currentStep; }, goNext: () => { if (currentStep === 0) { const result = basicInfo.validate(); if (!result.valid) return false; } if (currentStep === 1) { const result = address.validate(); if (!result.valid) return false; } currentStep = Math.min(currentStep + 1, 2); return true; }, goPrev: () => { currentStep = Math.max(currentStep - 1, 0); }, submit: async () => { const basicResult = basicInfo.validate(); const addressResult = address.validate(); if (!basicResult.valid || !addressResult.valid) return false; const payload = { ...basicInfo.state, ...address.state, }; const response = await fetch('/api/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); return response.ok; }, }; }第四步,在应用入口创建表单上下文,并通过Provider注入。
const FormContextReact = React.createContext<FormContext | null>(null); function App() { const formContext = React.useMemo(() => createFormContext(), []); return ( <FormContextReact.Provider value={formContext}> <FormWizard /> </FormContextReact.Provider> ); } function useFormContext(): FormContext { const ctx = React.useContext(FormContextReact); if (!ctx) throw new Error('FormContext not provided'); return ctx; }第五步,在各个步骤组件中消费上下文。
function BasicInfoStep() { const form = useFormContext(); const { name, email, phone } = form.basicInfo.state; return ( <div> <input value={name} onChange={e => form.basicInfo.setName(e.target.value)} /> <input value={email} onChange={e => form.basicInfo.setEmail(e.target.value)} /> <input value={phone} onChange={e => form.basicInfo.setPhone(e.target.value)} /> <button onClick={() => form.goNext()}>下一步</button> </div> ); }这套方案跑下来,整个表单的状态管理非常清晰。每个步骤只关心自己的上下文,步骤切换和提交逻辑集中在聚合上下文里。新增步骤只需要新增一个上下文并在聚合上下文里注册,不需要改动已有代码。
4.2 上下文模式在异步场景下的处理
异步操作是上下文模式最容易出问题的地方。因为异步操作的结果可能在上下文已经销毁之后才返回,这时候如果直接更新状态,就会造成内存泄漏或者状态污染。
我的处理方式是在上下文内部维护一个"活跃标记",异步操作开始前检查标记,操作完成后再次检查,如果上下文已经销毁就丢弃结果。
function createAsyncContext() { let active = true; let state = { data: null, loading: false, error: null }; async function fetchData() { if (!active) return; state.loading = true; try { const result = await api.getData(); if (!active) return; // 上下文已销毁,丢弃结果 state.data = result; state.error = null; } catch (e) { if (!active) return; state.error = e.message; } finally { if (active) state.loading = false; } } return { get state() { return { ...state }; }, fetchData, destroy: () => { active = false; }, }; }这个模式看起来简单,但它解决了一个非常实际的问题。我在一个实时搜索功能里就遇到过,用户快速输入时触发了多个搜索请求,后发的请求先返回,导致搜索结果错乱。加上活跃标记之后,只有最后一次请求的结果会被采纳,前面的请求结果全部丢弃。
注意:活跃标记只能解决"上下文销毁后丢弃结果"的问题,不能解决"请求竞态"的问题。请求竞态需要用请求ID或AbortController来处理。两者结合使用才能保证异步场景下的状态一致性。
4.3 上下文模式的性能优化
上下文模式用不好,性能会比全局状态还差。因为每个上下文都是一个独立的订阅源,如果消费点订阅了太多上下文,任何一个上下文变化都会触发重新渲染。
优化的核心思路是"按需订阅"。消费点只订阅自己真正需要的上下文,而不是一次性订阅所有上下文。在React里,可以通过拆分Context来实现。比如把FormContext拆成BasicInfoContext、AddressContext、StepContext三个独立的Context,消费点按需使用。
const BasicInfoContextReact = React.createContext<BasicInfoContext | null>(null); const AddressContextReact = React.createContext<AddressContext | null>(null); const StepContextReact = React.createContext<StepContext | null>(null); function BasicInfoStep() { const basicInfo = React.useContext(BasicInfoContextReact); // 只订阅basicInfo,address和step变化不会触发重新渲染 }另一个优化点是上下文的粒度。不要把太多状态塞进一个上下文,也不要把状态拆得太碎。我的经验是,一个上下文的状态字段控制在5到10个之间比较合适。太少会导致上下文数量爆炸,太多会导致订阅粒度太粗。
还有一个容易被忽略的优化点是上下文的创建时机。如果上下文是在组件内部创建的,每次组件重新渲染都会创建新实例,导致所有消费点重新渲染。正确的做法是在组件外部或者用useMemo/useRef来保证上下文实例的稳定性。
// 错误做法 function App() { const formContext = createFormContext(); // 每次渲染都创建新实例 return <Provider value={formContext}>...</Provider>; } // 正确做法 function App() { const formContext = React.useMemo(() => createFormContext(), []); return <Provider value={formContext}>...</Provider>; }5. 常见问题与排查技巧实录
5.1 上下文状态不更新或更新延迟
这是最常见的问题。表现是调用上下文的写方法之后,消费点没有重新渲染,或者渲染出来的还是旧数据。
排查思路分三步。第一步,确认写方法是否真的修改了状态。可以在写方法里加console.log,看看调用时状态有没有变化。第二步,确认消费点是否订阅了正确的上下文。如果消费点用的是useContext,检查Provider的value是否变化了。第三步,确认上下文的创建时机是否正确。如果上下文是在组件内部创建的,每次渲染都会创建新实例,消费点订阅的是旧实例,自然看不到更新。
我遇到过一次很隐蔽的情况,写方法修改的是状态的副本而不是原始状态。比如state = { ...state, name: 'new' }这种写法,如果state是用let声明的,重新赋值后闭包里的state引用变了,但外部通过getter拿到的还是旧引用。解决方法是把状态放在一个对象里,修改对象的属性而不是重新赋值。
// 有问题的写法 let state = { name: '' }; function setName(v) { state = { ...state, name: v }; // 重新赋值,闭包引用变了 } // 正确的写法 const state = { name: '' }; function setName(v) { state.name = v; // 修改属性,引用不变 }5.2 上下文之间的循环依赖
当两个上下文互相引用时,就会出现循环依赖。比如订单上下文需要读取用户上下文的信息,用户上下文又需要读取订单上下文的信息。这种循环依赖会导致初始化失败或者运行时错误。
解决循环依赖的方法有两种。第一种是引入第三个上下文作为中介,两个上下文都依赖中介上下文,而不是互相依赖。第二种是延迟依赖,上下文初始化时不直接引用对方,而是在方法调用时通过参数传入。
// 循环依赖 const userContext = createUserContext(orderContext); const orderContext = createOrderContext(userContext); // 延迟依赖 const userContext = createUserContext(); const orderContext = createOrderContext(); // 在方法调用时传入 orderContext.getUserOrders(userContext.getUserId());我倾向于第二种方案,因为它更灵活,而且不会引入额外的上下文。但要注意,延迟依赖意味着上下文之间的耦合关系是隐式的,需要在文档里写清楚。
5.3 上下文持久化数据损坏
持久化数据损坏通常发生在状态结构变更之后。旧版本的数据缺少新字段,或者字段类型不匹配,导致反序列化后上下文行为异常。
排查方法是先检查持久化数据的版本号,然后对比当前状态结构的字段。如果版本号不匹配,直接丢弃旧数据。如果版本号匹配但字段缺失,可以在恢复时做字段补全。
function restore() { const raw = localStorage.getItem('cart'); if (!raw) return null; try { const parsed = JSON.parse(raw); if (parsed.version !== PERSIST_VERSION) return null; // 字段补全 return { items: parsed.data.items || [], totalPrice: parsed.data.totalPrice || 0, couponCode: parsed.data.couponCode || null, }; } catch (e) { // 数据损坏,直接丢弃 localStorage.removeItem('cart'); return null; } }提示:持久化数据一定要做try-catch。我见过因为localStorage里存了非法JSON导致整个应用白屏的情况。加一层try-catch,最坏情况就是丢失持久化数据,不会影响应用启动。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 状态不更新 | 上下文实例不稳定 | 检查创建时机 | 用useMemo或外部创建 |
| 状态更新延迟 | 订阅了错误的上下文 | 检查useContext参数 | 确认Provider和Consumer配对 |
| 循环依赖 | 上下文互相引用 | 检查初始化顺序 | 引入中介上下文或延迟依赖 |
| 持久化数据损坏 | 版本不匹配或JSON非法 | 检查localStorage内容 | 加版本号和try-catch |
| 内存泄漏 | 异步操作未取消 | 检查异步回调 | 加活跃标记或AbortController |
| 性能下降 | 订阅粒度过粗 | 检查重新渲染次数 | 拆分Context或按需订阅 |
6. 上下文模式在实际项目中的扩展应用
6.1 多环境配置的上下文管理
上下文模式不仅适用于UI状态,也适用于配置管理。一个应用通常有开发、测试、生产三套环境配置,每套配置的API地址、功能开关、日志级别都不同。用上下文模式来管理配置,可以让配置的切换变得非常清晰。
interface ConfigContext { apiBaseUrl: string; enableDebug: boolean; logLevel: 'debug' | 'info' | 'error'; featureFlags: Record<string, boolean>; } function createConfigContext(env: 'dev' | 'test' | 'prod'): ConfigContext { const configs = { dev: { apiBaseUrl: 'http://localhost:3000', enableDebug: true, logLevel: 'debug', featureFlags: { newUI: true } }, test: { apiBaseUrl: 'https://test-api.example.com', enableDebug: true, logLevel: 'info', featureFlags: { newUI: true } }, prod: { apiBaseUrl: 'https://api.example.com', enableDebug: false, logLevel: 'error', featureFlags: { newUI: false } }, }; return configs[env]; }这种模式下,切换环境只需要在应用入口传入不同的env参数,所有消费点自动获取对应环境的配置。而且因为配置是只读的,不需要担心被意外修改。
6.2 上下文模式在AI对话系统中的应用
AI对话系统是上下文模式的一个典型应用场景。一个对话系统通常需要管理对话历史上下文、用户画像上下文、工具调用上下文、系统提示词上下文。每个上下文有自己的更新规则和生命周期。
对话历史上下文是追加写的,每次用户或AI发言都会追加一条记录。用户画像上下文是只读的,从用户数据库加载。工具调用上下文是临时的,每次工具调用完成后就清理。系统提示词上下文是静态的,应用启动时加载一次。
用上下文模式管理这些状态,可以让对话系统的逻辑非常清晰。每次生成回复时,从各个上下文收集需要的信息,组装成提示词,调用模型,然后把结果写回对话历史上下文。整个过程不需要全局状态,也不需要到处传参。
6.3 上下文模式的测试策略
上下文模式的一个巨大优势是可测试性。因为每个上下文是独立的,可以单独测试,不需要启动整个应用。
测试上下文时,我通常关注三个方面:状态变更是否正确、边界条件是否处理、异步操作是否可靠。
describe('CartContext', () => { it('添加商品后总价正确', () => { const cart = createCartContext(); cart.addItem({ id: '1', name: '商品A', price: 100, quantity: 1 }); expect(cart.getTotalPrice()).toBe(100); }); it('移除商品后总价正确', () => { const cart = createCartContext(); cart.addItem({ id: '1', name: '商品A', price: 100, quantity: 1 }); cart.addItem({ id: '2', name: '商品B', price: 200, quantity: 1 }); cart.removeItem('1'); expect(cart.getTotalPrice()).toBe(200); }); it('优惠券无效时不改变总价', async () => { const cart = createCartContext(); cart.addItem({ id: '1', name: '商品A', price: 100, quantity: 1 }); const result = await cart.applyCoupon('INVALID'); expect(result).toBe(false); expect(cart.getTotalPrice()).toBe(100); }); });这种测试写起来很直接,跑起来也很快,因为不依赖任何UI框架。我通常会把上下文的测试覆盖率要求设到90%以上,因为上下文是业务逻辑的核心,这里出问题影响面最大。
6.4 上下文模式的迁移策略
如果你已经有一个用全局状态管理的项目,想迁移到上下文模式,我建议采用渐进式迁移,而不是一次性重写。
第一步,识别出项目中耦合最严重的状态模块,通常是那些被最多组件读写、修改最频繁的状态。第二步,把这个模块拆成一个独立的上下文,保留原有的全局状态作为兼容层,新代码用上下文,老代码继续用全局状态。第三步,逐步把老代码迁移到上下文,每迁移一个消费点就测试一次。第四步,当所有消费点都迁移完成后,删除全局状态。
这个过程中,兼容层是关键。兼容层的作用是让上下文和全局状态可以互相读写,保证迁移期间数据一致。具体实现可以是在上下文的写方法里同步更新全局状态,在全局状态的订阅里同步更新上下文。
function createCartContextWithCompat(globalStore) { const cart = createCartContext(globalStore.getState().cartItems); // 上下文写操作同步到全局状态 const originalAddItem = cart.addItem; cart.addItem = (item) => { originalAddItem(item); globalStore.dispatch({ type: 'cart/add', payload: item }); }; // 全局状态变化同步到上下文 globalStore.subscribe(() => { const globalItems = globalStore.getState().cartItems; if (JSON.stringify(globalItems) !== JSON.stringify(cart.getItems())) { cart.reset(); globalItems.forEach(item => cart.addItem(item)); } }); return cart; }这种兼容层会增加一些复杂度,但它让迁移过程变得可控。你可以一个模块一个模块地迁移,每次迁移后都能正常运行和测试,不会出现"迁移到一半系统跑不起来"的情况。
注意:兼容层是临时方案,迁移完成后一定要删除。长期保留兼容层会导致双向同步的逻辑越来越复杂,最终变成新的技术债务。
7. 我个人的一些实操体会
上下文模式这个东西,刚接触的时候会觉得多此一举。明明一个全局store就能搞定的事情,为什么要拆成这么多上下文,还要写这么多样板代码。但当你维护过一个超过十万行代码、状态耦合严重、改一处崩三处的项目之后,你就会明白边界清晰有多重要。
我现在的习惯是,新项目启动时先不急着写状态管理代码,而是先画一张状态关系图。把所有的状态列出来,标注它们之间的依赖关系,然后按照"使用场景"分组。每一组就是一个上下文。这个过程通常只需要半个小时,但它能避免后面几个月的重构痛苦。
还有一个体会是,上下文模式的价值不在于代码本身,而在于它强迫你思考状态的边界。当你定义上下文的时候,你不得不回答这些问题:这个状态属于谁?谁可以读?谁可以写?什么时候失效?这些问题在全局状态模式下是很容易被忽略的,但在上下文模式下是必须回答的。
最后分享一个小技巧。如果你不确定一个状态应该放在哪个上下文里,就问自己一个问题:这个状态变化时,哪些UI需要更新?如果答案是一组固定的、经常一起变化的UI,那它们就应该属于同一个上下文。如果答案是一组不相关的UI,那这个状态可能被放错了地方,应该拆到更细的上下文里。
这个判断标准我在多个项目中验证过,准确率很高。它背后的逻辑是,上下文的边界应该和UI的更新边界对齐。这样既能保证状态变化的传播范围最小,又能保证相关UI的同步更新。