如果你的项目也走到了这一步:功能全部上线,用户量开始往上走,但页面加载越来越慢,操作一阵一阵卡顿,数据库偶尔直接把 CPU 打满,那么这篇内容就是为你准备的。作为性能优化系列的下半部分,这里不再重复首屏加载、资源合并那些基础手段,而是把焦点放到运行时效率、数据层瓶颈、构建产物治理和问题排查方法上。无论你是前端开发者还是全栈工程师,只要日常会和页面性能、接口响应、构建速度打交道,这篇都能提供一套可以直接拿过去用的思路和实操工具。
1. 性能优化的整体思路与指标体系
1.1 先立个靶子:优化指标的选取
很多人做性能优化失败,不是因为技术不够,而是因为一开始就没搞明白“到底优化什么”。“页面变快了”这种模糊描述没法指导行动。选指标有个基本原则:以用户可感知的体验为核心,以业务指标为北极星,以技术指标为辅助锚点。
我一般会把指标分成三层来看:
- 业务层:核心转化率、跳出率、留存率。这一层是老板关心的,也是性能优化最终要影响的。
- 体验层:首屏加载时间、交互响应延迟、页面滚动流畅度、视觉稳定性。这一层决定了用户“爽不爽”。
- 技术层:LCP(最大内容绘制)、CLS(累计布局偏移)、INP(交互到下一次绘制的延迟)、长任务数量、接口响应时间、数据库慢查询数量。
这一层的技术指标就是“可以落地优化的目标”,而业务层指标是优化的终极验证。举个例子:某模拟项目X的信息流页面,用户反馈“卡得不想刷”,我们最初拍脑袋想优化图片加载,后来一测才发现问题是列表滚动时每次进入新屏都有大量 DOM 重排,导致帧率掉到 30fps 以下。所以,先测量、再定位、后优化是唯一的正确路径,不要凭感觉猜。
选指标时还有一个常见误区:只看平均值。平均值最容易掩盖问题,比如 90 分位和 95 分位的响应时间被平均值的“健康”掩盖。我的习惯是同时看 P50、P90、P95 三个分位值,如果 P95 远超 P50,说明尾延迟严重,往往对应某些特定场景的偶发卡顿,这种最影响用户体验。
1.2 测量工作怎么搭
工具链上我常用的组合是:浏览器 DevTools 的 Performance 面板做现场录制、Lighthouse 做整体评分和审计建议、Web Vitals 库做线上用户数据采集。三个工具各有侧重,不能互相替代。
Performance 面板是定位运行时卡顿的核心工具。录制 5~10 秒用户操作过程,重点看火焰图里的长任务(Long Task)。如果一个任务超过 50ms,就会阻塞主线程,用户能感受到卡顿;超过 200ms 的连续长任务,基本就是页面“死掉”的体验。
Lighthouse 适合做定量检查和持续回归。它在移动端模拟下跑出来的分数虽然不是绝对标准,但同一个项目同一配置下对比不同版本,能很快发现“这次改动让性能回退了”。
线上监控要采用真实用户数据。在页面里注入一段探针脚本,采集 LCP、CLS、INP 三个核心指标,上报到监控平台(自研或市面上的 APM 均可)。我经历过一个项目:本地开发跑得飞快,Lighthouse 分数也接近满分,但线上真实用户的 LCP 依然高达 5 秒。原因就是真实用户的网络环境、设备性能、并发情况跟本地测试差异太大。没有真实数据,优化就永远只能“盲人摸象”。
2. 加载与资源优化的进阶实操
2.1 图片资源体积控制与格式选择
图片往往占据网页加载流量的六成以上,这块的优化效果最直接。先说格式选择:当下最实用的是 WebP,压缩率比 JPEG 高 25%~35% 的同时画质几乎无差别;如果要极致压缩,AVIF 能再省 20% 左右,但编码耗时高、兼容性仍需降级方案。实际项目里我的做法是:优先 WebP,AVIF 作为增强选项,PNG 只在需要透明背景且颜色简单的场景保留。
压缩参数上,以 JPEG 转 WebP 为例,控制质量在 70~80 之间是安全区间。配合缩放尺寸,一张原本 2MB 的横幅图可以压到 150KB 以内。我常用的压缩命令(基于某图像处理工具的 CLI)是:
# 批量将 JPEG/PNG 转为 WebP for file in *.jpg *.png; do cwebp -q 75 -m 6 -pass 10 "$file" -o "${file%.*}.webp" done-q 75是输出质量,-m 6是压缩力度(0 最快、6 最慢但效果最好),-pass 10是分析次数。批量处理时不用过度压榨画质,75 基本是观感与体积的平衡点。
响应式图片也是重点。不要动不动就加载 4000px 原图再靠 CSS 缩小。HTML 里用srcset按屏幕宽度提供多档尺寸,配合sizes指定实际显示宽度,让浏览器自行选择合适版本:
<img src="banner-800.webp" srcset="banner-400.webp 400w, banner-800.webp 800w, banner-1200.webp 1200w" sizes="(max-width: 600px) 100vw, 800px" alt="运营位横幅">同时记得给img设置明确的width和height属性。这一步能避免图片加载完成后把下方内容“顶”下去,也就是规避 CLS 累计布局偏移的常用手段。
提示:图片懒加载虽然能降低首屏请求数,但注意不要把首屏内的大图也懒加载。浏览器解析到首屏阶段的懒加载图片时,会推迟请求,反而拖慢 LCP。我踩过一次这个坑,后来统一采用“首屏不懒加载、首屏外才加载”的策略。
2.2 代码分割与路由懒加载的细节
现代前端项目打包后动辄几 MB 的 JS,首屏解析时间非常可观。按路由拆分是最基本的手段。以 Vue/React 项目为例,使用动态import就能让构建工具自动拆分 chunk:
// 之前:一次性加载整个模块 import UserDashboard from './pages/UserDashboard.vue' // 之后:仅当访问该路由时才加载 const UserDashboard = () => import('./pages/UserDashboard.vue')这里的代价是多一次运行时请求。所以要区分优先级:首屏路由可以不用懒加载,直接打进主包;低频页面(如后台管理里的审核页、报表页)才适合拆出去。
拆分粒度不是越细越好。我以前犯过错误,把一个页面里的弹窗组件也拆成单独 chunk,结果用户频繁开关弹窗时每次都发请求,白屏等待特别明显。后来改成这样:
- 页面级模块按路由拆;
- 弹窗、抽屉等高频交互组件,使用
preload提前加载; - 体积小于 30KB 的模块不拆,合并进主包或页面 chunk。
为什么要定 30KB 这个值?因为一次额外 HTTPS 请求的开销(传输、握手、解析)在弱网环境下可能抵消掉拆分带来的解析收益。小模块合并,大模块拆分,才是合理策略。
2.3 缓存策略:强缓存与协商缓存的配合
缓存是性能优化里性价比最高的手段之一,但配置错了也很容易成为“毒药”。我的实践原则是:
- 带 hash 指纹的静态资源(JS、CSS、图片),使用强缓存
Cache-Control: max-age=31536000, immutable。文件名 hash 变了说明内容变了,浏览器自然会重新请求。 - 不带 hash 的入口 HTML,使用协商缓存
Cache-Control: no-cache,每次向服务器验证,确保发布新版本后用户能第一时间拿到最新 HTML,再通过 HTML 引用新的带 hash 资源完成版本切换。
这里有个关键细节:强缓存时间再长,也要配合合理的发布策略。假设你的构建工具生成的文件名不带 hash,而是固定的app.js,那设置一年强缓存就会导致用户永远用旧代码。要么改用带内容 hash 的命名方式,要么把缓存时间调到几分钟。生产事故往往就是这么来的。
静态服务端配置示例(某网关服务)如下:
location /static/ { add_header Cache-Control "public, max-age=31536000, immutable"; add_header ETag ""; expires 1y; } location / { add_header Cache-Control "no-cache"; etag on; }immutable字段告诉浏览器这个文件在过期前不用重新验证,减少了一次协商请求。注意旧浏览器可能不识别immutable,所以max-age才是兜底保障。
3. 运行时性能与渲染效率
3.1 长列表渲染与虚拟滚动
列表页是运行时性能的重灾区。直接渲染几千条 DOM 节点,哪怕数据只是纯展示,也会让页面卡成幻灯片。我的处理方案是虚拟列表:只渲染可视区域内的部分节点,配合滚动位置动态更新。
固定高度项的实现相对简单,核心是算出滚动偏移量后,切成可视区域对应的数据切片:
function getVisibleRange(scrollTop, itemHeight, containerHeight, buffer) { const start = Math.floor(scrollTop / itemHeight) - buffer; const end = Math.ceil((scrollTop + containerHeight) / itemHeight) + buffer; return { start: Math.max(start, 0), end }; }buffer是缓冲条目数,我一般设为 2~3 屏的量,这样快速滑动时不会露出空白。固定高度方案对场景有一定限制,比如注释内容、卡券、商品卡片高度往往不固定。这种情况下通常用估算高度 + 动态测量修正:先按预估高度渲染,待真实高度测量出来后修正缓存。实现复杂度高一个量级,但效果稳定。
注意:虚拟列表的滚动容器要设置明确的
overflow-y: auto和固定高度,否则内部滚动了外层页面也会跟着滚,体验会非常奇怪。另外,快速滑动场景容易出现白屏闪烁,原因是渲染滞后于滚动,这个我用缓冲区和 requestAnimationFrame 节流更新来解决。
3.2 布局抖动与动画性能
页面卡顿最常见的元凶是强制同步布局(Forced Synchronous Layout)。当你在 JavaScript 里读取一个布局属性(如offsetWidth、getBoundingClientRect),然后又立刻修改样式(如改width、left),浏览器不得不放弃已有的优化队列,立刻重新计算布局。这个“读-写-读-写”交替操作一旦在循环里出现,性能就崩了。
举一个典型的反面案例:
// 错误:循环里读写在交替,每次迭代都触发重排 for (let i = 0; i < items.length; i++) { const width = items[i].offsetWidth; // 读取 items[i].style.width = width + 10 + 'px'; // 写入 }正确做法是把读取和写入分开,先统一读,再统一写:
const widths = items.map(el => el.offsetWidth); widths.forEach((width, i) => { items[i].style.width = width + 10 + 'px'; });动画方面,能用transform和opacity实现的动效,尽量不要用width、height、top、left。前者只触发合成(Composite),不触发重排和重绘,可以在 GPU 上流畅执行;后者每一步都要重新走一遍“样式计算-布局-绘制-合成”管线。实际编码时同样的位移效果,top + left的帧率可能只有 30fps,transform: translate则能稳定在 60fps。
3.3 高频事件里的防抖、节流与 rAF
滚动、窗口缩放、鼠标移动这类事件每秒可以触发几十甚至上百次,每触发一次都可能引发状态更新、DOM 操作甚至接口请求,不限制的话点几下页面就能卡住。
防抖(debounce)适用于“停止操作后才需要执行”的场景,比如输入框实时搜索。节流(throttle)适用于“需要周期性执行”的场景,比如滚动监听。常规实现网上很多,这里说两个进阶技巧:
- 使用
requestAnimationFrame做节流,比setTimeout更贴合渲染帧率。浏览器会在下一次重绘之前执行回调,避免掉帧:
let ticking = false; window.addEventListener('scroll', () => { if (!ticking) { window.requestAnimationFrame(() => { // 处理滚动逻辑 ticking = false; }); ticking = true; } });- 对高频触发的事件处理函数本身要“轻”。不要在滚动回调里直接操作大量 DOM,或者每次都触发组件状态更新。我常用的模式是:滚动事件里只做标记,配合 rAF 统一处理一次;需要更新 Redux/Pinia 等全局状态时,先记录到局部变量,等帧回调时再 commit 一次。
4. 数据层与接口性能的调优
4.1 前端请求合并与并发控制
接口性能是页面性能的另一个大头。很多后端接口一次只能处理单条数据,但前端往往需要批量获取,最容易想到的办法是前端循环发 N 个请求。这种做法在 HTTP/1.1 时代会触发浏览器同域名并发限制(一般是 6 个),在 HTTP/2 时代虽然没了连接数问题,但 N 次请求的传输开销和服务器处理开销依然不容忽视。
我的原则是:能用网关聚合的,不放前端并发。在某模拟项目X里,详情页需要同时获取基础信息、用户状态、推荐列表、操作日志四类数据,最初前端并发发 4 个请求,接口快慢不一会导致页面内容一块一块地蹦出来。后来我们新增了一个聚合接口,服务端并行拉取数据后一次性返回,前端只等一次响应,渲染直接完整呈现。这个改动让详情页的有效渲染时间从平均 1.8 秒降到了 0.9 秒。
前端并发本身不是不能用,但要限制并发量,尤其移动端弱网环境下。实现一个简单并发池控制批量请求:
async function runWithConcurrency(tasks, limit = 4) { const results = []; let index = 0; async function worker() { while (index < tasks.length) { const current = index++; results[current] = await tasks[current](); } } const workers = Array.from({ length: Math.min(limit, tasks.length) }, worker); await Promise.all(workers); return results; }这样控制同时最多 4 个请求在飞,不会打满带宽和服务器资源。
4.2 数据库慢查询与索引优化
后端优化里最容易被忽视的是数据库。接口响应慢常常是 SQL 查询没走索引。定位方式是在数据库日志里开启慢查询记录,或者使用自带的查询分析工具。我拿到慢 SQL 后,第一件事是用EXPLAIN查看执行计划:
EXPLAIN SELECT id, title, status FROM orders WHERE user_id = 12345 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;重点看type字段。如果用的是ALL,说明是全表扫描,数据量一上来必慢;ref或range是走了索引,const是最理想的主键或唯一索引查询。
索引创建的原则是:贴合查询条件、覆盖高频排序、尽量做覆盖索引。以上面这条 SQL 为例,最合适的索引是(user_id, status, created_at)联合索引,这样 WHERE 条件和 ORDER BY 都能命中索引,不需要额外文件排序。同时如果查询只需要返回id, title, status三个字段,而这三个字段都在索引中,就走覆盖索引,连回表都省了。
不过联合索引要注意最左前缀原则:(user_id, status, created_at)这个索引,可以命中user_id单条件查询,也可以命中user_id + status,但不能跳过user_id直接查status。所以建索引前先统计高频查询模式,别凭感觉建。
心得:优化一条慢 SQL 时,不要只顾着加索引。如果业务允许,先把“是否需要实时精确计算”这个问题想清楚。例如统计类报表,完全可以用离线任务或缓存计数来替代实时
COUNT(*);热门列表页也没必要实时扫全表排序,用 Redis 缓存一份热榜数据即可。
4.3 传输层:压缩与字段减肥
接口返回的数据量直接影响传输时间。两个容易出效果的手段:响应压缩和字段裁剪。
压缩方面,服务端开启 gzip 是标配,但如今使用 Brotli 压缩能比 gzip 再减少 15%~20% 的体积。在同类型 Web 服务里,我一般这样配置:
gzip on; gzip_min_length 1k; gzip_types text/plain text/css application/javascript application/json;Brotli 的优先级更高,但要注意只在 HTTPS 下开启,且需要确认客户端能力协商:
brotli on; brotli_min_length 1k; brotli_types text/plain text/css application/javascript application/json;字段裁剪常说但容易被忽略。接口返回 40 个字段,前端只用 5 个,剩下的 35 个字符流纯属浪费。我见过一个用户信息接口返回了用户的所有扩展字段,单个 JSON 体积 80KB,实际上展示页只需要昵称和头像。后来按场景拆分字段,响应体积降到 5KB 以内。这不只是省流量,还直接压缩了解析时间。
5. 构建与发布阶段的优化
5.1 看包说话:打包体积分析
很多时候首屏性能瓶颈不在代码逻辑,而在“一次性加载了根本用不到的代码”。如果你从来没看过构建产物的体积分布,建议马上跑一次分析。我用过某体积分析插件的方式是:
# 生成构建产物分析报告,自动打开可视化页面 npm run build -- --report报告里会列出每个 chunk 的体积占比、依赖来源。看过之后你就能直观看到:一个“你好世界”级别的依赖(比如某个日期处理库)体积比业务代码还大。这类纯功能型库如果只在几个函数里用到,可以换用原生 API 或更轻量的替代方案。强烈建议给每个大依赖设“体积红线”,放入 CI 检查,超过阈值直接构建失败。
5.2 构建提速:多线程与持久化缓存
项目大了以后,开发时的热更新和构建时的打包都会变慢。优化手段有三板斧:
第一,多线程压缩和转译。以 Webpack 为例,配置某多线程工具让 Babel 转译和 Terser 压缩在不同线程并行执行,构建时间能缩短一半以上:
// 在 webpack 配置中启用多线程转译 { test: /\.js$/, exclude: /node_modules/, use: [ { loader: 'babel-loader', options: { cacheDirectory: true, }, }, { loader: 'thread-loader', options: { workers: 4, }, }, ], }第二,开启持久化缓存。升级到较新的 Webpack 版本后,cache: { type: 'filesystem' }可以让机制缓存写入磁盘,二次构建直接跳过大部分编译步骤。实测下来,优化前 40 秒的构建,开启后能压到 15 秒左右。
第三,按需编译。开发环境不打包整个项目,而是只编译当前修改的模块。这种按需编译策略能热更新时间控制在百毫秒级,大幅提升调试效率。
注意:多线程工具在 monorepo 或跨平台打包场景偶尔会遇到路径处理问题,真遇到就把某个 loader 移出线程队列。这种问题一般表现为“偶发报错、重新构建就通过”,定位半天也找不出原因。我的处理方式是先把体积大、转译耗时长的 loader 放入线程,其他保持原样,收益够用就好。
5.3 CDN 与边缘缓存
静态资源上 CDN 已经是行业标配,但真正要留意的不是“上没上”,而是“回源策略”和“缓存键设置”。如果 CDN 边缘节点没有命中缓存,请求会回源到你的服务器,不仅没省流量,反而增加了链路时延。
正确的缓存键模式是:带 hash 资源的 URL 本身就是唯一的,直接设置长期缓存;HTML 不做 CDN 缓存或只做极短缓存,避免用户拿到旧版本。在回源方面,CDN 回源时给静态资源设置低的缓存 TTL(比如 1 分钟),加上带 hash 的文件名,就能同时保证命中率和更新时效。
动态 API 的缓存要谨慎。我见过把用户信息接口缓存了 10 分钟的案例,导致用户修改资料后前端一直显示旧数据。动态接口的正确做法是:明确标注Cache-Control: private, no-store,或者按用户维度做分区缓存,并且配合缓存失效机制在数据变更时主动清除。
6. 常见问题与排查技巧实录
6.1 从用户报障到定位问题的完整流程
这里分享一个真实处理过的场景。某后台管理系统上线后,运营反馈“打开报表页要转圈 5 秒以上,经常白屏”。按照性能排查的标准流程,我们依次做了三件事:
第一步,复现并录制 Performance。在性能面板里录制从输入 URL 到页面完全加载的过程,发现白屏阶段有一条超过 1.2 秒的长任务,函数名指向报表组件的主渲染函数。
第二步,分析长任务的构成。展开调用栈发现,这个组件初始化时调用了全量数据聚合函数,内部有大量对象展开和深拷贝操作,而且执行了多次。数据量在千条级别时,计算耗时直接飙升。
第三步,定位到具体代码。问题出在数据请求返回后,代码先深拷贝了一份用于展示副本,再对副本做了 3 轮sort和filter。整个链路是“请求慢 + 处理慢”叠加。我们做了两个修改:去掉无意义的深拷贝,改为不可变数据结构;sort和filter合并成一次遍历,条件判断全部放进循环里。处理耗时从 1.2 秒降到 300 毫秒。
这说明了两个思路:
- 任何长任务都要先看调用栈,不要猜。
- 数据处理链路上的每个环节都可能有重复计算,深拷贝尤其危险,能免则免。
6.2 线上监控发现的“隐形杀手”
线上用户反馈“偶尔卡一下”,本地永远无法复现。这种情况一般要靠指标监控才能抓住。有一次我在真实用户数据里发现,某个页面的 INP(交互延迟)在 P95 分位值达到 800 毫秒,严重超标。追踪埋点日志发现,这些高延迟都发生在用户点击按钮后,而按钮回调里有同步的 localStorage 写入和某统计 SDK 上报。
接下来做了两个优化:
第一个优化是把 localStorage 写入从同步改为异步批量。每次点击都同步写一次显然不合理,改成定时批量刷新数据后,主线程不再等硬盘 IO。
第二个优化是使用空闲回调来执行非关键上报:
requestIdleCallback(() => { // 执行非关键的上报逻辑 }, { timeout: 2000 });这样能保证用户交互不被非关键任务阻塞。经过这两个改动,INP 的 P95 值降到了 200 毫秒以内,整体体验恢复正常。
6.3 避坑建议速查
| 坑点 | 表现 | 解决建议 |
|---|---|---|
| 强缓存时间过长但文件名未带 hash | 发布后用户一直看不到新功能 | 构建配置里强制文件名带内容 hash |
| 懒加载覆盖首屏图片 | 首屏白屏时间变长 | 首屏资源全部预加载,懒加载只用于非首屏 |
| 深拷贝大数据结构 | 页面主线程卡死或长任务超时 | 换用不可变数据结构或按需复制 |
| 循环中读写 DOM 布局属性 | 页面卡顿、帧率下降 | 读写分离,先统一读再统一写 |
| 未设置 CDN 缓存键 | 回源率高,CDN 形同虚设 | 带 hash 资源长缓存,HTML 不缓存或短缓存 |
| 接口返回超大 JSON | 响应慢、解析耗 CPU | 按场景裁剪字段,开启 Brotli 压缩 |
| 高并发请求打满浏览器连接 | 页面加载资源串行等待 | 使用并发池控制请求数,尽量合并接口 |
分享一个我踩过几次坑之后形成的经验:做性能优化永远不要“一把梭”。最有效落地的方式是:上线前做一次全局体检(Lighthouse、构建产物分析、慢查询扫描),列出 Top 10 问题,按“改动成本低 + 收益明显”排序,每周处理两到三项,每项改动后跑一遍回归数据对比。性能优化不是一次性的技术任务,而是一个需要持续维护、持续用数据验证的长期习惯。只要把“先测量、再定位、后优化”的流程走顺,大部分性能问题的解决,最多只差一个问题定位的成本。