Published on
3388

一次React内存泄漏的隐患:useCallback 和闭包如何给你带来麻烦

Authors
  • avatar
    Name
    小辉辉
    Twitter

这是一篇翻译文章,原文地址:

Sneaky React Memory Leaks: How useCallback and closures can bite you

内容简述

作者提到了自己在开发中遇到了一个React useCallback钩子函数和js闭包问题引发的复杂内存泄漏问题,针对这个问题作者花费了很长的时间才解决,这篇文章就是作者整个问题的处理经历。

原文翻译

作者写对闭包概念进行了简要的复习,如果你已经很熟悉了它的运作原理,可以直接跳过下面的章节。

简要回顾下闭包

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

function createCounter() {
  const unused = 0; // 该变量没有被内部函数使用
  let count = 0; // 该变量在内部函数使用
  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() {
    // 这是一个对外部变量firstVar引用的闭包
    const secondVar = 2;
    function third() {
      // 这是一个对外部变量firstVar、secondVar引用的闭包
      console.log(firstVar, secondVar);
    }
    return third;
  }
  return second();
}

const fn = first(); // 这里会返回third函数
fn(); // 输出 1, 2

在这个例子中,third函数可以通过作用域链访问变量firstVar。

js作用域链

译者注:上图我们可以发现,在third函数在调试时作用域Scopes内部存在两个Closures闭包,分别是second和first函数,对应里面的secondVar和firstVar变量。

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

请参阅这篇精彩的文章,深入了解该主题:Grokking V8 closures for fun (and profit?)。尽管它是 2012 年的,但它仍然具有现实意义,并很好地概述了闭包在 V8 中的工作原理。

闭包和React

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

以下是一个例子:

import { useState, useEffect } from 'react';

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

  const handleClick = () => {
    setCount(count + 1); // 这是一个对count变量引用的闭包
  };

  useEffect(() => {
    console.log(id); // 这是一个对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()函数引用不变来实现这一点。我们对handleEvent进行useCallback进行优化,以便仅在count状态改变时重新分配新函数。然后我们可以用React.memo()包装ExpensiveChildComponent组件避免在父级App渲染时重新渲染。到目前为止一切很完美。

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

import { useState, useCallback } from 'react';

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

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因此将由该上下文对象引用。这意味着,bigData只要被handleEvent()被引用,就永远不会被垃圾收集。该引用将一直保留,直到count更改函数handleEvent()重新创建。

译者注:上面两段话可以说是本文的核心地方,理解了这里后面的内存泄漏问题就好明白了。 最初我对作者上面的说法是持怀疑态度的,即函数内部的闭包对外部的引用是取决于该函数内部用到的外部变量,按照我的理解,上述的情况应该是这样的,handleEvent函数内部只引用了count变量,因此是无法获取到bigData变量的,而当我实际打开控制台进行调试时,终于承认了作者的说法,并将其用我的理解概括如下: 内部函数的上下文对象引用在一开始时就创建好,并且在该函数内部归所有函数共享引用,当前作用域下任何一个变量被引用都会被加入到这个上下文对象中。

下面的图片也证实了作者上述给出的结论。

上下文对象引用

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]);

  // 仅用于举例问题
  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变量的大小。

您能猜出当我们交替点击“handleClickA”和“handleClickB”按钮时会发生什么吗?

单击这些按钮 5 次后,让我们看看 Chrome DevTools 中的内存详情:

bigData未被垃圾回收

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

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

0. 第一次渲染:

App组件首次渲染时,它会创建一个闭包作用域,其中包含对所有变量的引用,因为我们在至少一个闭包中使用了所有变量。这包括bigData、handleClickA()和handleClickB()。我们在中handleClickBoth()函数中引用它们。我们将闭包作用域称为AppScope#0

闭包作用域AppScope#0

1. 点击 “increment A”:

  • 第一次单击“Increment A”将导致handleClickA()重新创建,因为我们对countA进行了更改- 我们称之为新的handleClickA()#1。
  • handleClickB()#0由于countB没有改变,因此不会被重新创建。
  • 但这意味着,handleClickB()#0仍然会保留对前一个AppScope#0的引用。
  • 新的handleClickA()#1将保存对AppScope#1的引用,而该引用保存对handleClickB()#0的引用。
闭包作用域链1

2. 点击“Increment B”:

  • 第一次点击“Increment B”将导致handleClickB()重新创建,因为我们更改了countB变量,从而创建了handleClickB()#1。
  • 由于countA没有改变,React不会重新创建handleClickA()
  • handleClickB()#1因此将持有对AppScope#2的引用,该引用持有对handleClickA()#1的引用,该引用持有对AppScope#1的引用,该引用持有对handleClickB()#0的引用。
闭包作用域链2

3. 第二次点击“Increment A”:

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

闭包作用域链3

问题根源概述

其中的根本问题在于单个组件中的不同useCallback钩子函数可能会通过闭包作用域相互引用并引用其他大内存的数据。然后,闭包将保留在内存中,直到重新创建useCallback钩子为止。如果组件中有多个useCallback钩子,则很难推断内存中保存了什么以及它们何时释放。useCallback钩子函数使用越多,您遇到此问题的可能性就越大。

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

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

  • 您有一些大型组件,几乎从未重新创建的,例如,一个应用程序外壳,你在其中提升了大量状态。
  • 您依赖于useCallback最小化重新渲染。
  • 您在记忆函数中调用其他函数。
  • 您处理大型对象,例如图像数据或大数组。

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

使用闭包和useCallback如何避免内存泄漏?

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

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

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

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

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

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

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

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

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

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

结论

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

非常感谢 David Glasser 在 2013 年发表的文章A surprising JavaScript memory leak found at Meteor为我指明了正确的方向。