JavaScript 常见内存泄漏的五大场景与解决方案
JavaScript 依赖自动垃圾回收,但开发者不当操作仍会导致内存泄漏。本文深入解析五种常见场景:未清理的定时器、重复绑定的事件监听器、未断开的观察者、全局变量污染以及 DOM 引用持有。通过具体代码示例与 Chrome DevTools 分析,提供修复方案,帮助前端开发者避免应用卡顿与崩溃。
JavaScript 本身不提供手动内存管理接口,而是由 JavaScript 虚拟机通过垃圾回收机制自动管理内存。
既然无法强制触发垃圾回收,我们如何确认其运行正常?又对该机制了解多少?
- 在垃圾回收过程中,脚本执行会被暂停。
- 系统会释放那些不再被访问的资源所占用的内存。
- 垃圾回收的具体行为是不确定的。
- 它不会一次性扫描整个内存空间,而是分多个周期逐步执行。
- 虽然不可预测,但系统会在必要时自动执行回收操作。
这是否意味着我们可以完全忽略资源分配和内存问题?并非如此。如果操作不当,仍可能产生内存泄漏。
什么是内存泄漏?
内存泄漏指的是软件无法回收的已分配内存块。
尽管 JavaScript 提供了垃圾收集程序,但这并不意味着能自动避免内存泄漏。要使对象符合被回收的条件,必须确保它不被其他任何地方引用。如果代码中持有对已废弃资源的引用,垃圾回收器将无法释放这些资源,这种现象被称为无意识的内存保持。
内存泄漏会导致垃圾回收器运行频率增加。由于回收过程会暂停脚本执行,这可能导致程序卡顿。用户一旦察觉性能下降,产品体验将大打折扣,甚至可能导致应用崩溃。
如何防止内存泄漏?核心在于避免保留不必要的资源引用。接下来我们将分析一些常见场景。
1. 计时器的监听
setInterval() 方法用于重复调用函数或执行代码片段,每次调用之间设有固定的时间延迟。该方法返回一个时间间隔 ID,该 ID 唯一标识此时间间隔,因此后续可通过调用 clearInterval() 来取消它。
我们创建一个组件,该组件调用回调函数以表示其在 x 个循环后完成。本例使用 React,但该逻辑适用于任何前端框架。
import React, { useRef } from 'react';
const Timer = ({ cicles, onFinish }) => {
const currentCicles = useRef(0);
setInterval(() => {
if (currentCicles.current >= cicles) {
onFinish();
return;
}
currentCicles.current++;
}, 500);
return (
<div>Loading ...</div>
);
}
export default Timer;
初看似乎没有问题。不急,我们再创建一个触发该定时器的组件,并分析其内存性能。
import React, { useState } from 'react';
import styles from '../styles/Home.module.css'
import Timer from '../components/Timer';
export default function Home() {
const [showTimer, setShowTimer] = useState();
const onFinish = () => setShowTimer(false);
return (
<div className={styles.container}>
{showTimer ? (
<Timer cicles={10} onFinish={onFinish} />
): (
<button onClick={() => setShowTimer(true)}>
Retry
</button>
)}
</div>
)
}
在 Retry 按钮上单击几次后,使用 Chrome Dev Tools 获取的内存使用情况结果如下:

当我们点击重试按钮时,可以看到分配的内存越来越多。这表明之前分配的内存未被释放,计时器仍在运行而非被替换。
如何解决此问题?setInterval 的返回值是一个间隔 ID,我们可以利用它来取消该间隔。在此特定情况下,我们可以在组件卸载后调用 clearInterval。
useEffect(() => {
const intervalId = setInterval(() => {
if (currentCicles.current >= cicles) {
onFinish();
return;
}
currentCicles.current++;
}, 500);
return () => clearInterval(intervalId);
}, [])
有时在编写代码时很难发现此类问题,最佳方式是将组件逻辑抽象化。
这里使用的是 React,我们可以将所有相关逻辑封装在一个自定义 Hook 中。
import { useEffect } from 'react';
export const useTimeout = (refreshCycle = 100, callback) => {
useEffect(() => {
if (refreshCycle <= 0) {
setTimeout(callback, 0);
return;
}
const intervalId = setInterval(() => {
callback();
}, refreshCycle);
return () => clearInterval(intervalId);
}, [refreshCycle, setInterval, clearInterval]);
};
export default useTimeout;
现在需要使用 setInterval 时,都可以采用以下做法:
const handleTimeout = () => ...;
useTimeout(100, handleTimeout);
现在你可以使用这个 useTimeout Hook,无需担心内存泄漏,这也体现了抽象化的优势。
2. 事件监听
Web API 提供了大量事件监听器。在前文中,我们讨论了 setTimeout。现在来看看 addEventListener。
在此示例中,我们创建一个键盘快捷键功能。由于不同页面有不同的功能需求,因此会创建不同的快捷键处理逻辑。
function homeShortcuts({ key}) {
if (key === 'E') {
console.log('edit widget')
}
}
// 用户在主页上登陆,我们执行
document.addEventListener('keyup', homeShortcuts);
// 用户做一些事情,然后导航到设置
function settingsShortcuts({ key}) {
if (key === 'E') {
console.log('edit setting')
}
}
// 用户在主页上登陆,我们执行
document.addEventListener('keyup', settingsShortcuts);
看起来运行良好,但在执行第二个 addEventListener 时,并未清理之前的 keyup。这段代码并非替换我们的 keyup 监听器,而是添加了另一个 callback。这意味着,当按下某个键时,将触发两个函数。
要清除之前的回调,我们需要使用 removeEventListener:
document.removeEventListener(‘keyup', homeShortcuts);
重构上述代码:
function homeShortcuts({ key}) {
if (key === 'E') {
console.log('edit widget')
}
}
// user lands on home and we execute
document.addEventListener('keyup', homeShortcuts);
// user does some stuff and navigates to settings
function settingsShortcuts({ key}) {
if (key === 'E') {
console.log('edit setting')
}
}
// user lands on home and we execute
document.removeEventListener('keyup', homeShortcuts);
document.addEventListener('keyup', settingsShortcuts);
经验表明,当使用来自全局对象的工具时,需要格外小心。
3. Observers
Observers 是浏览器的 Web API 功能,许多开发者对此并不熟悉。如果你想检查 HTML 元素的可见性或大小变化,这个功能非常强大。
IntersectionObserver 接口(属于 Intersection Observer API)提供了一种异步观察目标元素与其祖先元素或顶级文档视窗 viewport 交叉状态的方法。祖先元素与视窗 viewport 被称为根 root。
尽管功能强大,使用时也需谨慎。一旦完成对对象的观察,记得在不使用时取消它。
看看代码:
const ref = ...
const visible = (visible) => {
console.log(`It is ${visible}`);
}
useEffect(() => {
if (!ref) {
return;
}
observer.current = new IntersectionObserver(
(entries) => {
if (!entries[0].isIntersecting) {
visible(true);
} else {
visbile(false);
}
},
{ rootMargin: `-${header.height}px` },
);
observer.current.observe(ref);
}, [ref]);
上述代码看起来不错。然而,一旦组件被卸载,观察者会发生什么?它不会被自动清除,从而导致内存泄漏。如何解决?只需使用 disconnect 方法:
const ref = ...
const visible = (visible) => {
console.log(`It is ${visible}`);
}
useEffect(() => {
if (!ref) {
return;
}
observer.current = new IntersectionObserver(
(entries) => {
if (!entries[0].isIntersecting) {
visible(true);
} else {
visbile(false);
}
},
{ rootMargin: `-${header.height}px` },
);
observer.current.observe(ref);
return () => observer.current?.disconnect();
}, [ref]);
4. Window Object
向 Window 对象添加属性是一个常见错误。在某些场景中,这可能很难被发现,特别是在使用 Window 执行上下文中的 this 关键字时。请看以下示例:
function addElement(element) {
if (!this.stack) {
this.stack = {
elements: []
}
}
this.stack.elements.push(element);
}
这看起来无害,但这取决于你从哪个上下文调用 addElement。如果你从 Window Context 调用 addElement,属性会不断堆积。
另一个问题可能是错误地定义了一个全局变量:
var a = 'example 1'; // 作用域限定在创建var的地方
b = 'example 2'; // 添加到Window对象中
要防止此类问题,可以使用严格模式:
"use strict"
通过使用严格模式,向 JavaScript 编译器表明你想避免这些行为。当你需要时,仍可以使用 Window,但必须以明确的方式使用它。
严格模式如何影响我们前面的例子:
对于 addElement 函数,当从全局作用域调用时,this 是未定义的。
如果没有在变量上指定 const | let | var,你会得到以下错误:
Uncaught ReferenceError: b is not defined5. 持有 DOM 引用
DOM 节点也无法避免内存泄漏。我们需要注意不要保存它们的引用。否则,垃圾回收器将无法清理它们,因为它们仍然是可访问的。
用一小段代码演示一下:
const elements = [];
const list = document.getElementById('list');
function addElement() {
// clean nodes
list.innerHTML = '';
const divElement= document.createElement('div');
const element = document.createTextNode(`adding element ${elements.length}`);
divElement.appendChild(element);
list.appendChild(divElement);
elements.push(divElement);
}
document.getElementById('addElement').onclick = addElement;
注意,addElement 函数清除列表 div,并将一个新元素作为子元素添加到其中。这个新创建的元素被添加到 elements 数组中。
下一次执行 addElement 时,该元素将从列表 div 中删除,但它不适合进行垃圾收集,因为它存储在 elements 数组中。
我们在执行几次之后监视函数:

在上面的截图中可以看到节点是如何被泄露的。如何解决?清除 elements 数组将使它们有资格进行垃圾收集。
总结
在这篇文章中,我们已经看到了最常见的内存泄漏方式。很明显,JavaScript 本身并没有泄漏内存。相反,它是由开发者方面无意的内存保持造成的。只要代码是整洁的,而且我们不忘自己清理,就不会发生泄漏。
了解内存和垃圾回收在 JavaScript 中是如何工作的是必须的。一些开发者得到了错误的意识,认为由于它是自动的,所以他们不需要担心这个问题。
本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注本站的更多内容!
JS语法错误会阻止加载吗?区分SyntaxError与资源加载失败
2026-11-05 16:08:46
JavaScript常见SyntaxError语法错误排查与修复方法
2026-11-05 13:58:44
JS中slice、substring和substr截取字符串的区别
2026-11-05 13:43:47
JS 转 TS 手动迁移最佳实践:从配置 tsconfig 到消除 any 类型
2026-11-05 11:53:32
为什么JS数组push后得到的是数字而不是数组?
2026-11-05 09:59:18
