useCallback和闭包如何危害你的程序
小举 筑基

最近遇到了一个复杂的内存泄漏的问题,它是由 JavaScript 闭包和 React 的 useCallback 钩子的组合引起的。我花了相当长的时间才弄清楚发生了什么,所以我想分享一下我学到的东西。

我对闭包进行了简短的回顾,但如果您已经熟悉它们在 JavaScript 中的工作原理,请随意跳过这一部分。

关于闭包的简要回顾

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

1
2
3
4
5
6
7
8
9
10
11
12
function createCounter() {
const unused = 0; // 这个变量没有在内部函数中被引用
let count = 0; // 这个变量在内部函数被引用了
function inner() {
count++;
console.log(count);
}
return inner;
}
const counter = createCounter();
counter(); // 1
counter(); // 2

在此示例中, createCounter 函数返回一个可以访问 count 变量的新函数。这之所以能工作,是因为创建内部函数时, count 变量位于 createCounter 函数的词法作用域内部。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function first() {
const firstVar = 1;
function second() {
// 这是引用了 first() 函数内变量的闭包
const secondVar = 2;
function third() {
// 这是引用了 second() 函数 和 first() 函数内变量的闭包
console.log(firstVar, secondVar);
}
return third;
}
return second();
}
const fn = first(); // 返回 third() 函数
fn(); // 1 2

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

image

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

React 和闭包

对于所有函数式组件、钩子和事件处理程序,我们严重依赖 React 中的闭包。每当我们创建一个从组件范围访问变量(例如 state 或 props)的新函数时,很可能创建了一个闭包。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { useState, useEffect } from 'react';

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

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

useEffect(() => {
console.log(id); // 这是引用了props中id变量的闭包
}, [id]);

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

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

但是,当我们的应用程序不断增长并且开始使用 useMemouseCallback 等缓存技术来避免不必要的重新渲染时,有些事情需要特别的注意。

useCallback 和闭包

通过记忆缓存钩子,我们可以用更好的渲染性能来换取更多的内存使用。只要依赖关系不改变, useCallback 将保存对函数的引用。让我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useState, useCallback } from 'react';

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

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

return (
<div>
<p>{count}</p>
<ExpensiveComponent onEvent={handleEvent} />
</div>
);
}

在此示例中,我们希望避免重新渲染 ExpensiveChildComponent。我们可以通过尝试保持 handleEvent() 函数引用稳定来做到这一点。我们使用 useCallbackhandleEvent()进行记忆,以便仅在 count 状态更改时重新创建新的函数。然后,我们可以将 ExpensiveChildComponent 包装在 React.memo() 中,以避免在父级 App 渲染时重新渲染。到目前为止,一切都很好。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useState, useCallback } from "react";

class BigObject {
public readonly data = new Unit8Array(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() 创建了一个引用 App 函数内 count 变量的闭包,因此它将保存对组件上下文对象的引用。而且,即使我—们从未在 handleEvent() 函数中访问 bigDatahandleEvent() 仍将通过组件的上下文对象保存对 bigData 的引用。

所有闭包从创建之日起就共享一个公共上下文对象。由于 handleClick() 内部引用了 bigData ,因此 bigData 将由此上下文对象引用。这意味着,只要 handleEvent() 被引用, bigData 就永远不会被垃圾回收。此引用将一直保留,直到 count 更改并重新创建 handleEvent() 为止。

image

useCallback + 闭包 + 大数据对象 = 内存泄漏

让我们看一下最后一个例子,它将上述所有内容发挥到了极致。这个例子是我在我们的应用程序中遇到的简化版本。因此,虽然这个例子看起来有些做作,但它很好地说明了一般问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { useState, useCallback } from "react";

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

function App() {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
const bigData = new BigObject();

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


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

const handleClickBoth = () => {
handleClickA();
handlClickB();
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>
)
}

在此示例中,我们有两个使用 useCallback 进行缓存处理的事件处理程序 handleClickA()handleClickB() 。我们还有一个函数 handleClickBoth() ,它调用两个事件处理程序并打印 bigData 的长度。

你能猜出当我们交替单击 Increment A 和 Increment B 按钮时会发生什么吗?

让我们看看点击每个按钮 5 次后 Chrome DevTools 中的内存配置文件:

image

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

  1. 第一次渲染:

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

image

  1. 单击 Increment A 按钮:
  • 第一次点击 Increment A 将导致 handleClickA() 被重新创建,因为我们更改了 countA :让我们将创建的函数称为 handleClickA()#1;
  • handleClickB()#0 不会被重新创建,因为 countB 没有改变; 然而,这意味着 handleClickB()#0 仍将保留对先前 AppScope#0 的引用。
  • 新的 handleClickA()#1 将保存对 AppScope#1 的引用,而 AppScope#1 则保存对 handleClickB()#0 的引用。

image

  1. 单击 Increment B 按钮:
  • 第一次点击 Increment B 将导致 handleClickB() 被重新创建,因为我们更改了 countB ,从而创建了 handleClickB()#1

  • 此时 React 不会重新创建 handleClickA() ,因为 countA 没有改变。

  • 因此, handleClickB()#1 将保存对 AppScope#2 的引用,AppScope#2 保存着对 handleClickA()#1 的引用,同时handleClickA()#1保存着对AppScope#1 的引用,AppScope#1 保存着对handleClickB()#0 的引用 。

    image

  1. 第二次单击“Increment A”按钮:
    这样,我们可以创建一个无限的闭包链,这些闭包相互引用并且永远不会被垃圾收集,同时拖着一个单独的 10MB bigData 对象,因为它会在每次渲染时重新创建。

image

问题是单个组件中的不同 useCallback 钩子可能会通过闭包作用域相互引用或者引用一些其他昂贵的数据。然后,闭包将保存在内存中,直到重新创建 useCallback 钩子。组件中拥有多个 useCallback 钩子会使我们很难推断内存中保存的内容以及何时释放它。组件内的回调函数越多,遇到此问题的可能性就越大。

这会是个问题吗?

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

  1. 程序内存在一些几乎从未重新渲染的大型组件,例如,某个你将大量组件状态数据提升到的应用程序外壳组件。
  2. 大量依靠 useCallback 来最大限度地减少组件重新渲染。
  3. 在使用 useCallback 进行过缓存的函数内部调用了其他函数。
  4. 处理大型对象,例如图像数据或大型数组。

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

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

一些避免此类问题的建议:

  • Tip1: 使闭包作用域尽可能小。
    JavaScript 使得发现所有被捕获的变量变得非常困难。避免保留太多变量的最佳方法是减少闭包周围的函数大小。这意味着:
    1. 编写更小的组件,这将减少创建新闭包时范围内的变量数量。
    2. 编写自定义钩子,因为这样任何回调都只能关联在钩子函数的范围内。
  • Tip2: 避免捕获其他闭包,尤其是已经缓存的闭包。
    尽管这看起来很明显,但 React 很容易陷入这个陷阱。如果编写相互调用的较小函数,那么一旦添加第一个 useCallback ,就会出现要记住的组件范围内所有被调用函数这样的连锁反应。
  • Tip3: 只要在非常必要的时候在考虑使用 React 的缓存技术
    useCallbackuseMemo 是避免不必要的重新渲染的好工具,但它们是有代价的。仅当您发现渲染导致的性能问题时才使用它们。
  • Tip4(脱围机制): 对于大型对象使用 useRef
    这可能意味着,您需要自己处理对象的生命周期并正确清理它。虽然不是最优的,但比泄漏内存要好。

结论

闭包是 React 中广泛使用的模式。它们允许我们的函数记住组件上次渲染时范围内的 props 和 state 。当与 useCallback 等记忆技术结合使用时,这可能会导致意外的内存泄漏,尤其是在处理大型对象时。为了避免这些内存泄漏,请保持闭包范围尽可能小,在不必要时避免缓存记忆,对于大数据对象可能需要使用 useRef 。

希望这篇文章能在你使用 React 时帮助你进行更深入的思考。