先记住这个答案
Provider 的前后 value 用 Object.is 比较。若一个对象被不可变地更新,即使只改其中一个字段,新的对象引用也会使读取该 Provider 的消费者获得新值;消费者只使用另一个字段,并不会让 useContext 自动过滤这次变化。应同时区分 Context 通知和普通父层渲染:稳定 value 只消除前一种不必要变化,不能保证组件不受其它输入影响。
- Context 比较的是整个 value,而不是解构出的字段
- 只有读取相应 Provider 的消费者受这条 Context 更新影响
- 原地修改对象绕过通知,会让界面依赖偶然的其它更新
从一次通知数量更新看消费者范围
假设一个账户 Context 包含 displayName 和 unreadCount。通过展开旧对象创建新对象来增加未读数时,名称组件虽然只解构 displayName,也仍然读取了发生变化的 Context。名称文本可能与上一次完全相同,但这不意味着名称组件没有参与重新计算。
这个范围不能扩大成 Provider 下所有组件都会因 Context 被通知。没有读取该 Context 的组件不是这条通知的消费者;处在另一个同对象 Context Provider 之下的组件使用内层值。它们是否由于普通父组件渲染而执行,是需要另外分析的路径。
区分稳定引用与可靠的数据更新
把 unreadCount 直接写进原对象,再把同一对象作为 value 传递,Object.is 仍然相等,因此不会产生由 value 身份改变引起的 Context 传播。某个消费者如果恰好因其它状态再次渲染,可能又读到修改后的字段,最终表现为不同区域在不同时间更新。
正确性应建立在明确的状态更新上,而不是依赖其它操作顺便刷新页面。需要保留可变外部对象时,也应有清楚的订阅协议,让 React 知道何时读取新快照。Context 可以传递这样的资源或 store 实例,但资源内部变化并不会自动被转换成 Context 通知。
沿着触发动作定位多余的计算
先记录哪一个事件改变了哪份权威状态,再看 Provider 的 value 是真正数据变化,还是渲染时无条件新建的包装对象。前者可考虑拆分消费范围,后者可以评估稳定对象引用。最后检查昂贵组件是否还收到每次新建的 props,避免把父层更新误诊成 Context 的问题。
一个可比较的实验应只改变通知数量,保持名称、主题和列表输入不变,观察名称区域是否执行昂贵工作。Profiler 比高亮一次更新更能帮助判断成本;开发环境重复调用也要与生产交互区分。优化目标是减少不必要的工作,而不是让所有组件日志都只出现一次。
容易答错的地方
- 认为解构或可选链会缩小订阅范围
- 这些表达式只在拿到 Context 值之后读取属性,不会向 React 注册字段依赖。若需要按字段通知,要明确采用拆分 Context 或具有选择器订阅能力的状态方案。
- 把原地修改当作避免渲染的技巧
- 这样会让状态通知与实际数据分离,并可能破坏多个渲染快照之间的隔离。减少更新成本应优化数据范围和计算方式,不能靠跳过必要通知来制造暂时看似流畅的界面。
面试官还会怎么问?
把 value 用 useMemo 包起来就解决了吗?
它能在依赖未变时复用包装对象,减少无关父层渲染造成的新引用。真正的依赖变化后仍会得到新的 value,因此不能把它当成字段订阅工具。
同一个组件读取两个 Context 怎么算?
任一实际消费的 Context 值变化,都可能要求组件更新。拆分两个通道不会自动减少一个同时依赖两者的组件的必要工作,要结合各消费者的依赖分布判断。
为什么没有深比较每个字段?
内置 API 的约定就是按 Object.is 比较整个值,没有提供深比较或选择器参数。面试时说明这个可依赖的行为即可,不必编造 React 团队为某个业务场景作出设计的内部理由。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。