请使用 React Context 来创建共享环境,而非将其当作杂物箱。
Context通过树结构来分发值;它不负责定义所有权、更新边界或状态模型。建议使用功能较为有限的提供者以及真正的服务器状态管理工具。
来源: Most Developers Use React Context for the Wrong Problems
属性传递并不总是架构上的失败
React Context常常被用作快速解决方案。某个值需要传递给三个从不使用它的组件,另一个界面需要当前用户信息,还有个模态框必须从层级很深的地方打开。不断传递属性十分繁琐,于是出现了Provider,结构顿时显得整洁多了。
中间层组件会丢弃未被使用的属性,深层子组件则通过hook获取所需数据。这样的结构比在每一层都传递相同参数更为合理。
这种表面上的改善可能会掩盖一个更重要的问题:这个值真的应该放在Context中吗?
上下文解决了一个问题:父组件可以将值传递给子组件,而无需通过每一个中间文件。但它无法决定状态应存储在哪里、消费者应多久更新一次、无关的值是否应共享同一个数据结构,也无法确定哪个组件负责管理工作流。
这些决策依然存在。上下文能够将它们隐藏起来,因为访问过程变得不可见。子组件无需在属性中声明即可读取数据;提供者可以积累各种无关的字段,因为一切看起来都是“全局”的。一次小的更新可能会影响到数十个共享同一个上下文对象的消费者。一个本应可复用的组件,却可能依赖于仅在某个页面中存在的提供者。
问题不在于API本身,而在于用更便捷的访问方式来替代清晰的权限管理机制。
Props钻取方式因重复操作显而易见而声名不佳。数值从较高值开始,穿过各种布局组件,最终到达需要它的叶子节点。中间层看起来像复杂的管道系统,因此Context显得更像是升级版解决方案。
有时这种重复操作其实能提供有用信息——它能显示数值的来源、哪些分支依赖于该数值,以及依赖关系延伸的距离。Props使得输入内容可被搜索;当数值发生变化或消失时,其影响会局限在特定范围内且一目了然。
Context则消除了这样的路径依赖。后代节点无需更新中间契约即可开始读取数据。虽然树结构仍然依赖于提供者,但这种关系不会再出现在组件界面中。
这并不意味着所有的长属性链都必须保留。当一个大型子树确实只涉及单一功能时,使用属性传递反而会带来冗余。错误在于将每一个重复的属性都视为上下文缺失的证据。
有时数值过高是因为所有权分配有误。某个页面保存了对话框草稿,并将其通过多层结构传递给表单。虽然上下文能简化访问流程,但更合理的设计应是让表单或对话框直接拥有该草稿。
有时通过组合结构可以省去中间环节。父组件可以直接创建需要数据的元素,然后将该元素作为children传递给布局组件。这样布局组件就无需知晓其中的依赖关系。
属性重复只是症状表现,上下文仅是其中一种解决方案,并非默认的诊断方法。
上下文无法安全地将本地状态转换为全局状态
一种常见的做法是,每当多个组件需要更新相同状态时,就创建一个上下文。提供者负责存储值并导出设置函数,所有子组件都可以进行读写操作。
这种情况通常从小规模开始:
const AppContext = createContext(null);
function AppProvider({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [activeModal, setActiveModal] = useState(null);
const [selectedRecord, setSelectedRecord] = useState(null);
return (
<AppContext.Provider
value={{
sidebarOpen,
setSidebarOpen,
activeModal,
setActiveModal,
selectedRecord,
setSelectedRecord,
}}
>
{children}
</AppContext.Provider>
);
}
访问功能集中在一处,但状态的含义仍然模糊不清。侧边栏的显示、哪个模态框是打开的以及当前选中了哪条记录,这些功能往往属于不同的模块,并且更新时间也各不相同。
此时,树结构中的任何子组件都可以直接重写提供者的存储功能。表格行可以设置选中状态、打开模态框或关闭侧边栏。组件并不表达自身的意图,而是直接操作共享字段,组合出所需的任意功能组合。
接下来是隐式耦合的问题。这种模式假设已经存在选中状态,而表格则认为关闭模态框就能清除选中状态。侧边栏会对它并不负责的工作流做出反应。这些关系并未被记录在还原器、操作或功能边界中——它们存在的唯一原因在于许多组件可以调用多种设置函数。
将字段提升到上下文层仅能改变存储位置,却无法自动提升语义的清晰度。
更完善的提供者会暴露操作而非原始设置函数,且仅针对某一统一的功能范畴。对话系统可能会提供openDialog和closeDialog函数;认证提供者可能会暴露会话数据及登录操作;主题提供者则可能会暴露当前主题以及专门的更新函数。
不要仅仅因为“多个组件需要访问”就止步不前,而应思考它们是否共享同一个生命周期和同一功能目标。
一个上下文对象可能带来巨大的更新负担
上下文还负责分发更新。当提供者的值发生变化时,使用该上下文的组件可以重新渲染。如果该值确实只涉及某一方面的内容,并且相关组件确实依赖其变化,那倒也没问题。
问题出现在某个提供者将不同更新频率的值捆绑在一起时。
function ApplicationProvider({ children }) {
const value = {
user,
theme,
locale,
notifications,
sidebarOpen,
featureFlags,
liveJobStatus,
};
return (
<ApplicationContext.Provider value={value}>
{children}
</ApplicationContext.Provider>
);
}
主题切换不应导致仅需要所选记录的表格失效,侧边栏是否展开的标志也不应刷新仅需用户ID的表单。如果将这些字段合并到一个对象中,除非仔细进行缓存处理并拆分上下文,否则每个组件都会对每一次写入操作敏感。
团队往往在性能分析之后才会意识到这种代价。到那时,许多功能已经引入了这种复杂的结构。要进行拆分,就需要找出所有使用该对象的组件,并确定每个组件实际上需要哪些数据。
建议选择更新频率与需求相匹配的供应商。将高频变动的用户界面元素放在一处,而将稳定的会话数据存放在另一处。当对象标识在每次渲染时都会变化时,应对供应商相关值进行缓存。在将“又一个字段”合并到现有数据结构中之前,务必先进行测试。
上下文可能掩盖缺失的组件边界
当某个功能的连接方式显得复杂时,上下文可以掩盖缺失的边界。那种从页面级上下文中默默读取当前产品状态、草稿信息、验证结果、权限设置以及变更状态的编辑器,往往只在该页面内才有意义。
某些依赖关系属于隐含的,因为它们描述的是环境而非功能数据——比如主题、本地化设置、路由机制以及共享的HTTP客户端。这类依赖通常范围较广且相对稳定。
特性数据则有所不同。将其隐藏在 Context 中虽然能消除属性带来的干扰,但会增加架构间的依赖性。属性缺失并不意味着依赖关系也缺失了;通常只是这些依赖关系不再处于可见状态而已。
思考一下该组件是否能够在当前包围它的提供者之外被理解和测试。如果答案是否定的,那么 Context 可能是在弥补本应更明确的属性或组合契约所缺失的边界。
Context 常被用作替代 proper 的服务器状态管理方案
另一种误用方式是,因为有多个组件需要数据,就将获取到的数据直接放入 Context 中。提供者只需请求一次数据,存储响应后即可暴露数据、加载状态、错误信息以及刷新功能。这看起来就像是一个无需其他库的共享数据层。
真实的服务器状态很快就需要更多功能:请求去重、失效重试规则、重试机制、取消操作、分页处理、数据变更后的更新,以及刷新时仍能显示之前的数据。
Context框架无法提供这些功能,它只会转发服务提供商计算出的内容。
服务提供商逐渐变成了手动实现的缓存系统,各个组件都依赖于其特定的刷新机制,数据变更时还需手动修复共享数组的问题。不同的服务提供商会设计略有差异的加载和错误处理方式,使用单个路由可能会导致缓存丢失,而同时使用两个服务提供商则可能造成重复获取数据。
之所以需要专门的数据库,是因为远程状态与本地UI状态有着不同的生命周期:服务器端的所有权、数据新鲜度、缓存机制、失效处理、并发控制以及故障模式等。
Context 可以用于分发已配置的客户端或稳定的功能依赖关系。但用普通的 useState 和 useEffect 来重新构建请求协调机制通常并不合适。专注于通过单一工作流协调服务器数据的提供者仍然是可行的,但让数据全局可访问并不能解决同步问题。
使数据载荷可访问与管理远程数据是两回事。
Context 不能替代状态模型
当工作流变得复杂时,团队有时会将其放入 Context 中,以便每个步骤都能看到相同的字段。结账、入职流程、编辑器以及多步骤表单往往都是如此处理。
当提供者仅代表一个工作流并实现受控的流程转换时,这种方式还能正常工作;但若 Context 只是一堆字段和设置函数的集合,它就会变得很脆弱。
结账提供方可能包含地址字段、支付信息、配送选项、当前步骤、验证错误、加载状态标志、模态框状态标志、总计金额以及众多设置函数。每个子组件都可以独立修改其中的任意部分。
共享存储与定义规则并非同一概念。更改国家信息可能需要清除配送设置,而修改配送地址则可能需要重新计算总计金额。后退操作可能会保留某些字段的同时使其他字段失效。支付失败虽不会清除地址信息,但会改变流程走向。
这些关联关系需要一个状态模型——即用于定义有效状态转换的还原器或明确操作规则。上下文可以分发该模型,但它本身并非状态模型。
如果没有规则,就会出现各种不可能的组合:没有有效配送信息的支付步骤、同时为真和为假的成功与错误状态、属于先前地址的配送选项。你并不一定需要正式的状态机库,但仍需明确哪些事实是独立的、哪些组合是合法的,以及哪些事件会在一次状态转换中同时修改多个字段。
上下文能让良好的模型变得可行,但它无法仅通过将混乱的模型提升到更高的层级来挽救它。
全局访问会削弱所有权吗
上下文中的值既易于读取,也便于从多个位置进行写入。便利性可能会侵蚀所有权。
通知上下文由网络处理程序、表单、页面和后台任务更新;选择上下文则由表格、对话框、快捷键和路由变化所修改;偏好设置上下文则由每一个显示设置的控件直接写入。
当许多开发者共享同一个存储时,确保正确性的责任就会变得模糊。
更明确的职责划分源于更为精简的操作设计。相比原始的setNotifications,应优先使用addNotification、dismissNotification和clearExpiredNotifications;相比通用的setSession,则应优先使用signIn、signOut和refreshSession。这样的操作具有明确含义,也有助于维护者保持系统的稳定性。
即便如此,也需让提供者专注于特定功能。为每个功能都创建通用的调度器,只不过是以另一种名称实现了全局耦合而已。
许多组件可能会读取当前用户信息,但真正需要替换会话的组件应该很少。只要由提供者掌控生命周期,而使用者通过简化的接口进行交互,上下文就能保持易于维护的状态。
上下文提供者可能变成隐形的单体系统
体积过大的组件会通过文件长度表现出来,而那些体积较大的提供者则能更长时间保持沉默。
整体结构看似由小型组件和钩子构成,但实际上只有一个提供者负责管理应用状态、网络请求、数据持久化、浏览器事件、权限、通知、模态框以及各种派生值。由于协调工作由提供者承担,组件文件通常保持较短。
这依然属于单体结构,只不过它只是渲染了少量的 JSX 代码而已。
提供者会因为各种无关的原因发生变化。通知规则、主题需求、会话变更以及模态框的工作流程都会修改同一个模块,所有使用该模块的组件都依赖于它的稳定性。由于各操作需通过共享状态交互,测试范围也会随之扩大;当更新信息扩散时,性能会受到影响,而重构则不得不解决那些嵌入在全局使用的值中的复杂假设问题。
解决方案并非为了嵌套而无限嵌套。当每个提供者都有明确的责任时,嵌套是可接受的。通常,几个职责明确的提供者比一个假装共享单一生命周期的对象更清晰。
放置位置也很重要。某个路由所需的上下文应仅包裹该子树,而非整个应用。有限的放置范围能缩小依赖关系和更新范围。
全局可用性应是努力获得的成果,而非存放那些不便使用的属性的默认位置。
上下文在解决其真正能处理的问题时表现优异
当某个子树确实存在环境依赖或稳定的关注点时,上下文才能发挥最大作用。
主题能够统一应用,是因为许多后代需要相同的展示方式。本地化也能实现,因为格式设置与翻译可广泛适用。当所有使用者都处于同一边界内时,共享的 API 客户端、路由环境、表单作用域或单一工作流也可归入其中。
该值不必是静态的。它应当具有连贯的含义,并拥有与使用者需求相匹配的更新机制。
设计良好的上下文能够回答:此子树在何种环境中运行?这些组件共享哪些依赖?它们属于哪一种特定的工作流?
设计糟糕的上下文则会导致:哪些值作为属性传递时十分麻烦?这类问题会引发无关的状态、广泛的更新范围、隐藏的依赖关系以及原始的设置方式。系统结构变得混乱,所有权也更难界定。
在添加 Context 之前,需先考虑是否有具有意义的子树共享该值、各使用者是否拥有相同的生命周期、该值变化的频率,以及提供方能否发布简洁且稳定的契约。同时还要思考 Context 能够解决哪些问题。状态放置不当、功能边界缺失、组合性薄弱、服务器端状态缓存问题或工作流程定义不清,这些都不会因为更便捷的访问方式而得到解决。
Context 应该分配决策权,而非回避决策
大多数误用并非源于对 API 的误解,而是出于即时的便利性考虑。属性链消失了,组件获得了访问权限,功能无需重新组织结构即可上线,如今的代码确实更易于编写。
当访问需求超过所有权控制时,问题便出现了。不同的生命周期共享同一个提供者。用户会为那些他们并未关注的变更重新渲染内容。功能组件依赖于隐藏的运行环境。这些提供者逐渐变成了临时存储、请求缓存以及从未获得过明确建模的工作流引擎。
将 Context 称为糟糕的状态管理库是误解了它的本质。它从未宣称要定义状态管理机制,只是负责将某个值沿层级结构传递下去。
相关的设计依然需要明确:该值代表什么、哪个模块拥有它、谁有权限修改它、更新频率是多少,以及哪些用户真正属于该值的管辖范围。
只有在这些决策都已明确之后,Context 的作用才能得到最大发挥。