同步加载、异步加载、延迟加载、预加载这四个词,我几乎每次带人做前端性能排查,都会被拎出来重新对一遍。原因很实在:它们经常被混着用,甚至被当成同义词互相替换——有人把defer说成异步加载,有人把preload当成懒加载的优化版,还有人一看到首屏慢就把所有脚本改成async,结果页面交互顺序全乱。这四个词描述的其实是四件不同的事:同步加载管的是“解析到就立刻拿、拿完立刻执行”,异步加载管的是“别挡住解析,下载和执行时机另说”,延迟加载管的是“现在根本不该要,等快用上了再去要”,预加载管的是“现在提前要,把未来的等待挪到现在花掉”。它们不是四个可选项,而是一套分层的资源调度策略。这篇文章我打算把这四条路从浏览器内核的行为层面拆开,配上可以直接抄的分层方案、真实场景里的参数估算,还有我这些年踩过的坑。适合已经能写页面、但对性能优化还停留在“加个 defer 试试”阶段的前端,也适合做移动端、做后台系统、做内容站的同学按需取用——不管你手上是几十个路由的大项目,还是只有一个落地页的小站点,这四张牌的取舍逻辑是共通的。
1. 四个名词先摆到一张桌子上
1.1 用后厨上菜的方式理解四种加载
我习惯用一个后厨的比喻跟新人讲这四件事,讲完之后他们基本就不会再混淆。把浏览器想成一家餐厅的前厅,HTML 解析就是服务员摆台,JS 和图片就是后厨要出的菜。同步加载是:服务员摆到某一张桌子时,发现这道菜还没上,于是他停下手里的活儿,站在那儿等后厨做完、端上来、摆好,再继续摆下一张桌子——整个前厅停摆。异步加载是:服务员照常摆台,同时告诉后厨“这道菜你做完直接送,不用管我摆到哪了”,于是菜什么时候到取决于后厨的手速,可能早于、也可能晚于你摆完,甚至几道异步的菜到达顺序都说不准。延迟加载是:这张桌子上的客人还没来,服务员压根不去下单,等客人入座了(滚动到视口、点击展开、路由切换)才通知后厨开始做。预加载是:服务员提前知道晚上有包场,趁现在厨房空着,先把食材焯水、把汤底吊上,等客人真到了直接出锅——等待时间被前移,但前提是你得猜准,猜错了就是白白浪费食材和灶台。
这个比喻之所以好用,是因为它同时点出了四个维度:是否阻塞主流程、执行时机的决定方是谁、触发条件是什么、猜错的代价有多大。实际做技术选型的时候,我基本就是拿这四个维度去套的——不要问“哪个性能最好”,而要问“这段资源阻塞了谁、我能不能控制它的时机、我猜错的成本高不高”。很多性能问题不是选错了技术,而是把“猜”当成了“确定”,比如给一个用户行为强依赖的模块写了prefetch,猜用户下一步会去,结果大部分用户根本不去,白下载几十 KB,还跟首屏关键资源抢了带宽。
1.2 为什么浏览器最初只有同步这一条路
想搞明白异步为什么重要,得先回到同步为什么是默认值。早期网页是纯文档,脚本的作用是给文档打补丁——比如校验一个表单、写一段document.write。脚本可能修改 DOM 结构,而浏览器没法预测脚本会改哪一部分,所以最安全的设计就是:遇到<script>就暂停 HTML 解析,把脚本下载下来、执行完,再继续解析后面的 HTML。这套设计在脚本只有几 KB 的年代毫无问题,成本可以忽略。但当页面变成一个几百 KB 的应用时,这条规则就成了性能杀手:解析器被卡住,意味着后面的 HTML 迟迟不能变成 DOM,DOM 不完整,CSSOM 和渲染树就出不来,首屏就是一片白。
所以这四个概念本质上是一部“浏览器把控制权逐步还给开发者”的历史。第一步给的是defer和async,让下载不再阻塞解析;第二步给的是IntersectionObserver和loading="lazy",让“要不要现在下载”变成可编程的;第三步给的是preload、preconnect、fetchpriority,让“提前多少”变成可调的。到了今天,fetchpriority、Speculation Rules这类 API 已经能精细到“这张图是首屏 LCP,给我提到最高优先级”这种颗粒度。理解这条演进线,你在看各种“最佳实践”时就不会盲从——很多文章还在教你用 JS 监听 scroll 事件做懒加载,那套写法在IntersectionObserver普及后已经属于负优化了,滚动事件在主线程上疯狂触发,自己就把自己拖慢了。
1.3 四张底牌的核心差异对照
我把这四个东西的关键属性整理成一张表,这张表我建议你存下来,做评审的时候直接对着念,比临场解释快得多。
| 维度 | 同步加载 | 异步加载(async / defer / 动态注入) | 延迟加载(lazy) | 预加载(preload / prefetch / preconnect) |
|---|---|---|---|---|
| 是否阻塞 HTML 解析 | 阻塞 | 不阻塞(async 会在下载完成后插入执行,短暂打断解析) | 不涉及,元素原本就在 DOM 里 | 不阻塞 |
| 下载时机 | 解析到标签时立刻 | 解析到标签时立刻开始下载 | 满足触发条件时(进入视口、路由命中、用户交互) | 声明时立刻(preload)或空闲时(prefetch) |
| 执行 / 使用时机 | 下载完成后立刻 | defer 在解析完成后按顺序;async 在下载完成后立刻;动态注入默认 async | 触发后才开始,天然晚于首屏 | 只负责“备好”,用不用由后续逻辑决定 |
| 顺序保证 | 严格按文档顺序 | defer 保证顺序,async 不保证 | 不适用 | 不适用 |
| 典型失败后果 | 首屏白屏、长任务堆积 | 依赖关系断裂、报错未定义 | 布局抖动(CLS)、滚动时卡顿 | 重复下载、抢关键资源带宽、控制台警告 |
| 典型适用对象 | 必须最先执行的埋点、主题变量、polyfill 探测 | 统计脚本、独立组件、第三方挂件 | 长列表图片、折叠区域、非首屏路由 | 首屏字体、LCP 大图、下一个页面必然要用的接口 |
表格里有一行值得单独拎出来说:“典型失败后果”这一列,才是这四个概念真正的区别所在。很多人背得出定义,但一到线上出问题就懵——图片懒加载导致页面一跳一跳,那是 CLS;脚本加了async之后某个插件报xxx is not defined,那是顺序断裂;控制台报“预加载的资源在几秒内未被使用”,那是猜错了。定义只能帮你记住名词,后果才能帮你判断该翻哪张牌。
2. 同步加载:最老实,也最容易把首屏拖下水
2.1 script 标签被解析到的那一刻,浏览器在干什么
我把这个过程拆成几步,你在开发者工具里是能一一对应上的。HTML 解析器从上往下扫,扫到<script src="a.js">时,它会做三件事:第一,暂停 DOM 树的构建;第二,如果这个脚本还没有缓存,发起网络请求;第三,拿到响应后交给 JS 引擎编译执行,执行完毕后解析器才继续往下走。注意这里的暂停是解析暂停,不是渲染暂停,但结果是等价的——DOM 不完整,渲染就无从谈起。你在 Performance 面板里会看到主线程上出现一段很长的黄色块,标着Parse HTML被Evaluate Script打断,这就是同步脚本正在“收过路费”。
还有一个容易被忽略的耦合:如果页面里有<link rel="stylesheet">排在<script>前面,那么这个脚本的执行还必须等 CSSOM 构建完成。因为脚本里可能有getComputedStyle之类的操作,浏览器不敢在样式没算出来的时候执行它。这就是所谓的“CSS 阻塞 JS”。我曾经遇到过一个页面,样式表里有一个慢接口返回的字体文件,结果样式表迟迟不 ready,后面三个同步脚本全部干等,白屏时间被拉到了四秒多。这个问题的解法不是把脚本改成async就完事,而是要先把那个阻塞样式表的关键路径切短——比如把首屏关键 CSS 内联,非关键 CSS 用media或者后续加载。
注意:同步脚本的阻塞是“串行叠加”的。三个各需要 100ms 的同步脚本,代价不是 100ms,而是 300ms,而且这 300ms 里解析器一步都不能动。
2.2 算一笔账:三个同步脚本到底吃掉了多少时间
不估算就没法做决策,我给一个我常用的粗算模型。假设首屏有三个同步脚本,压缩传输后分别是 80KB、60KB、100KB,合计 240KB;服务器在 40ms 往返延迟、有效下行 10Mbps 的网络条件下;每个脚本的解析执行时间约 40ms。
传输时间:240KB 换算成比特是 240 × 1024 × 8 ≈ 1966Kb,除以 10240Kbps ≈ 192ms。因为三个脚本是串行的(第一个执行完才继续解析,才轮到第二个),每一次都要重新计算往返,保守加两次握手级别的等待,按 40ms × 3 = 120ms 算。执行时间三个合计 120ms。
总阻塞时长 ≈ 192 + 120 + 120 ≈ 432ms。
这 432ms 意味着什么?意味着用户在这段时间里看到的是白屏,意味着 LCP 的候选时刻被整体往后推了将近半秒。如果你在移动端弱网下(有效带宽降到 1.6Mbps,往返 150ms),这个数字会膨胀到两秒以上——这就是为什么同一个页面在办公室里“感觉很快”,在用户手里“慢得想关掉”。
反过来,如果把这 240KB 中真正必须首屏执行的只有 80KB 抽出来同步,剩下 160KB 改成defer,阻塞时长大概能压到 150ms 上下,剩下的在解析完成后补齐。再进一步,如果那 80KB 里还有一部分是“用户点了才用”的,那它连defer都不该占,直接丢进懒加载。
提示:这个模型是粗算,目的是让你形成“量级感”,不是让你拿去写 SLA。真正的数据永远以 Performance 面板和真实用户监控为准。
2.3 同步加载还不能一刀切掉,这些场景必须留着
有些文章把同步加载说得一无是处,这是矫枉过正。我梳理了几类我至今仍然坚持用同步(或至少在<head>里内联同步执行)的场景:
一是必须在渲染前生效的全局配置。比如主题色的初始化——用户选了暗色模式,你需要在首屏渲染前把>// 用户点击“导出报表”时才加载体积较大的导出模块 const exportBtn = document.querySelector('#export'); exportBtn.addEventListener('click', async () => { exportBtn.disabled = true; try { const { generateReport } = await import( /* webpackChunkName: "report-exporter" */ './modules/report-exporter.js' ); await generateReport(); } catch (err) { // 网络失败或模块加载失败都要有兜底 console.error('导出模块加载失败', err); } finally { exportBtn.disabled = false; } });
这段代码里有几个我踩过坑才加上的细节:webpackChunkName注释是为了让拆出来的 chunk 有个可读名字,线上排查时你能在 Network 面板里一眼认出来;try/catch是必须的,动态导入会因为网络问题失败,失败后按钮必须恢复可用,否则用户会以为页面卡死了;disabled的切换是为了防连点导致的重复加载。这些都是文档里不会重点写的东西,但线上真的会发生。
3.3 async 的顺序陷阱与补救写法
我再单独说说async的顺序问题,因为它是最容易翻车的地方。有一类脚本天生不能无序,比如“先注册全局 SDK,再调用它的方法”。这种场景下你有三个选择:
第一,两个都改成defer,简单直接,代价是执行被推迟到解析完成。如果这两个脚本对首屏渲染没有影响,这是最省心的方案。
第二,只给后执行的脚本加一个“等待前置条件”的逻辑,比如轮询检查全局对象是否就绪,或者让前置脚本执行完派发一个自定义事件,后续脚本监听这个事件再启动。写法上比defer复杂,但能保住“尽早开始”的优势。
第三,用动态import()显式表达依赖关系,让模块系统帮你排序。这条路最干净,前提是项目本身已经是模块化的。
我个人的偏好是:能用模块依赖表达的顺序,绝不用加载属性去赌。属性是声明式的、隐式的,出了问题只能靠猜;import是显式的、有类型检查和打包校验的,出问题构建阶段就报错了。第三方脚本没法模块化的时候,才退回到defer或事件协调。
还有一个细节:async脚本如果加载失败,浏览器不会重试,也不会阻塞后续内容,等于静默失败。对于统计类脚本这是可以接受的,对于功能类脚本就必须自己做失败兜底——监听script.onerror,决定是降级、重试还是给用户提示。我见过一个页面因为第三方组件 CDN 抖动,async脚本没加载上,导致整个评论区空白,用户完全不知道发生了什么,这种体验比慢一点糟糕得多。
4. 延迟加载:不是下载慢,是根本还没到该要的时候
4.1 原生 loading="lazy" 的边界与坑
<img loading="lazy">和<iframe loading="lazy">是浏览器原生的懒加载,写一个属性就生效,实现成本几乎为零。它的触发逻辑大体是“元素接近视口时开始加载”,具体阈值由浏览器决定,而且会考虑网络状况——弱网下预取距离会更保守。这个方案我现在的默认选择就是它,因为它是浏览器层面实现的,不占主线程,比自己写滚动监听要稳得多。
但它的边界也很明确。第一,只对img和iframe生效,背景图(CSSbackground-image)管不了,video的poster也管不了(部分场景可以用preload="none"配合)。第二,它是“接近视口才加载”,不是“完全不加载”,所以首屏之外的图片依然会在滚动到附近时被请求,如果你的列表页有几百张图,用户一路滚到底,该下的还是会下,只是错开了高峰。第三,阈值不可控,你没法精确指定“提前 500px 开始加载”,这在做某些交互动画(比如图片必须在滑入视口前就位,否则会看到空框)时不够用,这种场景就得自己写。
还有一类元素特别容易被误伤:首屏的 LCP 大图。给首屏主视觉图加loading="lazy"是典型的负优化,因为它本来应该最高优先级尽快下载,加了 lazy 之后浏览器会把它降级、延后,LCP 直接变差。我的判断标准很简单:这个图在首屏可视区域内,就绝对不加 lazy;在首屏之外,加。至于“首屏”怎么界定,我用视口高度来判断——首屏按 100vh 算,留一点余量的话 1.2 倍视口以内的图都不加。
4.2 用 IntersectionObserver 写一个可控的懒加载器
当原生属性不够用时,IntersectionObserver是我唯一推荐的方案。它的核心价值是:元素是否进入视口的判断由浏览器在合成线程完成,不占用主线程,这跟scroll事件监听 +getBoundingClientRect()的老写法有本质区别——后者会在滚动时疯狂触发主线程计算,滚动越流畅的页面越容易被它拖卡。
下面是我项目里常用的一版,支持提前量、支持失败兜底、支持只加载一次:
// rootMargin 用正值表示“提前多少像素开始加载” const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; const el = entry.target; const src = el.dataset.src; if (!src) return; // 用真实 src 替换占位地址 el.src = src; el.removeAttribute('data-src'); // 加载失败时回退到占位图,避免出现裂图 el.addEventListener( 'error', () => { el.src = '/assets/placeholder.svg'; }, { once: true } ); // 加载完就取消观察,避免重复触发 observer.unobserve(el); }); }, { root: null, // 提前 600px 开始下载,弱网下可以调到 1200px rootMargin: '600px 0px', threshold: 0.01, } ); document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));几个参数的选择我解释一下。rootMargin的 600px 不是随便写的:按常见手机屏幕宽度 390px 估算,一屏高度大概 700 到 800px,提前 600px 意味着用户还有接近一屏的滚动距离时就开始下载,这个距离差不多能覆盖 3G 网络下 200KB 图片的下载时间。弱网环境我会把/api之类的动态配置下发下来,把提前量调大。threshold设 0.01 而不是 0,是因为某些老版本浏览器在 0 阈值下对“刚好贴边”的元素判断不稳定。unobserve必须加,否则元素每次进出视口都会触发回调,白白消耗性能。
这套写法的代价是需要处理占位。因为src一开始是空的或者占位图,图片区域必须预先占好位置,否则图片一加载就会把下面的内容顶下去,这就是 CLS。解决办法有两个:给img写上明确的width和height属性(浏览器会用它们计算宽高比并预留空间),或者用 CSS 的aspect-ratio。这两个我都用,谁方便用谁。
4.3 路由级懒加载与代码分割的落地方式
页面级的懒加载,价值比图片大得多,因为它省的是 JS 执行时间,而 JS 执行是直接挤占主线程的。核心手段是路由级代码分割:把每个路由的组件单独打成一个 chunk,用户进到哪个路由才加载哪个。
以常见的组件化框架为例,写法是用框架提供的懒加载 API 包一层:
import { lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; // 每个路由单独分包,首屏只加载首页和必要的公共依赖 const Home = lazy(() => import('./pages/Home')); const Dashboard = lazy(() => import('./pages/Dashboard')); const Settings = lazy(() => import('./pages/Settings')); export default function App() { return ( <BrowserRouter> <Suspense fallback={<div className="page-skeleton" />}> <Routes> <Route path="/" element={<Home />} /> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/settings" element={<Settings />} /> </Routes> </Suspense> </BrowserRouter> ); }这里面有几个经验点。分包不要太碎:我曾经见过一个项目把每个页面拆成十几个小 chunk,结果打开一个路由要发十几条请求,HTTP/2 下虽然多路复用,但每条请求的头部开销和调度成本加起来反而更慢。我的经验是单页 chunk 控制在 50KB 到 200KB(压缩后)比较合适,小文件合并,大文件再拆。公共依赖要提出来:框架、UI 库、工具函数这些几乎每个路由都用的东西,必须配置到公共 chunk 里,否则会被重复打进每一个路由包,总下载量翻几倍。要有加载态:Suspense的 fallback 不是装饰,它是用户在弱网下唯一能看到的反馈,用一个和页面结构相近的骨架屏,比转圈好得多,因为骨架屏能减少用户的等待焦虑,也能减少布局跳变。
还有一种更细粒度的懒加载是“组件级”的,比如一个只在点击后才展开的富文本编辑器、一个图表库、一个地图组件。这类东西的共同特征是:体积大、使用频率低、不影响首屏理解。判断标准也很简单——这个组件的第一屏可见内容里需不需要它。不需要,就懒加载。
4.4 懒加载最容易踩的三个坑:CLS、首屏误伤、SEO
第一个坑是布局抖动。懒加载的图片如果没有占位,加载完成的瞬间会把下方内容整体推下去,用户正在读的文字突然跑位,这是最伤体验的问题之一,而且在性能指标里会被记为 CLS。解法前面说了,固定宽高比或者写死宽高。还有一个更隐蔽的情况:图片的宽高比和数据里记录的不一致,占位按 16:9 预留,实际图片是 4:3,加载完还是会跳。这种要在数据层就校验,或者用object-fit: cover强制裁切。
第二个坑是首屏误伤。除了前面说的 LCP 大图,还有一种情况是“看起来不在首屏、实际在首屏”。比如页面顶部有一个可折叠的公告栏,展开状态下会把下面的内容推下去,原本在首屏的图就变成了第二屏,但用户实际看到的位置和设计稿不一致。这类问题只能靠真机走查,我在交付前会拿三台不同高度的手机各滚一遍,专门看有没有“该出来的没出来”或者“不该出来的先出来了”。
第三个坑是搜索引擎抓取。现代搜索引擎的爬虫大多能执行 JS,也会模拟滚动,理论上懒加载不影响索引。但“理论上”和“实际上”之间隔着爬虫的预算限制——它可能只滚动一段就放弃了。我的保守策略是:正文里的关键图片、文章封面,不加懒加载;只有列表页的次要缩略图、评论区头像这类才加。另外,如果站点严重依赖图片搜索流量,宁可牺牲一点性能也不要冒这个风险。
5. 预加载:把未来的等待挪到现在来花
5.1 preload、prefetch、preconnect、dns-prefetch、prerender 的分工
这几个词经常被混着说,其实它们的激进程度是递进的,我按“动作深度”排一下:
| 指令 | 提前做到哪一步 | 优先级 | 典型用途 | 猜错的代价 |
|---|---|---|---|---|
dns-prefetch | 只做域名解析 | 极低 | 第三方域名、CDN 域名 | 几乎为零 |
preconnect | DNS + TCP + TLS 握手 | 低 | 一定会请求的跨域接口 | 很小,一个空闲连接 |
prefetch | 完整下载资源并放入缓存 | 最低(空闲时) | 下一个页面必然要用的 JS / 图片 | 浪费带宽,可能挤占首屏 |
preload | 以高优先级提前下载当前页面要用的资源 | 高 | 首屏字体、LCP 图片、关键 CSS | 重复下载、警告、抢带宽 |
prerender/ 预渲染 | 连页面渲染都提前做完 | 最低 | 极高概率的下一步导航 | 浪费大量资源,可能执行副作用 |
我的使用顺序基本是:dns-prefetch无脑加,preconnect挑两三个最重要的加,preload只给首屏关键资源加,prefetch看用户路径的确定性决定加不加,预渲染只在极少数场景用。之所以这么保守,是因为越往后,猜错的成本越高。preload猜错会导致同一个资源被下载两次——一次是 preload,一次是真正用到时的正常请求,因为浏览器发现 preload 的东西和实际请求的凭据模式、as 类型对不上,就不认缓存,等于凭空多了一次请求。
5.2 preload 的 as 属性和优先级,写错等于白写
preload最容易写错的地方是as。这个属性不是可选的装饰,它告诉浏览器“我要的是哪一类资源”,浏览器据此决定优先级、决定放在哪个缓存里、决定要不要做内容协商。写错或者不写,浏览器会警告,而且大概率不生效。
<!-- 首屏字体:必须带 crossorigin,否则会重复下载 --> <link rel="preload" href="/fonts/main-subset.woff2" as="font" type="font/woff2" crossorigin /> <!-- 首屏 LCP 大图 --> <link rel="preload" href="/images/hero-1200.webp" as="image" imagesrcset="/images/hero-800.webp 800w, /images/hero-1200.webp 1200w" imagesizes="100vw" /> <!-- 关键 CSS --> <link rel="preload" href="/css/critical.css" as="style" onload="this.rel='stylesheet'" /> <noscript><link rel="stylesheet" href="/css/critical.css" /></noscript>字体这一条我单独强调:跨域字体必须写crossorigin,因为字体请求在规范里是按 CORS 模式发起的,preload 如果不带这个属性,浏览器会认为它跟实际请求不是同一个东西,于是重新下载一遍。这个坑我见过太多次,控制台会明确提示“资源被 preload 但未被使用”,很多人以为是没生效,其实是匹配不上。
as="image"配合imagesrcset和imagesizes是响应式图片的正确写法,如果你只写href不写srcset,浏览器可能预加载了一个尺寸不对的图,等真正渲染时又按srcset选了另一张,白下。这个在实际项目里很常见,尤其是用了图片 CDN 自动裁切的场景。
还有一个容易被忽略的新属性fetchpriority,它可以直接调优先级,不需要走 preload:
<!-- 首屏主图,明确提到高优先级 --> <img src="/images/hero.webp" fetchpriority="high" width="1200" height="600" alt="" /> <!-- 底部推荐位的小图,明确降级,别跟首屏抢 --> <img src="/images/thumb.webp" loading="lazy" fetchpriority="low" width="200" height="120" alt="" />这两个属性搭配起来用,效果比单独用loading="lazy"好,因为它把“优先级”和“加载时机”这两件事分开控制了。
5.3 预加载翻车的几种典型姿势
第一种是预加载了又不用。控制台会警告“该资源在 load 事件后几秒内未被使用”。我遇到过一次,是在一个活动页里 preload 了三张轮播图,结果用户网络慢,首屏 JS 还没执行完就到了判定时间,警告刷屏,而且这三张图确实抢了主图的带宽。后来改成只 preload 第一张,其余靠正常的懒加载。
第二种是preload 太多,等于没有优先级。所有资源都是高优先级,那就没有高优先级。我给自己定的规则是:一个页面里preload不超过三个,且必须能说出每一个对应的是哪个首屏关键元素。说不出来的就删掉。
第三种是prefetch 猜错路径。prefetch是在浏览器空闲时下载,理论上不抢首屏,但如果用户首屏交互很重、空闲时间很少,prefetch就永远排不上队,白白占了一个缓存条目。更麻烦的是它可能下载了用户根本不会访问的页面,浪费了用户的流量。我的做法是:只在“上一步到下一步的转化率极高”的路径上加prefetch,比如列表页到详情页,用户在列表页停留超过两秒、且滚动过一定距离,才动态插入prefetch标签。这样命中率高得多。
第四种是**preconnect开了太多**。每个preconnect都会占用一个连接资源,开十几个反而拖慢。我的上限是四个,只给最重要的跨域域名。
6. 落到一个真实页面上:分层加载方案的完整实操
6.1 第一步永远是量,不是改
我自己踩过最大的坑就是“先改后测”。看到首屏慢,凭直觉把脚本全改成defer,把图片全加上lazy,结果某些场景下反而更慢,而且已经改乱了,很难判断是哪一步导致的。所以现在我固定按这个顺序来。
第一步,用开发者工具的 Network 面板打开,勾上“禁用缓存”,把网络限速调到“快速 3G”,然后刷新页面。重点看三列:大小、时间、优先级。按大小排序,看谁最占带宽;按时间排序,看谁最晚返回;看优先级列,确认首屏关键资源是不是真的拿到了 High 或 Highest。如果一张首屏大图的优先级显示为 Low,那基本可以确定是被 lazy 或者某个属性拖下去了。
第二步,看 Performance 面板的火焰图。我要找的是主线程上的长任务(超过 50ms 的黄色块),以及Parse HTML被打断的位置。如果发现解析过程被切成好几段,每一段中间都夹着Evaluate Script,那就是同步脚本在作祟,具体是哪几个,点开调用栈就能看到文件地址。
第三步,跑一遍 Lighthouse,看“消除阻塞渲染的资源”和“预加载关键请求”这两项。Lighthouse 的建议不一定全对,但它指出的阻塞资源清单是准的,可以当线索用。
第四步,也是最重要的一步:看真实用户数据。实验室环境再像,也不如真实分布。我会看两个分位数:LCP 的 75 分位和 INP 的 75 分位。如果 75 分位的 LCP 已经到了 3 秒以上,说明有相当一部分用户在网络条件差的环境里,这时候优化优先级要往前调。这一步的数据来源每个团队不一样,但思路是一样的:先知道“慢在谁身上”,再决定改什么。
6.2 分层策略与代码落地
量完之后我会把页面上的资源按“何时必须就位”分成四层,每一层对应一种加载方式:
| 层级 | 判定标准 | 加载方式 | 我常用的具体手段 |
|---|---|---|---|
| 第 0 层:渲染前必须 | 影响首屏样式或渲染正确性,体积很小 | 同步内联 | 主题变量、视口相关的类名挂载 |
| 第 1 层:首屏必须 | 首屏可见内容依赖,包括 LCP 元素 | 同步 / 高优先级 | 关键 CSS 内联、LCP 图fetchpriority="high"、关键字体preload |
| 第 2 层:首屏之后很快要用 | 首屏外但在第一次交互范围内 | defer/ 并行下载 | 主逻辑脚本、首屏外第一屏图片、下一个路由的公共 chunk |
| 第 3 层:用户触发才需要 | 折叠区、弹窗、非首屏路由、低频组件 | 懒加载 | loading="lazy"、IntersectionObserver、动态import() |
落地的时候,HTML 大概长这样:
<head> <!-- 第 0 层:极小,内联,渲染前生效 --> <script> (function () { try { var theme = localStorage.getItem('theme'); if (theme) document.documentElement.dataset.theme = theme; } catch (e) {} })(); </script> <!-- 第 1 层:关键 CSS 内联,非关键 CSS 异步 --> <style>/* 首屏关键样式,控制在 14KB 以内 */</style> <link rel="preload" href="/css/app.css" as="style" onload="this.rel='stylesheet'" /> <noscript><link rel="stylesheet" href="/css/app.css" /></noscript> <!-- 第 1 层:字体与 LCP 图 --> <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/images/hero.webp" as="image" /> <!-- 跨域域名提前握手 --> <link rel="preconnect" href="https://api.example-cdn.invalid" crossorigin /> <!-- 第 2 层:主逻辑,defer 保证顺序且不阻塞解析 --> <script src="/js/vendor.js" defer></script> <script src="/js/app.js" defer></script> </head>注意这里我用了onload把rel改成stylesheet的写法,这是“异步加载 CSS 但不阻塞渲染”的经典手法。它的副作用是:在 CSS 加载完成之前,页面会处于无样式状态,如果用户在极短时间内看到,会闪一下。解决办法是把首屏关键样式内联,这样即使外部 CSS 还没到,首屏也是对的。这个内联的体量要控制,我一般卡在 14KB 左右,因为这是首个 TCP 往返窗口的典型容量,超过就得再来一轮,反而拖慢。
提示:
preconnect的域名我写成了示例域名,实际项目里替换成你自己的接口域名。这个属性只对跨域资源有意义,同域资源不需要。
6.3 改完之后怎么验证没有回退
改完必须验证,而且不能只看“感觉快了”。我固定做三件事。
第一,对比同一网络条件下的关键指标。用同一台设备、同一个限速档位,改前改后各跑五次,看 LCP、CLS、INP 的中位数变化。只看一次容易被波动骗。
第二,检查控制台。重点看两类警告:preload 未被使用、资源加载失败。只要有一条,就说明有一处配置没对齐。
第三,走一遍功能回归。这是最容易被跳过但最要命的一步。改成defer的脚本会不会有执行顺序问题?改成懒加载的图片在快速点击跳转时会不会来不及加载?动态import()在某些路由下会不会失败?我一般会专门测三种情况:弱网(限速最慢档)、快速滚动到底、以及在页面加载中途点按钮。这三种最容易暴露问题。
还有一个小技巧:在代码里埋一个简单的性能标记,把关键里程碑打出来,上线后能对比不同版本的差异。
// 记录关键节点,方便线上排查 performance.mark('app-bootstrap-start'); // ...应用初始化逻辑 performance.mark('app-bootstrap-end'); performance.measure('bootstrap', 'app-bootstrap-start', 'app-bootstrap-end'); // 动态导入的耗时也值得单独看 const t0 = performance.now(); import('./modules/chart.js').then(() => { console.log('图表模块加载耗时', Math.round(performance.now() - t0), 'ms'); });这些标记在本地看不出价值,但线上出问题时,能帮你快速排除“是不是某个 chunk 加载慢了”这种可能。我吃过一次亏:用户反馈某个面板打不开,排查了半天以为是接口问题,最后发现是那个懒加载的 chunk 因为构建配置改了路径,404 了,而代码里没做失败提示,静默失败。
7. 常见问题与排查技巧实录
7.1 问题速查表
这张表是我这些年攒下来的,遇到现象先对一遍,能省不少时间。
| 现象 | 最可能的原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 首屏白屏时间长,火焰图里解析被切成多段 | 存在同步脚本 | 看Parse HTML打断位置的调用栈 | 挪到defer或懒加载,只留极小内联 |
报xxx is not defined,本地正常线上偶发 | async导致顺序错乱 | 检查脚本是否都加了async | 改用defer或动态import()表达依赖 |
| 控制台提示 preload 的资源未被使用 | as类型不符或字体缺crossorigin | 对比 preload 与实际请求的类型、凭据模式 | 补齐as和crossorigin,或删掉 preload |
| 同一资源在 Network 里出现两次 | preload 与实际请求不匹配 | 看两次请求的 Initiator 和请求头 | 对齐as、crossorigin、type |
| 滚动时图片位置跳动,CLS 偏高 | 懒加载图片没有预留尺寸 | 检查img是否有宽高或aspect-ratio | 补宽高属性,或用占位容器固定比例 |
| 首屏大图迟迟不出现 | 被加了loading="lazy" | 看该图请求的优先级 | 去掉 lazy,加fetchpriority="high" |
| 弱网下页面很慢,办公室很快 | 资源总量大、串行请求多 | 限速到 3G 复测 | 拆包、合并小文件、关键资源预加载 |
| 懒加载的组件点击后长时间空白 | 动态导入体积过大或失败静默 | 看该 chunk 的大小与请求状态 | 拆分 chunk、加加载态与失败兜底 |
| 滚动越滚越卡 | 用了 scroll 事件手动计算位置 | 看主线程是否有高频 layout | 换成IntersectionObserver |
| 页面上线后某个面板打不开,无报错 | 懒加载 chunk 路径错误 404 | Network 里筛 JS 请求看状态码 | 加onerror兜底与错误上报 |
7.2 几个只有踩过才知道的细节
第一个细节:defer的脚本也不是完全没有代价。它虽然不阻塞解析,但会在DOMContentLoaded之前集中执行,如果几个大脚本都在这个时间点跑,会形成一个很长的主线程任务,把首次交互的时间往后推。我的做法是控制defer脚本的总执行时间在 100ms 以内,超过的部分继续往后挪,改成“空闲时加载”或者“交互后加载”。判断标准是看这个脚本对首屏可交互有没有贡献——如果只是挂一些不影响首屏操作的监听器,那它完全没必要挤在DOMContentLoaded前面。
第二个细节:懒加载的“提前量”是跟网络状况相关的,不能写死一个值。我在 4G 下用 600px 提前量很舒服,到了弱网就来不及了,用户滑到图片位置时还是白框。后来改成根据navigator.connection.effectiveType动态调:4G 用 400px,3G 用 1000px,2G 直接不做懒加载,全部按正常加载走,因为弱网下“边滑边下”的体验还不如一次多下一点。这个 API 不是所有浏览器都支持,所以要有默认值兜底。
第三个细节:预加载和懒加载会在同一个页面上打架。比如你给某个图片既写了loading="lazy",又在<head>里preload了它,浏览器会困惑——preload 说“我很重要快下”,lazy 说“先别下”。实际表现通常是 preload 生效,lazy 白写,但如果配置有细微不一致(比如 preload 的是 A 尺寸,srcset选的是 B 尺寸),就会出现下两次。所以我在做优化清单的时候,会强制要求“预加载清单”和“懒加载清单”互斥,用脚本检查一遍有没有交集。
第四个细节:首屏关键 CSS 内联之后,构建产物会变得难维护。内联意味着样式散落在模板里,改一次要同步改两处。我的做法是用构建插件自动提取首屏用到的 CSS 规则内联进去,其余走异步加载,这样源文件还是正常的样式文件,只是产物做了处理。如果项目规模不大、样式不多,手动维护一小段关键 CSS 也完全可以接受,重点是别让它随着时间慢慢膨胀成几百行。
第五个细节:别忽略iframe的懒加载。嵌入的视频、地图、第三方评论区,往往是最拖慢首屏的东西,一个内嵌的第三方组件可能带来几十个请求。<iframe loading="lazy">一个属性就能把它们压到视口附近才加载,收益比优化自己的图片大得多。更好的做法是“点击后再加载”——先渲染一张缩略图加播放按钮,用户点了才把iframe插进去,这样首屏一个第三方请求都不会发。
最后分享一个我自己形成的判断习惯:面对任何一个资源,我先问三个问题——它影响首屏的第一眼吗?它的体积值得为它单独做一次网络往返吗?它失败的后果用户能感知吗?第一个问题答“是”,就归到第 0 层或第 1 层,同步或高优先级预加载;答“否”且第二个问题答“是”,就归到第 3 层做懒加载;第二个问题答“否”,那就干脆合并进别的资源里,别为几 KB 的东西单独发请求。第三个问题答“是”的,无论放在哪一层,都得配上失败兜底和用户可感知的提示。这套问法不依赖任何具体框架,换到移动端、小程序、甚至桌面端应用里,逻辑照样成立,因为资源调度的本质从来没变过——你只是在决定,什么时候把用户的等待花掉,以及花在哪。