
1. 先别急着改代码从“慢在哪”开始定位做前端优化最忌讳的一件事就是上来就开一堆配置什么懒加载、gzip、CDN全上结果改完一脸懵根本不知道哪一项起了作用。我第一次接手这类问题时也犯过这个毛病——用户反馈“页面打开要转好几圈”我第一反应就是把路由全部改成懒加载把第三方库全部外链到CDN一顿操作猛如虎结果首屏时间确实降了一些但并不知道瓶颈到底在哪后来回滚了半天才发现有个配置反而拖慢了速度。所以接到“VUE项目首次访问加载过慢”这个需求我的做法是先花半小时做体检让数据告诉我问题出在哪而不是凭感觉猜。1.1 用户说“慢”通常指的是哪段时间先说一个容易被忽略的概念“首次访问慢”和“页面卡顿”是两码事。首次访问慢说的是从用户在地址栏敲下域名回车到页面内容出现、可以正常操作这中间经历的时间。这个过程大致可以拆成几个阶段DNS解析和TCP/HTTP连接这是网络层的时间跟域名解析速度、服务器带宽、SSL握手都有关系前端代码优化的空间有限。HTML下载与解析浏览器拿到index.html后开始解析里面的script标签、link标签遇到外链资源就发请求去下载。静态资源下载JS、CSS、图片、字体这些文件要拉到本地这一步是首屏优化的主战场。文件越大、请求越多耗时就越长。JS解析与执行浏览器下载完JS之后还要经过解析、编译、执行三步。Vue是运行时框架加上业务代码、第三方库这三步的耗时往往超出很多人的预期。有个粗略的参考值一个100KB的JS文件解析执行可能需要100~300ms这还是在普通桌面浏览器上移动端只会更慢。Vue应用挂载createApp、use插件、注册组件、执行路由匹配最后把组件渲染成真实DOM挂载到#app节点上用户才能看到页面。用户感知的“慢”往往是上述多个阶段叠加的结果。而我们要做的就是输出一个数据报告明确每个阶段消耗了多少时间再针对耗时大户做专项优化。1.2 用构建产物分析器和Network面板做一次体检定位问题的第一步是看构建产物。绝大多数Vue项目都是通过打包工具webpack或Vite构建成静态文件上线的产物体积和首屏加载时间高度正相关。我一般用两个工具看产物webpack-bundle-analyzerVue CLI项目装上之后运行vue-cli-service build --report会在dist目录生成report.html浏览器打开就能看到每个依赖打包后占的体积哪个库是“重量级选手”一目了然。rollup-plugin-visualizerVite项目用这个效果类似在build脚本里加上它构建结束后自动打开体积分布页面。这两个工具看的是“静态体积”能告诉我们哪些库被打进了包里、占比多少。但体积大不等于用户下载慢还得结合Network面板看实际的传输情况。打开Chrome DevTools的Network面板默认勾选“Disable cache”刷新页面然后关注几个关键信息观察项关注点资源列表哪些文件的体积最大是不是有个几MB的主JS文件加载瀑布图有没有某个资源阻塞了后续资源的下载请求数量是否发起了大量小文件的请求导致往返延迟累计过高DOMContentLoaded时间标记DOM构建完成的时间点对应“页面框架出来了”的时刻Load时间页面所有资源加载完成的时间点我见过不少项目问题就出在“一个庞大的vendor.js”上。比如曾经接手一个后台管理系统主包的vendor.js达到了2.8MB打开Network一看这个JS文件在3G网络环境下光下载就要10多秒这就是典型的“第三方依赖全部塞进主包”导致的。1.3 用Performance面板确认白屏与可交互时间Network面板告诉我们“资源下载耗时”Performance面板则能还原“页面渲染链路”。具体操作打开DevTools切到Performance面板。点击Record按钮然后手动刷新页面。等页面完全加载后停止录音查看时间轴。重点关注几个指标FPFirst Paint浏览器第一次绘制像素的时间点此时页面可能还是一片白或者只有一个背景色。FCPFirst Contentful Paint第一个内容文字、图片、Canvas等被渲染出来的时间点。LCPLargest Contentful Paint页面上最大内容元素通常是首屏的主要区块渲染完成的时间。TTITime to Interactive页面可以正常响应用户操作的时间点。如果一个项目的FCP在2秒以上、TTI在5秒以上基本可以断定首屏体验已经明显偏慢需要做一次系统性的优化。完成这三步体检之后你会得到一份“病历单”上面写着哪个文件最大、哪个阶段最耗时、哪个请求阻塞了后续加载。接下来的每一招优化都可以对照这份病历单去打做完一步就回到Performance面板重新测一遍看哪个指标得到了改善。2. 路由级代码分割让首屏只加载首屏该有的代码体检中最高频出现的一个问题就是“全站页面代码被打到了一个JS文件里”。比如一个后台管理系统有十几个路由页面登录页还没展示却把商品管理、订单管理、数据分析这些页面的代码全部下载并解析了一遍。这种浪费在首次访问时尤为致命——明明用户只想看个登录页浏览器却提前把整套系统搬到了本地。解决办法就是“代码分割”Code Splitting让webpack把项目代码拆成多个chunk按需下载。代码分割在Vue项目里最常用的落地方式是“路由级懒加载”。2.1 webpack天生支持的分包机制动态importwebpack从4.0开始原生支持通过动态import()语法进行代码分割。当一个JS文件里出现import()而不是常规的import时webpack会意识到“这个模块不能被直接包含在当前包里”于是会自动把它单独拆成一个chunk文件并在运行时按需加载。这段文字描述有点抽象用一个简单的例子说明// 静态引入webpack会把Home组件打包进当前主包 import Home from ./views/Home.vue // 动态引入webpack会自动把Home组件拆成单独chunk用到时才下载 const Home () import(./views/Home.vue)注意第二段代码里的箭头函数它返回的是import()调用。webpack看到这个模式就会把Home.vue及其依赖拆到独立的chunk文件里比如Home.abc123.js。只有当用户实际访问这个路由时浏览器才去下载这个chunk。这背后还牵扯到一个Promise机制import()本身返回Promisewebpack在运行时加载chunk的文件内容然后解析并执行模块。Vue Router正好是基于Promise做路由组件加载的两者配合非常自然。2.2 改造Vue Router从静态引入到按需加载在Vue Router中把静态引入改成懒加载操作极其简单。以Vue Router 4为例// 改造前所有路由组件全部打进主包 import Home from ../views/Home.vue import User from ../views/User.vue import Admin from ../views/Admin.vue // 改造后每个路由对应一个chunk const Home () import(../views/Home.vue) const User () import(../views/User.vue) const Admin () import(../views/Admin.vue) const routes [ { path: /, name: home, component: Home }, { path: /user, name: user, component: User }, { path: /admin, name: admin, component: Admin } ]这里有一个细节值得展开默认情况下webpack会基于模块的路径自动生成chunk名称通常是一串哈希值加数字。如果你希望打包出来的文件名更清晰可以在import()里加webpack魔法注释const User () import(/* webpackChunkName: user */ ../views/User.vue) const Admin () import(/* webpackChunkName: admin */ ../views/Admin.vue)加了注释之后打包产物里会多出user.xxxxx.js和admin.xxxxx.js这样的文件。命名清晰的好处有两个一是浏览器Network里排查问题更方便二是固定chunk名可以在部署时配合缓存策略让不变的文件稳定命中缓存。还有一个容易被忽略的点对于“根路由”或者“应用启动后立即就要显示的页面”不建议做懒加载。举个例子后台系统通常一登录就跳到首页dashboard如果这个首页组件也懒加载用户会先看到白屏然后才去下载dashboard的chunk体验反而更差。正确的做法是首屏路由组件用静态引入非首屏路由才用懒加载。2.3 分包后的坑chunk命名、重复引用与公共代码下沉路由懒加载做完之后构建产物通常能小一大截但这一步也会带来几个新问题不注意反而会拖慢速度。第一个坑是chunk碎片化。如果路由组件数量特别多而且各自又依赖了一些小模块webpack可能拆出几十个“微型chunk”。每个chunk都是独立请求首屏加载时浏览器要并发发很多请求反而慢。遇到这种情况可以用webpack的splitChunks.cacheGroups把一些经常一起出现的业务模块合并。第二个坑是公共代码重复。举一个具体例子路由A的页面和路由B的页面都用了同一个自定义组件DatePickerPanel。如果不做额外配置webpack在webpack 4及以上的默认配置下会自动把这个公共组件提取到独立的chunk里两个路由页面都通过异步方式加载它没问题。但在某些自定义配置下可能出现两个chunk各自都打包了一份DatePickerPanel的代码导致总产物体积变大。检查的办法是更新依赖后重新跑一次showAnalyzer看有没有明显的重复模块。第三个坑是懒加载组件“加载失败后无提示”。当网络环境差或者CDN上某个chunk被误删时用户点击某个路由会直接白屏。Vue Router的懒加载组件在加载失败时会抛异常需要在路由守卫或者顶层ErrorBoundary里做容错调试。我习惯在router的errorCaptured钩子里捕获这类错误给出一个可重试的提示页面。路由级代码分割是首屏优化里“性价比”最高的一步——改造成本极低收益却立竿见影。但是它只能解决“业务代码”打包过大的问题。对于node_modules里的第三方依赖上一步并不会把它们拆出来这就得靠下一招了。3. 第三方依赖的拆分与外置别让node_modules拖垮首页路由懒加载跑通之后你大概率会看到一个现象主包确实变小了但vendor.js的体积几乎没怎么动。原因很简单——Vue、Vue Router、Pinia、axios、Element Plus、ECharts这些依赖只要项目里的任何一处引用了它们webpack就会把它们的代码放进公共vendor chunk里。如果这些库本身就很大vendor.js的体积自然就居高不下。针对第三方依赖优化手段主要有三种拆vendor子包、外置CDN、按需引入。三者并不互斥可以组合使用但要看项目实际情况选择优先级。3.1 splitChunks中的vendor策略把不常变的库单独打包webpack 4和webpack 5中optimization.splitChunks是控制分包的核心配置。默认配置下webpack会优先保证“被共享的模块”进入公共chunk但自动分包的粒度通常不是最理想的。比较常见的做法是手动增加cacheGroups把一些大体积且不常更新的库单独拆出来。以Vue CLI项目为例在vue.config.js中// vue.config.js const { defineConfig } require(vue/cli-service) module.exports defineConfig({ configureWebpack: (config) { if (process.env.NODE_ENV production) { config.optimization { ...config.optimization, splitChunks: { ...config.optimization.splitChunks, cacheGroups: { ...config.optimization.splitChunks.cacheGroups, echarts: { name: chunk-echarts, test: /[\\/]node_modules[\\/]echarts[\\/]/, priority: 30, chunks: all }, element: { name: chunk-element-plus, test: /[\\/]node_modules[\\/]element-plus[\\/]/, priority: 30, chunks: all } } } } } } })这样配置之后ECharts和Element Plus会被单独打成一个文件。好处是首屏只渲染非图表页面时不会去下载ECharts的代码而且这些大库的更新频率远低于业务代码独立出来之后可以配合缓存长期驻留在浏览器里二次访问时几乎零成本。这里要注意priority优先级的概念。webpack在决定一个模块归属哪个chunk时会取匹配到的组里优先级最高的那个。echarts和element-plus这些组的priority设高一点是希望它们能覆盖默认的vendors组避免被混进业务chunk里。3.2 外置到CDNvue、vue-router、echarts等重型依赖的处理如果说splitChunks是“让大家住进不同房间”那么外置CDN就是“干脆让一部分人不进家门”。通过webpack的externals配置我们可以告诉打包工具“这些库别打包了运行时从全局变量里拿。”然后把对应的script标签直接写在index.html里让浏览器从CDN上下载。// vue.config.js configureWebpack: { externals: { vue: Vue, vue-router: VueRouter, pinia: Pinia, axios: axios, echarts: echarts, element-plus: ElementPlus } }同时在public/index.html中通过CDN引入这些库script srchttps://cdn.jsdelivr.net/npm/vue3.4.21/dist/vue.global.prod.js/script script srchttps://cdn.jsdelivr.net/npm/vue-router4.3.0/dist/vue-router.global.prod.js/script script srchttps://cdn.jsdelivr.net/npm/pinia2.1.7/dist/pinia.iife.prod.js/script script srchttps://cdn.jsdelivr.net/npm/axios1.6.8/dist/axios.min.js/script script srchttps://cdn.jsdelivr.net/npm/echarts5.5.0/dist/echarts.min.js/script script srchttps://cdn.jsdelivr.net/npm/element-plus2.6.1/dist/index.full.min.js/script注意几个关键细节。第一个是最典型的“版本不一致”问题。externals里的包名和CDN库的全局变量名必须一一对应而且版本要跟项目开发时用的版本尽量一致。Vue 2和Vue 3对应的CDN入口文件完全不同Vue Router 3和4的全局变量都是VueRouter但API差异巨大版本不对会直接报错。建议在package.json里确认当前项目所引用的版本号再去CDN找对应的版本不要图省事直接拉latest。第二个问题是“CDN库的加载顺序”。Vue Router和Pinia都依赖VueElement Plus依赖Vue所以script标签必须按顺序加载——先Vue再Vue Router/Pinia最后Element Plus。如果顺序错了控制台会报Vue is not defined之类的错误。由于这些script还带defer或放在head里时可能阻塞解析我的习惯是放到body末尾。第三个问题是“CDN不可用时的兜底”。内网部署环境下外网CDN可能不可达白屏风险很高。稳妥的做法是把公共库的JS文件下载到本地静态服务器上然后引用本地路径或者用localforage这类工具做离线缓存。很多团队会选择“内网Nginx托管公共库”的方案既享受了该独立、该缓存的优点又不依赖外网。3.3 按需引入的边界Element UI与工具库的裁剪依赖外置解决的是“整体打包体积”的问题但有些库并没有提供完整的全局版本或者你只用到其中一小部分功能。这时候就该做“按需引入”。Element Plus官方文档推荐的是unplugin-auto-import和unplugin-vue-components这两个配套插件用起来之后组件会被自动按需打包没用到的不进产物。项目里如果用到的是Vue CLIwebpack构建可以这样配置// vue.config.js const AutoImport require(unplugin-auto-import/webpack) const Components require(unplugin-vue-components/webpack) const { ElementPlusResolver } require(unplugin-vue-components/resolvers) module.exports { configureWebpack: { plugins: [ AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] } }相比之下工具库的按需引入要简单很多。lodash用lodash-es替代配合webpack的tree-shaking可以只打包用到的函数dayjs支持ESM模块按需引入后体积非常小moment这种老牌库体积巨大且已经停止维护遇到新项目可以直接弃用。需要注意的一点是“按需引入的边界”——不要过度优化。有些团队为了把一个项目的依赖体积从5MB降到4MB把大量时间花在逐模块裁剪上但裁剪之后每次升级第三方库都要重新验证兼容性维护成本很高。我的原则是体积超过50KB的落地页依赖值得裁剪而体积小、使用频繁的基础库不值得。做完第三方依赖的拆分和外置之后主JS文件的体积一般会有明显下降。但别忘了首屏加载还包含传输环节——文件就算拆小了如果服务器响应慢、压缩没开、缓存策略不对用户依然会觉得慢。下一步就该上压缩和缓存了。4. 压缩、缓存与预加载把传输和解析的时间压到最低在一个典型的Vue首屏加载链路中JS下载只是第一步还有CSS、图片、字体等资源要拉取。优化完代码体积之后还需要关注“这些体积是怎么传输到浏览器”的。同样的静态文件开启gzip前后体积可能相差50%~70%而正确的缓存策略则能让二次访问直接变成“秒开”。4.1 gzip与brotli同样是压缩差距在10%到30%目前的主流做法是在构建阶段生成预压缩的.gz或.br文件再让Nginx在请求时直接返回预压缩文件这样既节省了CPU又能保证压缩率。前端侧Vue CLI项目可以安装compression-webpack-plugin// vue.config.js const CompressionWebpackPlugin require(compression-webpack-plugin) module.exports { configureWebpack: { plugins: [ new CompressionWebpackPlugin({ filename: [path][base].gz, algorithm: gzip, test: /\.(js|css|html|svg)$/, threshold: 10240, minRatio: 0.8 }) ] } }然后Nginx里开启静态压缩文件支持gzip_static on; gzip_vary on;gzip_static开关的含义是如果请求的文件旁边存在对应的.gz文件Nginx直接返回.gz文件而不是实时压缩。这种模式下浏览器发送Accept-Encoding: gzip头Nginx匹配到已存在压缩文件后就返回它如果没有该压缩文件则回退到实时gzip压缩。相比于gzipbrotli在同等级压缩率下体积更小尤其是对JS和CSS这种文本类的压缩效果更好平均比gzip再小10%~30%。构建侧生成.br文件的配置跟gzip类似只是把algorithm改成brotliCompress文件名后缀改成.br。Nginx侧则需要确认是否编译了ngx_brotli模块有的话开启brotli_static on; brotli on; brotli_comp_level 6; brotli_types text/plain text/css application/javascript application/json image/svgxml;如果服务器没有ngx_brotli模块可以退而求其次只打包gzip版本因为目前所有主流浏览器都支持gzip而brotli的兼容性在旧浏览器上会有问题。我的建议是“两份压缩文件都生成让Nginx根据浏览器的Accept-Encoding自动选择”。对于不支持brotli的老浏览器Nginx会回退到gzip完全不冲突。4.2 静态资源的缓存时间hash指纹和Cache-Control的配合缓存策略有个前提文件内容变了URL也要变浏览器才会重新下载。Vue CLI和Vite在构建时默认会给文件名加hash指纹比如app.8f3c2d.js。文件内容不变时hash不变URL不变浏览器可以放心地使用缓存内容一变hash就变浏览器会当成新文件重新请求。有了hash指纹Nginx的缓存配置可以非常激进location /static/ { expires 1y; add_header Cache-Control public, immutable; try_files $uri $uri/ /index.html; } location / { try_files $uri $uri/ /index.html; }immutable告诉浏览器这个文件在过期之前绝对不会变直接忽略条件请求不用再回服务器做ETag、Last-Modified协商。这能省下一次“验证缓存是否有效”的请求往返。有一个域名路径规划的小技巧值得一提把静态资源部署到独立的静态域名或独立的路径前缀比如/assets/下方便单独配置缓存头。index.html本身一定不能设长缓存因为它是整个应用的入口里面引用的JS、CSS文件名会随着版本变化而变化。如果index.html被缓存了用户拿到的永远是老版本即使服务端已经更新到新版本也无济于事。所以index.html的缓存时间要么设为0要么设为很短并配合no-cache。4.3 preload与prefetch什么该提前什么不该提前preload和prefetch是两个容易被误用的标签。简单理解preload告诉浏览器“这个资源当前页面马上就要用帮我提前加载”。prefetch告诉浏览器“这个资源之后可能会用到等浏览器空闲了再加载”。Vue CLI和Vite在构建时默认会自动给首屏入口JS、CSS加上preload给路由懒加载的chunk加上prefetch。这个默认行为在多数情况下是合理的但在特定场景下会出问题。举一个真实的例子一个后台管理系统有20个路由页面构建后每个页面一个chunkVite默认会把其中15个chunk都加prefetch。用户打开登录页浏览器在空闲时会把这些chunk全部下载下来白白消耗了流量和带宽。尤其对于移动端用户来说这种“把后续资源提前抢跑”的行为反而拉低了首屏体验。解决办法是关闭默认prefetch改成手动控制// vite.config.js export default { build: { rollupOptions: { output: { // 关闭自动prefetch } }, modulePreload: { polyfill: false } } }或者用更精细的方式判断用户浏览器的空闲状态再动态插入link relprefetch。比如用户登录后稳定停留在首页超过3秒我们再预下载他“下一步大概率会点”的那个页面chunk。这个策略名就叫“Predictive Fetching”本质上是用经验和数据去预测用户行为而不是一股脑把全站资源都预加载。preload则恰好相反——它是用来保障核心链路的。比如某个路由页面只有一张大图是LCP元素如果在首屏HTML中预加载这张图片可以显著改善视觉加载速度。但preload不能滥用一次性能同时加载的资源是有上限的preload的标签太多反而会相互挤占带宽。压缩、缓存和预加载做完传输层的问题基本解决。但回顾最初那张“病历单”还有一个隐性的耗时大户构建产物本身的质量。就算文件只有200KB如果里面充斥着sourceMap注释、冗余代码、未压缩的CSS解析和执行一样会很慢。这一步就要动构建配置了。5. 构建配置和工程细节从打包机器上挤出最后一秒很多人容易忽略一个事实构建配置不仅影响打包速度还会直接影响线上产物的质量和体积。一个典型的反面教材是项目里保留了sourceMap文件打包出来的JS体积直接翻倍导致首屏加载变慢另一个反面教材是browserslist配置得过于保守产物体积变大因为webpack要为老浏览器输出大量兼容代码。这两个问题都能通过调整构建配置解决。5.1 关掉sourceMap放开并行构建生产环境的sourceMap最好关掉。sourceMap能帮我们在线上环境定位报错但它会让产物体积增加20%~50%而且多数情况下sourceMap文件并不会被下载到浏览器除非浏览器设置了“Enable JavaScript source maps”并访问了.map文件。如果团队确实需要线上报错定位建议使用Sentry这类错误监控平台用sentry/webpack-plugin在构建时自动上传sourceMap到平台而不是把它们部署到静态服务器上公开访问。Vue CLI项目关闭sourceMap的方式// vue.config.js module.exports { productionSourceMap: false }Vite项目// vite.config.js export default { build: { sourcemap: false } }关掉sourceMap不仅减小了产物体积还直接提升了构建速度。压缩器Terser在压缩代码时不需要额外处理sourceMap映射打包时间能省下10%~20%。并行构建也是容易被忽略的一点。webpack默认是单线程构建如果服务器是多核CPU可以启用terser-webpack-plugin的parallel参数让压缩过程多进程并行执行// vue.config.js 需要安装 terser-webpack-plugin const TerserPlugin require(terser-webpack-plugin) module.exports { configureWebpack: { optimization: { minimizer: [ new TerserPlugin({ parallel: true, terserOptions: { compress: { drop_console: process.env.NODE_ENV production } } }) ] } } }这里有一个小坑drop_console会删掉所有console.log如果线上环境需要靠console排查问题需要三思。更稳妥的做法是保留console.warn和console.error或者只在特定环境变量下开启。5.2 调整browserslist别为老浏览器过度兼容browserslist配置直接决定webpack以及Babel、Autoprefixer需要为哪些浏览器生成兼容代码。配置越保守生成的代码就越“啰嗦”体积越大。很多项目的package.json里会有一行browserslist: [ 1%, last 2 versions, not dead ]如果团队考虑“我要兼容IE11”那打包出来的代码会包含大量polyfill和语法转换体积立刻膨胀。我的建议是根据项目的真实用户情况来定。如果是一个企业内部后台系统完全可以把最低浏览器版本设到Chrome 80、Edge 80、Safari 12这样可以放心使用很多现代语法特性产物体积也能进一步压缩。Vite项目和Vue CLI项目在browserslist上的处理逻辑略有区别但思路一致别为不存在的用户付费。5.3 使用现代模式构建与现代脚本兼容曾经有个很有意思的优化思路叫“Modern Mode”在Vue CLI 4时代构建时会把代码同时打包成现代版和旧版两套浏览器根据自身能力加载对应版本。现代版体积更小、执行更快旧版则用于老浏览器。这个模式在Vite里默认就是这样的思路build.target设为es2015或es2020构建出的产物默认是原生ES Module。Vite项目的target配置// vite.config.js export default { build: { target: es2015 } }如果项目用Vite构建把target从默认的modules通常是es2020调低到es2015可以让代码在更旧的浏览器上运行但代价是产物体积会变大反过来调高target代码更简洁更小但老浏览器会白屏。这个要根据实际用户浏览器分布来权衡。5.4 白屏体验兜底骨架屏与首屏loading构建配置优化之后首屏的真实加载时间可能还是会有几百毫秒到一个秒级。为了不让用户在这段时间里对着纯白页面干瞪眼可以做一个简单的“体验兜底”——在index.html里内联一段loading样式代码。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title后台管理系统/title style #app-loading { position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; background: #f0f2f5; color: #909399; font-size: 14px; } /style /head body div idapp/div div idapp-loading页面加载中…/div script typemodule src/src/main.ts/script /body /htmlVue应用挂载完成时在main.ts里移除app-loading节点const app createApp(App) app.mount(#app) // app挂载完成后移除loading占位 const loadingEl document.getElementById(app-loading) if (loadingEl) loadingEl.remove()这行字虽然看起来不起眼但在弱网环境下对用户心理体验的提升非常明显——给用户一个“系统还在工作”的信号远比一片白屏要好。更进阶一点的做法是做“骨架屏”把页面的大致轮廓先用纯CSS画出来等真实数据渲染时再替换掉。这种做法更适合首页是数据密集型页面的项目。6. 实测验证同一项目的优化前后对照前面讲的每个优化点如果都做完效果到底能有多少拿我最近优化过的一个真实项目举例一个基于Vue 3 Element Plus ECharts的运营后台系统用户反馈首次打开要等10多秒。做优化之前的体检数据如下指标优化前优化后变化幅度首屏JS总大小4.8MB1.2MB下降75%首屏JS gzip后1.6MB420KB下降73%请求数量8642下降51%白屏时间FCP4.2s1.8s下降57%可交互时间TTI7.6s2.9s下降62%打包耗时210s150s下降28%这份数据是在本地模拟“Slow 3G 4x CPU Slowdown”条件下测出来的目的是排除网速波动的影响得到一个可重复对比的基准值。具体做了哪些优化按照收益从大到小排列关闭sourceMap 路由全部懒加载首屏JS大小从4.8MB降到2.9MB这是最大的一刀。ECharts和Element Plus单独拆包重页面异步导入ECharts首屏JS进一步降到1.8MB。构建产物预压缩gzip/brotli传输体积从1.8MB降到420KBgzipbrotli下还能再缩50KB左右。Nginx开启Cache-Control immutableindex.html设为no-cache二次访问的白屏时间从1.8s降到0.6s几乎是秒开。拆掉axios、dayjs这类小库的CDN外置改为本地打包虽然体积变化不大但消除了外网CDN不可用的风险。调整browserslist关闭prefetch产物体积缩了约200KB同时减少了首屏后空闲时的无效下载。有一项优化收益不大但必须做关闭prefetch之后各路由页面的首访速度明显提升因为不再有“空闲时抢跑下载全站资源”的行为了。这项优化无法量化为某个具体指标但对真实用户体验的改善是显而易见的。优化做到这一步项目已经从“打开要转好几圈”变成了“基本秒开”。但在我的经验里性能优化不是一次性工作而是一个持续迭代的过程。做完这一轮优化更重要的收获是建立了“打包体检—定位瓶颈—专项优化—复测验证”这套流程。现在每次发版前我都会跑一次构建分析确保产物体积没有意外反弹。最后分享一个实际体会如果你是第一次做这类优化别贪多一次只做一个点每完成一步都回Performance面板看看数据有没有变化。很多优化手段之间有相互影响比如开preload和开prefetch的效果在某些项目里会彼此抵消一次改太多的话出了问题都不知道是哪一步引起的。从“路由懒加载”入手一步步来你会看到首屏时间一个数字一个数字地降下来。