事件监听器销毁完全指南:如何避免内存泄漏?

前言

我们在实际开发中可能遇到过这样的情况:打开一个网页,一开始很流畅,但后面越用越卡;尤其是切换页面后,感觉浏览器变慢了;长时间不刷新,页面最终崩溃了。

这很可能就是 内存泄漏 在作祟。

想象一下:我们有个垃圾桶,每天都在往里面扔垃圾,但从来不倒。一开始没什么问题,但一个月后,垃圾堆满了屋子,我们连站的地方都没有了。

事件监听器导致的内存泄漏,就是这样------垃圾不倒,导致越积越多。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,深入探讨事件监听器导致内存泄漏的成因、检测方法、预防措施,以及 TypeScript 如何帮助我们构建类型安全的清理策略。

为什么事件监听器会成为内存杀手?

从一个简单的例子开始

App.vue

html 复制代码
<template>
  <div>
    <button @click="show = !show">切换组件</button>
    <ChildComponent v-if="show" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

ChildComponent.vue

html 复制代码
<script setup>
import { onMounted } from 'vue'

onMounted(() => {
  // 每次组件挂载时,都添加一个滚动监听
  window.addEventListener('scroll', () => {
    console.log('滚动位置:', window.scrollY)
  })
})
</script>

这看起来没什么问题,但实际上发生了什么呢? 每次切换组件,都会增加一个新的监听器! 当成百上千次切换后,就有上千个监听器在工作...

为什么没有自动清理?

很多人都以为只要组件销毁了,它里面的东西会自动清理。但事实是:

  • Vue 可以自动清理:组件的数据、事件、计算属性等
  • Vue 不能自动清理:window/document 上的事件、定时器、WebSocket 等

内存泄漏的危害有多大?

指标 正常状态 泄漏状态 影响
内存占用 50MB 500MB+ 页面卡顿,甚至崩溃
事件响应 即时 延迟1-2秒 用户体验差
CPU使用率 10% 60%+ 电脑发烫,风扇狂转
电池消耗 正常 快3倍 移动端灾难

三种事件注册方式及其清理

三种注册方式对比

注册方式 优点 缺点 清理方法
内联事件 简单直接 无法移除多个,污染HTML 赋值为null
属性赋值 可移除 只能绑定一个 赋值为null
addEventListener 可绑定多个,灵活 需要对应 remove removeEventListener

内联事件的清理

typescript 复制代码
// 移除内联事件
const button = document.querySelector('button')
button.onclick = null

// 或者移除整个元素
button.remove()

// 更彻底:清空父元素内容
parent.innerHTML = ''  // 会移除所有子元素的事件

注:实际 Vue 开发中,不推荐直接使用内联事件,推荐使用 Vue 的事件绑定 @click 等。

属性赋值的清理

typescript 复制代码
// 注册
window.onresize = handleResize
document.onkeydown = handleKeyDown
button.onclick = handleClick

// 清理
window.onresize = null
document.onkeydown = null
button.onclick = null

注:属性赋值只能有一个监听器 window.onresize = fn1 window.onresize = fn2

此时 fn2 会覆盖 fn1

addEventListener 的正确清理

typescript 复制代码
function handleResize() {
  console.log('resize')
}
window.addEventListener('resize', handleResize)
window.removeEventListener('resize', handleResize)

为什么 removeEventListener 有时候不工作?

场景一:匿名函数无法移除

typescript 复制代码
window.addEventListener('click', () => {})
window.removeEventListener('click', () => {})  // ❌ 错误:匿名函数无法移除

因为匿名函数每次创建时都是新的,会重复创建,因此无法移除。

场景二:capture 参数不同,无法移除

typescript 复制代码
window.addEventListener('click', handleClick, true)
window.removeEventListener('click', handleClick, false)  //   ❌ 错误::capture 不同,无法移除

场景三:options 对象不同,无法移除

typescript 复制代码
const options1 = { passive: true }
const options2 = { passive: true }
element.addEventListener('click', handleClick, options1)
element.removeEventListener('click', handleClick, options2)  //  ❌ 错误:不同对象,无法移除

一句话总结:removeEventListener 的参数必须和 addEventListener 完全一致才能移除。

Vue 组件中的事件清理

最基本的清理模式

html 复制代码
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'

const scrollTop = ref(0)

// 1. 使用具名函数
function handleScroll() {
  scrollTop.value = window.scrollY
}

onMounted(() => {
  // 2. 注册事件
  window.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
  // 3. 组件卸载时移除事件
  window.removeEventListener('scroll', handleScroll)
})
</script>

<template>
  <div>滚动位置: {{ scrollTop }}</div>
</template>

封装可复用的组合式函数

typescript 复制代码
// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, handler) {
  // 确保 target 存在
  if (!target?.addEventListener) return
  
  // 注册
  onMounted(() => {
    target.addEventListener(event, handler)
  })
  
  // 自动清理
  onUnmounted(() => {
    target.removeEventListener(event, handler)
  })
}

使用示例:

typescript 复制代码
useEventListener(window, 'resize', () => {
  console.log('窗口大小变化', window.innerWidth)
})

useEventListener(document, 'visibilitychange', () => {
  console.log('页面可见性变化')
})

useEventListener(document, 'keydown', (e) => {
  if (e.key === 'Escape') {
    console.log('按下 ESC 键')
  }
})

支持多个事件的组合式函数

typescript 复制代码
// composables/useWindowEvents.js
import { onMounted, onUnmounted } from 'vue'

export function useWindowEvents(handlers) {
  const entries = Object.entries(handlers)
  
  onMounted(() => {
    entries.forEach(([event, handler]) => {
      window.addEventListener(event, handler)
    })
  })
  
  onUnmounted(() => {
    entries.forEach(([event, handler]) => {
      window.removeEventListener(event, handler)
    })
  })
}

使用示例:

typescript 复制代码
useWindowEvents({
  resize: () => console.log('resize'),
  scroll: () => console.log('scroll'),
  click: (e) => console.log('click at', e.clientX, e.clientY)
})

返回清理函数的 Hook 模式

typescript 复制代码
// composables/useResizeObserver.js
import { ref, onUnmounted } from 'vue'

export function useResizeObserver(target) {
  const width = ref(0)
  const height = ref(0)
  
  // 创建观察者
  const observer = new ResizeObserver((entries) => {
    const entry = entries[0]
    if (entry) {
      width.value = entry.contentRect.width
      height.value = entry.contentRect.height
    }
  })
  
  // 开始观察
  const el = unref(target)
  if (el) {
    observer.observe(el)
  }
  
  // 返回清理函数
  const cleanup = () => {
    observer.disconnect()
  }
  
  // 组件卸载时自动清理
  onUnmounted(cleanup)
  
  return {
    width,
    height,
    cleanup  // 也可以手动调用
  }
}

使用示例:

typescript 复制代码
const container = ref()
const { width, height } = useResizeObserver(container)

内存泄漏的检测与诊断

Chrome DevTools 内存面板使用

typescript 复制代码
// 步骤1:录制内存分配时间线
// Performance 面板 → Memory 勾选 → 开始录制
// 执行可能导致泄漏的操作 → 停止录制
// 查看内存曲线:正常应该波动后回落,泄漏会持续增长

// 步骤2:拍摄堆快照
// Memory 面板 → Take heap snapshot

// 步骤3:对比快照
// 操作前后各拍一次 → 选择 Comparison 视图
// 重点查看:
// - Detached 元素(已从 DOM 移除但未被回收)
// - 增加的 EventListener 数量
// - 新增的闭包引用

// 步骤4:使用 Allocation instrumentation on timeline
// 实时记录内存分配,定位泄漏的具体代码

Performance Monitor 实时监控

typescript 复制代码
// 在 DevTools 中打开 Performance Monitor(Ctrl+Shift+P 搜索)
// 关注指标:
// - JS Heap size:堆内存大小,正常应该稳定在某个范围
// - DOM Nodes:DOM 节点数量,动态内容应有增有减
// - Event Listeners:事件监听器数量,不应无限增长
// - Documents:文档数量,通常为1

// 正常情况:操作前后指标应该基本持平
// 泄漏情况:指标持续增长,不会下降

手动检测代码

typescript 复制代码
// 在开发环境添加监控工具
if (import.meta.env.DEV) {
  // 每5秒输出一次内存状态
  setInterval(() => {
    console.table({
      '时间': new Date().toLocaleTimeString(),
      'JS Heap': formatBytes((performance as any).memory?.usedJSHeapSize),
      'DOM Nodes': document.querySelectorAll('*').length,
      'Event Listeners': countEventListeners(),
      'Detached Nodes': countDetachedNodes()
    })
  }, 5000)
}

function countEventListeners(): number {
  // 遍历所有 DOM 元素,统计监听器(仅限 Chrome)
  let count = 0
  const allElements = document.querySelectorAll('*')
  
  allElements.forEach(el => {
    const listeners = (el as any).getEventListeners?.()
    if (listeners) {
      count += Object.values(listeners).flat().length
    }
  })
  
  return count
}

function countDetachedNodes(): number {
  // 统计已从 DOM 移除但未被回收的元素
  const heapSnapshot = (window as any).heapSnapshot
  if (!heapSnapshot) return 0
  
  let count = 0
  // 遍历堆快照统计 detached 元素
  // 具体实现依赖 DevTools 协议
  return count
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return bytes + ' B'
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
  return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}

常见陷阱与解决方案

陷阱一:在循环中注册事件

typescript 复制代码
// ❌ 错误:每秒增加一个监听器
setInterval(() => {
  window.addEventListener('resize', () => {
    console.log('resize')
  })
}, 1000)

// ✅ 正确:只注册一次
window.addEventListener('resize', () => {
  console.log('resize')
})

setInterval(() => {
  // 做其他事
}, 1000)

陷阱二:watch 中注册事件

typescript 复制代码
// ❌ 错误:每次 ID 变化都增加监听器
watch(() => route.params.id, () => {
  window.addEventListener('scroll', handleScroll)
})

// ✅ 正确:只注册一次
onMounted(() => {
  window.addEventListener('scroll', handleScroll)
})

function handleScroll() {
  // 根据当前 ID 做不同处理
  if (route.params.id) {
    console.log('当前ID:', route.params.id)
  }
}

onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll)
})

陷阱三:箭头函数的 this 问题

typescript 复制代码
class Component {
  data = 'test'
  
  // ❌ 错误:每次调用都创建新函数
  render() {
    button.addEventListener('click', () => {
      console.log(this.data)  // 无法移除
    })
  }
  
  // ✅ 正确:使用类属性方法
  handleClick = () => {
    console.log(this.data)
  }
  
  render() {
    button.addEventListener('click', this.handleClick)
    // 可以移除
    button.removeEventListener('click', this.handleClick)
  }
}

陷阱四:第三方库不销毁

typescript 复制代码
import Swiper from 'swiper'
import * as echarts from 'echarts'

let swiper = null
let chart = null

onMounted(() => {
  // ❌ 只创建不销毁
  swiper = new Swiper('.swiper', {})
  chart = echarts.init(document.getElementById('chart'))
})

onUnmounted(() => {
  // ✅ 必须调用销毁方法
  if (swiper) {
    swiper.destroy(true, true)
    swiper = null
  }
  
  if (chart) {
    chart.dispose()
    chart = null
  }
})

最佳实践清单

开发时 Checklist

  • 每个 addEventListener 都有对应的 removeEventListener
  • 清理函数是否在 onUnmounted 中调用?
  • 匿名函数是否改成了具名函数或变量引用?
  • 节流/防抖的定时器是否清理了?
  • IntersectionObserver/ResizeObserver 是否调用了 disconnect
  • 第三方库实例是否调用了 destroydispose 方法?
  • 动态添加的元素,事件是否在移除元素时清理?

代码审查 Checklist

  • 是否有在循环或高频操作中注册事件?
  • 事件回调中是否持有大量数据的引用?(可能导致内存泄漏)
  • 多个组件共享的全局事件,是否考虑了竞态条件?
  • 组件销毁时,是否清理了所有自定义事件?
  • 使用 once 选项的事件是否确实只需要执行一次?

性能监控 Checklist

  • 是否定期检查 DevTools 的 Event Listeners 数量?
  • 是否有内存泄漏的自动化测试?
  • 生产环境是否有内存监控告警?
  • 是否建立了性能基准,跟踪内存趋势?
  • 是否在关键操作前后进行了内存快照对比?

注册清理对应表

注册 清理
addEventListener removeEventListener
setInterval clearInterval
setTimeout clearTimeout
new Observer observer.disconnect()
new WebSocket websocket.close()
new Swiper swiper.destroy()
echarts.init chart.dispose()

结语

好的代码不仅要能运行,还要能优雅地停止。学会正确地清理事件监听器,是每个前端开发者从入门到进阶的必修课。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
电商API&Tina2 小时前
1688跨境寻源通API数据采集: 获得1688商品详情关键字搜索商品按图搜索1688商品
大数据·前端·数据库·人工智能·爬虫·json·图搜索算法
旷世奇才李先生2 小时前
066基于java的中医养生系统-springboot+vue
java·vue.js·spring boot
૮・ﻌ・2 小时前
Nodejs - 01:Buffer、fs模块、HTTP模块
前端·http·node.js
飘逸飘逸2 小时前
Autojs进阶-插件更新记录
android·javascript
大漠_w3cpluscom2 小时前
为什么 :is(::before, ::after) 不能工作?
前端
aXin_li2 小时前
从原子化到工程化:Tailwind CSS 的深层价值与实践思考
前端·css
IT_陈寒2 小时前
用Python爬虫抓了100万条数据后,我总结了这5个反封禁技巧
前端·人工智能·后端
qq_411262422 小时前
AP模式中修改下wifi名称就无法连接了,分析一下
java·前端·spring
BUG创建者2 小时前
uniapp 开发app时播放实时视频海康ws的流数据
前端·javascript·vue.js·uni-app·html·音视频