原文链接:www.schiener.io/2024-03-03/…
我在Ramblr(一家人工智能初创公司)工作,我们为视频标注开发复杂的React应用程序。最近,我遇到了一个由JavaScript闭包与React的useCallback钩子共同引发的复杂内存泄漏问题。由于我有.NET背景,花了相当长的时间才弄清楚症结所在,所以想在此分享我的发现。
我简单回顾了闭包的概念,但如果你已经熟悉JavaScript中闭包的工作原理,可以跳过这部分。
- 给React Query用户的续篇:《隐秘的React内存泄漏II:闭包与React Query的对决》
- 想了解React编译器如何处理这类问题? :《隐秘的React内存泄漏:为什么React编译器救不了你》
闭包基础回顾
闭包是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 组件首次渲染时,由于我们在至少一个闭包中使用了所有变量(包括 bigData、handleClickA() 和 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()#1,handleClickA()#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:非必要时不使用记忆化
useCallback 和 useMemo 虽是避免不必要渲染的强大工具,但有其代价。建议仅在因渲染导致可观测性能问题时使用它们。
技巧4(应急方案):对大对象使用useRef
这意味着你需要自行管理对象的生命周期并确保正确清理。虽非最优解,但比内存泄漏要好。
结论
闭包是React中大量使用的模式。它使我们的函数能够记住组件上次渲染时作用域内的属性和状态。当与useCallback等记忆化技术结合时(尤其是在处理大型对象的情况下),这可能导致意外的内存泄漏。为避免这类内存泄漏,请尽量保持闭包作用域最小化,非必要时避免使用记忆化,对于大型对象可考虑改用useRef。
特别感谢David Glasser于2013年发表的文章《Meteor中发现的一个令人惊讶的JavaScript内存泄漏》,这篇文章为我指明了正确的方向。
反馈?
您认为我是否有遗漏或误解之处?或许您有更好的解决方案,或从未遇到过此类问题。如果您有任何疑问或建议,欢迎通过LinkedIn或X/Twitter联系我。
祝调试愉快!
- 给React Query用户的续篇:《隐秘的React内存泄漏II:闭包与React Query的对决》
- 想了解React编译器如何处理这类问题?:《隐秘的React内存泄漏:为什么React编译器救不了你》