前言

Vue 3 本身已有编译优化和 Proxy 响应式,但业务场景仍可能出现大列表、频繁更新、首屏过慢等问题。本篇从渲染、数据、代码、测量四个层面梳理 Vue 性能优化策略。本篇会讲清楚:

  • shallowRef / shallowReactive / markRaw
  • v-once / v-memo 的使用与区别
  • 大列表虚拟滚动与懒加载
  • 优化应基于实际测量

一、优化原则

1.1 先测量,再优化

// 不要凭感觉优化
// 1. Chrome Performance 面板 → 录制交互,看 Long Task、Layout、Paint
// 2. Lighthouse → FCP、LCP、TBT
// 3. Vue DevTools → 组件 render 次数、耗时
原则 说明
有数据再动手 定位瓶颈是 render、网络还是计算
避免过早优化 简单页面不必堆 v-memo、shallowRef
优先低成本 懒加载、合理 key 往往比改架构见效快
关注用户感知 LCP、INP 比纯 JS 执行时间更重要

1.2 优化层次

编译层(Vue 3 内置)→ 静态提升、PatchFlag、Block Tree
渲染层             → key、v-once、v-memo、虚拟滚动
响应式层           → shallowRef、markRaw、减少 reactive 范围
代码层             → 路由/组件懒加载、Tree-shaking

二、渲染层优化

2.1 合理使用 key

列表用稳定唯一 id 作 key,减少错误复用和不必要 DOM 操作(详见《Key 的作用与原理》)。

2.2 v-once:静态内容只渲染一次

<header v-once>
  <h1>{{ title }}</h1>
  <nav>固定导航</nav>
</header>
  • 首次渲染后跳过该子树后续所有更新(含 props 变化)
  • 适合页脚、版权、固定说明等几乎不变的内容
  • 与编译期静态提升不同:v-once 是运行时指令,开发者手动标记

2.3 v-memo:条件缓存子树(Vue 3.2+)

<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
  <ExpensiveItem :item="item" />
</div>
  • 依赖数组不变 → 跳过该子树更新(含子组件)
  • 依赖变化 → 正常更新
  • 适合大列表中昂贵子组件,且只有部分字段影响展示
<!-- 选中态变化才重渲染该项 -->
<div v-memo="[item.id, item.selected]">
  <ItemCard :item="item" />
</div>

2.4 v-memo vs computed

对比项 v-memo computed
作用位置 模板子树 script 逻辑
跳过 整段模板 + 子组件 render 重复计算
依赖 显式数组 [a, b] 自动收集

v-memo 管「这块模板要不要重绘」;computed 管「这个值要不要重算」。

2.5 v-if vs v-show

场景 推荐
频繁切换显示 v-show(只改 display)
很少出现 v-if(不渲染 DOM)

2.6 减少组件嵌套与无效 render

  • 不要把大对象整包 reactive 后传给大量子组件
  • v-memo、拆分组件、Pinia 按需订阅减少无关更新

三、响应式层优化

3.1 shallowRef / shallowReactive

import { shallowRef, shallowReactive, triggerRef } from 'vue'

// 只代理 .value 引用变化,不深度代理内部
const bigData = shallowRef({ list: [...10000 items] })

// ✅ 替换整个对象 → 触发更新
bigData.value = { list: newList }

// ❌ 改深层属性 → 不触发更新
bigData.value.list.push(item)

// 手动触发
bigData.value.list.push(item)
triggerRef(bigData)
API 深度代理 触发更新
ref 是(对象) 深层变化也触发
shallowRef .value 替换触发
reactive 深层变化触发
shallowReactive 仅第一层 仅第一层属性触发

场景:第三方库实例、大型不可变数据、实时 tick 数据批量替换。

3.2 markRaw:标记永不代理

import { markRaw, reactive } from 'vue'

const chart = markRaw(echarts.init(dom))
const state = reactive({
  chart,  // 不会被 proxy,避免无效依赖
  option: {}
})

适合 ECharts、地图 SDK 等不需要响应式的大对象。

3.3 减少 reactive 范围

// ❌ 整个表单大对象都 reactive
const form = reactive({ /* 50 个字段 */ })

// ✅ 只有会驱动视图的字段 reactive
const visibleFields = reactive({ name: '', status: '' })
const staticConfig = { /* 只读配置,普通对象 */ }

3.4 computed 缓存 vs watch

// ✅ 派生数据用 computed,有缓存
const filtered = computed(() => list.value.filter(i => i.active))

// ✅ watch 默认懒执行,避免 immediate 滥用
watch(source, handler)  // 默认 flush: 'pre',按需触发

四、代码与加载优化

4.1 路由懒加载

{ path: '/report', component: () => import('@/views/Report.vue') }

按路由拆 chunk,减小首屏 JS(详见《路由懒加载》)。

4.2 组件异步加载

const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))

弹窗、Tab 内重型组件按需加载(详见《动态组件与异步组件》)。

4.3 Tree-shaking

  • 按需导入:import { ref } from 'vue',避免全量
  • 工具库:import debounce from 'lodash-es/debounce'
  • 构建工具生产模式自动 tree-shake

4.4 KeepAlive 缓存页面

Tab、列表→详情→返回场景缓存实例,避免重复创建(详见《KeepAlive 缓存组件》)。注意 max 控制内存。


五、大列表:虚拟滚动

5.1 问题

渲染 1 万条 <li> → 1 万个 DOM 节点 → 卡顿、内存高。

5.2 方案

只渲染可视区域 + 少量缓冲区的项:

<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
</script>

<template>
  <RecycleScroller
    :items="list"
    :item-size="50"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="row">{{ item.name }}</div>
  </RecycleScroller>
</template>

常用库:vue-virtual-scroller@tanstack/vue-virtual

5.3 配合优化

  • 列表项用 v-memo="[item.id, item.xxx]"
  • 项内避免深层 reactive
  • 稳定 key 用业务 id

六、其他实用手段

6.1 防抖 / 节流

搜索输入、scroll、resize 监听使用 debounce/throttle,减少 render 和请求次数。

6.2 Web Worker

大 JSON 解析、复杂计算放 Worker,避免阻塞主线程 UI。

6.3 图片与资源

  • 懒加载:loading="lazy"、v-lazy 指令
  • 合适格式:WebP、压缩、CDN
  • 路由 prefetch 空闲预加载(详见《路由懒加载》)

6.4 编译层(框架内置)

Vue 3 模板自动静态提升、PatchFlag、Block Tree,无需手写(详见《编译优化》)。手写 render 函数享受不到,优先写模板。


七、面试聚焦

7.1 优化应基于实际测量

Performance、Lighthouse、Vue DevTools 定位瓶颈,避免盲目 v-memo。

7.2 shallowRef 的 .value 变化会触发更新吗?

shallowRef.value = newObj 触发更新;改 value 内部深层属性不会,需 triggerRef 或整体替换。

7.3 v-memo 和 computed 的区别?

v-memo 跳过模板子树 render;computed 缓存计算结果。一个管视图,一个管逻辑。

7.4 v-once 和静态提升的区别?

静态提升是编译期自动识别静态节点;v-once 是运行时指令,手动标记且跳过后续所有更新。


八、易混淆点

  1. shallowRef 不是不更新:换 .value 会更新,只是不深追踪内部。
  2. v-once 后 props 变也不更新:确认内容真正静态再用。
  3. v-memo 依赖要写全:漏写依赖会导致该更新不更新。
  4. 虚拟滚动不减少数据量:只减少 DOM 数量,接口仍可能要分页。
  5. KeepAlive 占内存:需 maxinclude 控制。

九、思考与练习

1. 大列表卡顿如何优化?

解析:虚拟滚动减少 DOM;稳定 key;v-memo 昂贵子项;分页或懒加载数据。

2. shallowRef 适用场景?

解析:大型对象整体替换、第三方实例、频繁替换 .value 但不关心深层变化的场景。

3. v-memo 依赖数组如何写?

解析:列出影响该子树展示的所有响应式值,如 [item.id, item.status]

4. 首屏 JS 过大怎么办?

解析:路由懒加载、异步组件、Tree-shaking、分析 bundle(rollup-plugin-visualizer)。

5. 为什么 say「先测量再优化」?

解析:避免无效优化增加复杂度;不同瓶颈方案不同(网络 vs render vs 计算)。


总结

  • 原则:先测量,再针对性优化,避免过早优化
  • 渲染:key、v-once、v-memo、v-show/v-if、虚拟滚动
  • 响应式:shallowRef、markRaw、缩小 reactive 范围、computed 缓存
  • 代码:路由/组件懒加载、Tree-shaking、KeepAlive
  • 本质:减少 DOM 数量、减少无效 render、减小首屏体积

更多推荐