内存泄漏检测与修复完全指南
内存泄漏是前端应用中最隐蔽的性能问题之一。它不会立即导致崩溃,而是随着时间推移逐渐消耗系统资源,最终导致页面卡顿、浏览器崩溃甚至系统假死。
本文将系统讲解内存泄漏的原理、常见模式、检测方法和修复策略,帮助你构建更稳定的 Web 应用。
理解 JavaScript 内存管理
内存生命周期
分配内存 → 使用内存 → 释放内存
(Allocate) (Use) (Release)
JavaScript 的内存分配:
// 原始值
let number = 123 // 栈内存
let string = 'hello' // 栈内存(引用)+ 堆内存(字符串内容)
// 对象
let object = { a: 1 } // 堆内存
let array = [1, 2, 3] // 堆内存
let func = () => {} // 堆内存
垃圾回收机制
JavaScript 使用自动垃圾回收(Garbage Collection),主要采用标记-清除算法:
1. 从根对象(window、DOM 树等)开始遍历
2. 标记所有可达的对象
3. 清除未被标记的对象
什么是「可达」?
let obj = { data: 'hello' } // obj 引用对象,对象可达
obj = null // 断开引用,对象不可达 → 会被回收
什么是内存泄漏
内存泄漏:不再需要的内存没有被释放,持续占用资源。
// 泄漏示例:全局变量持有引用
let cache = []
function addData(data) {
cache.push(data) // 数据不断累积,永不释放
}
常见内存泄漏模式
1. 意外的全局变量
// ❌ 未声明的变量成为全局变量
function leak() {
leakedVar = 'I am global!' // 没有 let/const/var
}
// ❌ this 指向 window
function leak() {
this.leakedVar = 'Also global!' // 非严格模式下
}
// ✅ 使用严格模式防止
'use strict'
function safe() {
undeclaredVar = 'error' // 抛出 ReferenceError
}
2. 未清理的定时器
// ❌ 组件销毁后定时器仍在运行
function startPolling() {
setInterval(() => {
fetchData()
}, 1000)
}
// ✅ 保存引用并清理
let timer = null
function startPolling() {
timer = setInterval(() => {
fetchData()
}, 1000)
}
function stopPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
}
3. 未移除的事件监听器
// ❌ 添加监听器但不移除
function init() {
window.addEventListener('resize', handleResize)
document.addEventListener('scroll', handleScroll)
}
// ✅ 在适当时机移除
function init() {
window.addEventListener('resize', handleResize)
return function cleanup() {
window.removeEventListener('resize', handleResize)
}
}
// 或使用 AbortController
const controller = new AbortController()
window.addEventListener('resize', handleResize, {
signal: controller.signal
})
// 清理时
controller.abort() // 自动移除所有关联的监听器
4. 闭包引用
// ❌ 闭包持有大对象引用
function createHandler() {
const largeData = new Array(1000000).fill('data')
return function handler() {
// 虽然只用了 largeData.length,但整个数组被保留
console.log(largeData.length)
}
}
// ✅ 只保留需要的数据
function createHandler() {
const largeData = new Array(1000000).fill('data')
const length = largeData.length // 只保留需要的值
return function handler() {
console.log(length)
}
// largeData 可以被回收
}
5. DOM 引用
// ❌ 保留对已删除 DOM 的引用
const elements = {
button: document.getElementById('btn'),
container: document.getElementById('container')
}
function removeContainer() {
document.body.removeChild(elements.container)
// elements.container 仍然引用已删除的 DOM
// 整个 DOM 子树无法被回收
}
// ✅ 同时清除引用
function removeContainer() {
document.body.removeChild(elements.container)
elements.container = null
}
6. 缓存未设上限
// ❌ 无限增长的缓存
const cache = new Map()
function getCachedData(key) {
if (!cache.has(key)) {
cache.set(key, expensiveComputation(key))
}
return cache.get(key)
}
// ✅ 使用 LRU 缓存限制大小
class LRUCache {
constructor(maxSize = 100) {
this.maxSize = maxSize
this.cache = new Map()
}
get(key) {
if (!this.cache.has(key)) return undefined
// 移到最后(最近使用)
const value = this.cache.get(key)
this.cache.delete(key)
this.cache.set(key, value)
return value
}
set(key, value) {
if (this.cache.has(key)) {
this.cache.delete(key)
} else if (this.cache.size >= this.maxSize) {
// 删除最久未使用的
const firstKey = this.cache.keys().next().value
this.cache.delete(firstKey)
}
this.cache.set(key, value)
}
}
7. 观察者未取消订阅
// ❌ 订阅后不取消
const subscription = observable.subscribe(data => {
updateUI(data)
})
// 组件销毁后 subscription 仍然存在
// ✅ 在销毁时取消订阅
const subscription = observable.subscribe(data => {
updateUI(data)
})
// 组件销毁时
subscription.unsubscribe()
Chrome DevTools 内存分析
Memory 面板概览
三种分析模式:
| 模式 | 用途 |
|---|---|
| Heap snapshot | 拍摄堆内存快照,分析对象分布 |
| Allocation instrumentation on timeline | 追踪内存分配时间线 |
| Allocation sampling | 采样分析,开销较低 |
堆快照对比法
步骤:
- 打开 DevTools → Memory
- 执行可能泄漏的操作前,拍摄快照 1
- 执行操作(如打开/关闭弹窗)
- 手动触发 GC(点击垃圾桶图标)
- 拍摄快照 2
- 选择快照 2,视图切换为「Comparison」
- 与快照 1 对比,查看「# Delta」(新增对象数)
关注点:
# New:新增的对象数量# Deleted:删除的对象数量# Delta:净增量(New - Deleted)
红色信号: Delta 持续增长的对象类型
Timeline 分析法
步骤:
- 选择「Allocation instrumentation on timeline」
- 点击 Start 开始记录
- 执行操作
- 点击 Stop 停止记录
- 查看蓝色柱(分配)和灰色柱(释放)
识别泄漏: 蓝色柱(分配)远多于灰色柱(释放)
Performance Monitor
步骤:
- DevTools → More tools → Performance monitor
- 勾选「JS heap size」和「DOM Nodes」
- 执行操作,观察曲线
红色信号: 曲线持续上升不回落
实战:定位泄漏代码
// 假设发现 Array 对象异常增长
// 1. 在堆快照中找到可疑的 Array
// 2. 点击展开,查看 Retainers(保留者)
// 3. 追溯引用链,找到根源
// Retainers 示例:
// Array @123456
// ↖ cache in (closure) @234567
// ↖ handler in EventListener
// ↖ Window / global
Vue 中的内存管理
常见泄漏场景
1. 组件销毁时未清理
<script setup>
import { onMounted, onUnmounted } from 'vue'
// ❌ 未清理
onMounted(() => {
window.addEventListener('resize', handleResize)
setInterval(fetchData, 5000)
})
// ✅ 正确清理
let timer = null
onMounted(() => {
window.addEventListener('resize', handleResize)
timer = setInterval(fetchData, 5000)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (timer) {
clearInterval(timer)
}
})
</script>
2. 第三方库实例未销毁
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import Chart from 'chart.js/auto'
const chartRef = ref(null)
let chartInstance = null
onMounted(() => {
chartInstance = new Chart(chartRef.value, {
type: 'bar',
data: { ... }
})
})
onUnmounted(() => {
// ✅ 销毁 Chart 实例
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
})
</script>
<template>
<canvas ref="chartRef"></canvas>
</template>
3. 响应式引用大对象
<script setup>
import { ref, shallowRef } from 'vue'
// ❌ 深度响应式可能导致大量代理对象
const largeData = ref(hugeObject)
// ✅ 使用 shallowRef 减少响应式开销
const largeData = shallowRef(hugeObject)
// 或者使用 markRaw 标记不需要响应式的对象
import { markRaw } from 'vue'
const staticData = ref(markRaw(hugeObject))
</script>
封装自动清理的 Composable
// composables/useEventListener.ts
import { onUnmounted } from 'vue'
export function useEventListener<K extends keyof WindowEventMap>(
target: Window | Element,
event: K,
handler: (e: WindowEventMap[K]) => void,
options?: AddEventListenerOptions
) {
target.addEventListener(event, handler, options)
// 自动在组件销毁时清理
onUnmounted(() => {
target.removeEventListener(event, handler, options)
})
}
// 使用
import { useEventListener } from '@/composables/useEventListener'
useEventListener(window, 'resize', handleResize)
// 无需手动清理
// composables/useInterval.ts
import { onUnmounted, ref } from 'vue'
export function useInterval(callback: () => void, interval: number) {
const isActive = ref(true)
const timer = setInterval(() => {
if (isActive.value) {
callback()
}
}, interval)
const stop = () => {
isActive.value = false
clearInterval(timer)
}
onUnmounted(stop)
return { isActive, stop }
}
React 中的内存管理
useEffect 清理
import { useEffect, useState } from 'react'
function Component() {
const [data, setData] = useState(null)
useEffect(() => {
// 防止组件卸载后设置 state
let isMounted = true
const timer = setInterval(() => {
fetchData().then(result => {
if (isMounted) {
setData(result)
}
})
}, 5000)
window.addEventListener('resize', handleResize)
// ✅ 清理函数
return () => {
isMounted = false
clearInterval(timer)
window.removeEventListener('resize', handleResize)
}
}, [])
return <div>{data}</div>
}
AbortController 取消请求
useEffect(() => {
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(data => setData(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err)
}
})
return () => {
controller.abort()
}
}, [])
自定义 Hook 封装
// hooks/useSubscription.ts
import { useEffect, useRef } from 'react'
export function useSubscription<T>(
subscribe: (callback: (value: T) => void) => () => void,
callback: (value: T) => void,
deps: any[] = []
) {
const callbackRef = useRef(callback)
callbackRef.current = callback
useEffect(() => {
const unsubscribe = subscribe((value) => {
callbackRef.current(value)
})
return unsubscribe
}, deps)
}
// 使用
useSubscription(
(callback) => eventEmitter.on('update', callback),
(data) => setData(data),
[]
)
生产环境监控
Performance API 监控
// 监控内存使用
function monitorMemory() {
if (performance.memory) {
const { usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit } = performance.memory
const usage = {
used: Math.round(usedJSHeapSize / 1024 / 1024), // MB
total: Math.round(totalJSHeapSize / 1024 / 1024), // MB
limit: Math.round(jsHeapSizeLimit / 1024 / 1024), // MB
percentage: (usedJSHeapSize / jsHeapSizeLimit * 100).toFixed(2)
}
console.log(`Memory: ${usage.used}MB / ${usage.limit}MB (${usage.percentage}%)`)
// 内存使用超过阈值时上报
if (usedJSHeapSize / jsHeapSizeLimit > 0.9) {
reportHighMemoryUsage(usage)
}
return usage
}
return null
}
// 定期检查
setInterval(monitorMemory, 30000)
检测 DOM 节点泄漏
function monitorDOMNodes() {
const count = document.getElementsByTagName('*').length
console.log(`DOM nodes: ${count}`)
// 节点数异常时告警
if (count > 3000) {
console.warn('DOM nodes exceed threshold')
}
return count
}
集成到监控平台
// 上报到监控服务
function reportMemoryMetrics() {
const memory = performance.memory
if (!memory) return
const metrics = {
type: 'memory',
timestamp: Date.now(),
usedHeapSize: memory.usedJSHeapSize,
totalHeapSize: memory.totalJSHeapSize,
domNodes: document.getElementsByTagName('*').length,
url: location.href
}
// 发送到监控服务
navigator.sendBeacon('/api/metrics', JSON.stringify(metrics))
}
// 页面卸载时上报
window.addEventListener('beforeunload', reportMemoryMetrics)
// 定期上报
setInterval(reportMemoryMetrics, 60000)
修复清单
诊断流程
1. 确认症状
↓ 页面越来越慢?内存持续增长?
2. 复现问题
↓ 找到触发条件(特定操作、时间累积)
3. 拍摄快照
↓ 操作前后对比
4. 分析增量
↓ 哪些对象异常增长
5. 追溯引用链
↓ 找到保留对象的根源
6. 修复代码
↓ 断开引用、添加清理逻辑
7. 验证修复
↓ 重新测试确认
常见修复方案
| 泄漏类型 | 修复方案 |
|---|---|
| 全局变量 | 使用 let/const、严格模式 |
| 定时器 | 保存引用、在销毁时 clear |
| 事件监听 | removeEventListener 或 AbortController |
| 闭包引用 | 只保留必要数据、解除引用 |
| DOM 引用 | 删除 DOM 后清空变量 |
| 缓存过大 | 使用 LRU、设置上限 |
| 订阅未取消 | 在销毁时 unsubscribe |
预防措施
- 使用严格模式
'use strict' - ESLint 配置 no-unused-vars
- 组件/Hook 封装清理逻辑
- Code Review 关注资源清理
- 定期进行内存分析
- 生产环境监控内存指标
总结
内存泄漏的核心问题是无用对象仍被引用,导致垃圾回收器无法回收。
预防原则:
- 谁创建谁清理:添加监听器/定时器的代码负责清理
- 封装成对操作:init/destroy、subscribe/unsubscribe 成对出现
- 使用 WeakMap/WeakSet:不阻止对象被回收
- 限制缓存大小:使用 LRU 等策略
检测流程:
- Performance Monitor 观察趋势
- 堆快照对比法定位对象
- Retainers 追溯引用链
- 修复代码并验证
内存管理是长期工作,建立良好的编码习惯和监控机制,才能从根本上避免内存问题。


