Skip to content

前端性能优化 ​

前端性能直接影响用户体验和 SEO 排名。本文覆盖核心指标、加载优化、渲染优化和运行时优化。


核心指标(Web Vitals) ​

三大核心指标 ​

指标含义目标衡量方式
LCP最大内容元素渲染完成时间< 2.5sPerformanceObserver
INP交互到下一帧绘制的延迟(替代 FID)< 200msPerformanceObserver
CLS页面生命周期内布局偏移总和< 0.1PerformanceObserver

测量方式 ​

js
// 使用 web-vitals 库(Web Vitals 是 Google 提出的用户体验指标库)
import { onLCP, onINP, onCLS } from 'web-vitals'   // 引入三个核心指标的测量函数

onLCP(console.log)                                  // 测量 LCP(最大内容绘制)并输出结果
onINP(console.log)                                  // 测量 INP(交互到下一帧绘制延迟)并输出结果
onCLS(console.log)                                  // 测量 CLS(累积布局偏移)并输出结果

加载优化 ​

路由懒加载 ​

什么是路由懒加载?为什么要用?

默认情况下,所有页面的代码会打包成一个 JS 文件。用户访问首页时,需要下载整个文件(包含所有页面代码),导致首屏加载很慢。

路由懒加载把每个页面的代码分成独立的文件,只有访问该页面时才下载:

  • 首屏只加载首页的代码,加载更快
  • 其他页面按需加载,不浪费带宽
  • 浏览器会缓存已下载的页面,下次访问更快
js
// ✅ 懒加载:访问时才加载(动态导入语法)
const Home = () => import('@/views/Home.vue')       // 访问 /home 时才加载 Home.vue
const User = () => import('@/views/User.vue')       // 访问 /user 时才加载 User.vue

// ❌ 同步加载:首屏加载全部页面(静态导入)
import Home from '@/views/Home.vue'                 // 首屏加载时就下载 Home.vue
import User from '@/views/User.vue'                 // 首屏加载时就下载 User.vue

验证懒加载是否生效:

1. 打开浏览器 Network 面板
2. 访问首页,查看加载的 JS 文件
3. 点击路由切换到其他页面
4. 应该看到新的 JS 文件被单独加载(而不是一次性加载所有页面)

组件异步加载 ​

vue
<!-- Vue 3 script setup 中使用 defineAsyncComponent -->
<script setup>
import { defineAsyncComponent } from 'vue'

// 重量级组件延迟加载(弹窗、图表等)
const HeavyChart = defineAsyncComponent(() => import('@/components/HeavyChart.vue'))
const RichEditor = defineAsyncComponent(() => import('@/components/RichEditor.vue'))
</script>

图片优化 ​

为什么要优化图片?

图片通常占页面总体积的 60% 以上,是影响加载速度的主要因素。

三种优化方式:

  • 懒加载:首屏外的图片不立即加载,等滚动到可视区域再加载,减少首屏请求数
  • 响应式图片:手机加载小图,桌面加载大图,避免手机用户下载不必要的大图
  • WebP 格式:比 JPEG 小 25-35%,肉眼几乎看不出区别
html
<!-- 懒加载:首屏外的图片不立即加载(loading="lazy" 是浏览器原生属性) -->
<img loading="lazy" src="photo.jpg" alt="">

<!-- 响应式图片:不同屏幕加载不同尺寸 -->
<!-- srcset 定义多张图片,sizes 定义显示条件,src 是默认图片(兼容旧浏览器) -->
<img
  srcset="small.jpg 480w, medium.jpg 768w, large.jpg 1200w"
  sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1200px"
  src="medium.jpg"
  alt=""
>

<!-- WebP 格式:体积比 JPEG 小 25-35%(picture 元素提供多种格式选择) -->
<picture>
  <source srcset="photo.webp" type="image/webp">              <!-- 优先使用 WebP 格式 -->
  <img src="photo.jpg" alt="">                                 <!-- 不支持 WebP 时降级为 JPEG -->
</picture>

验证图片优化是否生效:

1. 打开浏览器 Network 面板 → Img 过滤
2. 滚动页面,查看图片是否按需加载(懒加载生效)
3. 在不同设备宽度下,查看加载的图片尺寸是否正确(响应式生效)
4. 检查图片格式,WebP 格式应该优先加载

预加载关键资源 ​

html
<!-- 预加载:当前页面马上要用的关键资源 -->
<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>
<link rel="preload" href="/critical.css" as="style">

<!-- 预连接:提前建立与第三方域名的连接 -->
<link rel="preconnect" href="https://cdn.example.com">
<link rel="dns-prefetch" href="https://api.example.com">

<!-- 预获取:下一页可能需要的资源 -->
<link rel="prefetch" href="/about.js">

代码分割 ​

什么是代码分割?为什么要手动分包?

默认情况下,Vite 会把所有代码打包成一个文件。但这样做有问题:

  • 文件太大,加载慢
  • 任何代码修改都会导致整个文件失效,用户要重新下载

手动分包的好处:

  • 第三方库单独打包:Vue、Element Plus 等库更新频率低,单独打包后浏览器可以长期缓存
  • 业务代码独立:业务代码频繁变动,单独打包不影响第三方库的缓存
  • 并行加载:多个小文件可以并行下载,比一个大文件更快
js
// vite.config.ts — 手动分包(manualChunks 配置第三方库单独打包)
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {                             // 手动配置代码分割策略
          'vue-vendor': ['vue', 'vue-router', 'pinia'],  // Vue 生态单独打包
          'echarts': ['echarts'],                           // ECharts 单独打包(体积大)
          'element-plus': ['element-plus']                  // Element Plus 单独打包
        }
      }
    }
  }
})

验证代码分割是否生效:

bash
# 1. 构建项目
yarn build

# 2. 查看 dist/assets/js 目录
ls dist/assets/js/

# 3. 应该看到多个独立的 JS 文件:
#    - vue-vendor.xxxxx.js
#    - echarts.xxxxx.js
#    - element-plus.xxxxx.js
#    - index.xxxxx.js(业务代码)

Gzip / Brotli 压缩 ​

什么是 Gzip/Brotli 压缩?为什么要用?

Gzip 和 Brotli 都是压缩算法,可以把 JS、CSS 等文件压缩成更小的体积,传输到浏览器后再解压。

Brotli 比 Gzip 好在哪?

  • Brotli 压缩率比 Gzip 高 15-20%,文件更小
  • 现代浏览器都支持 Brotli
  • 缺点:压缩速度稍慢,但只在构建时压缩一次,不影响运行时
bash
npm install -D vite-plugin-compression
js
// vite.config.ts
import compression from 'vite-plugin-compression'

export default defineConfig({
  plugins: [
    compression({ algorithm: 'gzip' }),       // Gzip 压缩
    compression({ algorithm: 'brotliCompress', ext: '.br' })  // Brotli 压缩
  ]
})

CDN 加速 ​

什么是 CDN?为什么要用 CDN 引入第三方库?

CDN(内容分发网络)在全球有很多节点,用户会从离自己最近的节点下载资源,速度更快。

用 CDN 引入 Vue、Axios 等库的好处:

  • 加载更快:CDN 节点离用户近,网络延迟小
  • 多项目共享缓存:用户访问过其他用 Vue 的网站后,Vue 的 JS 文件已经缓存,你的网站不用再下载
  • 减小打包体积:这些库不打包到你的项目中,构建产物更小
js
// vite.config.ts — 外部化大依赖
export default defineConfig({
  build: {
    rollupOptions: {
      external: ['vue', 'vue-router', 'axios', 'echarts'],
      output: {
        globals: {
          vue: 'Vue',
          'vue-router': 'VueRouter',
          axios: 'axios',
          echarts: 'echarts'
        }
      }
    }
  }
})
html
<!-- index.html — CDN 引入(顺序需与 external 配置一致,确保全局变量在使用前定义) -->
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.prod.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-router@4/dist/vue-router.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>

渲染优化 ​

虚拟列表(大数据量) ​

bash
npm install @tanstack/vue-virtual
vue
<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'

const parentRef = ref<HTMLElement>()
const items = Array.from({ length: 10000 }, (_, i) => `第 ${i} 条`)

const virtualizer = useVirtualizer({
  count: items.length,
  getScrollElement: () => parentRef.value!,
  estimateSize: () => 35,
  overscan: 5,
})
</script>

<template>
  <div ref="parentRef" style="height: 400px; overflow: auto;">
    <div :style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }">
      <div
        v-for="row in virtualizer.getVirtualItems()"
        :key="row.key"
        :style="{ position: 'absolute', top: `${row.start}px`, height: `${row.size}px`, width: '100%' }"
      >
        {{ items[row.index] }}
      </div>
    </div>
  </div>
</template>

验证虚拟列表是否生效:

1. 打开浏览器 Elements 面板
2. 查看列表容器中的 DOM 元素数量
3. 滚动列表,观察 DOM 元素数量是否保持稳定(不会随数据量增长)
4. 滚动应该流畅,无明显卡顿

防抖与节流 ​

js
// 防抖:连续触发只执行最后一次(搜索输入)
import { useDebounceFn } from '@vueuse/core'
const debouncedSearch = useDebounceFn((keyword) => {
  fetchSearchResults(keyword)
}, 300)

// 节流:固定间隔执行一次(滚动监听)
import { useThrottleFn } from '@vueuse/core'
const throttledScroll = useThrottleFn(() => {
  checkVisibility()
}, 100)

v-if vs v-show ​

vue
<!-- v-if:条件为 false 时完全不渲染(适合切换频率低的场景) -->
<AdminPanel v-if="isAdmin" />

<!-- v-show:始终渲染,用 CSS display 控制(适合频繁切换) -->
<Tab v-show="activeTab === 'info'">信息</Tab>

v-memo(Vue 3.2+) ​

vue
<!-- 只有 item.id 或 item.selected 变化时才重新渲染 -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
  <p>{{ item.name }}</p>
  <p>Selected: {{ item.selected }}</p>
</div>

keep-alive 缓存 ​

什么是 keep-alive?什么时候用?

默认情况下,Vue 组件在切换时会销毁再重建,这会丢失组件的状态(比如输入框的内容、滚动位置)。

keep-alive 可以缓存组件实例,切换时不会销毁,下次切换回来时直接复用。

典型场景:

  • Tab 页切换:切换 Tab 时不希望丢失当前 Tab 的状态
  • 列表→详情→返回:从列表进入详情页,返回时希望列表保持原来的滚动位置
  • 表单填写:用户填写了表单但还没提交,切换页面后返回时希望保留已填内容
vue
<template>
  <!-- 缓存组件实例,避免重复创建/销毁 -->
  <router-view v-slot="{ Component }">
    <keep-alive :include="cachedViews">
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

<script setup>
const cachedViews = ['Home', 'UserList']  // 只缓存指定页面
</script>

运行时优化 ​

computed 缓存 ​

js
// ✅ computed 有缓存,依赖不变则不重新计算
const fullName = computed(() => {
  console.log('computed 执行')  // 只在 firstName 或 lastName 变化时执行
  return firstName.value + ' ' + lastName.value
})

// ❌ 每次渲染都会执行
function getFullName() {
  return firstName.value + ' ' + lastName.value
}

shallowRef / shallowReactive ​

js
import { shallowRef, triggerRef } from 'vue'

// 只追踪引用变化,不深度追踪对象内部属性
// 适合大型只读数据(图表数据、表格数据)
const bigData = shallowRef({ /* 大量数据 */ })

// 修改时需要重新赋值(替换引用)
bigData.value = { ...newData }

// 或手动触发更新
bigData.value.name = 'new'
triggerRef(bigData)

避免不必要的响应式 ​

js
import { markRaw } from 'vue'

// markRaw:标记为非响应式(不会被 Vue 追踪)
// 适合不需要响应式的第三方实例
const chart = markRaw(new ECharts(dom))
const editor = markRaw(new MonacoEditor(dom))

网络优化 ​

HTTP 缓存 ​

nginx
# Nginx 配置
# 强缓存:文件名带 hash 的资源(1 年)
location /assets/ {
  expires 1y;
  add_header Cache-Control "public, immutable";
}

# 协商缓存:index.html(每次都验证)
location = /index.html {
  add_header Cache-Control "no-cache";
}

接口请求优化 ​

js
// 请求取消(切换页面时取消未完成的请求)
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
// 页面卸载时取消
onUnmounted(() => controller.abort())

// 接口缓存(避免重复请求)
const cache = new Map()
async function fetchWithCache(url) {
  if (cache.has(url)) return cache.get(url)
  const data = await fetch(url).then(r => r.json())
  cache.set(url, data)
  return data
}

验证优化效果 ​

使用 Lighthouse 审计 ​

1. 打开 Chrome DevTools → Lighthouse 面板
2. 选择设备:Mobile 或 Desktop
3. 选择类别:Performance
4. 点击 "Analyze page load"
5. 查看得分和优化建议

使用 Performance 面板分析 ​

1. 打开 Performance 面板
2. 点击 Record 按钮
3. 操作页面(滚动、点击、输入)
4. 点击 Stop 按钮
5. 分析火焰图,查找长任务(Long Task)

验证 Web Vitals 指标 ​

js
// 在代码中添加测量
import { onLCP, onINP, onCLS } from 'web-vitals'

onLCP(console.log)   // 目标:< 2.5s
onINP(console.log)   // 目标:< 200ms
onCLS(console.log)   // 目标:< 0.1

常见问题 ​

首屏加载慢 ​

可能原因及解决方案:
1. JS 文件过大 → 路由懒加载 + 代码分割
2. 图片未优化 → 使用 WebP 格式 + 懒加载
3. 第三方库过大 → CDN 引入 + 按需加载
4. 未启用压缩 → 配置 Gzip/Brotli 压缩

页面卡顿 ​

可能原因及解决方案:
1. 长任务阻塞主线程 → 拆分任务(requestIdleCallback)
2. 频繁 reflow/repaint → 批量读写 DOM
3. 大量数据渲染 → 虚拟列表
4. 频繁触发事件 → 防抖/节流

内存泄漏 ​

可能原因及解决方案:
1. 事件监听器未移除 → 在组件销毁时移除
2. 定时器未清除 → 在组件销毁时清除
3. 闭包引用大对象 → 不需要时置空引用
4. 未断开观察者 → 在组件销毁时 disconnect

构建产物过大 ​

可能原因及解决方案:
1. 未启用代码分割 → 配置 manualChunks
2. 未启用压缩 → 配置 Gzip/Brotli
3. 未启用 Tree Shaking → 确保使用 ES Module(`import/export`),避免 `require`;第三方库如 lodash 应替换为 `lodash-es`;UI 库使用按需引入(如 `unplugin-vue-components` + `ElementPlusResolver`)
4. 未排除外部依赖 → 配置 external + CDN

参考 ​

个人学习笔记,部分内容借助 AI 辅助整理,仅供查阅参考,请以官方文档为准