1. 先搞清楚时间都去哪儿了:性能分析这一步不能省
接手这个项目的时候,线上首屏加载时间稳定在 5 秒左右,还是用的高速宽带。如果你也遇到过类似情况,第一反应可能跟我一样:先去找后端要接口耗时,或者怀疑是图片太大。但实际上,那次优化的最大瓶颈恰恰出在前端打包产物上——单页应用的首屏要等整包 JS 下载完才能渲染,而那个 bundle 当时已经膨胀到了 1.2MB 左右,光 gzip 下载就要掉一截时间。
前端性能优化最忌讳的就是凭感觉下手。我见过不少同学一上来就开 optimization.splitChunks,把 node_modules 一顿拆分,结果拆完体积没变,反而因为请求数暴增让首屏更慢了。正确的做法是先量化问题,用数据告诉自己没有优化的方向。我常用的工具组合其实很简单:
- Chrome DevTools 的 Lighthouse,快速生成一份性能报告,拿到 FCP、LCP、TTI 等核心指标;
- Webpack Bundle Analyzer,把打包产物按体积可视化,一眼找出体积大头;
- Performance 面板录制页面加载过程,定位长任务(Long Task)和 JS 执行阻塞。
那次我跑完 Lighthouse 之后,关键数据是这样的:FCP 大约 2.8 秒,LCP 大约 4.2 秒,TTI 更是超过了 5 秒。再把 Bundle Analyzer 打开,好家伙,整个 bundle 里 echarts 占了 350KB,moment 占了 220KB,lodash 全量引入占了 150KB,剩下的业务代码也普遍没有做按需加载。这就是典型的“能用能跑,但根本不敢做性能测试”的状态。
1.1 先量化再动手:五分钟摸清项目家底
给项目做性能分析,我不会一上来就改配置,而是先花个五到十分钟把“家底”摸清楚。下面这几个步骤基本是固定的:
打开 Chrome 的无痕模式,进入页面前先清空缓存,然后打开 DevTools 的 Network 面板,勾选 “Disable cache”,刷新页面,记录下首屏请求的数量、总传输体积、每个关键 JS/CSS 文件的加载耗时。重点看那几个超过 200KB 的文件,它们往往就是首屏加载的元凶。
接着跑一次 Lighthouse。不需要太复杂的配置,直接用默认的移动端模拟就行。注意 Lighthouse 的结果在弱网模拟下会偏高,但它的价值在于给你一个可横向对比的基线分数,后面每改一版配置,重新跑一次,就能看到分数变化。我当时定的目标是 Lighthouse Performance 至少到 85 分以上。
最后一步是用 Bundle Analyzer。如果你项目里还没配,直接加一个依赖就能跑:
bash复制npm install --save-dev webpack-bundle-analyzer
然后在 webpack 配置文件里加上对应插件,运行构建后它会自动打开一个可视化页面,左边是文件树,面积越大代表体积越大,可以非常直观地看出哪一个依赖包是“体积刺客”。这一步的产出不是优化方案,而是一份问题清单——知道要处理哪些东西,才知道怎么排优先级。
1.2 首屏 5 秒的元凶:不止是体积大
做完分析之后,我把首屏慢的原因归纳成了四类,这也是我后来制定优化方案的依据。
第一类是 bundle 体积过大,而且是“单体大包”模式。整个应用只有一个入口文件,所有页面、所有组件、所有第三方库全都打进了一个 JS 文件里,导致浏览器必须等这个文件完全下载并执行完,才能渲染首屏。这种模式在小型项目里没什么问题,但规模一上来,体积和加载时间就会线性增长。
第二类是第三方库使用方式不对。moment 直接全量引入,lodash 用 import _ from 'lodash' 的方式引入,echarts 也没有按需注册图表组件,这三个包加起来占了 bundle 的接近一半体积。而它们实际用到的功能可能连 20% 都不到,这就是纯浪费。
第三类是完全没有缓存策略。打包产物文件名用的是 main.js、app.js 这种固定名称,只要业务代码改了一行,整个文件重新生成,浏览器缓存全部失效,用户每次发版都要重新下载全量资源。
第四类是长任务阻塞渲染。Performance 面板录制显示,主线程在加载初期有一个将近 800ms 的 Long Task,排查后发现是某个模块在初始化时对一组比较大的结构化数据做了 JSON.stringify 序列化,而且这个序列化发生在页面渲染的关键路径上。这就带出了很多项目都会遇到的坑:JSON.stringify 用起来很简单,但在数据量大、嵌套深的场景下,它的耗时和内存占用都不可忽视。
把问题量化到这一步,“6 件事”的优化方向也就自然出来了。下面我按实际执行的顺序,逐个拆解具体的操作和配置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一件事:按需引入 + 消除多余代码,从源头减少体积
很多人一想到性能优化,第一反应就是开 gzip、上 CDN,这些当然都有用,但它们属于“运输环节”的优化。我更习惯先处理“生产环节”的问题——代码能不能更少?如果源头就少了几百 KB,后面的压缩、缓存策略效果都会更好。
2.1 从 moment 到 dayjs:替换体积大户
在 Bundle Analyzer 里看到 moment 占了 220KB,几乎是整个 bundle 的五分之一,当时我就知道必须动它。moment 本身功能强大,但体积也大,而且它默认把所有语言包都打包进去了。项目中我们只用到日期格式化、相对时间和时间戳转换这些基础功能,完全可以用 dayjs 替代。
替换过程比想象中简单。dayjs 的 API 和 moment 高度兼容,大部分代码只需要改 import 语句:
javascript复制// 之前
import moment from 'moment';
moment().format('YYYY-MM-DD HH:mm:ss');
// 之后
import dayjs from 'dayjs';
dayjs().format('YYYY-MM-DD HH:mm:ss');
如果项目里有一些用了 moment 特有的插件功能,比如 relativeTime,在 dayjs 里也有对应的插件,按需引入即可:
javascript复制import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
这一步之后,bundle 体积直接减少了约 200KB。对于这种“功能相同、体积减半甚至减到十分之一”的库替换,我建议优先级排到最高,性价比非常可观。组件库、工具函数库也同理,你在 analyze 页面上看到的每一个大面积色块,都值得审视一遍。
2.2 Tree Shaking 没生效?问题多半出在引入方式
把 lodash 全量引入换成按需引入,是另外一个性价比极高的改动。那时候项目里大量代码是这么写的:
javascript复制import _ from 'lodash';
_.debounce(fn, 300);
_.throttle(fn, 500);
这种写法在 webpack 里基本没法做 Tree Shaking,因为 import _ from 'lodash' 引入的是整个库的入口文件,不管你有没有用到其他函数,webpack 都会把整个 lodash 打包进去。改成下面两种方式之一,效果会好很多:
javascript复制// 方式一:按需引入具体函数
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
// 方式二:使用 lodash-es
import { debounce, throttle } from 'lodash-es';
我实际选择的是方式二,因为项目里用到的 lodash 函数比较多,逐个改成子路径引入太繁琐,而 lodash-es 保留了 ES Module 结构,webpack 可以正确识别并摇树。不过要注意,从 lodash-es 全量 import { debounce } from 'lodash-es' 这种写法虽然能触发 Tree Shaking,但如果项目中同时存在 lodash 和 lodash-es,要确保统一,混用会造成重复打包。
同理,echarts 也需要按需引入。echarts 的完整包非常大,正确做法是按需注册你实际用到的图表和组件:
javascript复制import * as echarts from 'echarts/
