前言
在日常开发中,我们可能遇到过这样的情况:写了一个 Vue 应用,数据量稍微大一点,页面就开始卡顿;用户只是点击了一个按钮,整个页面都要重新渲染;明明大部分内容都没变,却感觉应用像“老了十岁”一样慢。这是为什么呢?
Vue 的响应式系统很智能,但它也有“过度反应”的时候。就像我们只是拍了拍桌子,整个办公室的人都站起来看看发生了什么——这显然是一种浪费。
v-once 和 v-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只用于真正静态的内容- 组合使用时逻辑清晰
- 用性能工具验证了优化效果
性能优化的哲学
- 优化不是炫技:用数据和用户体感说话
- 适度原则:不是所有地方都需要优化
- 持续演进:性能优化是过程,不是终点
- 量化的力量:没有数据的优化是盲目的
结语
v-once 和 v-memo 是 Vue 提供的两个强大的优化工具,但它们不是银弹。真正的性能优化,是在理解业务场景的基础上,选择合适的技术,验证优化效果,持续改进的过程。让该更新的更新,该躺平的躺平,这才是 Vue 性能优化的真谛!
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!