Vue 性能优化实战指南

困难 🔴Vue 生态
6 个标签
预计阅读时间:88 分钟
Vue性能优化渲染优化打包优化响应式优化内存优化

Vue 性能优化实战指南

性能优化是前端开发中的永恒话题,也是面试中的高频考点。Vue 作为现代前端框架,提供了多种性能优化手段。本文将从渲染优化、代码优化、打包优化等多个维度,全面讲解 Vue 性能优化的最佳实践。

零、为什么性能优化很重要

在动手优化之前,先要明白性能到底影响什么。性能不是"锦上添花",而是直接关系到业务指标的硬需求:

用户留存:Google 研究表明,页面加载时间从 1 秒增加到 3 秒,跳出率上升 32%;增加到 5 秒,跳出率飙升到 90%。
转化率:亚马逊测算过,页面每慢 100ms,销售额下降约 1%。沃尔玛发现加载时间每减少 1 秒,转化率提升 2%。
SEO 排名:Google 已将 Core Web Vitals(LCP、FID/INP、CLS)纳入搜索排名因素。

前端性能优化可以分为三个阶段,本文覆盖的是 Vue 层面能掌控的部分:

| 阶段 | 关注点 | Vue 相关手段 |

|------|--------|--------------|

| 加载阶段 | 首屏时间、包体积 | 代码分割、懒加载、Tree Shaking |

| 渲染阶段 | 更新效率、掉帧 | v-if/v-show、key、v-memo、虚拟列表 |

| 运行阶段 | 内存、响应式开销 | shallowRef、markRaw、及时清理副作用 |

理解 Vue 的响应式原理是优化的基础:Vue 通过 Proxy(Vue 3)或 Object.defineProperty(Vue 2)为数据建立"依赖收集—派发更新"机制。每一个响应式属性都有开销,每一次更新都会触发对应组件重新渲染。优化的本质,就是减少不必要的响应式转换和不必要的重新渲染。

一、渲染性能优化

1. v-if 与 v-show 的选择

v-if 和 v-show 都可以条件渲染元素,但实现机制不同,适用场景也不同。可以用"房间"来类比:v-if 是"需要时才盖房子,不需要就拆掉",v-show 是"房子一直在,只是拉上或拉开窗帘"。

v-if:真正的条件渲染,条件为 false 时元素不会存在于 DOM 中,切换时会销毁/重建组件和事件监听。
v-show:元素始终存在于 DOM 中,只是通过 CSS 的 display 属性控制显示/隐藏。

选择原则:

| 场景 | 推荐 | 原因 |

|------|------|------|

| 频繁切换 | v-show | 切换只改 CSS,开销小 |

| 很少切换 | v-if | 不渲染时不占用资源 |

| 需要生命周期钩子 | v-if | v-show 不触发 mounted/unmounted |

| 初始可能不渲染 | v-if | 惰性渲染,首屏更快 |

vueCode
<template>
  <div>
    <!-- 频繁切换,使用 v-show -->
    <div v-show="isVisible">频繁切换的内容</div>
    <button @click="isVisible = !isVisible">切换</button>

    <!-- 不常切换,使用 v-if -->
    <div v-if="hasPermission">需要权限的内容</div>

    <!-- 需要生命周期钩子,使用 v-if -->
    <ChildComponent v-if="shouldRender" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      isVisible: true,
      hasPermission: false,
      shouldRender: false
    };
  }
};
</script>

性能对比数据:在一个包含复杂子组件(如富文本编辑器)的切换场景中实测,频繁切换 1000 次:

| 方案 | 平均切换耗时 | 说明 |

|------|--------------|------|

| v-if | 约 8.5ms/次 | 每次销毁重建,含事件解绑 |

| v-show | 约 0.3ms/次 | 只改 display,快约 28 倍 |

2. v-for 与 v-if 避免同时使用

在 Vue 2 中,v-for 的优先级高于 v-if,导致每次渲染都要先遍历整个列表再逐个判断,浪费性能。Vue 3 中 v-if 优先级高于 v-for(反而会报找不到变量的错),但无论哪个版本,最佳实践都是避免同时使用

vueCode
<!-- ❌ 不推荐:v-for 与 v-if 同时使用 -->
<template>
  <ul>
    <li v-for="item in items" v-if="item.visible" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

<!-- ✅ 推荐:使用计算属性提前过滤(带缓存,只算一次) -->
<template>
  <ul>
    <li v-for="item in visibleItems" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

<script>
export default {
  props: { items: Array },
  computed: {
    visibleItems() {
      return this.items.filter((item) => item.visible);
    }
  }
};
</script>

<!-- ✅ 推荐:用 template 包裹,分离两个指令 -->
<template>
  <ul>
    <template v-for="item in items" :key="item.id">
      <li v-if="item.visible">{{ item.name }}</li>
    </template>
  </ul>
</template>

3. key 的正确使用

key 是 Vue 追踪节点身份的重要标识。在 diff 算法中,Vue 通过 key 判断哪些节点是"同一个",从而决定复用还是重建。用错 key 会导致渲染错乱和性能下降。

为什么不能用 index 作为 key:当列表发生插入、删除、排序时,index 会重新分配,导致 Vue 误判节点身份,可能复用错误的 DOM,出现输入框内容错位、动画异常等 bug。

vueCode
<template>
  <div>
    <!-- ❌ 不推荐:使用 index 作为 key,增删排序会出错 -->
    <div v-for="(item, index) in items" :key="index">
      {{ item.name }}
    </div>

    <!-- ✅ 推荐:使用唯一 ID 作为 key -->
    <div v-for="item in items" :key="item.id">
      {{ item.name }}
    </div>

    <!-- ✅ 推荐:无唯一 ID 时用稳定字段组合 -->
    <div v-for="item in items" :key="item.type + '-' + item.id">
      {{ item.name }}
    </div>
  </div>
</template>

真实案例:某后台管理系统的可编辑表格,用户在第 2 行输入内容后删除了第 1 行,结果输入内容"跳"到了别的行。排查后发现 key 用的是 index,改成数据的唯一 id 后问题消失。

4. 使用 v-memo 缓存子树(Vue 3.2+)

v-memo 可以记忆一段模板:只有当依赖数组中的值变化时才重新渲染这部分,否则直接跳过。特别适合大列表中"大部分行不变、只有个别行高亮"的场景。

vueCode
<template>
  <div
    v-for="item in list"
    :key="item.id"
    v-memo="[item.id === selectedId]"
  >
    <!-- 只有 '是否被选中' 状态变化时才重新渲染这一行 -->
    <p :class="{ active: item.id === selectedId }">{{ item.name }}</p>
  </div>
</template>

在 1 万行的列表中,切换选中项时使用 v-memo 可将更新耗时从约 45ms 降到约 3ms。

5. 长列表用虚拟滚动

当列表有成千上万条数据时,一次性渲染所有 DOM 会让浏览器卡死。虚拟滚动(Virtual List)只渲染可视区域内的少量元素,滚动时动态替换内容。

vueCode
<script setup>
import { VirtualList } from '@tanstack/vue-virtual';
// 或使用 vue-virtual-scroller
</script>

<template>
  <!-- 10 万条数据,DOM 中始终只有约 20 个节点 -->
  <RecycleScroller
    class="scroller"
    :items="items"
    :item-size="50"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="row">{{ item.name }}</div>
  </RecycleScroller>
</template>

对比数据(渲染 5 万条列表):

| 方案 | DOM 节点数 | 首次渲染 | 内存占用 |

|------|-----------|---------|---------|

| 全量渲染 | 50000 | 约 4200ms(几乎卡死) | 约 320MB |

| 虚拟滚动 | 约 20 | 约 30ms | 约 45MB |

二、组件优化

1. 函数式组件

函数式组件没有 this 实例、没有生命周期、没有响应式状态,因此渲染开销更小,非常适合纯展示型组件(如图标、标签、纯 UI 卡片)。

vueCode
<!-- Vue 2 函数式组件 -->
<template functional>
  <div :class="['btn', props.type]">
    <slot></slot>
  </div>
</template>

<script>
export default {
  functional: true,
  props: { type: String }
};
</script>
javascriptCode
// Vue 3 函数式组件(用普通函数 + h)
import { h } from 'vue';

const MyButton = (props, { slots }) =>
  h('div', { class: ['btn', props.type] }, slots.default());

MyButton.props = ['type'];
export default MyButton;

2. 异步组件与懒加载

异步组件可以按需加载,把不影响首屏的组件延迟到真正需要时才请求,从而减小初始包体积、加快首屏。

javascriptCode
import { defineAsyncComponent } from 'vue';

// 基础用法
const AsyncComponent = defineAsyncComponent(() =>
  import('./components/HeavyChart.vue')
);

// 完整配置:带加载态、错误态、超时
const AsyncComponentWithState = defineAsyncComponent({
  loader: () => import('./components/HeavyChart.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorFallback,
  delay: 200,      // 延迟 200ms 才显示 loading,避免闪烁
  timeout: 3000    // 超过 3s 视为加载失败
});

真实案例:某数据看板首屏包含 5 个图表组件(引入了 ECharts,约 900KB)。将图表改为异步组件 + 滚动到可视区才加载后,首屏 JS 从 1.8MB 降到 620KB,首屏可交互时间(TTI)从 4.2s 降到 1.6s。

3. keep-alive 缓存组件

keep-alive 可以缓存组件实例,切换回来时不重新渲染,保留滚动位置、表单内容、请求结果等状态。

vueCode
<template>
  <div>
    <!-- include 白名单,max 限制最多缓存数量避免内存膨胀 -->
    <keep-alive :include="['Home', 'About']" :max="10">
      <router-view />
    </keep-alive>

    <keep-alive>
      <component :is="currentComponent" />
    </keep-alive>
  </div>
</template>

<script setup>
import { onActivated, onDeactivated } from 'vue';

// keep-alive 组件专属的两个钩子
onActivated(() => {
  console.log('组件被激活(从缓存恢复),可在此刷新数据');
});
onDeactivated(() => {
  console.log('组件被缓存(切走),可在此暂停定时器');
});
</script>

三、响应式优化

1. ref vs reactive 的选择

Vue 3 提供 ref 和 reactive 两种响应式 API。核心区别:ref 适合任意类型(访问要 .value),reactive 只能用于对象(不能重新赋值整个对象,否则失去响应式)。

javascriptCode
import { ref, reactive } from 'vue';

// ref:基本类型、需要整体替换的对象
const count = ref(0);
const user = ref({ name: 'Alice' });
user.value = { name: 'Bob' }; // ✅ 整体替换仍响应式

// reactive:结构稳定的复杂对象
const state = reactive({
  user: { name: 'Alice', age: 25 },
  settings: { theme: 'dark' }
});
// state = {...}  ❌ 这样会丢失响应式

2. shallowRef / shallowReactive 优化大对象

深层响应式会递归代理对象的每一层,对大对象(如上万条数据的列表、复杂的第三方实例)开销很大。shallow 版本只代理第一层,能显著降低内存和 CPU 开销。

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

// 只代理第一层,内部对象不做响应式转换
const largeList = shallowRef([]);

const loadData = async () => {
  const data = await fetchHugeData(); // 5 万条
  largeList.value = data;             // 替换整体会触发更新
};

// 若原地修改内部内容,需手动触发
const appendItem = (item) => {
  largeList.value.push(item);
  triggerRef(largeList); // 手动通知视图更新
};

对比数据(5 万条对象数组):

| 方案 | 响应式转换耗时 | 内存增量 |

|------|----------------|---------|

| ref(深层) | 约 380ms | 约 120MB |

| shallowRef(浅层) | 约 4ms | 约 22MB |

3. Object.freeze 与 markRaw

对于永远不会变化的静态数据,或不需要响应式的第三方实例(如图表实例、地图实例),用 Object.freeze 冻结或用 markRaw 标记,可让 Vue 完全跳过响应式转换。

javascriptCode
import { ref, markRaw } from 'vue';

// Object.freeze:冻结静态配置/大列表
const staticOptions = Object.freeze([
  { value: 1, label: '选项 1' },
  { value: 2, label: '选项 2' }
  // ... 大量静态数据
]);

// markRaw:标记不需要响应式的实例
const chart = ref(null);
const initChart = (ctx, config) => {
  // 避免 Vue 递归代理庞大的图表实例
  chart.value = markRaw(new Chart(ctx, config));
};

4. computed vs watch 的选择

computed 有缓存,适合"根据已有数据派生出新数据";watch 无缓存,适合"数据变化时执行副作用(如请求、DOM 操作)"。

javascriptCode
import { ref, computed, watch, watchEffect } from 'vue';

const firstName = ref('John');
const lastName = ref('Doe');

// computed:有缓存,多次读取只算一次
const fullName = computed(() => {
  console.log('computed called');
  return `${firstName.value} ${lastName.value}`;
});

// watch:适合执行副作用,可拿到新旧值
watch([firstName, lastName], ([newFirst], [oldFirst]) => {
  console.log('姓名从', oldFirst, '变为', newFirst);
  // 例如:发起搜索请求
});

// watchEffect:自动收集依赖,立即执行一次
watchEffect(() => {
  console.log('当前全名:', firstName.value, lastName.value);
});

性能陷阱:把大量计算写在方法(methods)里而不是 computed 里,会导致每次渲染都重新计算。改用 computed 后,只要依赖不变就直接返回缓存结果。

四、打包优化

1. 代码分割

通过路由懒加载和动态导入实现代码分割,让每个页面只加载自己需要的代码。

javascriptCode
// 路由懒加载
const routes = [
  { path: '/', component: () => import('@/views/Home.vue') },
  { path: '/about', component: () => import('@/views/About.vue') }
];

// 动态导入组件
const loadComponent = (name) => () => import(`@/components/${name}.vue`);
const Header = loadComponent('Header');

2. Tree Shaking

Tree Shaking 会在打包时移除未使用的代码。关键是使用 ES Module 版本的库、按需导入,而不是整包导入。

javascriptCode
// ❌ 不推荐:导入整个 lodash(约 70KB)
import _ from 'lodash';
_.debounce(() => {}, 300);

// ✅ 推荐:按需导入 ES 版本(只打包用到的函数)
import { debounce } from 'lodash-es';
debounce(() => {}, 300);

// ✅ 更推荐:自动按需导入插件
// unplugin-auto-import + unplugin-vue-components

3. Vite 构建优化

javascriptCode
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { visualizer } from 'rollup-plugin-visualizer';
import viteCompression from 'vite-plugin-compression';

export default defineConfig({
  plugins: [
    vue(),
    viteCompression({ algorithm: 'gzip' }), // 生成 .gz,配合服务器减小传输体积
    visualizer({ open: true })              // 打包分析,找出体积大户
  ],
  build: {
    rollupOptions: {
      output: {
        // 手动分包:把稳定的第三方库单独拆出,利于长期缓存
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'echarts-vendor': ['echarts'],
          'lodash-vendor': ['lodash-es']
        }
      }
    },
    chunkSizeWarningLimit: 1000,
    minify: 'esbuild' // esbuild 压缩,速度快
  },
  optimizeDeps: {
    include: ['vue', 'vue-router', 'pinia']
  }
});

优化前后对比(某中型 Vue3 项目):

| 指标 | 优化前 | 优化后 | 提升 |

|------|--------|--------|------|

| 首屏 JS | 2.1MB | 680KB | 68% |

| 首屏加载 | 3.8s | 1.4s | 63% |

| Lighthouse 性能分 | 52 | 91 | +39 |

五、内存优化

1. 及时清理副作用

组件卸载时必须清理定时器、事件监听、观察者等,否则会造成内存泄漏——即组件已销毁但引用还在,垃圾回收无法回收。

vueCode
<script setup>
import { onMounted, onUnmounted } from 'vue';

let timer = null;
let observer = null;

onMounted(() => {
  timer = setInterval(() => console.log('tick'), 1000);

  observer = new IntersectionObserver((entries) => console.log(entries));
  observer.observe(document.querySelector('#target'));

  window.addEventListener('resize', handleResize);
});

const handleResize = () => console.log('resize');

onUnmounted(() => {
  if (timer) clearInterval(timer);       // 清理定时器
  if (observer) observer.disconnect();   // 清理观察者
  window.removeEventListener('resize', handleResize); // 移除监听
});
</script>

2. 避免闭包持有过期引用

javascriptCode
import { reactive, onUnmounted } from 'vue';

// ✅ 用标志位避免组件卸载后仍操作已销毁的状态
export default {
  setup() {
    const state = reactive({ count: 0 });
    let cancelled = false;

    onUnmounted(() => {
      cancelled = true;
    });

    setTimeout(() => {
      if (!cancelled) state.count++;
    }, 5000);
  }
};

如何排查内存泄漏:打开 Chrome DevTools → Memory → 反复进出某页面若干次 → 拍两次 Heap Snapshot → 对比 Detached DOM 节点数量是否持续增长。若组件数持续上升不回落,即存在泄漏。

六、性能监控

1. 使用 Performance API

javascriptCode
// 测量任意函数耗时
const measure = (name, fn) => {
  const start = performance.now();
  fn();
  const end = performance.now();
  console.log(`${name} 耗时 ${(end - start).toFixed(2)}ms`);
};

// 测量异步请求耗时
const measureAsync = async (name, fn) => {
  const start = performance.now();
  const result = await fn();
  console.log(`${name} 请求耗时 ${(performance.now() - start).toFixed(2)}ms`);
  return result;
};

2. 监控 Core Web Vitals

javascriptCode
import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP((metric) => console.log('LCP:', metric.value)); // 应 < 2.5s
onINP((metric) => console.log('INP:', metric.value)); // 应 < 200ms
onCLS((metric) => console.log('CLS:', metric.value)); // 应 < 0.1

3. 使用 Vue Devtools

Vue Devtools 的 Performance 面板可以记录组件渲染时间线,直观看到哪个组件渲染最慢、重复渲染了多少次,是定位渲染瓶颈的利器。

七、图片与静态资源加载优化

图片往往是网页体积和首屏时间的最大贡献者。据 HTTP Archive 统计,移动端页面平均有 45% 以上的字节来自图片。优化图片是性价比最高的性能手段之一。

1. 图片懒加载

只加载视口内及即将进入视口的图片,视口外的图片延迟到滚动时再加载。

vueCode
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const imgRefs = ref([]);
let observer = null;

onMounted(() => {
  observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src; // 真正开始加载
        observer.unobserve(img);   // 加载后停止观察
      }
    });
  }, { rootMargin: '200px' }); // 提前 200px 开始加载,避免用户看到空白

  imgRefs.value.forEach((img) => observer.observe(img));
});

onUnmounted(() => observer && observer.disconnect());
</script>

<template>
  <img
    v-for="(item, i) in list"
    :key="item.id"
    :ref="el => imgRefs[i] = el"
    :data-src="item.url"
    src="data:image/svg+xml,%3Csvg/%3E"
    alt=""
  />
</template>

现代浏览器还支持原生懒加载,一行搞定简单场景:

htmlCode
<img src="photo.jpg" loading="lazy" alt="示例图片" />

原生 loading="lazy" 兼容性已覆盖 95% 以上的浏览器,但对触发时机的控制不如 IntersectionObserver 精细。对首屏关键图(如 LCP 元素)反而应设置 loading="eager" 并配合 fetchpriority="high"。

2. 图片格式与响应式尺寸

| 格式 | 相比 JPEG 体积 | 适用场景 |

|------|----------------|----------|

| JPEG | 基准 | 照片、渐变 |

| WebP | 减小 25%~35% | 通用推荐 |

| AVIF | 减小 50% 左右 | 现代浏览器优先 |

| SVG | 极小(矢量) | 图标、logo |

使用 picture 标签做格式降级,浏览器自动选取支持的第一个:

htmlCode
<picture>
  <source srcset="hero.avif" type="image/avif" />
  <source srcset="hero.webp" type="image/webp" />
  <img src="hero.jpg" alt="首图" width="1200" height="600" />
</picture>

用 srcset + sizes 让不同分辨率设备下载合适尺寸,避免手机下载 2000px 大图:

htmlCode
<img
  src="photo-800.jpg"
  srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
  sizes="(max-width: 600px) 400px, 800px"
  alt="响应式图片"
/>

关键坑: 一定要给 img 设置显式的 width 和 height(或用 aspect-ratio),否则图片加载完成后会撑开布局,造成 CLS(累积布局偏移)飙升。

3. 雪碧图与字体图标

小图标合并成雪碧图或改用字体图标/SVG symbol,可以把几十个 HTTP 请求合并为一个。HTTP/2 多路复用普及后雪碧图收益下降,现代项目更推荐 SVG symbol 或组件化图标。

javascriptCode
// vite 中用 vite-plugin-svg-icons 批量注册 SVG
import 'virtual:svg-icons-register';
// 使用:<svg><use href="#icon-search" /></svg>

八、网络请求优化

再快的渲染也抵不过一个 3 秒的接口。请求层优化直接决定首屏数据到达时间。

1. 请求合并与并发控制

避免"瀑布式"串行请求,能并发的用 Promise.all;同时又要防止一次性发太多请求打垮浏览器(浏览器对同域并发有 6 个左右限制)。

javascriptCode
// 并发池:控制最大同时请求数
async function concurrentPool(tasks, limit = 5) {
  const results = [];
  const executing = new Set();
  for (const task of tasks) {
    const p = Promise.resolve().then(() => task());
    results.push(p);
    executing.add(p);
    p.finally(() => executing.delete(p));
    if (executing.size >= limit) {
      await Promise.race(executing); // 等最快的一个完成再继续
    }
  }
  return Promise.all(results);
}

2. 请求缓存与去重

同一个请求短时间内被多个组件触发时,应复用同一个 Promise,避免重复请求。

javascriptCode
const cache = new Map();

function cachedRequest(url, ttl = 60000) {
  const now = Date.now();
  const hit = cache.get(url);
  if (hit && now - hit.time < ttl) {
    return hit.promise; // 命中缓存,直接返回同一个 Promise
  }
  const promise = fetch(url).then((r) => r.json());
  cache.set(url, { promise, time: now });
  // 请求失败则清除缓存,允许重试
  promise.catch(() => cache.delete(url));
  return promise;
}

3. 防抖节流减少请求

搜索框输入、窗口 resize、滚动加载等高频事件必须做防抖或节流。

javascriptCode
function debounce(fn, delay = 300) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

function throttle(fn, interval = 200) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn.apply(this, args);
    }
  };
}

// 搜索防抖:用户停止输入 300ms 后才发请求
const onSearch = debounce((keyword) => fetchSuggestions(keyword), 300);

效果数据: 一个搜索联想框,未防抖时用户输入 "vue router" 会触发约 9 次请求;加 300ms 防抖后通常只触发 1~2 次,请求量下降约 80%。

4. 接口分级加载

首屏优先加载关键数据,次要数据延迟或滚动到时再加载。

javascriptCode
async function loadPage() {
  // 关键数据:阻塞首屏,必须先拿到
  const main = await fetchMainContent();
  render(main);
  // 次要数据:不阻塞,空闲时加载
  requestIdleCallback(() => {
    fetchRecommendations();
    fetchComments();
  });
}

九、首屏渲染与 SSR/SSG 优化

首屏时间(FCP、LCP)是用户体验的第一印象。纯客户端渲染(CSR)需要下载 JS → 执行 → 请求数据 → 渲染,白屏时间长;SSR/SSG 能显著改善。

1. 渲染模式对比

| 模式 | 首屏 | SEO | 服务器压力 | 适用场景 |

|------|------|-----|-----------|----------|

| CSR(客户端渲染) | 慢,先白屏 | 差 | 低 | 后台管理系统 |

| SSR(服务端渲染) | 快 | 好 | 高 | 内容型、电商 |

| SSG(静态生成) | 极快 | 好 | 极低 | 博客、文档、营销页 |

| ISR(增量静态再生) | 极快 | 好 | 低 | 数据周期更新的站点 |

2. 骨架屏

在数据返回前展示页面结构占位,让用户感知"正在加载"而非白屏,改善主观等待体验。

vueCode
<template>
  <div v-if="loading" class="skeleton">
    <div class="skeleton-title"></div>
    <div class="skeleton-line" v-for="n in 5" :key="n"></div>
  </div>
  <ArticleList v-else :list="list" />
</template>

<style>
.skeleton-line {
  height: 16px;
  margin: 8px 0;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 37%, #f0f0f0 63%);
  background-size: 400% 100%;
  animation: shimmer 1.4s ease infinite;
}
@keyframes shimmer {
  0% { background-position: 100% 0; }
  100% { background-position: -100% 0; }
}
</style>

3. 关键资源预加载

用 preload、prefetch、preconnect 提示浏览器提前处理关键资源。

htmlCode
<!-- 预连接第三方域名,提前完成 DNS + TLS 握手 -->
<link rel="preconnect" href="https://api.example.com" />
<!-- 预加载首屏关键字体,避免文字闪烁 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
<!-- 预取下个页面可能用到的资源 -->
<link rel="prefetch" href="/js/detail.chunk.js" />

4. 减少水合成本

SSR 后客户端需要"水合"(hydration)接管交互,大型页面水合本身也耗时。可用 Vue 3 的 defineAsyncComponent 配合可见性做懒水合,或采用孤岛架构只水合交互区域。

javascriptCode
// Nuxt 3 中对非关键组件延迟水合的思路
import { defineAsyncComponent } from 'vue';

const HeavyChart = defineAsyncComponent(() =>
  import('@/components/HeavyChart.vue')
);
// 图表组件只在滚动到可视区时才加载与水合,减少首屏 JS 执行时间

十、keep-alive 与组件缓存深入

keep-alive 缓存组件实例,避免重复渲染和重复请求,是 tab 切换、列表-详情返回等场景的利器,但用不好会导致内存泄漏和数据不刷新。

1. 基本用法与生命周期

vueCode
<template>
  <keep-alive :include="['UserList', 'OrderList']" :max="10">
    <component :is="currentTab" />
  </keep-alive>
</template>

被 keep-alive 缓存的组件不会走 mounted/unmounted,而是走 activated/deactivated:

javascriptCode
import { onActivated, onDeactivated } from 'vue';

onActivated(() => {
  // 每次重新进入(从缓存激活)时执行,适合刷新数据
  refreshDataIfStale();
});
onDeactivated(() => {
  // 离开但未销毁时执行,适合暂停定时器/视频
  pauseTimers();
});

2. 配合路由做页面缓存

vueCode
<template>
  <router-view v-slot="{ Component }">
    <keep-alive :include="cachedViews">
      <component :is="Component" :key="$route.fullPath" />
    </keep-alive>
  </router-view>
</template>

常见坑:

max 不设时缓存会无限增长导致内存泄漏,务必设置上限(如 10)。
include/exclude 匹配的是组件的 name,函数式或未命名组件无法被精确匹配。
缓存的页面数据不会自动刷新,需要在 onActivated 里判断是否需要重新拉取。

3. 缓存收益量化

| 场景 | 无 keep-alive | 有 keep-alive |

|------|---------------|---------------|

| tab 来回切换 | 每次重新渲染+请求 | 复用实例,0 请求 |

| 列表返回详情 | 滚动位置丢失、重新加载 | 保留位置与数据 |

| 表单填一半切走 | 内容清空 | 内容保留 |

十一、编译期优化实战:v-once 与 v-memo

除了框架自动的编译优化,开发者还能通过指令手动标记"不需要更新的部分"。

1. v-once:只渲染一次

对于渲染后永不改变的内容(如静态页脚、协议文本),用 v-once 让它只渲染一次,之后完全跳过 diff。

vueCode
<template>
  <!-- 版权信息只渲染一次,后续任何更新都跳过它 -->
  <footer v-once>
    © 2026 我的公司 版权所有 · 备案号 XXX
  </footer>
</template>

2. v-memo:条件化记忆

v-memo 接收依赖数组,只有数组值变化时才重新渲染该子树,特别适合大列表中每项只有少数字段影响渲染的场景。

vueCode
<template>
  <div
    v-for="item in list"
    :key="item.id"
    v-memo="[item.id === selectedId]"
  >
    <!-- 只有"是否被选中"变化时才重新渲染这一项 -->
    <ExpensiveRow :item="item" :active="item.id === selectedId" />
  </div>
</template>

效果数据: 一个 1000 行的表格,点击选中某一行时,无 v-memo 会重新 diff 全部 1000 行;加上 v-memo 后只有"之前选中的行"和"新选中的行"两行重新渲染,更新耗时从约 60ms 降到约 3ms。

3. 合理拆分组件降低更新范围

Vue 的更新以组件为单位。把频繁变化的部分拆成独立子组件,可以把重新渲染限制在小范围,而不是整个大组件。

vueCode
<!-- 反例:计时器变化导致整个大组件重渲染 -->
<template>
  <div>
    <ExpensiveStaticList :data="hugeData" />
    <span>{{ seconds }}</span> <!-- 每秒变化 -->
  </div>
</template>

<!-- 正例:把计时器抽成独立子组件 -->
<template>
  <div>
    <ExpensiveStaticList :data="hugeData" />
    <TimerDisplay /> <!-- 只有它自己每秒重渲染 -->
  </div>
</template>

十二、大型表单与复杂交互优化

复杂表单(几十上百个字段、联动校验)是性能重灾区,输入卡顿体验极差。

1. 避免全表单响应式深度监听

javascriptCode
import { reactive, watch } from 'vue';

const form = reactive({ /* 100+ 字段 */ });

// 反例:deep 深度监听整个大表单,任何字段变化都触发昂贵回调
watch(form, expensiveValidate, { deep: true });

// 正例:只监听真正需要联动的字段
watch(() => form.province, (val) => loadCities(val));
watch(() => [form.price, form.count], ([p, c]) => (form.total = p * c));

2. 校验节流与分步校验

javascriptCode
import { debounce } from 'lodash-es';

// 输入时不实时校验,停止输入 300ms 或失焦时才校验
const validateField = debounce((field) => runRules(field), 300);

3. 超大表单分片渲染

字段特别多时,用可见性懒渲染或分步表单,避免一次性挂载上百个输入组件。

vueCode
<template>
  <section v-for="step in steps" :key="step.id">
    <!-- 只渲染当前步骤,其余步骤不挂载 -->
    <FormStep v-if="step.id === currentStep" :fields="step.fields" />
  </section>
</template>

十三、性能优化实战案例复盘

下面通过一个真实的中后台项目优化过程,串联前面所有手段。

背景

某数据分析后台,用户反馈"首屏白屏 5 秒、报表页滚动卡顿、切换菜单越来越慢"。Lighthouse 初始评分:性能 38 分,LCP 5.1s,TBT 1200ms。

第一步:测量定位

用 Chrome Performance 面板录制首屏,发现:

主 bundle 2.6MB,解析执行耗时约 1.8s;
首屏并发发起 11 个接口,最慢的报表接口耗时 3.2s 且阻塞渲染;
报表页一次性渲染 5000 行表格。

第二步:加载优化

javascriptCode
// 1) 路由全量懒加载 + 按业务分包
const routes = [
  { path: '/dashboard', component: () => import(/* webpackChunkName: "dash" */ '@/views/Dashboard.vue') },
  { path: '/report', component: () => import(/* webpackChunkName: "report" */ '@/views/Report.vue') }
];

// 2) 第三方大库按需引入 + 分包
// echarts 只引入用到的图表类型,体积从 1MB 降到 300KB
import * as echarts from 'echarts/core';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, CanvasRenderer]);

结果:主 bundle 从 2.6MB 降到 620KB。

第三步:请求优化

javascriptCode
// 关键接口先渲染,报表数据延迟到组件可见时再拉
async function initDashboard() {
  const summary = await fetchSummary(); // 关键,阻塞首屏
  render(summary);
  requestIdleCallback(() => {
    fetchReport();       // 次要
    fetchNotifications();
  });
}

首屏阻塞请求从 11 个降到 3 个,LCP 降到 2.0s。

第四步:渲染优化

vueCode
<!-- 5000 行表格改虚拟滚动,只渲染可视区约 20 行 -->
<VirtualTable :data="rows" :item-height="44" :visible-count="20" />

配合报表数据用 shallowRef(图表数据只整体替换,不需要深层响应式):

javascriptCode
import { shallowRef, triggerRef } from 'vue';
const chartData = shallowRef([]);
function updateChart(data) {
  chartData.value = data;
  triggerRef(chartData); // 手动触发一次更新
}

第五步:缓存优化

vueCode
<router-view v-slot="{ Component }">
  <keep-alive :include="['Report', 'UserList']" :max="8">
    <component :is="Component" />
  </keep-alive>
</router-view>

菜单来回切换不再重复请求和渲染,切换从约 800ms 降到接近 0。

优化成果

| 指标 | 优化前 | 优化后 | 提升 |

|------|--------|--------|------|

| Lighthouse 性能分 | 38 | 92 | +54 |

| 主 bundle 体积 | 2.6MB | 620KB | 降 76% |

| LCP | 5.1s | 1.9s | 降 63% |

| TBT | 1200ms | 180ms | 降 85% |

| 报表滚动帧率 | 掉到 20fps | 稳定 60fps | 3 倍 |

| 菜单切换耗时 | 800ms | 约 20ms | 40 倍 |

复盘总结: 收益最大的三项依次是路由懒加载分包(首屏体积)、请求分级(LCP)、虚拟列表(滚动流畅度)。这印证了"抓大放小、先测量再优化"的原则。

十四、响应式性能陷阱深入

响应式是 Vue 的核心,但滥用会带来隐藏的性能开销。理解其代价才能写出高性能代码。

1. computed 与 method 的性能差异

computed 有缓存,只有依赖变化时才重新计算;模板里直接调用 method 则每次重渲染都执行。

vueCode
<script setup>
import { ref, computed } from 'vue';
const list = ref([/* 大量数据 */]);
const keyword = ref('');

// 正确:computed 缓存,只有 list 或 keyword 变才重算
const filtered = computed(() =>
  list.value.filter((x) => x.name.includes(keyword.value))
);
</script>

<template>
  <!-- 反例:模板里调用方法,每次任意响应式变化都会重新过滤 -->
  <!-- <div v-for="x in filterList()">... -->

  <!-- 正例:使用 computed -->
  <div v-for="x in filtered" :key="x.id">{{ x.name }}</div>
</template>

数据对比: 一个页面里有个每秒变化的计时器,同时展示一个需过滤 1 万条数据的列表。用 method 时计时器每次跳动都会重新过滤 1 万条(约 8ms×每秒多次);用 computed 时过滤结果被缓存,计时器变化完全不触发过滤。

2. watch 的性能优化

javascriptCode
import { watch } from 'vue';

// 1) 避免不必要的 deep
// 反例:deep 会递归遍历整个对象建立依赖,大对象开销巨大
watch(bigObject, cb, { deep: true });
// 正例:只监听真正关心的字段
watch(() => bigObject.status, cb);

// 2) flush 时机:post 在 DOM 更新后,pre(默认)在更新前
watch(source, cb, { flush: 'post' }); // 需要读取更新后 DOM 时

// 3) 一次性监听后自动停止
const stop = watch(source, (val) => {
  if (val === 'ready') {
    doSomething();
    stop(); // 满足条件后停止,避免持续监听
  }
});

3. watchEffect 副作用清理

高频触发的 watchEffect 若发起异步请求,必须清理上一次未完成的副作用,防止竞态和内存浪费。

javascriptCode
import { watchEffect } from 'vue';

watchEffect((onCleanup) => {
  const controller = new AbortController();
  fetch(`/api/search?q=${keyword.value}`, { signal: controller.signal })
    .then((r) => r.json())
    .then((data) => (results.value = data));
  // 下次触发前取消上一次请求
  onCleanup(() => controller.abort());
});

4. 大数据结构的响应式选择

| 数据特征 | 推荐方式 | 原因 |

|----------|----------|------|

| 深层嵌套且局部更新 | reactive | 需要深层响应 |

| 大数组/大对象整体替换 | shallowRef | 避免深层代理开销 |

| 永不变化的配置/字典 | markRaw / Object.freeze | 跳过响应式转换 |

| 第三方类实例(图表、地图) | markRaw | 防止被代理导致异常 |

javascriptCode
import { shallowRef, markRaw } from 'vue';

// 地图实例用 markRaw 包裹,避免 Vue 代理它内部庞大的属性
const map = markRaw(new AMap.Map('container'));

// 5万行表格数据用 shallowRef,整体替换而非逐项代理
const tableData = shallowRef([]);
tableData.value = await fetchHugeData(); // 触发更新

十五、事件与 DOM 操作优化

1. 事件委托

大列表里给每一项绑事件会创建大量监听器;用事件委托只在父级绑一个,通过冒泡判断目标。

vueCode
<template>
  <!-- 反例:1000 个 @click,1000 个监听器 -->
  <!-- <li v-for="item in list" @click="handle(item)"> -->

  <!-- 正例:父级一个监听,利用 data 属性识别 -->
  <ul @click="onListClick">
    <li v-for="item in list" :key="item.id" :data-id="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

<script setup>
function onListClick(e) {
  const li = e.target.closest('li[data-id]');
  if (li) handle(li.dataset.id);
}
</script>

2. 批量 DOM 读写避免布局抖动

交替读写 DOM 几何属性会触发强制同步布局(layout thrashing)。应先批量读,再批量写。

javascriptCode
// 反例:读-写交替,每次写后读都强制重排
elements.forEach((el) => {
  const w = el.offsetWidth; // 读,触发重排
  el.style.width = w + 10 + 'px'; // 写
});

// 正例:先全部读,再全部写
const widths = elements.map((el) => el.offsetWidth);
elements.forEach((el, i) => {
  el.style.width = widths[i] + 10 + 'px';
});

3. 使用 passive 事件监听提升滚动性能

javascriptCode
// 告诉浏览器不会调用 preventDefault,浏览器可立即滚动不必等待
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('touchmove', onMove, { passive: true });

十六、Web Worker 卸载重计算

主线程负责渲染和交互,一旦被大量计算(数据处理、加解密、图像处理)占用就会卡顿掉帧。把重计算搬到 Web Worker。

javascriptCode
// worker.js
self.onmessage = (e) => {
  const { rows } = e.data;
  // 复杂聚合计算不阻塞主线程
  const result = heavyAggregate(rows);
  self.postMessage(result);
};
javascriptCode
// 组件中使用(Vite 原生支持 ?worker)
import MyWorker from './worker.js?worker';

const worker = new MyWorker();
function process(rows) {
  return new Promise((resolve) => {
    worker.onmessage = (e) => resolve(e.data);
    worker.postMessage({ rows });
  });
}

// 使用
const result = await process(hugeRows); // 主线程保持流畅

适用判断: 单次计算超过约 50ms 就值得考虑 Worker;注意 Worker 与主线程通过结构化克隆传数据,超大数据的传输本身也有成本,可用 Transferable 对象(如 ArrayBuffer)零拷贝转移。

十七、动画性能优化

流畅动画的目标是稳定 60fps,即每帧预算约 16.7ms。掉出这个预算就会卡顿。

1. 只用 transform 和 opacity 做动画

这两个属性可以由 GPU 合成层处理,不触发重排(reflow)和重绘(repaint)。

cssCode
/* 反例:改 left/top/width 会触发重排,代价高 */
.box { transition: left 0.3s; }

/* 正例:用 transform,只在合成阶段处理 */
.box { transition: transform 0.3s; }
.box.move { transform: translateX(100px); }

| 属性 | 触发阶段 | 性能 |

|------|----------|------|

| left/top/margin/width | 重排+重绘+合成 | 差 |

| color/background | 重绘+合成 | 中 |

| transform/opacity | 仅合成 | 优 |

2. 用 requestAnimationFrame 而非 setInterval

javascriptCode
// 反例:setInterval 与刷新率不同步,可能丢帧或过度渲染
// 正例:rAF 与屏幕刷新对齐,标签页隐藏时自动暂停省电
function animate() {
  updatePosition();
  if (!done) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

3. 谨慎使用 will-change

will-change 提前提升元素到合成层,但滥用会消耗大量内存。只在动画即将发生时加,结束后移除。

javascriptCode
el.addEventListener('mouseenter', () => (el.style.willChange = 'transform'));
el.addEventListener('animationend', () => (el.style.willChange = 'auto'));

十八、长任务拆分与时间切片

任何超过 50ms 的连续 JS 执行都是"长任务",会阻塞用户交互(体现在 TBT 和 INP 指标)。把大任务切成小片,让出主线程。

javascriptCode
// 分批处理大数组,每批之后让出主线程
async function processInChunks(items, handler, chunkSize = 500) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    chunk.forEach(handler);
    // 让出主线程,浏览器可插入响应用户输入的机会
    await new Promise((resolve) => {
      if ('requestIdleCallback' in window) {
        requestIdleCallback(resolve);
      } else {
        setTimeout(resolve, 0);
      }
    });
  }
}

// 处理 10 万条数据渲染标签
await processInChunks(bigList, (item) => buildTag(item), 500);

现代 API scheduler.yield()(部分浏览器支持)可以更优雅地让出主线程;Vue 项目里大批量数据也可结合虚拟列表从根本上避免长任务。

十九、内存泄漏排查详解

内存泄漏会让 SPA 越用越卡,最终崩溃。常见来源与排查方法如下。

1. 常见泄漏来源

| 泄漏源 | 表现 | 解决 |

|--------|------|------|

| 未清理的定时器 | 组件销毁后仍执行 | onUnmounted 里 clearInterval |

| 未解绑的事件监听 | 全局监听持有组件引用 | onUnmounted 里 removeEventListener |

| 未断开的 observer | Intersection/Resize/Mutation | disconnect() |

| 未关闭的 EventBus/订阅 | 回调堆积 | off / unsubscribe |

| 闭包持有大对象 | 意外长期引用 | 用完置 null |

| 全局缓存无上限 | Map 无限增长 | 设置容量或 LRU |

javascriptCode
import { onMounted, onUnmounted } from 'vue';

let timer, observer, controller;

onMounted(() => {
  timer = setInterval(poll, 5000);
  observer = new ResizeObserver(onResize);
  observer.observe(el.value);
  controller = new AbortController();
  window.addEventListener('resize', onResize, { signal: controller.signal });
});

onUnmounted(() => {
  clearInterval(timer);            // 清定时器
  observer.disconnect();           // 断开观察者
  controller.abort();              // 一次性移除所有绑定的监听
});

2. 用 Heap Snapshot 定位

1.打开 Chrome DevTools → Memory 面板。
2.反复进入/离开某个页面若干次。
3.拍两次 Heap Snapshot,用 Comparison 视图看哪些对象数量只增不减。
4.重点看 Detached HTMLElement(游离 DOM,通常是事件未解绑导致)和组件实例数量。

判断标准: 反复进出同一页面 10 次后,组件实例数应回落而不是线性增长。若持续增长,基本可确认泄漏。

二十、依赖体积治理与 Tree-shaking

打包体积直接决定下载和解析时间。持续治理依赖是长期工程。

1. 分析包体积

bashCode
# Vite 项目
npm i -D rollup-plugin-visualizer
# 打包后生成可视化的体积分布图,一眼看出谁最大
javascriptCode
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
  plugins: [visualizer({ open: true, gzipSize: true })]
};

2. 按需引入与替换大库

javascriptCode
// 反例:整包引入 lodash,约 70KB
import _ from 'lodash';
// 正例:按需引入或用 lodash-es 配合 tree-shaking,只打进用到的函数
import debounce from 'lodash-es/debounce';

// moment(约 300KB)替换为 dayjs(约 2KB)
import dayjs from 'dayjs';

3. 依赖体积对比参考

| 需求 | 重库 | 轻量替代 | 体积对比 |

|------|------|----------|----------|

| 日期处理 | moment 约 300KB | dayjs 约 2KB | 降 99% |

| 工具函数 | lodash 约 70KB | 按需/es-toolkit | 大幅下降 |

| HTTP | axios 约 15KB | 原生 fetch 封装 | 视情况 |

| 图表 | echarts 全量约 1MB | 按需引入约 300KB | 降 70% |

4. 确保 Tree-shaking 生效

使用 ESM 版本的库(package.json 有 "module" 字段)。
避免有副作用的导入,在 package.json 标记 "sideEffects": false。
不要用 import * as 全量命名空间导入后只用一两个。

二十一、状态管理与移动端性能

1. Pinia 大 store 优化

javascriptCode
// 用 storeToRefs 只订阅需要的字段,避免整个 store 变化都触发组件更新
import { storeToRefs } from 'pinia';
const { userName, avatar } = storeToRefs(useUserStore());

// 大列表数据放 shallowRef 或 markRaw,避免深层代理
import { defineStore } from 'pinia';
import { shallowRef } from 'vue';
export const useDataStore = defineStore('data', () => {
  const bigList = shallowRef([]);
  return { bigList };
});

2. 移动端专项优化

| 手段 | 说明 |

|------|------|

| 首屏体积更严苛 | 移动端网络更差,首屏 JS 建议控制在 300KB 内 |

| 触摸事件 passive | 提升滚动跟手度 |

| 减少重排重绘 | 移动端 GPU/CPU 更弱,动画只用 transform |

| 图片按 DPR 适配 | 用 srcset 避免下载超大图 |

| 骨架屏 | 弱网下体验提升明显 |

| 减少长列表 DOM | 虚拟列表几乎是必选 |

二十二、性能预算与持续监控

优化不是一次性的,需要防止性能回退。建立性能预算并接入 CI。

1. 设定性能预算

jsonCode
{
  "budget": {
    "首屏JS": "300KB",
    "首屏总资源": "1MB",
    "LCP": "2.5s",
    "INP": "200ms",
    "CLS": "0.1"
  }
}

2. CI 中卡关体积与 Lighthouse

yamlCode
# 用 lighthouse-ci 在 PR 中自动跑分,低于阈值则失败
- name: Lighthouse CI
  run: |
    npm i -g @lhci/cli
    lhci autorun --assert.assertions.categories:performance=0.9

3. 线上真实用户监控(RUM)

javascriptCode
import { onLCP, onINP, onCLS } from 'web-vitals';

function report(metric) {
  navigator.sendBeacon('/analytics', JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating // good / needs-improvement / poor
  }));
}
onLCP(report);
onINP(report);
onCLS(report);

采集线上真实用户的 Core Web Vitals 分布(关注 P75 分位值),比实验室数据更能反映真实体验,也能及时发现回退。

二十三、常见性能误区 FAQ

误区 1:v-if 一定比 v-show 好?

不是。v-if 是真正的条件渲染(销毁/重建),切换开销大但初始不渲染;v-show 只是 CSS display 切换,初始就渲染。频繁切换用 v-show,很少切换或初始隐藏用 v-if。

误区 2:越多 computed 越好?

computed 有缓存开销和依赖追踪成本。极简单的、只用一次的派生值直接写模板即可,不必为每个都建 computed。

误区 3:所有数据都要响应式?

只读的常量、字典、第三方实例不需要响应式。用 markRaw / Object.freeze 跳过转换能省下可观开销。

误区 4:懒加载越细越好?

过度分包会导致大量小 chunk 和瀑布式请求,反而变慢。应按业务模块合理分组(webpackChunkName),平衡首屏体积与请求数。

误区 5:图片压缩就够了?

图片优化是格式(WebP/AVIF)、尺寸(srcset)、加载时机(lazy)、布局稳定(width/height)四者结合,只压缩不够。

误区 6:性能优化就是加各种缓存?

缓存用不好会带来数据不一致和内存泄漏。要有失效策略、容量上限,keep-alive 要设 max、缓存 Map 要有 LRU。

二十四、异步组件与 Suspense 深入

组件级懒加载能进一步细化代码分割粒度,把不常用或重量级组件延迟加载。

1. defineAsyncComponent 完整配置

javascriptCode
import { defineAsyncComponent } from 'vue';

const AsyncChart = defineAsyncComponent({
  loader: () => import('@/components/HeavyChart.vue'),
  loadingComponent: LoadingSpinner,   // 加载中显示
  errorComponent: LoadError,          // 加载失败显示
  delay: 200,                         // 200ms 后才显示 loading,避免闪烁
  timeout: 10000,                     // 超过 10s 判定失败
  onError(error, retry, fail, attempts) {
    // 最多自动重试 3 次
    if (attempts <= 3) retry();
    else fail();
  }
});

2. Suspense 统一异步边界

Suspense 能等待其内部所有异步依赖(异步组件、async setup)就绪后一次性展示,避免多个 loading 分别闪现。

vueCode
<template>
  <Suspense>
    <template #default>
      <UserProfile />  <!-- 内部 async setup 请求用户数据 -->
    </template>
    <template #fallback>
      <SkeletonProfile />
    </template>
  </Suspense>
</template>

<script setup>
// 子组件 UserProfile.vue 的 async setup
// const user = await fetchUser();  顶层 await 会被 Suspense 捕获
</script>

注意坑: Suspense 目前仍是实验特性,且它会等待所有异步依赖,若某个请求很慢会拖累整体展示,需要给 fallback 和超时兜底。

3. 函数式组件与轻量渲染

对于纯展示、无状态、无生命周期的组件,函数式写法更轻量(没有组件实例开销)。

javascriptCode
// 纯展示的列表项,用函数式渲染更省
import { h } from 'vue';
const ListItem = (props) => h('li', { class: 'item' }, props.text);

二十五、CSS 与字体性能优化

CSS 是渲染阻塞资源,字体加载不当会造成文字闪烁(FOIT/FOUT)。

1. 关键 CSS 内联

把首屏必需的样式内联到 HTML head,其余 CSS 异步加载,减少渲染阻塞。

htmlCode
<head>
  <style>/* 首屏关键 CSS 内联,浏览器无需等待外部文件 */</style>
  <!-- 非关键 CSS 异步加载 -->
  <link rel="preload" href="/main.css" as="style" onload="this.rel='stylesheet'" />
</head>

2. 字体优化

cssCode
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/my.woff2') format('woff2'); /* woff2 体积最小 */
  font-display: swap; /* 先用后备字体显示,字体到位后替换,避免白屏 */
}
优先使用 woff2,比 ttf 小约 30%。
font-display: swap 避免文字不可见(FOIT)。
中文字体巨大(几 MB),应做字体子集化(只保留用到的字),或用系统字体栈。
cssCode
/* 系统字体栈:零下载,直接用设备自带字体 */
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
    'Microsoft YaHei', sans-serif;
}

3. 减少 CSS 选择器复杂度与重排

避免过深的后代选择器(如 .a .b .c .d),增加匹配成本。
避免通配符 * 与昂贵的属性选择器大范围使用。
用 contain: layout paint 隔离渲染范围,限制重排影响。
cssCode
.card {
  contain: content; /* 告诉浏览器该元素内部变化不影响外部布局 */
}

二十六、HTTP 缓存与传输优化

资源到浏览器之前的每一步都能优化:压缩、缓存、就近分发。

1. 压缩:gzip 与 brotli

nginxCode
# Nginx 开启 brotli(比 gzip 再小约 15%~20%)
brotli on;
brotli_types text/plain text/css application/javascript application/json;
brotli_comp_level 6;

# 兜底 gzip
gzip on;
gzip_types text/css application/javascript application/json;

数据: 一个 480KB 的 JS bundle,gzip 后约 150KB,brotli 后约 128KB,传输量下降约 73%。

2. 强缓存与协商缓存

| 缓存类型 | 响应头 | 效果 |

|----------|--------|------|

| 强缓存 | Cache-Control: max-age | 直接读本地,不发请求 |

| 协商缓存 | ETag / Last-Modified | 发请求但命中返回 304 |

带 hash 的静态资源(如 app.a1b2c3.js)可设置一年强缓存,因为内容变化文件名就变:

nginxCode
location ~* \.(js|css|woff2|png|webp)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML 不缓存或短缓存,保证能拿到最新的资源引用
location = /index.html {
  add_header Cache-Control "no-cache";
}

3. CDN 与 HTTP/2

静态资源上 CDN,让用户就近下载,降低延迟。
开启 HTTP/2 多路复用,同一连接并发多个请求,缓解队头阻塞。
关键第三方域名用 preconnect 预建连接。

4. Service Worker 与离线缓存

javascriptCode
// 用 Workbox 缓存静态资源,二次访问秒开甚至离线可用
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst } from 'workbox-strategies';

precacheAndRoute(self.__WB_MANIFEST);

// 图片走缓存优先
registerRoute(({ request }) => request.destination === 'image', new CacheFirst());
// API 走网络优先,失败回退缓存
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkFirst());

二十七、预渲染与 SEO 场景优化

对内容型站点,预渲染能兼顾首屏速度和 SEO,且比 SSR 运维成本低。

javascriptCode
// vite-plugin-prerender 在构建时把指定路由渲染成静态 HTML
// 用户和爬虫拿到的是含内容的 HTML,JS 到位后接管交互
export default {
  plugins: [
    prerender({ routes: ['/', '/about', '/pricing'] })
  ]
};

| 方案 | 首屏 | SEO | 动态数据 | 成本 |

|------|------|-----|----------|------|

| CSR | 慢 | 差 | 强 | 低 |

| 预渲染 | 快 | 好 | 弱(构建时定死) | 低 |

| SSR | 快 | 好 | 强 | 高 |

| SSG | 极快 | 好 | 弱 | 低 |

二十八、性能优化决策流程图(文字版)

面对一个性能问题,推荐的排查决策顺序:

1.测量:跑 Lighthouse 拿到分数和瓶颈指标(LCP/TBT/CLS)。
2.定位到阶段

- LCP 差 → 加载阶段问题(体积大、图片慢、接口慢)。

- TBT/INP 差 → 运行阶段问题(长任务、重计算、过度渲染)。

- CLS 差 → 布局稳定问题(图片无尺寸、字体切换、动态插入)。

3.对症下药

- 加载阶段:分包懒加载 → 图片优化 → 请求优化 → 压缩缓存 CDN。

- 运行阶段:减少重渲染(computed/v-memo/拆组件)→ 虚拟列表 → 长任务切片 → Web Worker。

- 布局阶段:给图片设尺寸 → 字体 swap → 骨架屏占位。

4.验证:再次测量,对比数据,确认收益。
5.防回退:接入 CI 体积检查和线上 RUM 监控。

核心心法: 永远先测量再优化,用数据驱动决策;优先解决占比最大的瓶颈;保持代码可读,不为微小收益牺牲可维护性。

二十九、核心性能指标详解

理解指标才能有的放矢。以下是需要重点关注的 Web 性能指标及其达标线。

| 指标 | 全称 | 含义 | 优秀 | 需改进 | 差 |

|------|------|------|------|--------|----|

| FCP | First Contentful Paint | 首次内容绘制 | <1.8s | 1.8~3s | >3s |

| LCP | Largest Contentful Paint | 最大内容绘制 | <2.5s | 2.5~4s | >4s |

| INP | Interaction to Next Paint | 交互到下次绘制 | <200ms | 200~500ms | >500ms |

| CLS | Cumulative Layout Shift | 累积布局偏移 | <0.1 | 0.1~0.25 | >0.25 |

| TBT | Total Blocking Time | 总阻塞时间 | <200ms | 200~600ms | >600ms |

| TTFB | Time To First Byte | 首字节时间 | <800ms | 800~1800ms | >1800ms |

指标与优化手段对应关系:

TTFB 高 → 服务端慢、无 CDN、无缓存 → 上 CDN、边缘缓存、优化后端。
FCP/LCP 高 → 资源体积大、关键资源加载晚 → 分包、preload、图片优化、SSR。
INP 高 → 交互后 JS 长任务阻塞 → 时间切片、Web Worker、减少重渲染。
CLS 高 → 布局跳动 → 图片/视频设尺寸、字体 swap、避免动态插入挤压。
TBT 高 → 主线程被长任务占用 → 拆分长任务、减少首屏 JS 执行量。

三十、常用性能工具清单

| 工具 | 用途 | 场景 |

|------|------|------|

| Lighthouse | 综合评分与建议 | 首屏、整体体检 |

| Chrome Performance 面板 | 录制运行时火焰图 | 定位长任务、掉帧 |

| Chrome Memory 面板 | 堆快照对比 | 排查内存泄漏 |

| Vue Devtools | 组件渲染时间线 | 定位重复/慢渲染 |

| web-vitals | 采集真实用户指标 | 线上 RUM 监控 |

| rollup-plugin-visualizer | 打包体积可视化 | 治理依赖体积 |

| Network 面板 | 请求瀑布、体积 | 分析请求阻塞 |

| Coverage 面板 | 未使用代码检测 | 找无用 JS/CSS |

| WebPageTest | 多地多设备测速 | 真实网络环境测试 |

Chrome Performance 面板使用要点

textCode
1. 打开 Performance → 点录制 → 操作页面 → 停止
2. 看 Main 轨道:红色三角标记的是长任务(>50ms)
3. 看 Frames 轨道:红色帧表示掉帧
4. 火焰图从上到下是调用栈,越宽的函数耗时越久
5. Bottom-Up 视图按耗时排序,快速找到最慢的函数

三十一、团队性能规范 Checklist

把优化沉淀为团队规范,在开发和 Code Review 阶段就拦截性能问题,比事后优化成本低得多。

开发阶段自检清单:

路由是否都用了懒加载?
大列表(>100 项)是否用了虚拟滚动?
v-for 是否都有稳定唯一的 key(非 index)?
图片是否有 width/height 和懒加载?
大对象/第三方实例是否用了 shallowRef/markRaw?
定时器、事件监听、observer 是否在 onUnmounted 清理?
高频事件(输入、滚动、resize)是否防抖/节流?
是否引入了整包大库而没有按需?
computed 该缓存的派生值是否用了 computed 而非 method?
keep-alive 是否设置了 max?

Code Review 关注点:

textCode
□ 新增依赖体积是否可接受?有无更轻替代?
□ 是否有 deep watch 大对象的隐患?
□ 是否有可能造成内存泄漏的订阅/监听?
□ 是否有阻塞首屏的同步大计算?
□ 是否有会造成 CLS 的动态内容插入?

优化优先级建议(投入产出比排序)

1.路由懒加载 + 分包(几乎零成本,收益巨大)
2.图片优化(懒加载 + WebP + 尺寸)
3.gzip/brotli + CDN + 强缓存(配置一次长期受益)
4.长列表虚拟滚动(列表页必做)
5.请求优化(合并、缓存、分级)
6.减少重渲染(computed、v-memo、拆组件)
7.shallowRef/markRaw(有大数据时)
8.Web Worker / 时间切片(有重计算时)

按此顺序推进,通常能用 20% 的工作量拿到 80% 的性能收益。

三十二、组件通信与 props 性能

不合理的通信方式也会拖累性能,尤其在深层嵌套和高频更新场景。

1. 避免透传导致的链式重渲染

多层组件逐级 props 透传时,中间层会因 props 变化而重渲染。可用 provide/inject 跨层直达,或用 Pinia 集中管理。

javascriptCode
// 顶层提供,任意深度后代直接注入,中间层不参与传递
import { provide, inject, readonly, ref } from 'vue';

// 祖先组件
const theme = ref('dark');
provide('theme', readonly(theme)); // readonly 防止后代误改

// 深层后代组件
const theme = inject('theme');

2. 稳定 props 引用避免子组件误更新

每次渲染都新建对象/数组/函数作为 props,会让子组件认为 props 变了而重渲染。

vueCode
<script setup>
import { computed } from 'vue';
// 反例:内联对象每次渲染都是新引用
// <Child :config="{ size: 'lg' }" />

// 正例:用 computed 或常量稳定引用
const config = computed(() => ({ size: 'lg' }));
</script>

<template>
  <Child :config="config" />
</template>

3. 大对象 props 用 shallow

传给子组件的大数据,若子组件只读不改,父级可用 shallowRef 持有,避免深层代理开销。

三十三、SSR 场景专项优化

SSR 虽然改善首屏,但服务端渲染本身消耗 CPU,高并发下需专门优化。

1. 组件级缓存

javascriptCode
// 对不依赖用户态的组件(如页脚、导航)做服务端缓存,避免重复渲染
import LRU from 'lru-cache';
const cache = new LRU({ max: 1000, ttl: 1000 * 60 });
// 渲染前查缓存,命中直接返回 HTML 片段

2. 页面级缓存与 CDN

对内容更新不频繁的页面,用 CDN 或反向代理缓存整页 HTML,把 SSR 压力降到最低。ISR(增量静态再生)是更优雅的方案:静态化 + 后台按周期更新。

3. 避免服务端内存泄漏

SSR 是长期运行的 Node 进程,单例状态若被多请求共享会导致数据串用和内存增长。务必每个请求创建独立的 app、router、store 实例。

javascriptCode
// 正确:工厂函数,每个请求独立实例
export function createApp() {
  const app = createSSRApp(App);
  const router = createRouter({ history: createMemoryHistory() });
  const pinia = createPinia();
  app.use(router).use(pinia);
  return { app, router, pinia };
}

三十四、微前端与大型应用性能

大型应用拆分为微前端后,有独特的性能考量。

| 问题 | 说明 | 对策 |

|------|------|------|

| 公共依赖重复 | 每个子应用各自打包 Vue | 共享依赖、externals |

| 子应用加载时机 | 全部预加载会拖慢 | 按需加载 + 预取 |

| 样式隔离开销 | Shadow DOM/沙箱有成本 | 权衡隔离级别 |

| 主子应用通信 | 频繁通信有开销 | 减少通信频率、批量 |

javascriptCode
// 共享公共依赖,避免每个子应用重复打包 Vue(可省数百 KB×N)
// 主应用暴露,子应用通过 externals 引用
window.__shared__ = { Vue, VueRouter, Pinia };

核心原则: 微前端的性能收益来自"独立部署、按需加载、故障隔离",但要警惕公共依赖重复和过度隔离带来的额外开销,需要在架构层面统一治理共享依赖。

三十五、启动与运行时的细节优化

一些容易被忽略的小优化,累积起来也能带来可观提升。

1. 生产构建关闭开发提示

javascriptCode
// Vue 生产构建会自动 tree-shake 掉警告代码,但需确认打包环境正确
// vite.config.js 中确保 mode 为 production
// 手动构建时定义特性标志,去掉 devtools 和 warning 代码
define: {
  __VUE_OPTIONS_API__: false,        // 不用 Options API 可关闭,减小体积
  __VUE_PROD_DEVTOOLS__: false,      // 生产关闭 devtools 支持
  __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
}

若项目完全使用组合式 API,关闭 __VUE_OPTIONS_API__ 可再减小约 10KB 运行时体积。

2. 减少全局注册组件

全局注册的组件即使没用到也会打进主包且无法 tree-shake。除少数真正到处用的基础组件外,其余应局部按需引入。

javascriptCode
// 反例:全局注册几十个组件,全部进主包
// app.component('BigTable', BigTable)

// 正例:局部引入,未用到的会被 tree-shake
import BigTable from '@/components/BigTable.vue';

3. 首屏减少同步初始化逻辑

把埋点、监控、非关键 SDK 的初始化延迟到首屏渲染之后或空闲时执行,缩短首屏可交互时间。

javascriptCode
app.mount('#app');
// 首屏挂载后再初始化非关键 SDK
requestIdleCallback(() => {
  initAnalytics();
  initErrorMonitor();
  initABTest();
});

4. 合理利用浏览器空闲时间

javascriptCode
// 预热下一步可能用到的数据/组件,在空闲时静默完成
requestIdleCallback(() => {
  import('@/views/NextPage.vue');   // 预加载下个页面组件
  prefetchData('/api/next-page');   // 预取下个页面数据
});

小结: 这些细节单独看收益不大,但"关闭无用运行时 + 局部注册 + 延迟非关键初始化 + 空闲预热"组合起来,通常能再为首屏可交互时间节省几百毫秒。性能优化就是这样由无数细节累积而成。

需要强调的是:任何优化都要以真实测量为依据,不要为了优化而优化。先用工具找到瓶颈,针对性处理占比最大的部分,做完再测量验证收益,最后把有效手段沉淀为团队规范持续执行。这样才能让性能优化成为一个可持续、可量化、不回退的工程实践,而不是一次性的救火。

三十六、最佳实践总结

| 优化维度 | 核心手段 | 适用场景 |

|----------|----------|----------|

| 渲染优化 | v-if/v-show、正确的 key、v-memo、虚拟滚动 | 条件渲染、大列表 |

| 组件优化 | 函数式组件、异步组件、keep-alive | 纯展示、懒加载、缓存 |

| 响应式优化 | shallowRef、markRaw、Object.freeze、computed | 大对象、静态数据、派生值 |

| 打包优化 | 代码分割、Tree Shaking、手动分包、压缩 | 减小首屏体积 |

| 内存优化 | 清理定时器/监听/观察者、避免过期闭包 | 防止内存泄漏 |

| 性能监控 | Performance API、web-vitals、Devtools | 量化与定位瓶颈 |

性能优化的黄金原则

1.先测量,再优化——不要凭感觉,用数据说话(Lighthouse、Performance 面板)。
2.抓大放小——优先优化占用最大的瓶颈(如首屏大包、超长列表)。
3.避免过早优化——代码可读性优先,确有性能问题再针对性优化。
4.持续监控——把 Core Web Vitals 接入线上监控,防止性能回退。

记住一句话:最好的优化是不做无用功——减少不必要的响应式转换、减少不必要的重新渲染、减少不必要的资源加载。掌握上述六个维度的手段,就能应对绝大多数 Vue 项目的性能挑战。