首页 / 文章

React记忆化机制适得其反时:为何减少渲染次数反而会让应用运行更慢。

当React记忆化机制适得其反时:memo、useMemo和useCallback为大型页面增加了开销并导致闭包失效,将状态移出这些函数并分析执行流程后问题得以解决。

发布者:REACTAPP.TOP阅读约 13 分钟2888 词

来源: The React Optimization That Made Our App Slower

修正错误方案的案例

想象这样一个大型页面:每次有人在过滤器中输入内容时,它都会重新渲染。页面上包含表格、图表、摘要以及多个交互面板,React Profiler还会显示出一长串组件列表。这似乎证明了React正在做过多工作。

通常的解决办法是:将组件用memo包裹,处理函数用useCallback封装,计算逻辑用useMemo处理,选择器则使用自定义的相等性函数。对于仍然会渲染的组件,会进一步稳定其属性;对于引用会变化的对象,则会进行记忆化处理;对于比较失败的判断,则会用更深入的比较方式替代。

最终,性能分析工具确实显示组件渲染次数减少了。不过应用程序并没有变得更快。某些交互的反应速度变慢,一些界面占用的内存增加,还有些组件因为为保持引用稳定而省略了依赖项,从而显示出过时的数据。由于性能现在取决于散布在代码结构中的缓存和比较规则,调试也变得更加困难。团队调整的只是那些容易观察到的数值,而非用户真正期待的改进效果。

使用记忆化并非错误,因为 React 提供了这些工具来解决真正的问题。真正的缺陷在于人们认为始终阻止渲染总比允许渲染更好。渲染本身需要耗费精力,而决定是否跳过渲染同样需要考量。当这个决策意味着要重新构建依赖数组、比较大量的属性值、保留旧值以及运行定制的缓存逻辑时,这种优化所带来的成本反而可能高于它原本要避免的工作量。

“不必要的”究竟意味着什么

第一个错误在于对“不必要的渲染”的定义。尽管某个组件的最终 DOM 输出并没有明显变化,但它仍出现在性能分析工具中,因此该渲染就被视为浪费。这忽略了众多组件的渲染成本其实非常低的事实。

设想一个函数组件,它接收少量原始属性并返回一个简单的元素树。该组件的执行速度极快,React会对比输出结果,几乎不会修改DOM,因此阻止此类调用几乎无法带来任何性能提升。

而经过记忆化的版本则有自己的任务要完成。React需要保留之前的属性,接收新的属性并进行比较。当属性较多,且其中包含对象、数组或函数时,比较操作可能会频繁失败,或者耗时过高,从而抵消掉所有优势。渲染次数实际上已成为一种性能指标,即便延迟并非由跳过的渲染导致,较低的渲染次数依然更佳。

这是个很容易陷入的陷阱,因为性能分析工具会显示渲染过程,而其他开销却隐藏不见。属性比较、JavaScript内存分配、缓存保留、存储选择器、布局、绘制以及第三方库都可能影响交互体验,而人们却只关注渲染次数。当组件正在执行重要任务且其输入参数通常保持不变时,就应当避免重新渲染。不能仅仅因为重新渲染发生了就去阻止它。

memo的实际开销

人们常将memo描述为一种在属性未变化时防止子组件重新渲染的机制。但这种描述遗漏了一个步骤:React必须先比较旧属性和新属性,才能决定是否跳过重新渲染。

使用原始属性时,比较操作的成本很低。但当组件接口较为复杂时,其成本就难以预测了。行对象、配置数组、权限模型、回调集合以及嵌套选项都被传递进来。memo包装接收器并不能让这些值保持稳定,它只是增加了另一个阶段,让React去检查这些值是否稳定而已。

由于父组件在渲染过程中至少会重建一个引用,这些检查大多会失败。React虽然进行了比较,但仍然会继续渲染,这样一来不仅增加了工作量,却没有消除任何问题。

接下来是自定义的比较函数,用于更细致地检查数值。某个数据网格组件会检查其列、被选中的行、分页信息、是否正在加载以及行本身。该比较器相当于该组件依赖模型的第二个副本,但它可能以两种方式出错:彻底的比较会耗费实时资源,而粗略的比较则可能遗漏必要的渲染。

现在出现了一个新的正确性边界。任何影响渲染的因素都必须在比较器中得到体现,因此添加属性意味着需要同时修改组件及其记忆化逻辑。那些原本默认就能正确渲染的组件,只有在其对应的相等性函数能够完美描述它们时才能保持正确。

深度比较

当浅层比较频繁失效时,深层比较似乎是显而易见的下一步。如果两个配置对象包含相同的值,为何不同的引用就要强制触发渲染呢?只需比较内容,若结构相同则直接跳过即可。

这种方法在处理小型对象的演示中可行。但在实际应用中,某些属性会包含数百甚至数千个值,深层比较需要遍历数组、检查嵌套对象,并在每次父组件更新时重复整个过程。受保护的组件可能只会渲染少量数据行,或者返回带有自身缓存机制的第三方组件以保持子树稳定。进行相等性检查所消耗的成本可能高于直接调用组件本身。

深层比较的复杂度还与输入数据的规模相关,而非变化的规模。即使只是单个小的状态更新,也可能迫使比较器遍历整个集合,只为证明其中大部分内容并未发生变化。

结构也并不总是适合用于比较的可靠依据。函数、日期、地图、类实例以及库对象可能具有通用深度相等性函数无法理解的行为或特性,因此即便看似全面的比较也可能保留过时的行为。该团队放弃了 React 简单的引用模型,转而采用自定义的语义相等性系统,却从未明确界定每个领域值中“相等”的含义。

更有效的解决方案并非试图更严格地比较那些不稳定的对象,而是要在底层数据未发生变化时保持其身份不变,缩小属性契约的范围,或将更新操作移至拥有该数据的组件附近。

值缓存

接下来是各种值。一旦其周围的组件被缓存,过滤后的数组、配置对象、格式化后的标签、查找映射表以及简单的计算都会被赋予 useMemo。

有些操作确实需要这样做。如果输入变化的频率低于父组件渲染的频率,那么对大量数据集进行排序或构建复杂的模型就值得缓存。但很多操作并不需要。拼接两个字符串或创建一个小型对象的成本其实低于为其构建的冗余结构:组件仍然需要生成依赖数组,React仍然需要将每个依赖项与之前的项进行比较,之前的计算结果仍然会占用内存,而且开发者仍然需要判断列表是否完整。

代码的视觉结构也变得更加复杂。原本只需一个表达式就能表达清楚的值,现在却要被包裹在 Hook 和依赖数组之中。

const label = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName, lastName]
);

在运行时,缓存几乎不会增加任何内容,这说明该计算值得进行优化。更糟糕的是,到处使用 useMemo 会扰乱数据流。开发者保持对象引用的稳定性,并非因为创建这些对象成本高昂,而是因为其他已缓存的组件依赖于它们的唯一标识。每一次优化都会引发另一次优化,最终形成一棵只有在所有链接都保持有效时才能正常工作的缓存树。

当缓存能够保留那些成本较高的计算结果时,其价值才最为显著。而此处它所保留的其实是其他缓存机制所依赖的引用,这完全是不同的目标。

回调函数与过期的闭包

在渲染过程中创建的回调函数每次都会获得新的唯一标识。当这些新引用被传递给已缓存的子组件时,会导致子组件的比较失败。为了解决这个问题,人们几乎将所有的处理函数都用 useCallback 包装起来。

这虽然减少了某些引用变更,但却导致了更长的依赖列表。保存处理程序依赖于草稿、选中的记录、当前用户、权限、通知服务以及变异函数,而其中任何一项的变动都会产生新的回调函数。这些回调函数很少能保持足够长的稳定性以便被重复使用。子组件仍会正常渲染,而父组件则需额外承担一个 Hook 和一个依赖契约。

开发者有时会移除依赖项以保持回调的稳定性,但这会导致过时的闭包出现。由于保持回调的“身份”比维持当前行为更为重要,回调会持续读取旧的版本、过时的权限值或之前选中的记录。这类代码在初始交互路径上可以通过测试,但一旦状态发生变化且回调未被重新创建,就会出错。这类缺陷很难排查,因为相关函数依然存在且能正常运行,只是基于历史数据执行操作。为了保持回调的“身份”,数据的新鲜度反而被牺牲了。

一个更恰当的问题是,这个子组件是否真的需要稳定的回调函数。对于那些未被缓存、成本较低的子组件而言,useCallback几乎起不到作用。如果回调函数用于触发订阅或涉及昂贵的缓存操作,那么稳定性就非常重要,此时可以通过调整所有权、传递更简化的操作,或将相关逻辑置于其读取状态更近的位置来减少依赖项。具有固定标识的函数本身并不会更快,只有当有实际意义的内容重复使用它时才有帮助。

缓存会占用内存

缓存通过保存之前的计算结果来避免重复运算,但这种存储行为本身就需要占用内存。单个小型缓存值的记忆占用通常不算问题,但在大型应用中,众多已挂载的组件可能会存储数组、映射表、处理后的数据记录、格式化后的模型以及捕获更多数据的闭包函数。

这就是为何单一的性能分析视图无法完整描述系统性能的原因之一。虽然缓存后的计算结果在渲染时间线上可能暂时消失,但随后却会导致内存占用增加和更长的暂停时间。这种做法是否值得取决于结果的大小、重新构建的成本、被重用的频率以及该组件在页面中显示的时间长短。对于那些很少被打开的面板,即使缓存了庞大的衍生模型,其成本也可能高于在需要时重新构建它。缓存并非用于存储旧数据的免费空间,它实际上是用计算效率的牺牲来换取状态的保留以及更多的无效化处理逻辑。

真正的问题在于状态存储的位置

关键发现是,重新渲染的行为反映了状态存储的位置。那个不断变化的过滤器存储在页面组件中,而该组件同时还负责渲染图表、摘要、大型表格以及多个面板。每次按键都会导致页面重新渲染,并为所有子组件重新生成属性值。

通过记忆化机制保护儿童相关元素,意味着要稳定他们的数组、对象和回调函数,以及自定义选择器和比较逻辑,同时整个架构仍以一个统一的更新边界为核心。将过滤状态移至表格附近后,大部分问题都得到了解决,页面的其他部分也不再需要自我防护。

其他地方也出现了类似情况:对话表单的状态存在于屏幕组件中,悬停状态则作用于整个卡片集合之上,不同的上下文承载着更新频率差异极大的值,而全局存储选择器返回的数据量超过了使用者的需求。记忆化机制只是暂时掩盖了那些导致微小变化扩散过远的权限设计问题。

在那些限制措施实施后,许多缓存层变得毫无用处。由于状态不再位于这些组件之上,处于更新路径之外的组件停止了渲染;同时,由于父组件不再对子组件进行过度管控,子组件的接口也变得更简单了。

渲染次数减少,浏览器负载不变

即便缓存机制避免了React的渲染操作,某些交互仍可能保持缓慢,因为React并非主要的性能瓶颈。

某个图表库仍在执行代价高昂的命令式更新操作。庞大的DOM树在尺寸改变时仍需重新布局。某种CSS效果会导致大规模重绘,同步的存储写入操作会阻塞主线程,而输入处理程序则会在下一个字符出现之前就解析完整个文档。组件之所以能被关注,只是因为性能分析工具会检测它们;而随着渲染成本降低,其背后的浏览器工作负载便显现了出来。

React的性能只是前端性能的一部分,并不能代表整个前端性能。即便使用了记忆化功能,Effect依赖项的变化仍可能导致第三方组件重新渲染。跳过父组件的渲染并不能缩短过大图片的解码时间。稳定的回调函数也无法解决因读取和写入元素尺寸顺序错误而导致的布局问题。需要从JavaScript、React渲染、DOM修改、样式计算、布局、绘制到网络层面逐一排查,因为加快某一环节的速度可能并不会改变整个流程。当浏览器持续忙碌时,React却显得悄无声息。

更难修改的代码

性能损耗仅仅是问题的一半,应用程序的维护难度也随之增加。

每个组件的边界都会引发新的问题:该组件是否被缓存了?哪些属性需要稳定的引用?自定义比较器能否处理新的属性?是否有值是故意被排除在依赖列表之外的?修改某个对象的结构是否会使多个缓存失效?日常的功能开发工作需要理解那些与产品实际行为并无关联的优化规则。

想象一下,有开发者在一个配置对象中添加了一个字段,却意外地引发了大规模的重新渲染。同事们便通过更复杂的比较方式来“解决”这个问题。后来又有人在该对象中添加了一个函数,结果发现比较器将两个明显不同的配置视为相等。代码是针对当前的结构进行优化的,因此任何改动都会打破那些精心平衡的引用关系和依赖数组结构。

良好的性能架构能让常见修改的成本更低,同时不必强制每次修改都符合隐含的缓存假设。但这并不意味着优化后的代码就必须保持简单。某些高成本的交互确实需要专门的记忆化机制和谨慎的引用处理。代码的复杂度应与实际收益成正比,并集中在已知的热点区域,而此处却因为认为任何渲染都可能在未来成为热点而将复杂度分散到各个地方。

通过衡量交互行为改变了决策

转折点在于开始对完整的用户交互进行性能分析,而非仅统计渲染次数。

那些被用户认为速度慢的操作被记录下来,以便分析时间消耗在何处。有些组件虽然频繁渲染,但实际上几乎没消耗多少资源。少数计算操作和第三方小部件占据了主要时间,而自定义的比较操作则被记录为真正的 JavaScript 计算工作。在某些情况下,去掉缓存机制虽然会导致稍多的渲染次数,但总体耗时却减少了。

起初这看起来有些反常,因为人们通常认为减少渲染次数就是进步。但实际上浏览器并不关心组件函数被调用了多少次,它只在意在下一次响应式帧生成之前完成了多少工作。

从那以后,变化的评判标准变成了总交互成本。输入响应是否更快了?主线程是否得以释放?在长时间运行过程中内存使用是否稳定?是生产环境版本得到了改进,而不仅仅是开发环境?修复方式也变得更加本地化:某个代价高昂的转换、某个大型子树或某个不稳定的库配置可以直接处理,而应用程序的其余部分则可以取消其防御性缓存机制。性能分析并不能带来重新设计,它只是防止团队去追求那些与用户体验无关的数字指标。

保留的内容

并非所有的 memo、useMemo 和 useCallback 都被移除,因为那样做只会反复犯同样的错误。

记忆化功能仍用于那些属性基本不变的昂贵组件上。那些计算成本高且在父组件频繁渲染时会被重复使用的转换结果会被缓存,而那些在订阅、第三方集成或具有实际意义的记忆化边界中身份识别至关重要的回调函数也会保持稳定。

被摒弃的做法包括:对简单计算进行缓存、使用用于广泛结构检查的自定义比较函数,以及不愿让廉价组件进行渲染。那些频繁变化的状态会被移至使用它们的接口附近,而彼此关联度较低的上下文值则会被分开处理。这样做的结果并非让渲染次数降至最低,而是一种更易于理解的更新模型,并且在有依据的情况下才会进行优化。这使得应用程序既更快,也更容易修改。

渲染并非敌人

这个故事的起点是一种合理的直觉:看到有工作正在执行就试图阻止它。但错误在于将每一次渲染都视为浪费,而将每次跳过的渲染视为胜利。

缓存机制需要比较输入、保存值、追踪依赖关系,并且随着代码的变化仍需保持正确性。自定义的相等性判断可能带来的成本高于一次渲染。稳定的回调函数可能会使过时的状态无法改变。缓存的结果会占用内存。而种种优化措施交织在一起,会让组件树变得难以修改,且用户无法察觉到任何好处。

在 React 中进行渲染通常成本较低。弱状态管理、重复的高成本计算、广泛的订阅机制、第三方更新以及浏览器布局往往更为重要。最有效的做法是让那些低成本的操作继续保持低成本:不必再将普通组件包裹在缓存中,而应专注于那些真正高成本的运算。优化应旨在降低整体交互成本,而不仅仅是减少 React 调用函数的次数。一旦问题从“如何阻止 React 渲染”转变为“为何每次更新都会变得如此昂贵”,性能问题就能得到解决。

相关阅读

  • 为何使用 React 后前端开发依然困难:状态管理、效应处理、边界界定与 AI 工具 — React 使界面构建变得更简单,但并未让设计变得更轻松。真正的前端工作在于状态管理、复杂的效应处理、系统边界界定以及 AI 生成的代码。
  • 为 React Native 应用添加启动画面(iOS 和 Android 版) — 详细讲解在 iOS 和 Android 上为 React Native 应用设置启动画面的方法:Xcode 的启动画面设置、Android 的主题与布局,以及使用 react-native-splash-screen 避免出现白色闪屏现象。
  • 30个提升React与Redux应用可扩展性与可维护性的实用技巧 —— 三十条关于状态管理、服务器数据、Hooks、选择器、文件夹结构、测试以及代码重构的简短建议,专为组件数量超过千个的应用设计。