导致 React 不必要重新渲染的九种常见模式
介绍了九种常见的 React 状态与效应模式,它们会悄悄扩大重新渲染的范围,以及如何重构组件以使更新仅限于特定部分。
来源: 9 React Habits That Quietly Cause Unnecessary Re-Renders
React 本身通常运行速度很快。问题出现在:一次微小的更新却迫使组件树中远超实际需要的部分重新进行计算时。
很长一段时间里,人们认为过度重绘是 React 自身需要解决的问题。当某个组件的更新频率超出正常范围时,人们的第一反应是将其用 memo 包装,或者使用 useMemo 和 useCallback。如果界面依然反应迟缓,就会对更上层的组件采取同样的处理方式,期望 React 最终能恢复正常状态。
那种修补方式有时会让性能分析图表看起来更好,但很少能让底层组件结构变得更清晰。State在层级结构中的位置仍然过高,上下文值依然被捆绑在一起,派生数据依然存在重复,引用依然不稳定,而Effects在运行时仍会触发进一步的更新。实际上,记忆化只是被用来屏蔽那些本不必如此复杂的数据流对UI的影响。
单纯的重新渲染并不算错误。React的设计理念就是在组件的输入可能发生变化时再次调用其函数,而且很多组件的计算成本很低,因此阻止每一次无害的渲染所带来的复杂性反而会超过其带来的好处。
真正的问题不在于组件是否被重新渲染,而在于是什么触发了这次渲染,以及React调用它之后又执行了哪些操作。
大多数可以避免的重复渲染问题,根源在于在组件规模较小时人们认为无害的一些习惯。只有当组件变大、其子元素变重,或者某个快速变化的值开始使页面的大部分内容失效时,这些习惯的代价才会显现出来。
1. 将频繁变化的状态置于树结构过高的位置
将状态提升到共享的祖先节点,是 React 开发者最早掌握的模式之一。当两个组件依赖相同的值时,将该值的控制权交给它们最近的公共父节点,既能保持两者同步,又能避免出现多个数据来源。
然而,如果仅仅因为页面级组件能够访问所有内容,就把所有的本地交互状态都提升到该层级,就会引发问题。
搜索框会在控制面板上保留其文本内容。下拉菜单的展开/收起状态会显示在表格数据旁边。卡片网格会记录悬停状态。模态框内的表单字段则由触发该模态框的页面来管理。现在,每一次按键或轻微交互都会迫使对应的顶层组件重新渲染。
由于页面组件每次都会返回全新的子元素,因此即使图表、表格、导航和对话框的实际数据并未改变,更新搜索文本仍会导致它们重新运行。
这也并非意味着要将所有状态都埋得越深越好。状态应置于仍需协调相关组件的最小边界上。那些URL、表格以及导出按钮都依赖的过滤器,或许确实应该放在这些区域之上。而当前是否有单个工具提示处于打开状态,则通常无需将其放在那里。
对于那些更新速度极快的值而言,这种区分尤为重要。输入、拖动、指针追踪、动画进度以及实时表单字段都可能每秒产生大量更新。如果将这类操作放在成本较高的子树之上,原本仅涉及局部交互的操作就会变成整个页面需要处理的任务。
将状态移至真正使用它的组件附近,通常比用记忆化功能包裹整个页面更能直接提升性能,因为一旦父组件不再拥有该状态,React 就没有父级可以重新使其失效。
2. 为不相关的值使用同一个上下文
上下文的存在是为了解决特定问题:让子组件能够读取共享值,而无需强制中间的每个组件传递它根本不会用到的属性。
一旦某个提供者变成了所有“全局”内容的容器,问题就出现了。
常见的情况是有一个覆盖整个应用范围的上下文,其中包含了当前用户信息、主题设置、未读通知数量、侧边栏显示状态、区域设置、功能开关、权限信息以及几個操作函数。这样看起来很整洁——一个提供者,一个用于使用它的自定义 Hook。
但从更新的角度来看,这些值并不承担相同的职责。通知计数可能每分钟更新多次;主题则可能数月不变。侧边栏的显示状态属于本地UI状态,而已认证用户信息则反映的是会话本身。将它们捆绑在一起意味着每个使用方实际上都在订阅那些它可能根本不关心的变化。
通常,提供方在每次渲染时也会重新构建其上下文值:
function AppProvider({ children }) {
const [theme, setTheme] = useState("light");
const [notifications, setNotifications] = useState([]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const value = {
theme,
setTheme,
notifications,
setNotifications,
sidebarOpen,
setSidebarOpen,
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
每次提供方重新渲染时,value都会变成一个全新的对象。即使用户读取的具体字段实际上并未发生变化,他们也会因为引用地址的改变而认为发生了变化。
将传递给提供者的值缓存起来,可以避免因无关父组件更新而导致的重新渲染,但它无法将上下文值拆分为独立的部分。如果通知内容发生变化,整个组合对象仍会被视为已更改。
更好的方法是根据职责划分和更新频率来拆分上下文,而非仅仅为了方便而使用一个提供者文件。那些很少变化的配置可以放在一个上下文中,而变化频繁的实时数据则可以拥有独立的上下文。纯粹的本地布局状态可能根本无需通过整个应用程序的上下文来传递。
上下文不仅仅是值的传输机制——它也是更新分发的一种方式。提供者值的构成方式决定了每次更改能够传播的范围。
3. 当身份确实重要时重建对象、数组和函数
在每次渲染时创建新的值是普通的 JavaScript 行为。内联对象、过滤后的数组或新的箭头函数通常产生的开销极小,试图避免使用它们并不会带来任何好处。
一旦某些下游逻辑依赖该值的身份而非其内容,情况就会变得复杂。
经过记忆化的子组件会通过引用方式检查其属性。Effect 也会通过引用方式检查其依赖项列表。表格组件可能在列数组的引用发生变化时重新构建其内部结构。上下文提供者则会在其所持有的值指向新的引用时通知订阅者有更新发生。
两个对象可以持有完全相同的数据,但在内存中仍然是两个不同的对象。如果子组件在每次父组件渲染时都会收到一个全新的设置对象,它就无法知道这些设置实际上并没有任何变化。
常见的做法是默认将每个对象用 useMemo 包装,每个函数用 useCallback 包装。但这只不过是用一种习惯性做法替代了另一种而已。记忆化并非没有成本——它有自身的开销,需要维护自己的依赖数组,还有相应的运行成本。只有当稳定的引用能够让昂贵的操作被跳过或重复使用时,记忆化才能发挥价值。
那些永远不会变化的值通常可以直接在组件外部定义。与其仅仅为了整洁而将多个属性打包成一个对象,子组件往往可以直接接收这些独立的原始值。只有当函数被用于 Effect、订阅操作或记忆化边界中,且函数的标识确实会影响其行为时,才需要对其进行稳定处理。
真正的考验在于观察新引用带来的后续影响。如果下游没有任何组件会受到影响,那么每次渲染时重新生成该值并无害处。但如果某个新对象导致大型表格需要重建,或引发订阅关系的断开与重新连接,那么该对象的唯一性就成为了组件性能保障的一部分,应当得到相应的对待。
4. 存储计算值并通过效应保持其更新
造成额外渲染的最不明显的因素之一,就是为那些本可以通过现有属性或状态直接计算得到的内容而保留状态数据。
可以考虑这样一种组件:它保存完整的记录列表、当前的过滤文本,以及单独存储的已过滤列表。或者是一种表单,用于记录其各个字段、验证错误,同时还包含一个独立的 canSubmit 标志。又或者是一种列表组件,既保存选中的 ID,也保存另一个状态值来存储对应的选中对象。
由于这些重复的值可能会不同步,开发者会添加一个 Effect 来保持其一致性:
function Products({ products }) {
const [search, setSearch] = useState("");
const [visibleProducts, setVisibleProducts] = useState(products);
useEffect(() => {
setVisibleProducts(
products.filter((product) =>
product.name.toLowerCase().includes(search.toLowerCase())
)
);
}, [products, search]);
// ...
}
当 search 发生变化时,流程如下:组件首先使用状态中过时的已过滤列表进行渲染,React 会记录这次渲染,随后 Effect 被触发,更新状态并安排下一次渲染。
那次第二次渲染的存在只是因为计算结果被当作独立的状态项来存储了。
如果是在渲染时直接计算visibleProducts,它就会自动保持同步,多余的渲染也会完全消失。当过滤逻辑的计算成本较高时,可以将该计算封装在以实际输入为键的备忘录中。关键在于,这种缓存机制仅用于存储计算结果——绝不能将缓存的结果变成另一个可独立修改的真相来源。
这种模式带来的问题不仅限于不必要的渲染。派生状态可能会短暂显示与输入值相矛盾的结果,而将多个Effect堆叠在一起则可能导致连锁式的重新渲染。消除冗余状态通常也会同时去掉相关的同步Effect。
5. 订阅超出实际需求的更多外部存储
基于选择器的状态库能够实现非常精准的更新,但前提是组件确实会选取狭窄、具体的值。
一种常见的捷径是订阅整个状态存储或某个大型功能级对象,然后再提取出单个字段使用。这样一来,只要该较大对象内的任何内容发生变化——甚至是组件从未触碰过的字段——组件都会重新渲染。
这个问题并非状态管理库所独有,在数据获取缓存、基于观察者的系统以及手动实现的订阅钩子中同样存在。仅仅为了方便而从大型数据源中读取内容,会导致组件对远超其实际需求的变更做出响应。
从技术层面讲,组件被允许访问的状态量与应当触发其重新渲染的状态量并非同一个问题。
6. 不稳定的键会导致重复挂载而非更新
重新渲染会在保持组件身份、本地状态及位置不变的情况下再次执行该组件的渲染。而重复挂载则有所不同——它会彻底销毁现有的组件实例,然后从头开始构建一个全新的实例。由于 React 无法再复用之前的本地状态、现有的 DOM 关系或与组件生命周期相关的任何资源,因此重复挂载的代价通常要高得多。
不稳定的键会悄悄地将本应简单的更新变成反复的重复挂载。
如果键是由随机值、当前时间戳,或在编辑项目时会变化的字段生成的,React 会认为原来的项目已经完全消失。由于每次渲染都会生成不同的身份标识,之前的子元素会被移除,取而代之的是一个全新的子元素。
通常,在有人察觉到性能问题之前,可见的症状就已经出现了。文本输入框在输入过程中会失去焦点,展开的行会自动关闭,动画会从开头重新播放,本地组件的状态也会消失。当重新渲染的组件需要执行更复杂的初始化工作——比如启动编辑器、图表、观察者、订阅功能、媒体播放器或发起网络请求时,性能影响就会变得十分严重。
当列表项可以被重新排序、插入或删除时,使用数组索引作为键会导致更为隐蔽的问题。即便该位置现在被其他项占据,React仍可能将组件状态与列表中的某个特定位置绑定在一起。这种不匹配会导致状态附着到错误的项上,从而引发不必要的状态同步工作,而这一切都是因为键并未真正反映项的稳定标识。
精心选择的键应能代表列表中某个项的持久标识。只有当该项需要被视为完全不同的实例时才应更改键值,而不能仅仅因为发生了渲染操作就改变。
在某些情况下,刻意更改键值确实是合适的做法,因为这能迫使 React 重置整个子树。但这应当是经过深思熟虑的决定,因为这意味着要丢弃 React 之前为该子树保存的所有状态。非故意的重新挂载所带来的代价远高于普通的重新渲染,而且如果键表达式乍看之下似乎并无问题,就很容易被忽视。
7. 在组件不可见时仍保持其挂载状态
组件即便不可见,依然可以继续执行任务。
标签页、抽屉式组件、模态框以及可折叠面板通常仍保持挂载状态,但通过 CSS 使其在视觉上隐藏,因为这种方式能够保留内部状态,并让界面能够立即重新打开。对于内容较少的场景,这种权衡往往是合理的。
然而对于规模较大的界面而言,保持挂载状态意味着它们可能会持续监听上下文和外部存储、不断接收新的属性、持续重新渲染、持续进行计算,并保持相关效应的活跃状态——而用户却无法看到任何结果。仪表板可能在当前无人查看的标签页背后仍让多个图表保持完全活跃状态;隐藏在面板中的编辑器可能仍在后台处理文档更新;即使视觉上已被关闭的对话框,仍可能保留着庞大的表单结构及其验证逻辑。
您的应用为此付出了实际代价,因为 React 仍然将那个已挂载的子树视为相关渲染路径的一部分。设置 display: none 只会改变浏览器的布局方式——它并不会影响 React 是否仍然拥有并更新该子树。
话虽如此,卸载所有隐藏的 UI 元素也并非总是正确的做法。每次用户切换标签页时都从头重建一个功能复杂的编辑器,整体上可能会更慢,而且还会导致本应保留的用户状态丢失。关键是要根据组件的生命周期及其实际开销来做出明智的选择。
一种方法是在首次激活时再加载沉重的面板。那些确实需要保持显示状态的元素可以存在于较小的专用容器中,而渲染出的树结构本身则可以被移除。有些产品则采取折中的方式,仅保留最近使用的面板并释放其余的。
真正的问题不在于某个元素当前是否显示在屏幕上,而在于它在屏幕外时是否仍应接收并响应更新。
8. 因依赖关系不稳定而让效果重新启动
任何依赖于特定值的效应,只要该值发生变化就会进行清理并重新运行——当该效应所代表的外部关系确实发生了变化时,这正是正确的做法。
问题在于,不稳定的依赖关系会让人误以为每次渲染时该关联都发生了变化,即便实际上并没有任何实质性的改变。
试想这样一个组件:它创建一个选项对象并将其传递给自定义 Hook,而该 Hook 则将该对象作为 Effect 的依赖项。由于父组件在每次渲染时都会创建一个全新的对象,Effect 就会不断断开并重新建立与该对象的连接。同样地,如果每次渲染都重新创建事件处理程序并将其作为监听器 Effect 的依赖项,那么该监听器也会被反复移除和重新附加。即便查询配置对象中的重要字段实际上并未发生变化,它仍可能每次都触发新的请求。
这种开销并不总是以渲染问题形式表现出来。相反,它表现为重复的网络请求、频繁的连接建立与关闭、界面闪烁,或是事件监听器时有时无。更糟糕的是,这些效果的重新启动往往会再次调用状态设置函数,从而引发更多渲染操作,掩盖问题的根本原因。
解决办法并非仅仅为了消除警告就从数组中删除依赖项。依赖项的存在正是为了明确说明效果需要读取哪些值,隐藏某个依赖项会导致过时的闭包以及同步问题。
更好的做法是在依赖项真正重要的时候为其赋予稳定的标识。根据具体情况,你可以直接用原始值在 Effect 内部构建对象,让回调函数保持稳定以避免每次渲染时都被重新生成,或者意识到该逻辑根本就不属于 Effect,而应将其移至事件处理函数中。
Effect 应该重新启动是因为外部系统确实需要新的同步——而非因为某次渲染恰好生成了功能上完全相同的对象。
9. 用表示相同内容的新对象替换状态
根据 React 比较值的方式,当新状态与当前状态相同时,它有时会跳过重新渲染。但对于对象和数组而言,即使实际内容相同,只要给 React 提供一个新的引用,状态看起来也会不同。
这种模式在事件处理函数和状态管理逻辑中很常见。过滤器会被重置为一个全新的对象,而这个新对象实际上与之前的对象内容相同。对于并未改变任何内容的操作,还原器会返回一个新构建的状态对象。即使数组中的每个元素都没有变化,从轮询中获取的响应也会被复制到一个新数组中。
React 本身没有通用方法来检测深层的语义相等性。如果状态的所有者返回了一个新对象,React 会认为这是一次足够重要的更新,从而对其进行处理。
为避免这种情况,当某个动作实际上不会改变任何内容时,reducer应返回现有的状态对象。状态设置器可以在替换整个结构化对象之前先比较那些重要的值。负责规范化传入数据的层可以保留未发生变化的记录的相同引用,而无需在每次响应后都从头重建整个集合。
这并不意味着就要对每一次状态更新都进行深度比较。深度比较所带来的开销可能超过它试图避免的渲染成本,而且还会引入另一种“相等性”概念,需要始终如一地维护。一种更可持续的做法是:当底层数据实际上没有发生变化时,自然地保留对象的唯一性。
结构共享使得 React 及其下游的所有缓存组件能够区分真正的变化与相同信息的重复副本。新的引用应当表示状态已改变——而不仅仅是存储了相同值的内存分配发生了变化。
目标并非消除重新渲染
React 应用之所以快速,并非因为它们拒绝进行渲染,而是因为它们能够识别有意义的变化,并仅在确实需要重新计算输出的部分进行渲染。
重新渲染一个小型、简单的组件,通常比为其构建和维护复杂的缓存机制更为经济。仅涉及少数轻量级使用者的上下文更新,可能根本无需进行任何架构上的改动。关于性能的决策应基于交互的实际成本、性能分析结果以及预期的使用规模来制定,而非盲目地追求尽可能避免渲染的行为。
上述习惯之所以具有风险,恰恰是因为它们会悄悄扩大需要更新的内容范围。将状态提升到过高的层级,会让原本微小的局部交互演变成涉及整个页面的操作。宽泛的上下文和存储订阅机制会将无关的变化传播到那些本不必知晓这些变化的组件中。新创建的引用会导致稳定输入在每次渲染时看起来都不同。派生状态与效应结合使用可能会引发一系列额外的更新。不稳定的键值会放弃重用机制,转而进行完全的重新渲染。隐藏的子树会持续执行无人可见的操作,而结构上完全相同的状态却仍会被视为全新的内容。
一旦正确理解了这些底层关系,记忆化确实能发挥重要作用。当组件的边界、状态所有权以及引用标识本身就清晰明确时,记忆化的效果最佳。若没有这样的基础,记忆化只不过是在仍然庞大的更新图中加了一层防御措施而已。
关于 React 性能,最有用的问题并非“如何阻止该组件渲染?”,
而是“究竟发生了什么变化,谁负责处理这些变化,以及为何该组件会被要求再次重新评估自身?”。
一旦能明确回答这些问题,大部分不必要的重新渲染情况往往会自行消失——而不会让组件树变成一堆缓存和比较函数的混乱组合。