[vue-router]06-内存泄漏与性能优化

3 阅读16分钟

【Vue 路由系列 06】SPA 内存泄漏与性能排查:从泄漏源到 DevTools 实战

这是我在模拟面试中没来得及回答的最后一问——但恰恰是最能体现工程深度的问题。

在前五篇中,我们覆盖了 Vue Router 的完整知识体系:物理层(01)→ 交通管制(02)→ 路由配置(03)→ 打包优化(04)→ 状态保持(05)。这一篇作为系列的收尾,关注长期运行的健康度——SPA 的内存泄漏不像后端 OOM 那样直接崩溃,而是像慢性毒药:页面越来越卡、切换越来越慢、最终浏览器标签页吃掉几个 G 内存。同时,除了泄漏,路由切换本身的性能优化也是面试中的加分项。


一、什么是 SPA 的内存泄漏

1.1 定义

内存泄漏(Memory Leak):程序中不再需要的内存没有被垃圾回收器(GC)回收,导致可用内存持续减少。

正常情况:
  创建对象 → 使用 → 不再引用 → GC 回收 ✅

内存泄漏:
  创建对象 → 使用 → "以为"不再引用 → 实际上还有隐藏引用 → GC 无法回收 ❌
  → 内存持续增长 → 页面越来越卡

1.2 为什么 SPA 特别容易泄漏

特性为什么容易泄漏
单页应用用户可能几小时不刷新页面,所有泄漏都会累积
路由切换频繁组件销毁时清理不彻底,旧组件的资源残留在内存中
事件监听器多DOM 事件、Window 事件、定时器、网络请求……
第三方库复杂ECharts 实例、WebSocket 连接、IntersectionObserver 等
闭包滥用回调函数隐式持有外部变量引用

二、十大常见泄漏场景

🔴 场景 1:未清除的事件监听器 —— 最常见的泄漏

// ❌ 泄漏写法
export default {
  mounted() {
    // 注册了全局事件,但离开时没移除
    window.addEventListener('resize', this.handleResize)
    document.addEventListener('scroll', this.handleScroll)
    window.addEventListener('keydown', this.handleKeydown)
    
    // 或者 Bus 事件
    eventBus.on('user-updated', this.onUserUpdated)
  },
  // 没有 beforeUnmount / beforeDestroy!这些监听器永远存在!
}

// ✅ 正确写法
export default {
  mounted() {
    window.addEventListener('resize', this.handleResize)
    document.addEventListener('scroll', this.handleScroll)
    eventBus.on('user-updated', this.onUserUpdated)
  },
  
  // Vue 3
  beforeUnmount() {
    window.removeEventListener('resize', this.handleResize)
    document.removeEventListener('scroll', this.handleScroll)
    eventBus.off('user-updated', this.onUserUpdated)
  },
  
  // Vue 2 (用 beforeDestroy)
  // beforeDestroy() { ... }
}

更优雅的写法(自动配对)

// 封装一个工具函数:自动追踪并清理事件监听
function useEventListener(target, event, handler, options) {
  onMounted(() => target.addEventListener(event, handler, options))
  onBeforeUnmount(() => target.removeEventListener(event, handler, options))
}

// 使用
export default {
  setup() {
    useEventListener(window, 'resize', handleResize)
    useEventListener(document, 'scroll', handleScroll, { passive: true })
  }
}

🔴 场景 2:未清除的定时器

// ❌ 泄漏写法
export default {
  data() {
    return {
      timer: null,
      countdown: 60
    }
  },
  created() {
    // 倒计时定时器
    this.timer = setInterval(() => {
      this.countdown--
      if (this.countdown <= 0) {
        this.doSomething()
      }
    }, 1000)
    
    // 延迟执行
    setTimeout(this.fetchData, 5000)
  }
  // 定时器没有清除!组件销毁了定时器还在跑!
}

// ✅ 正确写法
export default {
  setup() {
    const timer = ref(null)
    
    onMounted(() => {
      timer.value = setInterval(doSomething, 1000)
    })
    
    onBeforeUnmount(() => {
      if (timer.value) {
        clearInterval(timer.value)   // 清除 interval
        timer.value = null
      }
      clearTimeout(someTimeoutRef)  // 清除 timeout
    })
  }
}

🔴 场景 3:未断开的 WebSocket / EventSource / SSE

// ❌ 泄漏写法
export default {
  async mounted() {
    // 建立了实时连接
    this.ws = new WebSocket('wss://api.example.com/realtime')
    this.ws.onmessage = (event) => this.handleMessage(JSON.parse(event.data))
    
    // 或 SSE
    this.es = new EventSource('/api/stream')
    this.es.addEventListener('update', (e) => this.handleUpdate(e))
  }
  // 连接从未关闭!
}

// ✅ 正确写法
export default {
  setup() {
    let ws = null
    let es = null
    
    function connect() {
      ws = new WebSocket('wss://api.example.com/realtime')
      ws.onmessage = handleMessage
      
      es = new EventSource('/api/stream')
      es.addEventListener('update', handleUpdate)
    }
    
    onMounted(connect)
    
    onBeforeUnmount(() => {
      ws?.close(1000, 'Component unmounting')  // 正常关闭码 + 原因
      ws = null
      es?.close()
      es = null
    })
  }
}

🟠 场景 4:未释放的 ECharts / Canvas / WebGL 实例

这是 SPA 中最隐蔽也最严重的泄漏之一:

// ❌ 泄漏写法
export default {
  mounted() {
    // 初始化图表
    const chartDom = this.$refs.chartContainer
    this.myChart = echarts.init(chartDom)
    
    const option = { /* ... */ }
    this.myChart.setOption(option)
    
    // 窗口大小变化时自适应
    window.addEventListener('resize', () => {
      this.myChart.resize()
    })
  }
  // ⚠️ ECharts 实例内部持有了 canvas 上下文、DOM 引用、事件监听器等大量资源
  // 不调用 dispose() 全部泄漏!
}

// ✅ 正确写法
export default {
  setup() {
    let chartInstance = null
    
    onMounted(() => {
      const chartDom = document.getElementById('chart')
      chartInstance = echarts.init(chartDom)
      chartInstance.setOption(option)
      
      // resize 监听也要在卸载时移除
      const handleResize = () => chartInstance?.resize()
      window.addEventListener('resize', handleResize)
      
      // 存储以便后续清理
      storeCleanup(() => {
        window.removeEventListener('resize', handleResize)
      })
    })
    
    onBeforeUnmount(() => {
      if (chartInstance) {
        chartInstance.dispose()  // ⭐ 关键!彻底销毁实例
        chartInstance = null
      }
    })
  }
}

ECharts dispose 做了什么?

echarts.dispose() 内部操作:
  1. 移除 canvas/SVG DOM 元素
  2. 清除所有内部事件绑定
  3. 释放 ZRender 渲染引擎资源
  4. 断开数据绑定的引用
  5. 清空动画队列
  6. 将实例从内部注册表删除
  → 如果不做这一步,每次进入这个页面就新增一个 ~5-10MB 的对象

🟠 场景 5:未 disconnect 的 IntersectionObserver —— 我的真实案例

这就是我在静默搜索复盘笔记中提到的那个真实 bug:

// ❌ 我在 processCatch.vue 中的原始代码(简化版)
export default {
  mounted() {
    // 用 IntersectionObserver 做滚动加载
    this.observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          this.loadMoreData()
        }
      })
    }, { rootMargin: '200px' })
    
    this.observer.observe(this.$refs.sentinel)
  }
  // ⚠️ 没有在 beforeUnmount 中 disconnect!
  // Observer 持有 sentinel 元素的引用 → 元素无法被 GC → 组件无法被回收
}

修复

// ✅ 正确写法
export default {
  setup() {
    let observer = null
    
    onMounted(() => {
      observer = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) loadMoreData()
        })
      }, { rootMargin: '200px' })
      
      observer.observe(sentinelRef.value)
    })
    
    onBeforeUnmount(() => {
      observer?.disconnect()  // ⭐ 断开观察!
      observer = null
    })
  }
}

同样适用于其他 Observer API

API创建方法清理方法
IntersectionObservernew IntersectionObserver().disconnect()
MutationObservernew MutationObserver().disconnect()
ResizeObservernew ResizeObserver().disconnect()
PerformanceObservernew PerformanceObserver().disconnect()

🟡 场景 6:keep-alive 导致的累积泄漏

<template>
  <div class="heavy-dashboard">
    <!-- 每次激活都创建新的图表 -->
    <div v-for="(chart, i) in charts" :ref="el => setChartRef(el, i)" :key="i"></div>
  </div>
</template>

<script>
export default {
  name: 'HeavyDashboard',
  data() {
    return {
      chartInstances: [],  // 存放 ECharts 实例
      charts: [/* ... */]
    }
  },
  activated() {
    // ⚠️ 每次激活都创建新图表,但旧的没有 dispose!
    this.initAllCharts()
  }
}
// keep-alive 下组件不会销毁,activated 可能被调用几十次
// 每次 activated 都新建图表实例 → 几十 MB 的泄漏
</script>

<script>
// ✅ 正确写法
export default {
  name: 'HeavyDashboard',
  data() {
    return { chartInstances: [] }
  },
  activated() {
    // 先清理旧的,再创建新的
    this.disposeAllCharts()
    this.initAllCharts()
  },
  deactivated() {
    // 停用时也可以选择性地清理重型资源
    this.disposeAllCharts()
  },
  methods: {
    disposeAllCharts() {
      this.chartInstances.forEach(chart => chart?.dispose())
      this.chartInstances = []
    }
  }
}
</script>

💡 Vue 3 命名提示:在 Vue 3 中,<keep-alive><component> 仍然是 kebab-case 写法(向后兼容),但官方文档中统一使用 PascalCase:<KeepAlive><Component>。两种写法都有效,但在 Vue 3 项目中建议使用 PascalCase 以保持一致性。

🔬 进阶:WeakMap 防泄漏 对于需要缓存但又不希望阻止 GC 的场景,可以考虑使用 WeakMap

// WeakMap 的键是弱引用——如果组件实例被销毁,对应的缓存条目会自动被 GC 回收
const cache = new WeakMap()
cache.set(componentInstance, heavyData)
// 当 componentInstance 不再被任何地方引用时,cache 中的这个 entry 会自动清除

这比手动管理 include/exclude 列表更安全,适合做"有则用、无则弃"的轻量级缓存。


🟡 场景 7:闭包中的隐式引用

// ❌ 隐式泄漏:闭包持有大型对象的引用
export default {
  data() {
    return {
      hugeData: []  // 10 万条数据的大数组
    }
  },
  methods: {
    handleClick() {
      // 这个回调被添加到全局或长生命周期的对象上
      someLongLivedObject.onClick = () => {
        // 闭包隐式引用了 this.hugeData
        console.log(this.hugeData.length)
      }
      // 即使组件销毁了,hugeData 也无法被回收!
      // 因为 someLongLivedObject.onClick 还持有它的引用
    }
  },
  beforeUnmount() {
    // 即使这里清空了 hugeData
    this.hugeData = []
    // 但如果忘了 remove onClick 回调,空数组本身也无法回收
    // (因为闭包链还连着)
  }
}

// ✅ 正确做法:确保回调也被移除
beforeUnmount() {
  someLongLivedObject.onClick = null  // 切断引用链
  this.hugeData = []
}

🟢 场景 8:未取消的 AbortController

虽然这不是传统意义上的"内存泄漏"(AbortController 本身很小),但它会导致无意义的网络请求持续占用连接和回调

// ❌
async fetchData() {
  const controller = new AbortController()
  this.controller = controller
  
  try {
    const res = await fetch(url, { signal: controller.signal })
    // ...
  } catch (err) {
    if (err.name !== 'AbortError') throw err
  }
  // controller 变量超出作用域了
  // 如果组件销毁时没有 abort:
  // → fetch 还在飞 → 返回后 .then/.catch 执行
  // → 但组件已经不在了 → 结果无处安放
}

// ✅ 
onBeforeUnmount(() => {
  controller?.abort()  // 取消进行中的请求
})

🟢 场景 9:Detached DOM Node(脱离文档树的节点)

// ❌
const fragment = document.createDocumentFragment()
fragment.appendChild(someNode)

// someNode 被放入了 fragment,不在 DOM 树中
// 但 JS 变量仍然持有对它的引用
// 它占用的内存永远不会被回收

// ✅ 用完即弃
someNode = null  // 手动解除引用

🟢 场景 10:Vue Router 的守卫泄漏

// ❌ 在组件内动态添加全局守卫但不移除
mounted() {
  // 添加了一个全局前置守卫
  this.removeGuard = router.beforeEach((to, from) => {
    if (to.path === '/special' && !this.canAccess) {
      return false
    }
  })
}
// 组件销毁后这个守卫还在!每次导航都会经过它!

// ✅
beforeUnmount() {
  if (typeof this.removeGuard === 'function') {
    // 注意:Vue Router 4 的 beforeEach 返回值不能直接用于移除
    // 需要用其他方式管理
    // 推荐的做法是用标志位而非动态添加/移除全局守卫
    this.isDestroyed = true
  }
}

三、路由切换性能优化(非泄漏篇)

除了内存泄漏,路由切换本身也有大量可以优化的地方。这些优化不一定和"泄漏"有关,但直接影响用户感知的性能。

3.1 组件卸载时的性能优化

问题:重型组件销毁太慢

当一个组件内部有大量 DOM 节点、复杂计算属性或大数组数据时,beforeUnmount → unmounted 的过程可能需要几十甚至几百毫秒:

用户点击导航到 /about
    ↓
当前组件开始卸载
    ↓
Vue 遍历所有响应式依赖进行解绑
    ↓
DOM 执行 removeChild(逐个删除节点)
    ↓
GC 尝试回收
    ↓
... 200ms 后才完成 ...
    ↓
新组件才开始渲染

用户感知到的就是"卡顿"——点击后没有立即反应。

优化方案
// 方案一:先隐藏,异步卸载
// 在 beforeRouteLeave 中先让组件不可见,再异步执行清理
import { nextTick } from 'vue'

onBeforeRouteLeave((to, from) => {
  // 立即隐藏(CSS 控制不占位)
  document.body.style.pointerEvents = 'none'  // 防止用户重复点击
  
  // 异步执行重型清理
  requestIdleCallback(() => {
    disposeHeavyResources()
  })
  
  return true
})

// 方案二:对大列表做虚拟滚动
// 如果页面有 10000 行数据,即使要卸载,Vue 也要逐个解除绑定
// 使用虚拟滚动让 DOM 中只有可视区域的 ~50 个节点
// 这样组件销毁时的 DOM 操作量减少 99%

// 方案三:分批清理
function batchCleanup(items, batchSize = 50) {
  let i = 0
  function processBatch() {
    const end = Math.min(i + batchSize, items.length)
    for (; i < end; i++) {
      cleanup(items[i])
    }
    if (i < items.length) {
      requestAnimationFrame(processBatch)
    }
  }
  requestAnimationFrame(processBatch)
}

3.2 路由切换中的防抖与节流

场景:快速连续导航

用户快速连续点击菜单或按钮:

T=0ms:   点击 /dashboard  → 触发 beforeEach + 组件加载 + 渲染
T=50ms:  点击 /users       → 上一次的渲染还没完!取消它,重新开始
T=100ms: 点击 /settings    → 又取消...
T=150ms: 再点 /dashboard   → 又重新加载...

每次导航都会触发:守卫执行、chunk 下载、组件创建、DOM 渲染……中间的几次完全浪费。

优化方案:导航防抖

// utils/navigationDebounce.js
let pendingNavigation = null
let navigationTimer = null

export function debounceNavigation(router, target, delay = 300) {
  // 取消上一次待执行的导航
  if (navigationTimer) {
    clearTimeout(navigationTimer)
  }
  
  // 取消正在进行的导航(如果有)
  if (pendingNavigation) {
    pendingNavigation.abort?.()
    pendingNavigation = null
  }
  
  // 延迟执行新的导航
  navigationTimer = setTimeout(() => {
    pendingNavigation = router.push(target).catch(err => {
      // 忽略导航失败(可能是被覆盖了)
      if (!isNavigationFailure(err)) throw err
    }).finally(() => {
      pendingNavigation = null
    })
    navigationTimer = null
  }, delay)
}

// 使用
// debounceNavigation(router, { name: 'user-list' })

3.3 大型列表的路由场景优化

虚拟滚动 + 路由恢复
<!-- UserList.vue -->
<template>
  <!-- 使用虚拟滚动:只渲染可视区域 + 缓冲区的行 -->
  <VirtualList
    :items="userList"
    :item-size="56"
    :buffer="10"
    v-slot="{ item }"
  >
    <UserRow :user="item" @click="goToDetail(item)" />
  </VirtualList>
</template>

<script setup>
// 虚拟滚动的好处不仅是渲染快,
// 在路由切换时它的优势更明显:
//
// 普通列表 10000 行:
//   卸载时 Vue 要处理 10000 个 VNode 的解绑
//   需要 ~100-300ms
//
// 虚拟滚动 ~50 行:
//   只需处理 50 个 VNode 的解绑
//   需要 ~1-5ms
//
// 路由切换速度提升 20-60 倍!
</script>

推荐库

特点适用场景
@tanstack/vue-virtual无头 Hook,高度可定制复杂表格/网格
vue-virtual-scroller开箱即用 API 简单列表/瀑布流
vuetable-2 / ag-grid-vue3表格专用,内置排序/筛选/编辑数据表格

3.4 预加载关键路由 chunk

利用 04 篇学过的 prefetch 思想,在空闲时预加载用户大概率会访问的页面:

// 利用 requestIdleCallback 浏览器空闲时预加载
if ('requestIdleCallback' in window) {
  requestIdleCallback(() => {
    // 预加载"下一步可能访问"的路由 chunk
    import(/* webpackPrefetch: true */ '@/views/UserDetail.vue')
    import(/* webpackPrefetch: true */ '@/views/OrderList.vue')
  }, { timeout: 2000 })  // 最多等 2 秒
}

或者基于用户行为预测:

// 用户鼠标 hover 到菜单项超过 200ms 时预加载
const menuItems = document.querySelectorAll('[data-prefetch]')
menuItems.forEach(item => {
  let hoverTimer = null
  item.addEventListener('mouseenter', () => {
    hoverTimer = setTimeout(() => {
      const targetPath = item.dataset.prefetch
      // 预加载对应 chunk(但不导航)
      router.resolve(targetPath).matched.forEach(record => {
        // 触发懒加载函数但不等待结果
        Promise.resolve(record.components.default).catch(() => {})
      })
    }, 200)
  })
  item.addEventListener('mouseleave', () => clearTimeout(hoverTimer))
})

3.5 性能优化总结表

优化方向具体措施效果
卸载加速虚拟滚动减少 DOM 量;异步清理重型资源切换时间 -80%~95%
导航去抖快速连点只执行最后一次减少无效网络请求
预加载idle 时 prefetch 或 hover 时预取下次导航几乎瞬时
首屏优化路由分组 + vendor 分离(04篇)+ SkeletonFCP -50%
动画过渡transition mode="out-in"(05篇)消除闪烁,体验流畅

这些优化不是所有项目都需要,但在面试中提到它们能体现你对 SPA 全链路性能的理解——不只是"会用 Vue Router",而是知道怎么让它跑得更快。


四、Chrome DevTools 内存排查实战

3.1 工具概览

Chrome DevTools → Performance / Memory 面板
    │
    ├── Memory 面板(堆快照)
    │   ├── Heap snapshot     → 拍摄某一时刻的内存快照,对比找泄漏
    │   ├── Allocation sampling   → 记录 JS 内存分配情况(轻量级)
    │   └── Allocation instrumentation → 详细记录每次分配(重量级)
    │
    ├── Performance 面板
    │   └── 录制一段时间内的性能数据,包含内存趋势图
    │
    └── Elements 面板
        └── 查看事件监听器(Event Listeners 面板)

3.2 三步排查法

第一步:录制内存趋势
1. 打开 Chrome DevTools → Performance 面板
2. 勾选 "Memory" 复选框
3. 点击 Record 开始录制
4. 执行操作:反复进入/退出目标页面(至少 5-10 次)
5. 停止录制
6. 观察 Memory 曲线:

   正常曲线:
   ╱╲╱╲╱╲╱╲  ← 每次进入上升一点,离开下降回来
                 最终回到接近起点的位置
   
   泄漏曲线:
   ╱─╱─╱─╱─╱  ← 只升不降,或者降不回原来的水平
                 每次都留一些残余,持续增长 ↑↑↑
第二步:Heap Snapshot 对比
1. 打开 Memory 面板 → 选择 "Heap snapshot"
2. 点击 "Take snapshot" → Snapshot #1(基线)
3. 执行操作(进入/退出目标页面几次)
4. 再点 "Take snapshot" → Snapshot #2
5. 再操作几次 → Snapshot #3
6. 选择 Summary 视图,将视图切换到 "Comparison"
7. 选择 Snapshot #3 与 #1 对比
   
   关注以下字段增长的对象:
   - (string):字符串数量暴增?
   - (array):数组数量暴增?
   - 具体构造函数名:如 VueComponent、ECharts 等
8. 点击 Retainers 展开引用链,找到谁在持有它
第三步:定位泄漏源
// Heap Snapshot 中的关键列含义:

Shallow Size:  对象本身占用的内存(不含引用的对象)
Retained Size: 对象及其所有依赖对象的总内存(释放它能回收多少)

// 找泄漏的核心思路:
// 1. 在 Comparison 中找到 Delta(数量变化)为正且数值较大的对象类型
// 2. 点击展开查看具体实例
// 3. 点击 Retainers 查看"谁还在引用它"
// 4. 沿着引用链往上找,直到找到一个你认识的变量
// 5 → 那个变量就是泄漏源

3.3 实战案例排查演示

假设我们怀疑某个列表页有内存泄漏:

操作步骤:
1. 打开 Memory → Heap Snapshot → Take Snapshot (#1)
2. 导航到 /user/list 页面
3. Take Snapshot (#2)
4. 导航到 /user/detail 页面(离开列表页)
5. Take Snapshot (#3)
6. 导航回 /user/list 页面
7. 导航到 /user/detail 页面(再次离开)
8. Take Snapshot (#4)

对比 #4 和 #2:
┌────────────────────────────┬─────────┬─────────┬──────────┐
│ Constructor                │ #2 Count│ #4 Count│ Delta     │
├────────────────────────────┼─────────┼─────────┼──────────┤
│ (string)                    │ 1,234   │ 1,456   │ +222     │
│ Array                       │ 89      │ 112     │ +23      │
│ Object                      │ 567     │ 589     │ +22      │
│ HTMLDivElement              │ 234     │ 245     │ +11      │
│ VueComponent               │ 12      │ 14      │ +2       │ ⚠️
│ echarts$1                   │ 1       │ 2       │ +1       │ 🔴
│ IntersectionObserver        │ 1       │ 2       │ +1       │ 🔴
└────────────────────────────┴─────────┴─────────┴──────────┘

发现:echarts 和 IntersectionObserver 的实例数随页面访问次数增加!
→ 说明每次离开都没有正确清理
→ 点击 echarts$1 的 Retainers:
  Context → UserList component → this.myChart
→ 找到了!this.myChart 没有调用 dispose()

四、预防性最佳实践清单

4.1 组件生命周期清理模板

// 一个"防泄漏"的标准 Vue 3 组件骨架
import { onMounted, onBeforeUnmount, ref } from 'vue'

export function useCleanableResource() {
  // 所有需要清理的资源集中管理
  const cleaners = []
  
  function addCleaner(fn) {
    cleaners.push(fn)
  }
  
  function cleanupAll() {
    cleaners.forEach(fn => fn())
    cleaners.length = 0
  }
  
  onBeforeUnmount(cleanupAll)
  
  return { addCleaner }
}

// 使用示例
export default {
  setup() {
    const { addCleaner } = useCleanableResource()
    
    // 定时器
    const timer = setInterval(fn, 1000)
    addCleaner(() => clearInterval(timer))
    
    // 事件监听
    const handler = () => {}
    window.addEventListener('resize', handler)
    addCleaner(() => window.removeEventListener('resize', handler))
    
    // Observer
    const observer = new IntersectionObserver(callback)
    observer.observe(el)
    addCleaner(() => observer.disconnect())
    
    // 图表
    const chart = echarts.init(dom)
    addCleaner(() => chart.dispose())
    
    // WebSocket
    const ws = new WebSocket(url)
    addCleaner(() => ws.close())
  }
}

4.2 清理检查清单(P0/P1/P2)

P0 —— 必须处理(否则一定泄漏)
资源创建方式必须执行的清理
setInterval/setTimeoutsetInterval(fn, ms)clearInterval(id) / clearTimeout(id)
WebSocketnew WebSocket(url)ws.close()
EventSourcenew EventSource(url)es.close()
EChartsecharts.init(dom)chart.dispose()
*Observernew Intersection/Mutation/ResizeObserver(cb)observer.disconnect()
全局事件window/target.addEventListenerremoveEventListener
P1 —— 应该处理(可能导致问题)
资源说明
事件总线 (EventBus)$off 解绑自定义事件
第三方 SDK 实例调用其 destroy/dispose/unmount 方法
动态添加的全局守卫通过标志位禁用或使用可移除的方式
动态创建的 DOMelement.remove() + 置空引用
大型 ArrayBuffer/Blob.close() 或置空
P2 —— 建议处理(优化体验)
资源说明
进行中的 fetch/xhr 请求controller.abort() 避免无用回调
CSS 动画/过渡重置 animation/play state
全局 modal/toast确保组件销毁时隐藏
keep-alive 缓存列表限制 max 上限,主动移除不需要的缓存

五、面试高频问题速查

Q1:你在项目中遇到过哪些内存泄漏问题?怎么解决的?

最典型的是 ECharts 图表页面的泄漏。在一个带有多个 ECharts 图表的仪表盘组件中,由于没有在 beforeUnmount 中调用 chart.dispose(),每次进入该页面就会新增几个未被释放的图表实例。排查过程是:先用 Chrome DevTools Performance 面板录制反复进出该页面的操作,观察到内存曲线呈阶梯状只升不降;再用 Heap Snapshot 对比发现 echarts$1 构造器的实例数随访问次数递增;最后定位到是 this.myChart 没有释放。解决方式是在 onBeforeUnmount 中对所有图表实例逐一调用 dispose() 并置空引用。

Q2:如何预防 SPA 的内存泄漏?

核心原则是"成对出现"——每个资源的创建都要有对应的清理。具体做法:(1)封装统一的 useCleanableResource composable,将所有需要清理的资源(定时器、事件监听、Observer、图表实例等)注册进去,统一在 onBeforeUnmount 时清理;(2)对于 keep-alive 包裹的组件,在 deactivated 中释放重型资源(如 ECharts),在 activated 中重建;(3)建立团队代码审查 checklist,重点检查是否有未清理的 setIntervaladdEventListenernew Observer 等;(4)CI 中集成 Lint 规则检测常见的泄漏模式(如 vue/no-unused-properties 结合 no-before-rules)。


七、本篇小结

内存泄漏部分

泄漏场景根因解决方法
未清除的事件监听器addEventListener 无配对 removeuseEventListener 自动配对封装
未清除的定时器setInterval 无 clearIntervalonBeforeUnmount 中 clear
未断开的 WS/SSE连接对象未被 closews.close() + 置空
ECharts 未 dispose实例持有大量内部资源chart.dispose()
IntersectionObserver 未 disconnect持有 DOM 引用阻止 GCobserver.disconnect()
keep-alive 累积activated 反复创建新资源deactivated 中清理 + activated 中重建
闭包隐式引用回调持有大对象确保回调也被移除/置空
Detached DOMDOM 节点脱离树但仍有引用置空所有引用
排查工具Chrome DevToolsPerformance 录制趋势 + Heap Snapshot 对比 + Retainers 追踪

性能优化部分

优化方向核心方法效果
组件卸载加速虚拟滚动减少 DOM;异步清理重型资源切换时间 -80%~95%
导航防抖快速连点只执行最后一次;取消进行中的导航减少无效请求和渲染
路由预加载idle 时 prefetch;hover 时预取下次导航几乎瞬时
防抖节流在路由中的使用搜索/筛选/滚动事件的防抖减少 API 请求和不必要的重渲染

系列总结


系列总结

至此,Vue 路由系列的六篇文章全部完成。让我们回顾整个知识体系:

【Vue 路由系列】完整知识地图
│
├── 01-前端路由的本质
│   ├── Hash 模式(hashchange 事件)
│   ├── History 模式(pushState + popstate 事件)
│   ├── ⚠️ pushState 不触发 popstate
│   ├── ⚠️ history.back() 是方法不是事件
│   └── History 模式的 404 问题与 fallback 配置
│
├── 02-Vue Router 导航系统
│   ├── 守卫执行顺序(全局前→独享→组件离→组件入→全局解→全局后)
│   ├── next() vs return (Vue Router 4)
│   ├── 死循环陷阱(登录拦截排除登录页)
│   └── 并发竞态(单例 Promise,❌ 不能存 localStorage)
│
├── 03-动态路由与权限控制
│   ├── addRoute / removeRoute API
│   ├── F5 刷新 404 的根因与标准解法
│   ├── 404 通配符的最后挂载时机
│   └── 三级权限模型(路由→按钮→接口)
│
├── 04-路由懒加载与打包优化
│   ├── () => import() 懒加载原理
│   ├── Webpack webpackChunkName 分组
│   ├── Vite manualChunks 分组策略
│   └── HTTP/2 时代仍需分组的原因
│
├── 05-状态保持与滚动还原
│   ├── scrollBehavior + savedPosition
│   ├── keep-alive + include/exclude
│   ├── history.state 自定义状态存储
│   └── 三方案对比与决策树
│
└── 06-内存泄漏与性能排查
    ├── 十大泄漏场景详解
    ├── Chrome DevTools 三步排查法
    └── P0/P1/P2 清理清单

参考来源:Gemini 3.1 Pro 模拟面试记录(路由与单页应用章节)、Vue Router 4 官方文档、Chrome DevTools 文档


🔗 系列回顾