Vue 项目性能优化——实践指南(历史方案与现代补充)
本文保留原文按 Vue 代码、Webpack、基础 Web 技术三部分整理的主线。原文主要对应 Vue 2、Vue CLI 2/3 和 Webpack 3/4;其中
CommonsChunkPlugin、url-loader、babel-plugin-component、旧 source map 名称等已经过时。当前优化应以真实测量、Vue 3/Vite 或 Webpack 5 的构建结果以及 Web Vitals 为依据。
本文内容分为:
- Vue 代码层面的优化;
- Webpack 历史配置与现代替代方案;
- 缓存、压缩、CDN 和性能分析等基础 Web 技术。
一、代码层面的优化
1.1、区分使用 v-if 和 v-show
v-if 是真正的条件渲染。切换时,条件块中的 DOM、事件监听器和子组件会被创建或卸载;初始条件为假时具有惰性。
v-show 不会反复创建和卸载元素,而是通过 CSS 的 display 控制显隐。
因此:
- 条件很少改变,或内容不需要初始渲染时,适合
v-if。 - 需要频繁切换,且节点初始化成本可以接受时,适合
v-show。
这不是绝对的性能规则,实际还要看子树大小、切换频率和是否需要保留组件状态。
1.2、区分 computed 和 watch
computed 用于从响应式状态派生值,并缓存计算结果:
const activeUsers = computed(() => {
return users.value.filter(user => user.isActive)
})
watch 用于在指定来源变化后执行副作用,例如请求接口、写入 storage 或同步第三方库:
watch(searchText, async (value, oldValue, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`, {
signal: controller.signal
})
results.value = await response.json()
})
不要在 computed getter 中发请求或修改其他状态。watch 默认惰性执行,深层监听、immediate 和 flush 应按实际需要配置;Vue 3.5+ 还提供了 watcher 清理 API。
1.3、列表使用稳定的 key,不要把 v-if 和 v-for 混在一起
列表项应使用稳定、唯一的业务 key:
<ul>
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
</ul>
列表过滤应提前放在 computed 或普通函数中:
const activeUsers = computed(() => {
return users.value.filter(user => user.isActive)
})
原文说“v-for 比 v-if 优先级高”,只适用于 Vue 2。Vue 3 已改为 v-if 优先,而且在同一元素上混用仍然不利于阅读和性能。Vue 3 中也可以使用 <template v-for> 把条件放到合适的位置:
<template v-for="user in users" :key="user.id">
<li v-if="user.isActive">{{ user.name }}</li>
</template>
1.4、长列表和不可变数据
原文使用 Object.freeze() 避免 Vue 2 对只读数据进行深度观察:
// Vue 2 历史方案
export default {
data: () => ({
users: []
}),
async created() {
const { data } = await axios.get('/api/users')
this.users = Object.freeze(data)
}
}
这个方案有两个边界:Object.freeze() 只冻结当前对象层级,且冻结后不能直接修改;示例中的 axios 响应通常还需要取 response.data。它是 Vue 2 时代的优化技巧,不要在没有测量时盲目使用。
Vue 3 对大型不可变数据更推荐使用 shallowRef(),通过替换根引用触发更新:
import { shallowRef } from 'vue'
const users = shallowRef([])
async function loadUsers() {
const response = await fetch('/api/users')
users.value = await response.json()
}
如果页面真正的问题是 DOM 节点数量,应使用列表虚拟化,只渲染视口附近的项;也可以根据场景评估 v-memo、分页和增量渲染。
1.5、正确清理事件和副作用
Vue 会清理模板指令和组件内部的事件,但通过 window、document、WebSocket、定时器或第三方库手动注册的副作用,需要自行清理:
// Vue 2
export default {
mounted() {
window.addEventListener('click', this.handleClick)
},
beforeDestroy() {
window.removeEventListener('click', this.handleClick)
}
}
Vue 3 使用 onMounted / onUnmounted:
<script setup>
import { onMounted, onUnmounted } from 'vue'
function handleClick(event) {
console.log(event)
}
onMounted(() => window.addEventListener('click', handleClick))
onUnmounted(() => window.removeEventListener('click', handleClick))
</script>
监听器必须使用同一个 target、事件类型和函数引用才能移除。涉及 window 或 document 的代码还要避免在 SSR 期间直接执行。
1.6、图片懒加载
对于首屏之外的大量图片,可以优先使用浏览器原生能力:
<img
src="/images/article-cover.webp"
loading="lazy"
width="800"
height="600"
alt="文章封面"
>
width 和 height 有助于浏览器提前预留空间,减少布局偏移;响应式图片可以搭配 srcset 和 sizes。首屏主图通常不应盲目设置 lazy,应该根据 LCP 目标和实际布局决定。
原文使用的 vue-lazyload 是 Vue 2 生态的历史插件,且 npm install vue-lazyload --save-dev 不适合运行时依赖。现代项目优先使用 loading="lazy";需要自定义占位、预加载或复杂可见性控制时,可以使用 IntersectionObserver 或维护良好的 Vue 3 组件。
1.7、路由懒加载
单页面应用如果把所有路由组件都同步打入首屏,首屏 JavaScript 会变大。动态 import() 可以让构建工具拆出代码块,在访问路由时加载:
Vue Router 3 历史写法:
const Foo = () => import('./Foo.vue')
const router = new VueRouter({
routes: [
{ path: '/foo', component: Foo }
]
})
Vue Router 4 当前写法:
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/foo',
component: () => import('./views/FooView.vue')
}
]
})
路由懒加载是代码分割的一种形式。应根据路由访问频率、网络条件和 chunk 数量设置拆分边界,不能只追求 chunk 越多越好。路由组件通常直接使用动态 import(),不需要额外包一层 defineAsyncComponent()。
1.8、第三方插件的按需引入
原文使用 babel-plugin-component 和 Element UI:
// Vue 2 / Element UI 历史方式
import Vue from 'vue'
import { Button, Select } from 'element-ui'
Vue.use(Button)
Vue.use(Select)
这套方案依赖 Babel 6/旧 Element UI 的组件安装机制。当前 Vue 3 项目应优先选择提供 ESM 的依赖,让构建工具进行 tree-shaking,并按组件库官方文档使用 Element Plus 等库的自动导入或局部导入方案。不要为了“按需”复制一份已经过时的 Babel 配置;还要确认样式是否被单独引入。
通用原则:
- 只导入实际使用的 API。
- 优先 ESM 和 tree-shaking 友好的包。
- 检查依赖的实际构建体积,而不是只看 npm 包体积。
- 对很大的库寻找轻量替代品,但要先确认功能和维护状态。
1.9、优化无限列表
长列表的瓶颈经常来自 DOM 节点数量,而不是单纯的 Vue 计算。窗口化(virtualization)只渲染视口附近的内容,可以减少节点创建和更新成本。
可参考:
使用虚拟列表时还要处理动态高度、键盘导航、滚动位置、可访问性和搜索过滤;如果列表并不大,虚拟化本身的复杂度可能得不偿失。
1.10、SSR、SSG 和预渲染
服务端渲染(SSR)是在服务器生成首屏 HTML,再在客户端 hydration;静态站点生成(SSG)则是在构建时生成 HTML 文件。它们可以改善首屏内容到达时间,并让内容更容易被搜索引擎获取,但也引入服务端负载、hydration 一致性和数据预取等问题。
SSR 的优点:
- 首屏 HTML 可以直接包含内容,通常有利于 LCP 和 SEO。
- 用户不必等待全部客户端 JavaScript 下载后才看到首屏文本。
SSR 的限制:
- 服务端不能访问
window、document等浏览器 API。 - Vue 2 中
beforeMount、mounted不在服务端执行;Vue 3 的onMounted等 DOM 生命周期同样不在 SSR 执行。 - Vue 3 可以使用
onServerPrefetch(),也可以交给 Nuxt 等框架处理服务端数据获取和 hydration。 - 服务端渲染会增加 CPU、内存和缓存设计成本。
如果主要是博客、营销页等内容型页面,SSG 往往比每次请求都 SSR 简单。旧的 prerender-spa-plugin 可以作为 Vue CLI 时代的历史方案保留,但当前应结合 Vite、Nuxt 或实际部署框架选择 SSG/预渲染方式。不要笼统地说搜索引擎“完全不会等待 Ajax”;正确结论取决于搜索引擎、内容出现时机和站点的渲染策略。
二、Webpack 层面的优化
2.1、Webpack 处理图片资源
原文的 url-loader 配置属于 Webpack 3/4 时代。Webpack 5 应优先使用 Asset Modules:
// webpack 5
module.exports = {
module: {
rules: [
{
test: /\.(png|jpe?g|gif|svg|webp)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024
}
},
generator: {
filename: 'images/[name].[contenthash][ext]'
}
}
]
}
}
小文件可以内联为 data URL,大文件输出为带 hash 的资源。阈值应结合实际网络和缓存测量,不要认为所有图片都适合转 base64。图片压缩可以在构建阶段使用 image-minimizer-webpack-plugin 或在资源流水线中处理;不要重复压缩已经是 WebP/AVIF 的资源。
2.2、减少 Babel 辅助代码
原文提到的 babel-plugin-transform-runtime 和 babel-runtime 是 Babel 6 时代名称。现代 Babel 7 使用:
{
"plugins": [
["@babel/plugin-transform-runtime", {
"corejs": false
}]
]
}
同时根据构建目标安装匹配版本的 @babel/runtime,它是运行时代码依赖,通常应放在 dependencies 而不是只放在 devDependencies。现代 ESM、目标浏览器配置和构建工具也会影响最终是否需要这些辅助代码,不能只靠一个插件保证体积变小。
2.3、提取公共代码和代码分割
原文的 CommonsChunkPlugin 已在 Webpack 4 中移除。Webpack 5 使用 optimization.splitChunks 和 runtimeChunk:
module.exports = {
optimization: {
splitChunks: {
chunks: 'all'
},
runtimeChunk: 'single'
}
}
Webpack 会根据模块图和配置拆分公共依赖。拆分时应关注:
- 首屏真正需要的代码是否减少。
- chunk 是否过多,导致请求和解析成本上升。
- 依赖更新后 hash 是否稳定,缓存是否能复用。
- 动态 import 的边界是否符合用户访问路径。
Webpack 3 时代的 vendor.js、manifest.js 配置可以作为历史资料保留,但不能直接复制到 Webpack 5 或 Vite。
2.4、模板预编译
使用 Vue 单文件组件和构建工具时,模板通常在构建阶段编译为 render 函数,生产环境不需要携带完整模板编译器,可以减小体积并避免运行时编译成本。
vue-template-loader、Browserify/v2 Vueify 等属于旧工具链。Vue 3 使用 @vue/compiler-sfc,Webpack 项目通常配合 Vue Loader 16/17,Vite 项目则由 @vitejs/plugin-vue 处理。模板预编译不是把模板简单交给几条正则表达式替换,而是由编译器生成 AST 和运行时代码。
2.5、提取组件 CSS
开发环境中通过 style 注入方便热更新;生产环境可以提取 CSS,以利于缓存、压缩和并行加载。Webpack 5 常见的生产配置是 mini-css-extract-plugin,而不是依赖旧版 Vue CLI 模板的固定文件名。
CSS 提取并不自动解决所有 FOUC 问题。SSR、关键 CSS、字体加载和样式分割仍应结合页面首屏结构验证。避免把不需要的全局样式和第三方主题全部打入首屏。
2.6、合理配置 Source Map
Source Map 让压缩后的线上代码可以映射回源代码,便于定位错误,但公开 source map 也可能暴露源码。原文中的 cheap-module-eval-source-map 属于旧 Webpack 命名,不能作为 Webpack 5 通用推荐。
常见选择:
- 开发环境:
eval-source-map或eval-cheap-module-source-map,便于调试。 - 生产环境:根据是否公开源码选择
source-map、hidden-source-map或不生成 source map。 - 线上错误监控:将 source map 上传到监控平台,但不一定公开给浏览器。
cheap、module、eval 和 source-map 分别影响列信息、源码映射方式、生成速度和 map 文件形式,应以 Webpack devtool 文档 为准,而不是只记住一个固定值。

2.7、构建结果分析
Bundle Analyzer 仍然有用,但原文的 webpack.prod.conf.js、config.build.bundleAnalyzerReport 和 npm run build --report 是 Vue CLI 2 的项目约定。现代 Webpack 可以直接配置:
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static'
})
]
}
Vite、Nuxt 等工具应使用各自的分析插件或构建报告。分析时重点查看重复依赖、首屏 chunk、动态 chunk 和 source map,不要只看压缩前的单个文件大小。

2.8、构建耗时优化
如果构建时间过长,可以先使用构建工具的 profile、cache 和分析功能定位瓶颈,再考虑:
- 升级到受支持的 Webpack/Vite 和 loader。
- 开启持久化缓存,减少重复编译。
- 减少不必要的 polyfill、loader 和大型依赖。
- 将类型检查、Lint、测试等任务合理并行或移出热更新主链路。
- 不要因为“优化构建”而关闭必要的 source map、校验和错误提示。
三、基础的 Web 技术优化
3.1、压缩传输内容
Express 可以使用 compression 中间件协商压缩:
import express from 'express'
import compression from 'compression'
const app = express()
app.use(compression())
生产环境也可以由 Nginx、CDN 或网关处理 Brotli/gzip。压缩率不是固定的“通常 70%”:文本类型受内容影响较大,图片、视频、压缩包等通常不应重复压缩。响应还需要正确设置 Content-Encoding,并在缓存层考虑 Vary: Accept-Encoding。

3.2、浏览器缓存
HTTP 缓存可以减少重复下载,但应根据资源是否带内容 hash 设计策略:
- 带内容 hash 的 JS、CSS、图片等静态资源可以使用长期缓存,例如:
Cache-Control: public, max-age=31536000, immutable
- HTML 入口通常需要快速发现新版本,可以使用
Cache-Control: no-cache配合 ETag/条件请求,而不是无条件长期缓存。 - 个性化响应不应进入共享缓存,应根据情况使用
private或no-store。 no-cache的含义是使用前需要重新验证,不等同于完全不存储;no-store才是禁止存储。
缓存策略必须配合文件名 hash、CDN 刷新和发布流程,否则用户可能拿到旧入口或旧 chunk。
3.3、CDN 的使用
CDN 可以把静态资源缓存到更靠近用户的节点,降低网络延迟和源站带宽压力。现代 HTTP/2 和 HTTP/3 支持多路复用,因此不能简单依靠“使用不同域名增加并发连接数”来优化;过多域名还会增加 DNS、TLS 和连接建立成本。
使用 CDN 时应考虑:
- 资源缓存时间和发布失效策略。
Cache-Control、ETag和内容 hash。- gzip/Brotli 协商、跨域和字体响应头。
- 源站回源、可用区、隐私和访问控制。
3.4、使用 Chrome Performance 查找性能瓶颈
Chrome DevTools 的 Performance 面板适合实验室分析:
- 打开开发者工具的 Performance 面板。
- 点击 Record 开始录制。
- 刷新页面或执行需要分析的交互。
- 点击 Stop,查看主线程任务、网络、布局、绘制和长任务。

同时建议结合:
- PageSpeed Insights 和 Lighthouse:实验室指标和诊断。
- Chrome User Experience Report(CrUX):真实用户数据。
- RUM:项目自己的真实用户监控。
- Vue Devtools 或
app.config.performance:分析 Vue 组件相关标记。
当前 Web Vitals 目标
页面性能不应只看打包文件大小,还应关注真实用户体验。当前常用的核心指标包括:
- LCP:最大内容绘制,良好目标通常不超过 2.5 秒。
- INP:交互到下一次绘制,良好目标通常不超过 200 毫秒。
- CLS:累计布局偏移,良好目标通常不超过 0.1。
这些目标通常按移动端和桌面端的第 75 百分位评估。FID 已由 INP 取代。应先测量,再根据瓶颈选择减少 JavaScript、优化 LCP 图片、减少长任务、预留图片尺寸或虚拟化列表等措施。
性能优化检查清单
- 先用 Performance、Lighthouse、RUM 或构建分析确认瓶颈。
- 通过 computed 过滤列表,使用稳定 key,避免无意义的深度 watch。
- 对非首屏路由和大功能使用动态 import。
- 使用原生图片懒加载、响应式图片和合适的图片格式。
- 清理窗口事件、定时器、请求和第三方订阅。
- Webpack 5 使用 Asset Modules、SplitChunks 和合适的 source map;Vite/Nuxt 使用对应构建工具能力。
- 为带 hash 的静态资源设置长期缓存,为 HTML 设置重新验证策略。
- 使用 Brotli/gzip、CDN 和 HTTP/2/3,但不要重复压缩或盲目拆域名。
- 用 LCP、INP、CLS 和真实用户数据验证优化是否有效。
官方参考
- Vue 3:性能最佳实践
- Vue 3:异步组件
- Vue 3:SSR
- Vue Router 4:懒加载路由
- Webpack 5:Asset Modules
- Webpack:SplitChunksPlugin
- Webpack:devtool
- Babel:transform-runtime
- MDN:图片元素
- MDN:Lazy loading
- MDN:HTTP 缓存
- MDN:HTTP 压缩
- Web Vitals
原文出处
本文整理自:
作者:随风而逝_风逝
来源:稀土掘金。著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。