【翻译】隐秘的React内存泄漏:useCallback与闭包如何成为陷阱

20 阅读10分钟

原文链接:www.schiener.io/2024-03-03/…

作者:Kevin Schiener

我在Ramblr(一家人工智能初创公司)工作,我们为视频标注开发复杂的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 使用的 V8 JavaScript 引擎中,未被使用的变量可能不会被保存到上下文对象中。

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

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中,闭包被广泛应用于所有函数组件、钩子(hooks)和事件处理函数中。当你创建一个新函数并访问组件作用域内的变量(例如状态或属性)时,很可能就会创建闭包。

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}>Increment</button>
    </div>
  );
}

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

然而,随着应用程序规模增长,当你开始使用 useMemo 和 useCallback 等记忆化技术来避免不必要的重新渲染时,就需要留意一些细节。

闭包与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() 函数引用的稳定性。通过使用 useCallback 对 handleEvent() 进行记忆化,可以确保仅在 count 状态变化时才重新赋值。接着,我们将 ExpensiveChildComponent 用 React.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 变量,它会持有对组件上下文对象的引用。即使 handleEvent() 函数从未访问 bigData,但通过组件的上下文对象,handleEvent() 仍然会间接持有对 bigData 的引用。

所有闭包都共享它们创建时的同一个上下文对象。由于 handleClick() 闭包捕获了 bigData,该上下文对象就会引用 bigData。这意味着,只要 handleEvent() 仍被引用,bigData 就永远无法被垃圾回收。这种引用关系会一直持续,直到 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 A</button>
      <button onClick={handleClickB}>Increment B</button>
      <button onClick={handleClickBoth}>Increment Both</button>
      <p>
        A: {countA}, B: {countB}
      </p>
    </div>
  );
};

在这个示例中,我们有两个被记忆化的事件处理函数 handleClickA() 和 handleClickB()。此外还有一个 handleClickBoth() 函数,它会同时调用这两个事件处理函数并输出 bigData 的长度。

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

让我们点击每个按钮5次后,查看Chrome DevTools中的内存分析截图: 看起来 bigData 从未被垃圾回收。每次点击后内存使用量都在持续增长。在这个案例中,应用程序持有11个 BigObject 实例的引用,每个实例大小为10MB——包括首次渲染创建的实例以及每次点击后新增的实例。

内存保留树向我们揭示了问题的本质。我们似乎创建了一个循环引用的链条。让我们逐步分析这个过程:

0. 首次渲染:

当 App 组件首次渲染时,由于我们在至少一个闭包中使用了所有变量(包括 bigDatahandleClickA() 和 handleClickB()——这些都在 handleClickBoth() 中被引用),因此会创建一个闭包作用域来保存这些变量的引用。我们将其称为 AppScope#0

1. 点击"Increment A"按钮:

首次点击"Increment A"将导致 handleClickA() 因 countA 的变化而被重新创建,我们称新函数为 handleClickA()#1
而 handleClickB()#0 不会重新创建,因为 countB 未发生变化。
这意味着 handleClickB()#0 仍然持有对之前 AppScope#0 的引用。
新的 handleClickA()#1 则持有对 AppScope#1 的引用,而 AppScope#1 又引用了 handleClickB()#0

2. 点击"Increment B"按钮:

首次点击"Increment B"会因 countB 的变化导致 handleClickB() 被重新创建,从而生成 handleClickB()#1
由于 countA 未改变,React 不会重新创建 handleClickA()
此时,handleClickB()#1 持有对 AppScope#2 的引用,而 AppScope#2 引用了 handleClickA()#1handleClickA()#1 又指向 AppScope#1,最终 AppScope#1 仍保留着对最初的 handleClickB()#0 的引用。

3. 第二次点击"Increment A"按钮:

通过这种方式,我们会不断创建互相引用的闭包链条,这些闭包永远无法被垃圾回收。同时,由于每次渲染都会重新创建独立的10MB bigData 对象,内存负担将持续加重。

问题本质简析

总而言之,问题的核心在于:同一个组件中的多个 useCallback 钩子可能通过闭包作用域相互引用,或引用其他占用大量内存的数据。这些闭包会一直保留在内存中,直到对应的 useCallback 钩子被重新创建。当一个组件中存在多个 useCallback 钩子时,将非常难以判断内存中保留了哪些数据、这些数据何时会被释放。你使用的回调函数越多,遇到这个问题的可能性就越大。

你可能会遇到这个问题吗

以下因素会增加你遇到此问题的概率:

  • 你拥有一些几乎从不重新创建的大型组件(例如承载了大量状态的应用程序壳层)。
  • 你依赖 useCallback 来最小化重新渲染。
  • 你在记忆化函数中调用了其他函数。
  • 你处理大型对象(如图像数据或大型数组)。

如果你不需要处理任何大型对象,额外引用几个字符串或数字可能不会造成问题。大多数闭包的交叉引用会在足够多的属性变更后自行清理。但需要注意,你的应用程序占用的内存可能会超出预期。

如何避免闭包与useCallback造成的内存泄漏?

以下是我总结的几个避免此问题的实用技巧:

技巧1:尽可能缩小闭包作用域

JavaScript 很难直观展示所有被捕获的变量。最有效的避免保留过多变量的方法是缩减闭包周围的函数规模,具体包括:

  • 编写更小的组件:这将减少创建新闭包时作用域内的变量数量。
  • 使用自定义Hook:这样回调函数仅能捕获Hook函数作用域内的变量(通常只有函数参数)。

技巧2:避免捕获其他闭包(尤其是记忆化闭包)

虽然这看似显而易见,但React很容易让人掉入这个陷阱。如果编写多个相互调用的小函数,一旦某个函数添加了 useCallback,组件作用域内所有被调用的函数都可能被迫形成记忆化链。

技巧3:非必要时不使用记忆化

useCallbackuseMemo 虽是避免不必要渲染的强大工具,但有其代价。建议仅在因渲染导致可观测性能问题时使用它们。

技巧4(应急方案):对大对象使用useRef

这意味着你需要自行管理对象的生命周期并确保正确清理。虽非最优解,但比内存泄漏要好。

结论

闭包是React中大量使用的模式。它使我们的函数能够记住组件上次渲染时作用域内的属性和状态。当与useCallback等记忆化技术结合时(尤其是在处理大型对象的情况下),这可能导致意外的内存泄漏。为避免这类内存泄漏,请尽量保持闭包作用域最小化,非必要时避免使用记忆化,对于大型对象可考虑改用useRef

特别感谢David Glasser于2013年发表的文章《Meteor中发现的一个令人惊讶的JavaScript内存泄漏》,这篇文章为我指明了正确的方向。

反馈?

您认为我是否有遗漏或误解之处?或许您有更好的解决方案,或从未遇到过此类问题。如果您有任何疑问或建议,欢迎通过LinkedInX/Twitter联系我。

祝调试愉快!