前端性能优化这个话题,我在团队内部讲过很多次,每次讲完都觉得还能再展开不少。最近接了一个业务线的性能专项,把从前端资源加载到交互流畅度这条链路重新捋了一遍,顺手把过程中的经验和踩过的坑整理下来。这篇文章适合两类人看:一类是刚开始接触性能优化的新手,可以通过它建立整体认知,避免东一榔头西一棒子;另一类是已经在做优化的老手,可以用这篇文章当一份检查清单,看看有没有漏掉的关键点。我会尽量用实际工作中遇到的场景来说,不讲学院派空话,希望读完你就能按图索骥去排查自己的项目。
1. 性能优化的底层逻辑:先看浏览器是怎么干活的
很多朋友一提到性能优化,第一反应就是"压缩图片""开个缓存""加个CDN",这些操作本身没错,但如果没有理解浏览器的工作流程,就只能停留在"别人说这样能优化"的阶段,遇到问题依然无从下手。这一章先把底层的运行机制讲清楚,后面的所有手段都会回到这条主线上来。
1.1 从输入网址到页面出现,浏览器经历了什么
一次普通的页面访问,浏览器要做的事远比你想象得多。输入网址后要先做DNS解析,把域名转成IP;然后建立TCP连接,接着发送HTTP请求、等待响应;拿到HTML后开始解析文档,边解析边构建DOM树和CSSOM树;两棵树合并成渲染树之后,浏览器要计算每个节点的几何位置,完成布局,然后把像素画上去,最后还要经过合成才显示到屏幕上。
如果用做菜来类比,DNS解析相当于查清楚菜谱放在哪个书架上,TCP连接相当于提前把火生好,HTML解析相当于备菜,CSSOM的构建相当于确认调味料配比,布局相当于设计摆盘位置,绘制才是真正下锅翻炒。任何一个环节慢了,上菜时间都会受影响。理解了这条链路之后,再看各种优化手段就会清晰很多:有些优化是针对传输环节的,比如CDN和HTTP缓存;有些是针对解析构建环节的,比如减少阻塞脚本;有些是针对渲染绘制环节的,比如减少重排重绘。它们各管一段,互不冲突,需要根据页面的实际情况组合使用。
1.2 两个方向和一个顺序建议
性能优化虽然手段繁多,但归纳起来就是两个大方向:一个是让页面更快地到达用户,解决的是加载速度问题;一个是让页面在用户操作过程中保持流畅,解决的是交互体验问题。前者的核心指标是FCP、LCP、TTFB,后者的核心指标是INP、CLS和长任务数量。
我的建议是,动手优化之前先花半天时间做两件事:第一,把页面当前的真实性能数据测出来,用Lighthouse或者浏览器自带的Performance面板都可以;第二,把最近一个月的线上性能监控数据拉出来看,找出趋势变化和异常点。有了数据再决定先优化哪个环节,不要凭感觉挑几个看起来很高深的技巧硬上。
我自己见过不少项目,优化方向搞反了。有个页面布局抖动得很厉害,团队却先去换图片格式,结果收益微乎其微,真正的问题在于列表渲染时不断修改DOM高度导致频繁重排。方向不对,再努力的优化都是白费力气,所以顺序建议放在所有手段之前。
1.3 用指标替代感觉
判断性能好不好,不能靠"感觉还行",得有一组可量化的指标。现在业界比较公认的核心指标主要有这么几个:
| 指标 | 全称 | 衡量内容 | 健康参考值 |
|---|---|---|---|
| FCP | First Contentful Paint | 首次内容绘制,页面出现第一个内容的时间 | 小于1.8秒 |
| LCP | Largest Contentful Paint | 最大内容绘制,首屏最大元素出现的时间 | 小于2.5秒 |
| CLS | Cumulative Layout Shift | 累积布局偏移,页面元素意外移动的程度 | 小于0.1 |
| INP | Interaction to Next Paint | 交互到下一次绘制的延迟,衡量交互响应 | 小于200毫秒 |
| TTFB | Time To First Byte | 首字节时间,服务器响应速度 | 小于800毫秒 |
我自己在实际工作中最关注的就是LCP和INP,一个管用户第一眼的感受,一个管用户接下来所有操作的顺滑度。CLS也别忽视,那种"正要点按钮,按钮突然跑了"的体验,比加载慢更劝退人。这些指标不需要每时每刻盯着看,但优化前后一定要有对比数据。每个优化手段,我都会在文章里顺带说明它对哪个指标的影响最大,方便你对照判断。
2. 加载链路上的优化:网络开销怎么省
加载速度的优化是性能优化里收益最直接的部分。用户访问一个页面,除了等待服务器处理请求,剩下的时间基本都花在网络传输上。把传输的东西变少、路径变短、次数变少,加载自然就快了。这一章按照"减体积、加缓存、短路径、延加载"四个思路展开。
2.1 资源体积是第一道关口:压缩与图片选型
先说最基础也最容易被忽视的:资源体积。
JavaScript和CSS在打包构建阶段就应该做压缩和混淆,这一步现代构建工具默认都会做。但有些项目因为历史原因,线上还跑着未压缩的脚本,这就很离谱了。建议在构建配置里开启压缩插件,去掉console输出和注释,生产环境的脚本体积能减少不少。
真正差距大的是图片。很多页面的总重量里,图片占了大头。我接手的一个活动页面,首屏三张横幅图加起来有2MB,换用WebP之后直接降到900KB左右。图片格式选型上,普通照片用JPEG过渡,要求高的直接用WebP,支持更好就上AVIF。WebP在同样画质下比JPEG小30%左右,AVIF比WebP还能再小一些,但AVIF的解码成本更高,移动端低端机上要慎用。
文本资源也不要放过Gzip和Brotli压缩。服务端开启Brotli后,JavaScript和CSS这类文本资源通常能比Gzip再小15%到20%。第一次做这个优化的时候,光开启压缩,整个页面资源包从1.2MB降到了400KB出头,用户体感完全是两个页面。给静态资源做压缩的性价比极高,改动小、收益大,值得作为优化优先级最高的任务之一。
2.2 HTTP缓存:让重复访问不再花钱
网络传输再好,都不如网络传输不发生。HTTP缓存就是解决这个问题的核心手段,它的核心价值是让用户在第二次访问时,能直接复用第一次下载的资源。
强缓存让浏览器在缓存有效期内直接使用本地副本,不再发请求到服务器。生产环境的静态资源要配好Cache-Control,对于带内容哈希的文件名,可以放心地把max-age设得很大,甚至用immutable;对于HTML页面本身则不要长缓存,否则页面更新了用户看到的还是旧版本。
协商缓存的原理是浏览器带条件请求到服务器,服务器对比资源的修改时间或ETag,没变化就返回304,只传响应头不传响应体。这个机制的收益在于,即使缓存过期了,验证成本也远小于重新下载完整资源。
这里要特别说一下内容哈希的作用。文件名里带上文件内容的哈希,内容不变文件名就不变,浏览器可以安心长缓存;内容变了文件名就变了,浏览器自然请求到新资源。这个机制是现代前端工程化缓存策略的核心。理解了它,你就知道为什么每次发版后,大部分静态资源都不用等用户强制刷新,新用户拿到的永远是匹配版本的文件。
2.3 CDN、预解析、预连接:缩短网络路径
资源体积降下来之后,接着要解决的是网络路径问题。CDN的核心思路是把静态资源分发到离用户更近的边缘节点,让用户从邻居家拿东西,而不是每次都回老家扛。对跨地域用户比较多的站点来说,CDN带来的加载提升非常明显,特别是静态资源这类不常变化的文件。
除了CDN,还有一些零成本的链路加速手段,写在HTML的head标签里就能生效。dns-prefetch可以在浏览器空闲时提前解析后续要访问的域名;preconnect比dns-prefetch更进一步,提前完成DNS解析、TCP握手和TLS协商;preload用于当前页面马上要用到的关键资源;prefetch用于用户未来可能访问的页面资源。
这几者的区别用大白话说就是:preload是"我马上要用,你先准备好",prefetch是"我以后可能用,你有空再拿",preconnect是"我知道要去哪家店,先把路探好"。实际使用中,字体文件、首屏大图、关键脚本适合preload,下一页面的入口脚本适合prefetch,跨域的资源接口域名适合preconnect。注意不要把所有资源都加preload,那样反而会让关键资源和其他资源争抢带宽,得不偿失。
2.4 懒加载的收益和反例
懒加载是一个经常被误解的优化手段。它的本质是让不在视口内的资源延迟加载,从而减少首屏的网络请求量。图片懒加载可以用原生loading="lazy"属性,兼容性已经很好,更精细的控制可以交给IntersectionObserver来监听元素何时进入视口。
但懒加载有一个非常典型的大坑:首屏LCP元素绝对不能懒加载。我就见过团队给首屏最大的商品主图加了loading="lazy",结果LCP从2秒直接变成4秒。因为懒加载会让最大内容延后出现,而LCP恰恰统计的就是这个元素出现的时间。懒加载体现在长列表、图片流这类场景才有意义,不要无差别乱加。
还要提醒一点,图片不管懒不懒加载,都要提前把宽高占住。不加宽高会导致图片加载完成后下方内容突然被顶开,CLS直接爆表。要么在img标签上写死width和height属性,要么用aspect-ratio配合容器宽度撑开比例。这两种方式都能给浏览器预留空间,避免加载过程中的布局跳变。
提示:优化的第一原则是减少"必须做的事",第二原则才是让"必须做的事"更快。懒加载属于第一原则,缓存和压缩属于第二原则,两者配合使用,不要本末倒置。
3. 渲染阶段的优化:页面怎么被画出来
资源加载完之后,浏览器开始解析、构建、渲染。这个阶段很多问题都出在浏览器主线程上,主线程既要执行JavaScript,又要计算样式、布局、绘制,任务太多就会拥堵。这一章重点讲怎么减少渲染链路上的阻塞。
3.1 关键渲染路径上的阻塞控制:脚本加载时机
HTML解析过程中遇到普通script标签会停下来,先下载再执行脚本,执行完才继续解析。这就是脚本阻塞渲染的原因。优化思路很简单:要么让脚本晚点执行,要么让它异步执行。
async和defer是最常用的两个属性。async是下载完立即执行,不保证执行顺序,适合完全独立、不依赖其他脚本的代码;defer是等HTML解析完成后按顺序执行,适合依赖DOM操作或者有执行顺序要求的脚本。对于页面首屏而言,不需要立即执行的脚本,都应该用defer而不是默认同步加载。
CSS也会阻塞渲染。浏览器只有把CSSOM构建完成才会进行渲染,所以在CSS文件到达之前,页面对用户是不可见的。这也是为什么关键CSS要内联到HTML里的原因,首屏能看到的样式要尽量减少对额外网络请求的依赖。换个角度理解:首屏的CSS是"必需品",应该跟HTML一起到达;首屏之下的CSS可以由脚本动态加载,不用拖慢初始渲染。
我在实际项目里见过这样一个场景:某个页面首屏只需要一个活动横幅,却引入了完整的全局样式文件,里面有大量用不到的组件样式。把首屏关键样式抽出来内联之后,虽然只省了一次往返请求,但FCP从2.8秒降到了1.9秒,因为我让第一次渲染不再等那个300KB的CSS文件了。
3.2 CSS优化:从选择器到渲染容器
CSS优化往往不是项目里的第一优先级,但有些细节确实能减少渲染成本。选择器这块,不要写那种过分复杂的选择器组合,浏览器匹配元素的时候是从右往左匹配的,标签选择器和通配符后面连着长串限定条件的写法,匹配成本会明显上升。日常开发用类名搭配语义化的结构就够了。
有一个很好用的CSS属性值得特别提一下:content-visibility。长页面里,视口外的区块渲染成本其实是可以省掉的。给这些区块加上content-visibility: auto,浏览器会跳过它们的主渲染工作,滚动到附近时才渲染。配合contain-intrinsic-size给元素一个预估尺寸,可以避免滚动条跳动的问题。这个属性对长列表、长文档类页面收益很明显,我试过的第一个版本,页面整体渲染时间降了将近一半,代价只是几行CSS。
还有一点,不要用@import加载CSS文件。@import会让CSS的加载变成串行依赖,增加额外的往返请求。没有特殊理由,全部用link标签加载。这是行业里公认的规范,但总能在老项目里看到@import出现,值得复查一遍。
3.3 重排与重绘:布局抖动从哪来
布局抖动是交互卡顿最常见的原因之一。要理解它,先要分清重排和重绘的区别。重排是元素几何尺寸或位置变化后,浏览器要重新计算布局;重绘是元素外观变化后重新画像素,但不影响布局。重排的代价远高于重绘,因为它可能影响一大片元素的位置。
动画场景里,尽量用transform和opacity,这两个属性可以通过合成器处理,不触发重排也不触发重绘,性能最好。用top/left做位移动画,会让浏览器每一帧都去重新计算布局,低端机上卡顿非常明显。这个优化我做过直接的对比测试:同一个悬浮动画,用top/left时低端机上掉帧明显,改成transform后稳定60帧,差距肉眼可见。
还有一个特别隐蔽的性能杀手:强制同步布局。如果你在脚本里先读取了一个元素的offsetHeight,紧接着又修改了这个元素的height属性,浏览器为了给你返回正确的offsetHeight,被迫提前执行一次布局计算。这种模式在循环里出现时,每轮迭代都触发一次全局布局,性能直接崩掉。正确做法是读写分离:先统一读取所有需要的数据,再统一修改样式,或者用requestAnimationFrame把写操作调度到下一帧。
will-change属性可以提前告诉浏览器某个元素要变化,把它提升到独立图层,但这个属性不能滥用。每个独立的合成图层都会占用GPU内存,几十个图层叠加会让移动端扛不住。常规做法是只有动画持续期间才加上,动画结束后移除。滥用will-change造成内存压力过大的问题,比不用它的问题更难排查,因为现象是卡顿,原因却在图层数量上。
3.4 首屏体验组合拳:骨架屏、预渲染与字体策略
首屏加载的体验优化是一个组合场景。网络再快,渲染总需要时间,这时候给用户一个反馈很重要。
骨架屏用灰色占位块勾勒出页面的布局结构,让用户知道页面正在加载,比白屏友好得多。实现方式可以是静态模板、服务端渲染的占位片段,也可以是一个独立组件。注意骨架屏的宽高要和真实内容接近,否则内容渲染出来后布局跳变,骨架屏反而拉高CLS。
更彻底的做法是预渲染或服务端渲染。对于一个内容型页面,在构建时生成静态HTML,用户首屏拿到的就是完整的DOM,脚本执行只负责后续交互的增强,LCP会有非常显著的变化。这个方案对部分内容站来说,比在前端做一百个优化都更管用,因为首屏HTML本身就是内容的最终形态。
字体加载也要处理好,这个坑很多团队都踩过。默认行为下,字体文件加载期间文字会隐藏,页面看起来一片空白。正确做法是给@font-face加上font-display: swap,文字先用系统字体显示,Web字体就绪后再替换。如果对视觉一致性要求不是极高,甚至可以使用font-display: optional,浏览器可以跳过Web字体直接使用系统字体,完全不阻塞渲染。再配合preload预加载字体文件,可以把字体替换的闪动时间压到最小。
4. JavaScript执行阶段:如何保持交互流畅
加载再快、渲染再顺,脚本一旦把主线程占满,页面一样卡成PPT。JavaScript执行阶段的优化,核心目标就是让主线程随时有空闲,能够及时响应交互。这一章从代码分割、长任务调度、事件与内存三个层面来拆解。
4.1 代码分割:只加载当前需要的
用户访问首页,不应该把整个应用几十个路由的代码全部下载下来。动态import()是现代前端工程标配的拆包手段,把代码按路由、按组件、按实际使用时机拆成多个chunk,用到再加载。
拆包的方式非常直接,以前可能是这样写:
// 原来:无论是否用到,全部打进主包 import { ChartModal } from './components/ChartModal';这样的代价是,用户只是浏览列表页,也要下载图表弹窗的整套代码。优化后可以改成点击时才加载对应模块:
// 优化后:点击时才加载对应模块 async function openChartModal() { const { ChartModal } = await import('./components/ChartModal'); modalRef.current = new ChartModal(); modalRef.current.open(); }我接手的一个后台系统,所有图表组件都在主包里,主包1.6MB。改成动态import之后,主包直接缩到600KB以下,首屏加载时间从5秒多降到2秒出头。拆包的收益,在没有做过的项目里总是被低估。公共依赖的处理上,把长期不怎么变化的第三方库单独拆成一个vendor包,利用长缓存让用户只下载一次。注意vendor包不要无脑放太多库,否则一个库升级会导致整个vendor缓存失效,反而波动更大。
4.2 长任务与主线程调度
浏览器的主线程是单线程的,JavaScript任务执行期间,页面的渲染和响应都被阻塞。一个超过50毫秒的长任务,就意味着用户可能感知到一次卡顿。长任务堆积越多,页面无响应的风险越大。
遇到那种非做不可的大计算任务,比如前端导出大数据表格、图片批量处理,不要在一个同步循环里跑完所有逻辑。把大任务拆成小任务,每一段执行后主动让出主线程,让浏览器有机会处理用户交互和渲染。
// 伪代码:时间切片处理大数据任务 const allItems = getLargeArray(); function processInChunks(items, totalChunks = 4) { const chunkSize = Math.ceil(items.length / totalChunks); let currentIndex = 0; function processChunk() { const end = Math.min(currentIndex + chunkSize, items.length); for (let i = currentIndex; i < end; i++) { processItem(items[i]); } currentIndex = end; if (currentIndex < items.length) { // 让出主线程,浏览器插入渲染或交互处理 requestAnimationFrame(processChunk); } else { onFinish(); } } requestAnimationFrame(processChunk); }requestIdleCallback也可以用来处理优先级低的任务,它会在主线程空闲时执行回调,适合做上报、清理这类不影响核心体验的工作。不过这个API的兼容性和执行时机在不同浏览器里表现不一致,依赖它的重要逻辑要自己写降级方案。
再往深一层说,现在主流框架都在往"并发渲染"的方向走,把渲染工作切成可中断的单元,在主线程繁忙时让出控制权,给更高优先级的交互更新让路。理解这个背后的时间切片思想,其实比追着框架特性跑更有价值,因为你会发现主流框架优化的还是同一个问题:主线程资源有限,必须分优先级使用。
4.3 事件与内存:打好日常基本功
交互卡顿不一定都是计算量大,有时候是基本功没做到位。
事件委托值得养成习惯。页面上几十个列表项每个都绑定click事件,不如在列表容器上绑一个,利用事件冒泡分发。减少监听器数量,既能提升初始化的速度,也能降低内存占用,对长列表尤其是动态列表的收益很明显。
高频事件要记得做节流和防抖。滚动、拖拽、窗口resize这些事件一秒能触发几十次,如果不做限制,事件处理函数也会跟着执行几十次,频繁触发布局和绘制,页面自然卡。
// 节流:固定时间内只执行一次 function throttle(fn, interval = 200) { let last = 0; return function(...args) { const now = Date.now(); if (now - last >= interval) { last = now; fn.apply(this, args); } }; } // 防抖:事件停止触发一段时间后才执行 function debounce(fn, delay = 300) { let timer; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; }注意:节流和防抖不是同一个东西。滚动条位置更新用节流合适,因为它需要持续反馈但可以降低频率;搜索框联想建议用防抖合适,因为要等用户停顿下来再发起请求。
内存泄漏是交互流畅度的隐形杀手。最常见的三个来源:setInterval或setTimeout没有清理、事件监听器绑定后没有解绑、全局变量里保存了不断增长的数据。页面长时间运行后内存持续上涨,最后就是卡顿和崩溃。排查内存泄漏用浏览器性能面板做堆快照,连续做几次同样的操作,对比内存曲线是否有持续攀升且不回落的情况。这个排查方法基本能定位出泄漏源在哪个模块。
5. 数据监控与问题排查:让优化可持续
做了前面的优化,只是完成了一半。优化效果怎么量化、线上问题怎么持续发现、后续性能怎么防止回退,这是另外一半。没有数据和机制支撑的优化,很容易回归原样。
5.1 建立性能基线:Lighthouse和Performance API
开始优化之前,先跑一次Lighthouse,把性能、可访问性、最佳实践、SEO的得分记录存档。Lighthouse的好用之处是报告会直接给出量化数据和改进建议,可以直接当成诊断报告用。不过它模拟的移动端网络环境偏弱,更多是用来做相对对比,看优化前后得分的相对变化。
线上真实用户数据可以通过Performance API采集。performance.getEntriesByType('navigation')里有很多加载相关的时间点;PerformanceObserver可以监听更多指标:
// 采集LCP等核心指标的真实用户数据 const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.entryType === 'largest-contentful-paint') { // 上报到自己的监控系统 report('LCP', entry.startTime); } } }); observer.observe({ type: 'largest-contentful-paint', buffered: true });浏览器性能面板是排查具体问题时必不可少的工具。录制一段交互过程,通过火焰图看主线程上每个任务的耗时分布,哪一段代码是瓶颈一目了然。网络面板看资源的体积、加载时序和缓存命中情况,排查加载问题非常顺手。两个面板配合使用,基本能定位绝大多数前端性能问题。
5.2 关键场景排查速查表
实际排查问题的时候,很多时候不是从头分析,而是根据现象快速定位方向。我把日常最常见的几类性能问题整理成了一张速查表:
| 现象 | 可能原因 | 优先检查方向 |
|---|---|---|
| 白屏时间过长 | 渲染阻塞脚本、首屏CSS未内联、网络太慢 | 网络面板看HTML和TTFB,检查首屏script是否使用defer |
| 首屏元素出现太晚 | LCP资源加载慢或被懒加载 | 确认LCP元素是否被加懒加载,用preload优先加载关键资源 |
| 滚动或点击卡顿 | 长任务过多、布局抖动 | Performance录制,看主线程长任务和布局事件分布 |
| 页面元素突然跳动 | 图片没占位、字体加载导致布局变化 | 检查图片宽高、font-display设置、广告位高度预留 |
| 内存持续上涨 | 定时器未清理、事件监听遗漏 | 堆快照对比、检查全局引用和闭包 |
| 图片加载缓慢 | 格式体积大、未走CDN | 网络面板看单图体积,换WebP或AVIF、接入CDN |
这张表不能覆盖所有场景,但覆盖了日常遇到的八成问题。拿着现象去对照方向,再用工具去验证细节,排查效率远高于漫无目的地翻代码。排查完之后把现象和结论记录到项目文档里,下次再遇到类似问题可以直接查,时间能省一大半。
5.3 防止性能回退:性能预算与日常习惯
性能优化最尴尬的事情是,优化完过两个月,又被人改回去了。新需求进来,开发者顺手往主包塞了几百KB的依赖;新图片上传,没压缩就直接替换。性能回退几乎是必然的,除非有机制防住。
我的做法是建立性能预算。在构建产物上检查,主包体积超过阈值就报警,超过限制直接让构建失败。指标方面,如果是大团队,可以把LCP、CLS这类核心指标接入线上监控,超过阈值触发告警。小团队实现不了那么重的体系,至少每个月跑一次Lighthouse,把得分趋势记录下来,回退早点发现。
更重要的是把优化意识变成日常开发的一部分。提交代码的时候想一想新引入的依赖体积是否合理,UI同学切图的时候提醒压缩后再上传,做首屏需求的时候先问一句"这个模块能不能异步加载"。
我自己的体会是,性能优化与其说是一次技术专项,不如说是一种长期养成的工程习惯。它不需要你掌握多么冷门的技巧,真正难的是持续地在意、持续地观测、持续地改进。把这一套思维固化到日常流程里,比任何一次集中攻坚都更有价值。
最后再分享一个小技巧:每次性能优化动工之前,先在项目根目录建一个performance.md,记录当前的基线数据、待优化清单、每项改动后的前后对比。这个文件既是你的工作日志,也是后续接手人的导航图。我靠这个简单的文档,让好几个项目的性能优化工作从"一次性活动"变成了"可持续流程",每次新需求评审时翻一翻,很多性能问题在源头就避免了。