先说个背景。上个月我接手了一个公司内部的运营后台,技术栈是React + Webpack 5,没上SSR,首屏加载从点击到看到完整页面要5秒左右,线上用户反馈已经炸了。我用Chrome DevTools的Performance面板和Lighthouse各跑了一轮,发现主要瓶颈不是网速,而是打包策略太粗糙:整个业务代码和第三方库全部打进了两个chunk里面,光main.js就接近2.8MB(未压缩),图片也全部走原始文件,没有任何压缩和懒加载处理。
这次优化做完之后,我在本地和测试环境都测过,首屏时间稳定在0.5秒左右,体积最大的一次构建产物从2.8MB降到了约700KB,压缩后传输体积只有不到200KB。整个过程我没用什么奇技淫巧,核心思路就是围绕Webpack配置做6件事:分析产物、压缩代码、拆包、第三方库瘦身、静态资源优化、持久化缓存。这篇文章把这6件事的记录和坑都写出来,希望对正在被首屏性能折磨的同学有点帮助。
1. 先别急着改配置,先搞清楚体积到底去哪了
很多人做性能优化的第一反应是去网上复制一堆splitChunks配置,然后发现改了以后首屏更慢了。我自己的经验是,优化之前一定要先做产物体积分析,不然就相当于闭着眼睛拆弹,拆完了才知道拆的是哪根线。
1.1 用webpack-bundle-analyzer把打包结果变成一张“账本”
我先在webpack.config.js里加了BundleAnalyzerPlugin,这一步是整套优化方案的起点。安装命令很简单:
bash复制npm install -D webpack-bundle-analyzer
然后在配置里引入:
js复制const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
openAnalyzer: false,
}),
],
};
我建议用static模式生成一个HTML报告,而不是启动本地服务自动开浏览器。原因很简单:static模式会把报告落地成文件,可以来回翻,而且不会像server模式那样改了配置就要重新起服务。
跑一次打包之后,浏览器打开生成的bundle-report.html,你会看到每个chunk对应的包大小分布图,整个页面就是一个可视化的“矩形面积图”,面积越大说明这个模块在产物里占的体积越大。这个工具可以帮助你快速定位三类问题:单体chunk过大、某个第三方库体积异常、重复依赖被打了多份。
1.2 从报告里读出真正有价值的信息
我第一次打开分析报告时,发现main.js的体积占比几乎是全部,里面引入的模块五花八门。逐个看下来,几个关键结论是:
- moment.js连带所有语言包占到了450KB左右,而项目里其实只用到了它的日期格式化和相对时间功能。
- lodash是整体引入的,当时代码里用的是
import _ from 'lodash',导致打包器把整个库都塞进去了。 - 项目里所有业务页面组件全部通过静态import引到路由配置中,webpack自然没办法拆包,于是一个main.js包含了所有页面代码。
- antd组件库虽然按文档配置了babel-plugin-import,但一些弹窗类组件仍然被全量打包。
这些信息直接决定了后面的优化顺序:先压缩和无损瘦身,再做代码分割,然后是第三方库替换、静态资源、缓存策略。如果一开始就纠结于压缩参数或者CDN域名,方向就偏了。
1.3 明确优化优先级,把精力花在回报最高的地方
体积分析报告的价值不只是看哪些库大,而是帮你算出每一项优化的“收益上限”。比如moment.js就算完全替换掉,最多省下450KB,而如果业务代码本身有1.5MB,那替换moment带来的提升就会被业务代码的体积稀释不少。
我当时的判断逻辑是:产出文件越大,网络传输时间和JS解析时间越长,对首屏的拖累越明显。而优化手段里,代码分割的收益往往比压缩更夸张,因为它是8:00前决定用户下载哪些资源,而不是仅仅把下载的资源变小。所以我的执行顺序是:压缩和tree-shaking这类“无损减重”先行,然后再做路由级代码分割和公共依赖拆分,最后再处理图片和缓存。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 压缩和tree-shaking:先把代码“体重”降下来
代码体积大是首屏慢的直接原因之一,而压缩是最容易上手、收益又稳的一步。Webpack 5在mode设置为production时会自动启用TerserPlugin,但默认配置不一定符合你的全部需求,所以生产环境的压缩配置我会手动覆盖一遍。
2.1 生产环境JS压缩:移除console,关掉注释
这一项对应的配置如下:
js复制const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
mode: 'production',
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
compress: {
drop_console: true,
pure_funcs: ['console.log'],
},
format: {
comments: false,
},
},
extractComments: false,
}),
],
},
};
这段配置里,parallel设置为true会启用多进程压缩,webpack会默认使用当前机器CPU核心数减一作为进程数,实测下来打包速度会快不少。drop_console会把所有console语句移除,这里要特别提醒一句:如果项目里有需要上线的告警日志或SDK调用,不要在全局把console全部drop掉,可以只drop console.log 和 console.info,保留 console.warn 和 console.error,或者用环境变量控制。
评论和license注释通过format.comments关闭后,也能省下一点体积。extractComments设为false是为了不让webpack把注释单独提取到一个.LICENSE.txt文件中,否则部署时可能会产生额外的文件请求。
2.2 CSS压缩和提取:别让浏览器在JS里等CSS
如果项目还在用style-loader,开发模式没问题,但生产环境一定要换成MiniCssExtractPlugin。原因很直接:style-loader会把CSS以style标签的形式通过JS动态插入,这意味着用户必须先下载并执行JS,CSS才会生效。要等JS解析完成后才看到页面样式,在弱网环境下体验会非常差。
我在项目中会把CSS单独提取成文件,然后配合css-minimizer-webpack-plugin来做压缩:
js复制const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
module.exports = {
module: {
rules: [
{
test: /\.(css|less)$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'less-loader',
],
},
],
},
optimization: {
minimizer: [
new CssMinimizerPlugin(),
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
}),
],
};
这个配置的效果是:CSS文件会被独立加载,浏览器可以并行下载CSS和JS,而不是等JS执行完再插入样式。需要注意,less-loader和css-loader的版本要匹配,webpack 5下推荐less-loader直接升到v11以上,否则会报模块系统兼容错误。
2.3 tree-shaking不是“默认开启”就完事了
tree-shaking能生效的前提是模块采用ESModule语法,因为ESModule是静态导入,webpack才能静态分析出哪些导出未被使用。如果项目里还混用了CommonJS的 require,那么这部分依赖是无法被摇树的。
这里有两个容易忽略的细节。第一,在package.json中要正确声明sideEffects字段,否则webpack不敢删除那些“看似没用”的代码,因为它无法判断导入这个模块时是否产生了副作用。我处理的方式是:
json复制{
"name": "my-project",
"sideEffects": [
"**/*.css",
"**/*.less"
]
}
第二,第三方库如果不是ESM版本,tree-shaking就跟没开一样。典型的例子是lodash,之前用CommonJS版本打包时,就算只引入一个方法,也会把整个库的逻辑带进去。后面我会专门讲第三方库的替换方案。
关于压缩,再补充一个容易混淆的点:很多人以为Webpack配置了Gzip压缩,其实Webpack的压缩只是把代码体积做到最小,真正的Gzip/Brotli压缩要由Nginx网关或者云厂商的CDN层来做,两件事不能混为一谈。
3. 路由级代码分割:首屏只加载首屏需要的代码
分析报告里最大的问题就是main.js包含所有页面代码。不管你怎么压缩,一个包含50个页面的bundle压出来也有几百KB。解决方案就是代码分割,让用户访问某个路由时只下载这个路由对应的代码块。
3.1 用动态import实现路由懒加载
React项目里最常见的做法是用React.lazy和Suspense配合动态import:
js复制import { lazy, Suspense } from 'react';
// 原来是 import Dashboard from './pages/Dashboard'
const Dashboard = lazy(() => import('./pages/Dashboard'));
const UserCenter = lazy(() => import('./pages/UserCenter'));
function App() {
return (
<Suspense fallback={<PageLoading />}>
<Switch>
<Route path="/dashboard" component={Dashboard} />
<Route path="/user" component={UserCenter} />
</Switch>
</Suspense>
);
}
webpack会把每个动态import的模块拆成独立的chunk文件,路由切换时才去加载对应代码。Vue项目等价写法是:
js复制const Dashboard = () => import('./pages/Dashboard.vue');
只要把路由映射改成这种函数返回的形式,Vue Router就能在路由切换时自动完成懒加载。
懒加载带来的直接效果是:首屏js体积从2.8MB降到了后续页面各自按需加载。但这里有一个体验上的坑——如果网络慢,用户切路由时会先看到fallback,也就是加载动画或白屏。所以我建议在关键页面不要做过于仓促的懒加载,比如用户从列表页跳详情页这种高频操作,可以配合prefetch或preload来做预取。
3.2 prefetch和preload:让懒加载不白屏
做了一个基础拆包后,我加了一行配置来优化懒加载的体验:
js复制module.exports = {
output: {
publicPath: '/',
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
},
],
},
experiments: {
// 开启后,webpack会为动态import自动生成preload/prefetch指令
// 但这种方式不可控,我习惯用注释指令手动控制
},
};
在动态import的时候,webpack支持Magic Comments来控制预加载行为:
js复制const Dashboard = lazy(() => import(/* webpackPrefetch: true */ './pages/Dashboard'));
const UserCenter = lazy(() => import(/* webpackPreload: true */ './pages/UserCenter'));
webpackPrefetch会在浏览器空闲时下载这个chunk,webpackPreload则会在当前页面加载的时候就并行下载。对于首屏必须展示的内容,不要用preload去抢带宽,prefetch更适合那些用户极大概率会访问但当前还没加载的页面。
踩过一个坑:一开始我给所有路由都加了webpackPrefetch,结果打开首页后浏览器疯狂下载十几个chunk,直接把带宽吃满了,体验反而更差。prefetch的使用要克制,只给那些用户路径前置、确实高频的页面加,比如从首页到详情页。
3.3 过度拆分的隐患:请求数和体积的平衡
代码分割不是拆得越细越好。在HTTP/1.1时代,浏览器对同一域名下的并发请求数是有限制的(一般是6个左右),如果拆出几十个chunk,文件请求阶段会成为新的瓶颈。
HTTP/2的多路复用可以缓解这个问题,但如果你的服务器或CDN还没有开启HTTP/2,建议用webpack的splitChunks里的maxAsyncRequests和maxInitialRequests来控制chunk数量。我比较推荐的初始值是:maxInitialRequests不超过4~6,maxAsyncRequests不超过6~8。这样既能保证首屏加载路径上的文件数量可控,又能让非首屏页面独立成块并按需加载。
4. SplitChunks提取公共依赖:Vendor和Framework分开打包
路由懒加载只能让首屏不加载全部业务代码,但剩下一个问题:如果每个页面都引入了同一个第三方库,拆分之后每个chunk里都有一份库代码,体积反而会变大。解决这类问题的核心是splitChunks里的cacheGroups。
4.1 一套可复用的公共依赖拆分配置
我最终采用的配置长这样:
js复制module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
minSize: 20000,
cacheGroups: {
framework: {
name: 'framework',
test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
priority: 30,
chunks: 'all',
},
vendors: {
name: 'vendors',
test: /[\\/]node_modules[\\/]/,
priority: 10,
chunks: 'all',
},
commons: {
name: 'commons',
minChunks: 2,
minSize: 0,
priority: 5,
chunks: 'all',
},
},
},
},
};
这段配置做了三件事。framework组优先把React三件套抽到一个独立的framework chunk里,这些库几乎不会变,文件名带着contenthash,部署后只要依赖不升级,文件名就不变,浏览器可以长期有效缓存。vendors组把剩余node_modules里的第三方包放到同一个vendors chunk。commons组则把业务代码里被两个及以上页面同时引用的公共模块提取到commons chunk中。
这里要重点看priority:数值越高的分组越优先被匹配。framework必须在vendors之前匹配成功,否则某个库可能被丢进vendors里,导致framework拆不出来或重复打包。实际项目中,如果没有给antd、axios这类重量级库单独开一组,它们会被抽进vendors,导致vendors文件过大。所以可以先开着一个chunk,跑一遍分析报告,再看是否需要为某个体积过大的库拉一个专属分组。
4.2 vendor拆分的度:不是拆得越碎越好
我在优化过程中做了一次错误的尝试:为了进一步缩减首屏体积,把axios、dayjs、lodash-es等每个库都单独拆成一个chunk。结果一个页面加载时要发起7到8个HTTP请求,用本地开发服务器感觉不明显,但放到测试环境的公网上后,首屏时间反而从1.2秒涨到了1.8秒。
后来我把分组调回了上述3~4个核心组的模式。原因在于,拆分组多对HTTP/1.1不友好,而且很多库之间本身就存在依赖关系,拆完之后浏览器必须先下载依赖库,才能执行业务库,串行等待增多。所以在chunk数量与体积之间,我建议优先保证请求数可控,体积上并不差这几十KB的并行加载。
4.3 拆包后还要配合稳定的moduleIds
在webpack 4时代,chunk和module的id默认根据模块解析顺序生成,所以每次文件变化可能导致chunk的hash变动,即使内容没变。Webpack 5已经把moduleIds默认改为deterministic,但为了稳妥,我仍然在生产配置里显式加上了:
js复制module.exports = {
optimization: {
moduleIds: 'deterministic',
chunkIds: 'deterministic',
},
};
这样做的意义是:业务代码改动不改变vendor文件的hash,浏览器不需要重新下载vendor文件,长期缓存策略才能真正生效。
5. 第三方库瘦身:moment.js和lodash换血实录
第三方库的优化占用了我整个执行周期的一半时间,因为替换库不是改一行import就完事,还涉及到API兼容、工具函数、日期格式、语言包等一系列问题。
5.1 moment.js换dayjs:体积从450KB降到几个KB
项目里用到的moment.js功能集中在日期格式化、相对时间(如“3分钟前”)和时区换算。我先把全局所有moment的import切换成dayjs:
bash复制npm uninstall moment
npm install dayjs
切换时注意API差异:dayjs的核心API和moment高度相似,但默认包不带语言包和插件。相对时间需要显式引入relativeTime插件和对应的locale:
js复制import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
// 用法和moment几乎一致
dayjs().fromNow(); // 输出“几分钟前”
替换后对业务代码的改动很少,大部分代码直接改import来源就行。如果需要保留moment的某些特殊格式或者用到了tz时区功能,可以单独引入moment-timezone,但量级已经比moment本体小很多。这一步直接让bundle体积少了超过400KB,是非常纯粹的“立竿见影”。
如果因为兼容性问题短期不能整体替换,也可以用moment-locales-webpack-plugin,把不需要的语言包从构建中剔除。但说实话,都做首屏优化了,我建议该换就换,dayjs本身就是moment官方推荐的替代方案之一,兼容成本并不高。
5.2 lodash按需引入:用lodash-es代替整体引入
之前代码里大量出现 import _ from 'lodash',这是很大的打包体积浪费。我先全局搜索,把所有用到的方法列出来,发现集中在debounce、throttle、cloneDeep、get、isEmpty这几个上。
最低成本的改动方式是安装lodash-es,然后把整体引入改成具名引入:
bash复制npm install lodash-es
js复制import { debounce, throttle, cloneDeep, get, isEmpty } from 'lodash-es';
lodash-es是ESM版本,webpack能够正确tree-shaking,打包后只会把用到的函数纳入产物。class类的复杂方法如cloneDeep大对象,要注意业务量级,如果需要高频期调用,后续还可以考虑手写浅拷贝来替代。
另外补充一点:如果使用的是lodash,不建议上lodash-webpack-plugin,那套方案维护成本高而且经常出现缺方法的怪问题,换成lodash-es才是正路。
5.3 antd按需加载:确保babel-plugin-import生效
antd按需引入的本质是通过babel-plugin-import,把 import { Button } from 'antd' 转换成:
js复制import Button from 'antd/lib/button';
import 'antd/lib/button/style';
需要注意,这个插件只对ESM的import生效,如果用CommonJS的require方式引入antd,按需效果会失效。babel.config.js里配置如下:
js复制module.exports = {
plugins: [
[
'import',
{
libraryName: 'antd',
libraryDirectory: 'es',
style: true,
},
],
],
};
如果项目用的antd v5,样式按需加载已经不怎么依赖babel-plugin-import了,因为它默认就是基于CSS-in-JS的按需抽取,直接按文档引入组件即可。v4及以下版本还是用插件更稳。
顺带说一句,我在优化过程中还顺手把代码里几处“深拷贝对象”从JSON.stringify加JSON.parse改成了浅拷贝或structuredClone,虽然不是首屏优化的主线,但这类操作在列表页高频触发时会拖慢运行时响应,做性能优化时顺手清理这类运行时开销,整体体验会有进一步的提升。
6. 图片、字体和静态资源:别让首屏载入“巨型文件”
JS和CSS处理完之后,我打开DevTools的Network面板看了一眼,发现首屏还有超过1.2MB的图片资源。这部分不优化,前面做的任何JS优化都会被图片拖回解放前。
6.1 小图转base64:阈值设置要科学
Webpack 5内置的asset module已经可以直接处理小体积资源的base64内联,不需要额外安装url-loader。关键在阈值的设置:
js复制module.exports = {
module: {
rules: [
{
test: /\.(png|jpe?g|gif|webp|svg)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024,
},
},
},
],
},
};
maxSize设置为8KB,只有小于8KB的图片才会被转成base64内联到JS或CSS里。base64会让文件体积增加大约33%,如果阈值设太高,比如128KB,很多图片会被转成base64塞进JS里,反而让HTML/JS首次解析成本大幅上升。
我之前错误地把阈值调到32KB,结果main.js又膨胀了,首屏CSS里也内联了大量不该内联的base64字符,后来才调回8KB。如果你项目里的图片大部分是界面图标且尺寸很小,可以适当放宽到10KB或12KB,但不要太过。
6.2 大图压缩和CDN:图片管线是另一条优化主线
对于超过阈值的大图,需要用image-webpack-loader做压缩。我的生产配置是在图片loader的use数组最后加了image-webpack-loader:
js复制module.exports = {
module: {
rules: [
{
test: /\.(png|jpe?g|webp)$/,
type: 'asset',
use: [
{
loader: 'image-webpack-loader',
options: {
mozjpeg: {
progressive: true,
quality: 70,
},
pngquant: {
quality: [0.65, 0.8],
speed: 4,
},
},
},
],
},
],
},
};
quality设置到70左右,肉眼基本无感,单张图片体积普遍能减少50%以上。这里有个小坑:image-webpack-loader在开发环境也会跑压缩,会拖慢本地构建速度,通常只在生产构建时才启用,可以用环境变量判断或者只在生产配置中加这个loader。
图片CDN这个环节也值得提一下。通常设置output.publicPath为CDN域名前缀,静态资源就会自动带上CDN地址。但要注意一个细节:publicPath会影响所有资源的加载路径,如果业务里还有动态拼接图片URL的地方,需要匹配前后端约定。
6.3 字体子集化:中文字体动辄几MB,必须处理
项目里有一个自定义的Woff2字体文件,压缩后仍有1.4MB,用于一组特殊文案。中文环境下其实这是常态,因为字符集太庞大了。
最初我用font-spider做子集化,只保留页面实际用到的字形。如果无法用子集化,另外一个方案是字体文件走懒加载,用CSS的font-display: swap防止阻塞渲染。针对“首屏要用到字体”的场景,预处理子集化是最有效的。
需要注意,CSS引用字体的方式和打包后路径可能不一致。webpack处理字体的loader同样可以使用asset module,设置一个比图片稍大的阈值,比如32KB,把小型字体也内联掉,减少一次网络请求。
7. 构建产物长效缓存:让“第二次打开”变得更快
首屏从5秒降到0.5秒,除了第一次访问,还有一个重要因素——老用户二次访问的速度。这就要靠构建产物的哈希命名和服务端的缓存策略来配合。
7.1 Webpack 5的文件系统缓存:让CI构建速度也不拖后腿
Webpack 5内置了持久化缓存,通过一行配置就能加速二次构建:
js复制module.exports = {
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename],
},
},
};
这样配置之后,第一次构建会把模块解析结果写入node_modules/.cache/webpack目录,之后的构建直接复用缓存,不需要重新解析全部文件。本地构建速度实测提升明显,尤其是当项目模块数量很多时,构建可以从几十秒缩短到十几秒。
需要注意,缓存目录通常要加到.gitignore里。如果某些依赖是通过git submodule或者动态生成的文件,缓存可能导致更新不生效,此时可以通过buildDependencies声明额外依赖文件,或者必要时清除node_modules/.cache目录。
7.2 contenthash和浏览器缓存:文件名不变,缓存就能一直用
生产环境的文件名使用contenthash而不是hash或chunkhash:
js复制module.exports = {
output: {
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
},
};
contenthash会基于文件内容生成,文件内容不变,文件名就不变。配合第4章的vendor拆分,业务代码更新时,业务chunk的contenthash变化,但framework和vendors的hash不变。用户在Nginx或CDN那层命中了强缓存,不需要重新下载vendor文件,二次打开速度自然快。
我在配置完成后,特意在Nginx上加了对应的缓存头,对带有contenthash的静态资源开启长期缓存,这类资源一旦部署完就不会在线上变更了:
nginx复制location /static/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}
immutable这个参数表示缓存期间永远不需要重新验证,对带hash的文件很安全,但对不带hash的HTML页面千万不能这么配。
7.3 做好验证:别让打包产物“名不副实”
哈希文件名策略踩过一回坑:当时chunk命名用name加contenthash,但某个业务chunk的hash没变,排查后发现是因为moduleIds没设置deterministic,模块顺序变化导致部分chunk的hash被连带影响。后来在配置里固定了moduleIds和chunkIds,解决了这个问题。
建议每次改完webpack配置后,都手动比对前后两次构建生成的vendor文件hash是否稳定。如果业务代码改动后vendor hash变了,说明缓存策略有问题,需要回头检查cacheGroups和moduleIds配置。
8. 最终效果复盘:每一步优化分别值多少钱
到这里,6件事全部执行完毕。我整理了一张表格,记录优化前后的关键数据,方便大家对照参考:
| 优化项 | 优化前 | 优化后 | 首屏贡献 |
|---|---|---|---|
| JS压缩 + tree-shaking | main.js 2.8MB | main.js 2.2MB | 中 |
| 第三方库替换(moment/lodash) | 650KB | 120KB | 高 |
| 路由级代码分割 | 单个2.8MB | 首屏按路由拆包 | 高 |
| SplitChunks拆分公共依赖 | 少量chunk | framework + vendors + commons | 高 |
| 图片压缩/内联/CDN | 1.2MB | 约350KB | 中 |
| 浏览器缓存和Gzip | 每次全量下载 | 二次访问命中缓存 | 高 |
在本地环境用Lighthouse模拟Slow 4G测试,优化前首屏FCP在4.8~5.2秒,优化后稳定在0.5~0.8秒,Next Contentful Paint的数据也保持在1秒以内。测试环境上线后,真实用户埋点统计的首屏平均加载时间从4.6秒降到了0.7秒左右。
这次优化的几个关键数字也验证了一件事:性能优化最怕的不是配置不会写,而是不先分析直接用别人的配置。我见过很多项目抄了splitChunks配置后反而更慢,就是因为没有搞清楚自己项目的体积结构、请求数量和浏览器并发限制。
最后再分享一个我实际操作中的体会:做首屏优化时,一定要把“第一次访问”和“第二次访问”分开来验证。第一次访问优化的是资源体积和加载策略,第二次访问优化的是缓存命中率。如果只盯着Lighthouse跑一次,忽略了缓存策略,线上老用户的体验提升会非常有限。建议每次改完配置后,都在无痕窗口和正常窗口各测一遍,数据才能说明问题。
整个优化过程中,收益最大、改变也最大的其实是几件小事:先分析再动手、拆包时考虑请求数、缓存策略落文件命名和Nginx头上。这6件事做完之后,项目后续的新页面开发也沿用了这套配置,前端同事反馈构建速度和线上体验都有明显改善。如果你的项目也卡在首屏加载慢的问题上,建议从分析报告开始,一步步做下来,效果会超出预期。
