阅读视图

发现新文章,点击刷新页面。

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

前言

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

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

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

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

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

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

从一个简单的例子开始

App.vue

<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

<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

内联事件的清理

// 移除内联事件
const button = document.querySelector('button')
button.onclick = null

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

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

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

属性赋值的清理

// 注册
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 的正确清理

function handleResize() {
  console.log('resize')
}
window.addEventListener('resize', handleResize)
window.removeEventListener('resize', handleResize)

为什么 removeEventListener 有时候不工作?

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

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

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

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

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

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

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

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

Vue 组件中的事件清理

最基本的清理模式

<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>

封装可复用的组合式函数

// 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)
  })
}

使用示例:

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

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

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

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

// 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)
    })
  })
}

使用示例:

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

返回清理函数的 Hook 模式

// 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  // 也可以手动调用
  }
}

使用示例:

const container = ref()
const { width, height } = useResizeObserver(container)

内存泄漏的检测与诊断

Chrome DevTools 内存面板使用

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

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

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

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

Performance Monitor 实时监控

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

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

手动检测代码

// 在开发环境添加监控工具
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'
}

常见陷阱与解决方案

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

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

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

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

陷阱二:watch 中注册事件

// ❌ 错误:每次 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 问题

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)
  }
}

陷阱四:第三方库不销毁

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()

结语

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

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

v-once和v-memo完全指南:告别不必要的渲染,让应用飞起来

前言

在日常开发中,我们可能遇到过这样的情况:写了一个 Vue 应用,数据量稍微大一点,页面就开始卡顿;用户只是点击了一个按钮,整个页面都要重新渲染;明明大部分内容都没变,却感觉应用像“老了十岁”一样慢。这是为什么呢?

Vue 的响应式系统很智能,但它也有“过度反应”的时候。就像我们只是拍了拍桌子,整个办公室的人都站起来看看发生了什么——这显然是一种浪费。

v-oncev-memo 就是来解决这个问题的。它们像两个聪明的“保安”,告诉 Vue:“这部分内容不用每次都检查,它没变” 和 “这部分内容只有在特定条件变化时才需要检查”。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,帮助我们彻底掌握这两个性能优化神器。

为什么要关注不必要的渲染

从一个简单的例子开始

我们先来看一个简单的例子:

<template>
  <div>
    <!-- 动态内容:会变化 -->
    <h2>当前计数:{{ count }}</h2>
    <button @click="count++">点我增加</button>
    
    <!-- 静态内容:永远不会变 -->
    <footer>
      <p>© 2026 我的公司. 版权所有</p>
      <p>联系方式:contact@example.com</p>
      <p>地址:xxx</p>
    </footer>
  </div>
</template>

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

这段代码看起来没什么,但实际上会发生了什么呢?

每次点击按钮是,count 都会变化,整个组件都会重新渲染。包括那个 永远不会变 的页脚。

虽然 Vue 的虚拟 DOM 会最终发现页脚没变,不会更新真实的 DOM,但这个过程仍然需要:

  • 执行渲染函数
  • 创建新的虚拟 DOM
  • 和旧的虚拟 DOM 进行对比
  • 确认没有变化,跳过更新

这就像我们每天早上去公司,尽管保安每天都会看到我们,但他们仍然每天都要重新核对我们的身份信息,这是一种不必要的浪费。

Vue 的默认更新机制

响应式数据变化
    ↓
组件重新渲染函数执行
    ↓
生成新的虚拟 DOM 树
    ↓
与旧虚拟 DOM 进行 diff 比较
    ↓
计算出需要更新的真实 DOM
    ↓
执行 DOM 更新

不必要的渲染有多"贵"?

我们先看一段数据:

组件规模 一次不必要的渲染耗时 每天10万次操作 额外开销
小型组件(50个节点) 0.5ms 50,000ms 50秒
中型组件(200个节点) 2ms 200,000ms 3.3分钟
大型组件(1000个节点) 10ms 1,000,000ms 16.7分钟

想象一下,用户每天要多等十几分钟,就因为应用在“瞎忙活”。

什么是不必要的渲染?

简单来说就是:渲染的结果和上一次 完全一样,但过程却重复执行了。

// 这是一个"不必要的渲染"的典型案例
const App = {
  template: `
    <div>
      <!-- 这部分每次都会重新计算,但结果永远一样 -->
      <div>{{ getStaticData() }}</div>
      
      <!-- 这部分确实需要更新 -->
      <div>{{ dynamicData }}</div>
    </div>
  `,
  
  methods: {
    getStaticData() {
      console.log('我被调用了!') // 其实只需要调用一次
      return '永远不变的内容'
    }
  }
}

问题:即使大部分内容没变,渲染函数仍会执行,虚拟 DOM 树仍会创建,diff 算法仍需遍历。

v-once:一次渲染,终身躺平

v-once 是什么?

v-once 是 Vue 提供的一个指令,它的作用就像它的名字一样:只渲染一次。之后无论数据怎么变化,这部分内容都不会再更新。

用生活化的比喻理解v-once

想象一下,我们正在装修房子:

  • 普通渲染:每天都要重新粉刷一遍墙壁,尽管颜色没变
  • v-once 渲染:装修一次,以后再也不动它

v-once 的基本用法

<template>
  <div>
    <!-- 普通内容:每次count变化都会更新 -->
    <p>当前计数:{{ count }}</p>
    
    <!-- v-once内容:只渲染一次,之后永远不变 -->
    <p v-once>初始计数:{{ count }}</p>
    
    <button @click="count++">增加计数</button>
  </div>
</template>

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

运行效果

  • 首次加载:两个都显示“0”
  • 点击按钮:上面变成“1”,下面还是“0”
  • 继续点击:上面一直变,下面永远是“0”

v-once的工作原理

让我们用流程图来理解:

首次渲染
    ↓
遇到 v-once 指令
    ↓
正常渲染内容
    ↓
将生成的虚拟DOM缓存起来
    ↓
打上"静态标记"
    ↓
─────────────────
    ↓
后续更新时
    ↓
遇到 v-once 标记
    ↓
直接返回缓存的虚拟DOM
    ↓
跳过所有更新逻辑

v-once 的实现机制

// 简化版的 v-once 实现原理
function processOnceNode(vnode) {
  if (vnode.shapeFlag & ShapeFlags.COMPONENT_ONCE) {
    // 如果是组件,标记为静态组件
    vnode.isStatic = true
    return vnode
  }
  
  // 如果是元素,创建静态节点
  const staticNode = createStaticVNode(
    vnode.children,
    vnode.props
  )
  
  // 后续更新直接返回缓存的静态节点
  return staticNode
}

v-once 的适用场景

场景一:页脚版权信息等纯静态内容

<!-- 页脚版权信息,永远不变 -->
<footer v-once>
  <p>© 2026 我的公司. All rights reserved.</p>
  <p>ICP备案号:xxxxx</p>
  <div class="contact">
    <p>邮箱:contact@example.com</p>
    <p>电话:400-123-4567</p>
  </div>
</footer>

场景二:一次性初始数据

<template>
  <div class="user-profile">
    <!-- 用户 ID 只在创建时显示,后续不变 -->
    <div v-once class="user-meta">
      <span>用户ID:{{ userId }}</span>
      <span>注册时间:{{ registerDate }}</span>
      <span>会员等级:{{ initialLevel }}</span>
    </div>
    
    <!-- 动态更新的内容 -->
    <div class="user-points">
      当前积分:{{ points }}
      <button @click="points++">签到</button>
    </div>
  </div>
</template>

场景三:复杂的静态组件

<template>
  <div class="dashboard">
    <!-- 左侧:帮助文档组件,完全静态,只需加载一次 -->
    <HelpDocumentation v-once class="sidebar" />
    
    <!-- 右侧:动态更新的内容 -->
    <div class="main-content">
      <DashboardCharts :data="liveData" />
      <RealTimeLogs :logs="systemLogs" />
    </div>
  </div>
</template>

场景四:与 v-for 配合优化列表

<template>
  <div class="data-table">
    <!-- 表格头部完全静态 -->
    <div v-once class="table-header">
      <div class="col">姓名</div>
      <div class="col">年龄</div>
      <div class="col">部门</div>
      <div class="col">操作</div>
    </div>
    
    <!-- 动态列表项 -->
    <div v-for="item in list" :key="item.id" class="table-row">
      <div class="col">{{ item.name }}</div>
      <div class="col">{{ item.age }}</div>
      <div class="col">{{ item.department }}</div>
      <div class="col">
        <button @click="edit(item.id)">编辑</button>
      </div>
    </div>
  </div>
</template>

v-once 的使用注意事项

注意事项 说明 示例
失去响应性 v-once 内的所有数据绑定都变成静态,不再响应更新 <div v-once>{{ count }}</div> 永远不会更新
子树全静态 v-once 作用于元素时,其所有子元素也变为静态 整个组件树都会静态化
避免滥用 只在真正不需要更新的地方使用,否则会导致数据和视图不一致 动态内容不能用 v-once
组件中使用 组件上加 v-once,整个组件只会渲染一次 <ComplexChart v-once />

v-once 性能收益实测

测试环境

  • 页面包含 200 个静态节点
  • 每秒触发 10 次更新
  • 运行 60 秒
指标 未优化 使用 v-once 提升
渲染函数调用次数 60,000 次 600 次 99%
虚拟 DOM 创建 60,000 次 600 次 99%
内存分配 850MB 85MB 90%
CPU 使用率 65% 8% 88%
平均帧率 45fps 60fps 33%

v-memo:有条件地记忆渲染

为什么要 v-memo?

v-once 虽然好,但它的缺点也很明显:要么永远更新,要么永远不更新。现实开发中,我们经常遇到这样的情况:

  • 列表项的大部分内容稳定,但少数字段会变
  • 组件的大部分数据不变,但需要响应某些特定变化

这时候就需要 v-memo 了。

v-memo 是什么?

v-memo 是 Vue 3.2+ 引入的新指令,它可以接受一个依赖数组,只有当数组中的值变化时,才会重新渲染。

用生活化的比喻理解 v-memo

想象一下,我们在公司里:

  • 普通员工:领导一喊,所有人都站起来(不管是不是叫自己)
  • v-memo 员工:只有听到自己名字才站起来

v-memo的基本用法

<template>
  <div 
    v-for="item in items" 
    :key="item.id"
    v-memo="[item.id, item.price, item.stock]"
  >
    <!-- 只有当 item.id、item.price 或 item.stock 变化时才重新渲染 -->
    <h3>{{ item.name }}</h3>
    <p>价格:{{ item.price }}</p>
    <p>库存:{{ item.stock }}</p>
    <button @click="toggleFavorite(item.id)">
      {{ item.isFavorite ? '取消收藏' : '收藏' }}
    </button>
  </div>
</template>

v-memo的工作原理

让我们用流程图来理解:

首次渲染
    ↓
计算依赖数组的值
    ↓
缓存这些值和生成的虚拟DOM
    ↓
─────────────────
    ↓
后续更新触发
    ↓
重新计算依赖数组的新值
    ↓
和缓存的值比较
    ↓
有变化?→ 是 → 重新渲染,更新缓存
    ↓       
    否
    ↓
直接返回缓存的虚拟DOM
    ↓
跳过所有更新逻辑

v-memo 工作机制的三阶段

1. 依赖收集阶段

  • 编译时解析依赖数组
  • 建立响应式依赖图谱
  • 为每个节点创建 memo 缓存

2. 缓存对比阶段

  • 重新渲染前计算依赖数组的新值
  • 与缓存的上次值进行浅比较
  • 若未变化 → 直接复用缓存的 VNode 树
  • 若已变化 → 重新生成 VNode 并更新缓存

3. 虚拟 DOM 跳过

  • 完全跳过该节点的 diff 计算
  • 不触发子树的渲染函数
  • 直接复用真实 DOM

v-memo的实战场景

场景一:超大规模商品列表

想象一个电商网站的商品列表,有1万件商品:

<template>
  <div class="product-list">
    <div 
      v-for="product in products" 
      :key="product.id"
      v-memo="[
        product.id, 
        product.price, 
        product.stock, 
        product.isFavorite
      ]"
      class="product-item"
    >
      <img :src="product.image" :alt="product.name" />
      <h3>{{ product.name }}</h3>
      <p class="price">¥{{ product.price }}</p>
      <p class="stock">库存: {{ product.stock }}件</p>
      <p class="sales">销量: {{ product.sales }}件</p>
      <p class="rating">评分: {{ product.rating }}分</p>
      <button 
        @click="toggleFavorite(product.id)"
        :class="{ active: product.isFavorite }"
      >
        {{ product.isFavorite ? '已收藏' : '收藏' }}
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 生成1万件商品
const products = ref(
  Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    name: `商品 ${i}`,
    price: Math.floor(Math.random() * 1000),
    stock: Math.floor(Math.random() * 100),
    sales: Math.floor(Math.random() * 1000),
    rating: (Math.random() * 5).toFixed(1),
    image: `https://picsum.photos/200/150?random=${i}`,
    isFavorite: false
  }))
)

function toggleFavorite(id) {
  const product = products.value.find(p => p.id === id)
  product.isFavorite = !product.isFavorite
  // ✅ 只有被点击的那一项会重新渲染
}
</script>

优化效果:

  • 用户点击收藏时,只有被点击的商品重新渲染
  • 后台更新价格时,只有价格变化的商品重新渲染
  • 其他 9999 件商品完全不动

场景二:复杂计算缓存

<template>
  <div class="dashboard">
    <!-- 只有当原始数据或用户设置变化时才重新计算 -->
    <div 
      class="dashboard-content"
      v-memo="[rawData.version, userSettings.theme]"
    >
      <DashboardHeader />
      
      <!-- 这里的数据需要复杂计算 -->
      <DataVisualization :data="processedData" />
      <StatsCards :stats="computedStats" />
      <ActivityChart :chart-data="chartData" />
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const rawData = ref(fetchData()) // 10MB的原始数据
const userSettings = ref({ theme: 'light', language: 'zh' })

// 复杂计算:处理10MB数据
const processedData = computed(() => {
  console.log('正在处理数据...') // 我们希望这个不要频繁执行
  return rawData.value.map(item => ({
    ...item,
    processed: heavyComputation(item)
  }))
})

// 当用户切换主题时,不应该重新计算processedData
// 但上面的v-memo确保了这一点:只有rawData.version或userSettings.theme变化时才重新渲染
</script>

场景三:聊天消息列表

<template>
  <div class="chat-messages">
    <div 
      v-for="msg in messages" 
      :key="msg.id"
      v-memo="[msg.id, msg.content, msg.timestamp, msg.isRead]"
      class="message"
      :class="{ 'message-self': msg.senderId === currentUserId }"
    >
      <img :src="msg.avatar" class="avatar" />
      <div class="content">
        <div class="sender">{{ msg.senderName }}</div>
        <div class="text">{{ msg.content }}</div>
        <div class="time">{{ formatTime(msg.timestamp) }}</div>
      </div>
      <div class="status">
        <span v-if="msg.isRead">已读</span>
        <span v-else-if="msg.isSending">发送中...</span>
        <span v-else-if="msg.isFailed">发送失败</span>
      </div>
    </div>
  </div>
</template>

<script setup>
const messages = ref([])

// 新消息到来时,只有新消息会渲染
// 已读状态变化时,只有那条消息会更新
// 其他消息完全不动
</script>

场景四:选中状态高亮

<template>
  <div class="image-gallery">
    <div 
      v-for="image in images" 
      :key="image.id"
      v-memo="[selectedId === image.id]"
      class="image-item"
      :class="{ selected: selectedId === image.id }"
      @click="selectedId = image.id"
    >
      <img :src="image.thumbnail" :alt="image.title" />
      <div class="overlay">
        <h4>{{ image.title }}</h4>
        <button @click.stop="download(image.id)">下载</button>
      </div>
    </div>
  </div>
</template>

<script setup>
const selectedId = ref(null)

// 点击时,只有之前选中的和当前选中的两个图片会重新渲染
// 其他9998张图片完全不动
</script>

v-memo 依赖项选择的黄金法则

  • 精准包含:只放那些真正会影响渲染的字段
  • 避免冗余:不要把整个对象放进去
  • 稳定依赖:不要用 Date.now() 这种每次都变的值
  • 版本控制:复杂对象可以用版本号

选择决策树

graph TD
    Start[遇到一个组件/元素] --> Question1{内容永远不变吗?}
    Question1 -->|是| A[用 v-once]
    Question1 -->|否| Question2{是长列表?<br>(>500项)}
    
    Question2 -->|否| B[暂时不需要优化]
    Question2 -->|是| Question3{更新频率高吗?}
    
    Question3 -->|低| C[保持现状]
    Question3 -->|高| Question4{能否精确控制更新?}
    
    Question4 -->|否| D[考虑虚拟滚动]
    Question4 -->|是| E[用 v-memo 精确优化]

v-once vs v-memo,如何选择?

特性对比表

对比维度 v-once v-memo
适用版本 Vue 2+ Vue 3.2+
更新策略 永不更新 条件更新
依赖声明 显式数组
学习难度 ⭐⭐⭐
适用场景 纯静态内容 大部分稳定的动态内容
代码侵入性

组合使用示例

<template>
  <div class="app">
    <!-- 1. 完全静态的头部 -->
    <header v-once>
      <AppLogo />
      <AppTitle />
      <NavigationMenu />
    </header>
    
    <!-- 2. 动态列表,但有条件更新 -->
    <div class="content">
      <div 
        v-for="item in items" 
        :key="item.id"
        v-memo="[item.id, item.updatedAt]"
      >
        <!-- 2.1 每个列表项内部的静态部分 -->
        <div v-once class="item-static">
          <img :src="item.avatar" />
          <span>ID: {{ item.id }}</span>
        </div>
        
        <!-- 2.2 每个列表项内部的动态部分 -->
        <div class="item-dynamic">
          <h3>{{ item.title }}</h3>
          <p>{{ item.content }}</p>
          <span>点赞: {{ item.likes }}</span>
        </div>
      </div>
    </div>
    
    <!-- 3. 完全静态的页脚 -->
    <footer v-once>
      <Copyright />
      <ContactInfo />
    </footer>
  </div>
</template>

性能收益对比

场景 优化前 v-once v-memo
静态页脚 每次更新都渲染 0次更新 不适用
收藏按钮点击 整个列表重绘 不适用 只更新单个项
价格批量更新 整个列表重绘 不适用 只更新价格变化项
列表项1000条 120ms 不适用 35ms

常见陷阱与解决方案

v-memo 依赖遗漏

<!-- ❌ 错误:遗漏了关键依赖 -->
<div 
  v-for="item in items"
  v-memo="[item.id]"
>
  {{ item.name }}  <!-- 当name变化时,这里不会更新! -->
  <span :class="{ active: item.isActive }">
    {{ item.status }}
  </span>
</div>

<!-- ✅ 正确:包含所有依赖 -->
<div 
  v-for="item in items"
  v-memo="[item.id, item.name, item.isActive, item.status]"
>
  {{ item.name }}
  <span :class="{ active: item.isActive }">
    {{ item.status }}
  </span>
</div>

在错误的位置使用 v-memo

<!-- ❌ 错误:在父容器上使用v-memo -->
<ul v-memo="[items.length]">
  <li v-for="item in items" :key="item.id">
    {{ item.name }}
  </li>
</ul>
<!-- 结果:items.length不变时,整个列表都不更新 -->
<!-- 但item.name变化时也不会更新! -->

<!-- ✅ 正确:在v-for的项上使用 -->
<ul>
  <li 
    v-for="item in items" 
    :key="item.id"
    v-memo="[item.id, item.name]"
  >
    {{ item.name }}
  </li>
</ul>

滥用v-once导致bug

<!-- ❌ 错误:动态内容用了v-once -->
<div v-once>
  <h3>当前用户:{{ username }}</h3>  <!-- 永远不会更新! -->
  <button @click="logout">退出登录</button>
</div>

<!-- ✅ 正确:只静态化真正静态的部分 -->
<div>
  <h3>当前用户:{{ username }}</h3>  <!-- 动态 -->
  <div v-once>操作面板</div>  <!-- 静态 -->
  <button @click="logout">退出登录</button>  <!-- 动态 -->
</div>

最佳实践清单

什么时候用 v-once?

  • 版权信息、页脚
  • 表格表头
  • 静态导航菜单
  • 一次性初始数据
  • 复杂的静态组件(帮助文档、使用说明)

什么时候用 v-memo?

  • 超长列表(>500项)
  • 高频更新的区域隔离
  • 选中状态切换
  • 复杂计算的缓存
  • 聊天消息列表

优化检查清单

  • v-memo 的依赖数组包含了所有影响渲染的字段
  • 避免在 v-memo 中使用 Date.now()Math.random()
  • v-memo 正确放在 v-for 的项上,而不是父容器
  • v-once 只用于真正静态的内容
  • 组合使用时逻辑清晰
  • 用性能工具验证了优化效果

性能优化的哲学

  1. 优化不是炫技:用数据和用户体感说话
  2. 适度原则:不是所有地方都需要优化
  3. 持续演进:性能优化是过程,不是终点
  4. 量化的力量:没有数据的优化是盲目的

结语

v-oncev-memo 是 Vue 提供的两个强大的优化工具,但它们不是银弹。真正的性能优化,是在理解业务场景的基础上,选择合适的技术,验证优化效果,持续改进的过程。让该更新的更新,该躺平的躺平,这才是 Vue 性能优化的真谛!

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

❌