React 内存泄漏:useCallback 和闭包如何对你造成影响

我工作在 兰布尔,一家 AI 初创公司,我们为视频注释构建复杂的 React 应用程序。我最近遇到了一个复杂的内存泄漏,它是由 JavaScript 闭包和 React 的 useCallback 钩子。由于我有 .NET 背景,所以我花了很长时间才弄清楚到底发生了什么,所以我想分享一下我所学到的东西。

我对闭包进行了简要的复习,但如果你已经熟悉它们在 JavaScript 中的工作方式,请随意跳过该部分。

简要回顾一下闭包

闭包是 JavaScript 中的一个基本概念。它们允许函数记住函数创建时范围内的变量。这是一个简单的例子:

function createCounter() {
  const unused = 0; // This variable is not used in the inner function
  let count = 0; // This variable is used in the inner function
  return function () {
    count++;
    console.log(count);
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2

在此示例中, createCounter 函数返回一个可以访问的新函数 count 变量。这是可能的,因为 count 变量在 createCounter 在创建内部函数时起作用。

JavaScript 闭包是使用上下文对象实现的,该对象保存对函数最初创建时范围内变量的引用。哪些变量保存到上下文对象是 JavaScript 引擎的实现细节,并且会进行各种优化。例如,在 Chrome 中使用的 JavaScript 引擎 V8 中,未使用的变量可能不会保存到上下文对象。

由于闭包可以嵌套在其他闭包中,因此最内层的闭包将保存对它们需要访问的任何外部函数作用域的引用(通过所谓的作用域链)。例如:

function first() {
  const firstVar = 1;
  function second() {
    // This is a closure over the firstVar variable
    const secondVar = 2;
    function third() {
      // This is a closure over the firstVar and secondVar variables
      console.log(firstVar, secondVar);
    }
    return third;
  }
  return second();
}

const fn = first(); // This will return the third function
fn(); // logs 1, 2

在此示例中, third() 函数可以访问 firstVar 通过作用域链来传递变量。

闭包作用域

因此,只要应用程序持有对该函数的引用,闭包作用域中的任何变量都不能被垃圾回收。由于作用域链的存在,即使是外部函数作用域也会保留在内存中。

请参阅这篇精彩的文章来深入了解该主题: 探索 V8 封盖的乐趣(和利润?)。尽管它是 2012 年的,但它仍然具有现实意义,并且很好地概述了 V8 中闭包的工作原理。

闭包和 React

对于所有功能组件、钩子和事件处理程序,我们严重依赖 React 中的闭包。每当您创建一个从组件范围访问变量(例如状态或 prop)的新函数时,您很可能正在创建一个闭包。

以下是一个例子:

import { useState, useEffect } from "react";

function App({ id }) {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1); // This is a closure over the count variable
  };

  useEffect(() => {
    console.log(id); // This is a closure over the id prop
  }, [id]);

  return (
    <div>
      <p>{count}p>
      <button onClick={handleClick}>Incrementbutton>
    div>
  );
}

在大多数情况下,这本身并不是问题。在上面的例子中,闭包将在每次渲染时重新创建 App 旧的将被垃圾回收。这可能意味着一些不必要的分配和释放,但这些通常非常快。

然而,当我们的应用程序增长并且你开始使用记忆技术时,比如 useMemouseCallback 为了避免不必要的重新渲染,需要注意一些事项。

闭包和 useCallback

利用记忆钩子,我们可以用更好的渲染性能来换取额外的内存使用。 useCallback 只要依赖项不变,就会保留对函数的引用。让我们看一个例子:

import React, { useState, useCallback } from "react";

function App() {
  const [count, setCount] = useState(0);

  const handleEvent = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return (
    <div>
      <p>{count}p>
      <ExpensiveChildComponent onMyEvent={handleEvent} />
    div>
  );
}

在此示例中,我们希望避免重新渲染 ExpensiveChildComponent我们可以这样做: handleEvent() 函数引用稳定。我们记忆 handleEvent()useCallback 仅当 count 状态改变。然后我们可以包装 ExpensiveChildComponentReact.memo() 避免在父级发生重新渲染时, App,渲染。到目前为止,一切顺利。

但是让我们对这个例子进行一点修改:

import { useState, useCallback } from "react";

class BigObject {
  public readonly data = new Uint8Array(1024 * 1024 * 10); // 10MB of data
}

function App() {
  const [count, setCount] = useState(0);
  const bigData = new BigObject();

  const handleEvent = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  const handleClick = () => {
    console.log(bigData.data.length);
  };

  return (
    <div>
      <button onClick={handleClick} />
      <ExpensiveChildComponent2 onMyEvent={handleEvent} />
    div>
  );
}

你能猜出会发生什么吗?

自从 handleEvent() 创建了一个闭包 count 变量,它将保存对组件上下文对象的引用。而且,即使 我们从不访问 bigData 在里面 handleEvent() 功能handleEvent() 仍将保留对 bigData 通过组件的上下文对象。

所有闭包从创建之日起就共享一个公共上下文对象。由于 handleClick() 关闭 bigDatabigData 将由该上下文对象引用。这意味着, bigData 永远不会被垃圾收集 handleEvent() 正在被引用。此引用将保留至 count 变化和 handleEvent() 被重新创建。

大物体捕捉

无限内存泄漏 useCallback + 闭包 + 大对象

让我们看最后一个将上述所有情况发挥到极致的例子。这个例子是我在我们的应用程序中遇到的情况的简化版本。因此,虽然这个例子可能看起来很牵强,但它很好地展示了一般问题。

import { useState, useCallback } from "react";

class BigObject {
  public readonly data = new Uint8Array(1024 * 1024 * 10);
}

export const App = () => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);
  const bigData = new BigObject(); // 10MB of data

  const handleClickA = useCallback(() => {
    setCountA(countA + 1);
  }, [countA]);

  const handleClickB = useCallback(() => {
    setCountB(countB + 1);
  }, [countB]);

  // This only exists to demonstrate the problem
  const handleClickBoth = () => {
    handleClickA();
    handleClickB();
    console.log(bigData.data.length);
  };

  return (
    <div>
      <button onClick={handleClickA}>Increment Abutton>
      <button onClick={handleClickB}>Increment Bbutton>
      <button onClick={handleClickBoth}>Increment Bothbutton>
      <p>
        A: {countA}, B: {countB}
      p>
    div>
  );
};

在此示例中,我们有两个记忆事件处理程序 handleClickA()handleClickB().我们还有一个函数 handleClickBoth() 调用两个事件处理程序并记录 bigData

你能猜出当我们交替点击“增加 A”和“增加 B”按钮时会发生什么吗?

单击这些按钮 5 次后,让我们看看 Chrome DevTools 中的内存配置文件:

BigObject 泄漏

似乎 bigData 永远不会被垃圾回收。每次点击都会使内存使用量不断增加。在我们的例子中,应用程序持有对 11 个 BigObject 实例,每个实例大小为 10MB。一个用于初始渲染,另一个用于每次点击。

保留树向我们展示了正在发生的事情。看起来我们正在创建一个重复的引用链。让我们一步一步地进行分析。

0. 第一次渲染:

什么时候 App 首次渲染时,它会创建一个 闭包作用域 它保存了所有变量的引用,因为我们至少在一个闭包中使用了它们。这包括 bigDatahandleClickA(), 和 handleClickB()。我们在 handleClickBoth(). 我们将闭包范围称为 AppScope#0

闭合链 0

1. 点击“增加A”:

  • 第一次点击“增加 A”将导致 handleClickA() 因为我们改变而需要重新创造 countA – 我们叫新的 handleClickA()#1
  • handleClickB()#0 将要 不是 重新创建 countB 没有改变。
  • 然而,这意味着 handleClickB()#0 仍将保留对前一个的引用 AppScope#0
  • 新的 handleClickA()#1 将引用 AppScope#1,其中包含对 handleClickB()#0

闭合链 1

2. 点击“增加B”:

  • 第一次点击“增加 B”将导致 handleClickB() 因为我们改变而需要重新创造 countB,从而创造 handleClickB()#1
  • React 将 不是 重新创建 handleClickA() 自从 countA 没有改变。
  • handleClickB()#1 因此将持有对 AppScope#2,其中包含对 handleClickA()#1,其中包含对 AppScope#1,其中包含对 handleClickB()#0

闭合链 2

3. 第二次点击“增加A”:

这样,我们可以创建一个无限的闭包链,它们相互引用,永远不会被垃圾收集,同时拖着一个单独的 10MB bigData 对象,因为它会在每次渲染时重新创建。

闭合链

总体问题概述

普遍的问题是不同的 useCallback 单个组件中的钩子可能会通过闭包作用域相互引用并引用其他昂贵的数据。然后闭包将保留在内存中,直到 useCallback 重新创建钩子。有多个 useCallback 组件中的钩子使得很难推断内存中保存了什么以及何时释放。回调越多,您遇到此问题的可能性就越大。

这对你来说会是个问题吗?

以下一些因素可能会使您更有可能遇到此问题:

  1. 您有一些几乎从未重新创建的大型组件,例如,提升了大量状态的应用程序外壳。
  2. 您依赖 useCallback 尽量减少重新渲染。
  3. 您可以从记忆函数中调用其他函数。
  4. 您可以处理大型对象,例如图像数据或大数组。

如果您不需要处理任何大型对象,引用几个额外的字符串或数字可能不是问题。大多数这些闭包交叉引用将在足够多的属性更改后清除。但请注意,您的应用可能会占用比您预期更多的内存。

如何避免使用闭包和 useCallback

以下是我可以为您提供的一些避免此问题的建议:

提示1:保持闭包范围尽可能小。

JavaScript 使得识别所有被捕获的变量变得非常困难。避免保存过多变量的最佳方法是减少闭包周围的函数大小。这意味着:

  1. 编写更小的组件。这将减少创建新闭包时范围内的变量数量。
  2. 编写自定义钩子因为任何回调都只能在钩子函数的范围内关闭。这通常只意味着函数参数。

提示 2:避免捕获其他闭包,尤其是记忆闭包。

虽然这看起来很明显,但 React 很容易陷入这个陷阱。如果你编写了相互调用的小函数,一旦你在第一个函数中添加 useCallback 组件范围内所有被调用的函数都会发生连锁反应并被记忆。

提示 3:如无必要,请避免使用记忆法。

useCallbackuseMemo 是避免不必要重新渲染的好工具,但它们需要付出代价。仅当您注意到渲染导致的性能问题时才使用它们。

提示 4(逃生舱):使用 useRef 对于大型物体。

这可能意味着你需要自己处理对象的生命周期并妥善清理。虽然不是最佳选择,但总比内存泄漏要好。

结论

闭包是 React 中一种常用的模式。它们允许我们的函数记住组件上次渲染时范围内的 props 和状态。当与以下记忆技术结合使用时,这可能会导致意外的内存泄漏: useCallback,尤其是在处理大型对象时。为了避免这些内存泄漏,请保持闭包范围尽可能小,避免在不需要时进行记忆,并尽可能回退到 useRef 对于大型物体。

非常感谢 David Glasser 2013 年的文章 Meteor 上发现令人吃惊的 JavaScript 内存泄漏 这为我指明了正确的方向。

反馈?

您觉得我漏掉了什么或搞错了什么吗?也许您对这个问题有更好的解决方案,或者您根本就没遇到过这个问题。

如果您有任何问题或意见,请随时通过以下方式联系我: LinkedIn 或者 X/推特

调试愉快!

Leave a Reply

Your email address will not be published. Required fields are marked *

近期新闻​

编辑精选​