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

36 阅读12分钟

前言

在日常开发中,我们可能遇到过这样的情况:写了一个 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.5ms50,000ms50秒
中型组件(200个节点)2ms200,000ms3.3分钟
大型组件(1000个节点)10ms1,000,000ms16.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%
内存分配850MB85MB90%
CPU 使用率65%8%88%
平均帧率45fps60fps33%

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-oncev-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-oncev-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 性能优化的真谛!

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