做了这么多年性能优化,我见过太多团队在“伪优化”上浪费时间——压缩了图片、删了没用的依赖、上了CDN,结果Lighthouse评分还是原地踏步。问题往往不出在这些地方,而在于最基础也最容易忽略的一环:资源到底是怎么被加载进来的。异步加载这个概念,几乎所有前端人都听过,但真正吃透的人不多。很多人知道 async 和 defer 这对兄弟,却说不清它们的本质区别;知道懒加载有奇效,却不知道某些场景下懒加载反而更慢;更离谱的是,有人把预加载玩成了“预堵塞”,一次给浏览器塞十几个 preload 任务,首屏反而变慢。
这篇文章是“原理篇”,我就不讲那种全网重复的 API 罗列了,而是直接从浏览器渲染机制开始拆——异步加载到底在解决什么问题、五种常用异步手段各自有什么边界和坑、真实项目里怎么做资源优先级调度、以及如何用数据验证优化有没有见效。文里所有的经验和教训都来自我实际参与过的项目,不是教科书里的概念讨论。
1. 先把浏览器渲染路径搞明白:异步加载解决的到底是什么问题
1.1 从“装修队进场”说清楚同步阻塞
浏览器在拿到 HTML 之后做的事情,本质上像一支装修队进场施工。HTML 解析器就是泥瓦工,它从第一个字节开始砌墙(构建 DOM),进程本身很流畅。但砌墙砌到一半,如果遇到一个<script src="...">,整支队伍会瞬间停下来等这个脚本下载完再执行,因为脚本里可能修改 DOM、可能改样式、可能重新定向。泥瓦工不能一边砌墙一边等别人递材料,现场就只能干等。
这一个“停下等你”的行为,就是render-blocking。在关键渲染路径(Critical Rendering Path)上,每一个同步资源都是被排进主线程队列里的,队列头一个没干完,后面谁都动不了。页面越复杂、脚本链越长,这个等待就越致命。我见过一个老项目,首屏要串行加载四个同步脚本,光排队就浪费了 900ms,而这期间用户看到的是一片空白。
很多人会问:那 CSS 呢?CSS 不也是同步阻塞吗?对,CSS 阻塞的是渲染,而 JS 阻塞的是 HTML 解析,两者作用的位置不一样,但都会卡在关键路径上。更微妙的是,CSS 还会阻塞“它后面的 JS 执行”——因为 JS 在执行时可能去查询样式(比如getComputedStyle),浏览器为了保证结果准确,必须先等 CSSOM 构建完成,哪怕这段 JS 压根不碰样式,也得排队等。
1.2 关键渲染路径:最短路径才是优化的核心
把整条路画出来就是这样的顺序:
- HTML 从字节流解析成 Token,再构造出 DOM 树
- CSS 从字节流解析成 Token,再构造出 CSSOM 树
- DOM 和 CSSOM 合并成渲染树
- 经过布局(Layout)和绘制(Paint)才能在屏幕上显示
这条链路上任何一个节点卡住,首屏就晚一点。所有性能优化的本质,就是三件事:减少关键资源的数量、缩短关键资源的下载和执行时间、把非关键资源从这条链路上移出去。异步加载正是第三件事最核心的手段——它不让浏览器在解析 HTML 时停下脚步,把某些资源的下载和执行挪到主流程之外,让首屏更快到达“第一次有意义绘制”的时刻。
1.3 为什么“异步”被误用的情况这么多
我见过不少团队把<script async>当成万能药,理由是“Async 就是异步嘛,加了肯定快”。但真到了线上,两个下行脚本执行顺序乱了,页面功能直接崩。原因在于 async 虽然让下载异步了,但它仍然会抢占主线程,而且 async 脚本执行时机不可预测。
所以,在讲任何手段之前,必须建立一个基本认知:“异步加载”不等于“不阻塞”,而是“把阻塞挪到更合理的时间点”。不同的异步手段,挪的时间点完全不同,使用场景也截然不同。这是整篇文章最重要的底层逻辑。
2. 五种异步加载手段的正确打开方式:从 async/defer 到动态注入
2.1 async 和 defer:不是同一个“异步”
这两个属性经常被混着提,实际行为差别很大。用表格看最直观:
| 属性 | 下载时机 | 执行时机 | 是否阻塞解析 | 多个脚本的执行顺序 |
|---|---|---|---|---|
| 无属性(默认) | 遇到即下载 | 下载完立即执行 | 是 | 按文档顺序 |
| async | 遇到即下载 | 下载完立即执行,不等 DOM | 否,但执行可能乱序 | 不保证顺序 |
| defer | 遇到即下载 | DOM 解析完成后、DOMContentLoaded 之前 | 否 | 按文档顺序 |
| type="module" | 遇到即下载(支持依赖解析) | 类似 defer | 否 | 按依赖关系 |
一个很关键的点:async 的下载虽然不阻塞 HTML 解析,但执行时依然会占用主线程。如果页面里有多个 async 脚本,它们的执行顺序完全取决于谁先下载完,而下载耗时又受网络、缓存、服务器响应影响,所以绝对不能依赖 async 脚本的执行顺序。
defer 就不一样了。它下载同样不阻塞,但执行会被推迟到“HTML 解析完成之后”,而且多个 defer 脚本会严格按它们在文档里的出现顺序执行。这个特性让 defer 成为对页面结构有依赖关系的脚本的首选:既能避免阻塞首屏,又不会打乱执行顺序。
拿我自己的实践来说,如果一个脚本不需要在 DOM 解析完之前执行、又需要依赖别的脚本,我会直接选 defer;如果是一个纯独立、跟其他代码没依赖的统计/埋点脚本,async 可以,但真要出事也无所谓。默认先考虑 defer,考虑 async 的时候先问一句:它真的不需要顺序吗?
2.2 动态创建脚本:按需注入的老方案依然好用
除了在 HTML 里写标签,动态创建<script>并插入文档也是一种常见的异步加载方式:
function loadScript(url) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = url; script.async = true; script.onload = () => resolve(script); script.onerror = () => reject(new Error('脚本加载失败: ' + url)); document.head.appendChild(script); }); }这种方式最适合“事件触发后”的场景,典型的有两种:一个是用户点击某个按钮时才加载对应的功能代码,比如一个复杂的富文本编辑器,你没必要在首屏就把它下载下来;另一个是某些第三方 SDK,希望在身份校验完成之后、或者广告位可见时才去拉取。
这里有一个细节容易出错:document.head.appendChild(script)这段代码执行后,脚本的下载是异步的,但如果你在同一个模块里又写了document.write或者同步耗时操作,仍然可能干扰主流程。动态注入脚本本身只是改变了资源加载时机,没有改变它是一种“运行时手段”的事实——你仍然需要考虑失败重试、超时降级、以及加载之后通知依赖方这些后续工作。
2.3 type="module":现代浏览器的天然异步
ES Module 在 script 标签里使用type="module"时,默认行为跟 defer 很接近:下载不阻塞解析,执行在文档解析完成后进行。但它比 defer 多做了一件事:可以分析模块依赖图,提前并行加载内部依赖。
<script type="module" src="/js/app.js"></script>app.js 里可能又import了三个模块,浏览器拿到 app.js 后会做一次静态分析,把依赖列出,然后尽可能并行下载,不需要你手动去写多个<script>标签。这在模块化工程里非常方便,Vite 和现代打包工具的开发模式基本都走这条链路。
但要注意,type="module"天然受到 CORS 限制,文件必须通过 HTTP 访问,直接用file://打开会报跨域错。另外,在旧浏览器上它完全不执行——如果项目里还有老设备用户,就需要搭配nomodule属性做降级:
<script type="module" src="/js/app.js"></script> <script nomodule src="/js/legacy-app.js"></script>2.4 懒加载:不到用的时候绝不下手
懒加载的本质不是“加载得更快”,而是“加载得更少、更晚”。图片和 iframe 的懒加载,最佳实现是用浏览器原生属性加上 IntersectionObserver:
<img src="placeholder.png">const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: '200px 0px' }); document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));两点实操心得。第一,懒加载的图片一定要预留占位高度。不预留的话,图片进入视口时才开始请求,请求完成后高度撑开,页面布局被顶下去,用户的滚动位置会跳动,体验反而更差。占位可以按已知宽高比算,也可以用一个固定容器。第二,loading="lazy"是原生属性,但它只对图片和 iframe 生效,别指望它帮你懒加载自定义组件或复杂业务代码。
另一个容易翻车的地方是:某些电商页面把首屏下方的图片也懒加载了,用户往下滚的时候能看到“加载中”的占位图。这其实是过度懒加载——如果这些图片在首屏流量里本来就要被很快看到,不如直接让浏览器提前加载,省掉 IntersectionObserver 的触发延迟。懒加载的目标是“距离视口还有一段距离的资源”,不是所有不在首屏的东西。
2.5 preload 和 prefetch:不是加载得更快,而是加载得“更早”
preload 和 prefetch 都算“预加载”,但它们针对的资源生命周期完全不同:
preload:告诉浏览器“这个资源是本页面马上要用的”,请求优先级会提高,适合字体、首屏关键图片、关键脚本。prefetch:告诉浏览器“这个资源是未来某个页面可能用的”,浏览器会在空闲时间悄悄下载,适合链接指向的下一个页面、可能打开的弹窗内容。
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin /> <link rel="prefetch" href="/next-page.js" as="script" />这里有个极常见的坑:字体文件用 preload 时,必须带crossorigin属性,哪怕字体和页面同源。因为字体请求默认用匿名 CORS 模式,缺了这个属性,浏览器会把 preload 当成跨域请求处理,导致加载失败。
另外最重要的一条铁律:preload 只加载,不执行。有些同学以为 preload 一个脚本,它就会自动执行,其实不会。你 preload 完之后,页面上还需要另一个真的<script>引用或动态加载它去执行,否则就白白浪费了一次网络请求。
3. 异步加载最容易翻车的三个场景:顺序依赖、错误兜底和首屏白屏
3.1 场景一:依赖顺序的脚本用了 async,线上偶发 undefined
去年一个内部系统遇到过这样的问题:入口页引入了一个基础库base.js,下面紧接着引入业务代码biz.js,源码里写得很清楚:
<script src="base.js"></script> <script src="biz.js"></script>后来为了让首页更快,把两个标签改成了:
<script async src="base.js"></script> <script async src="biz.js"></script>上线后系统偶发报错:base is not defined。不是每次必现,而是网络波动时出问题——base.js 体积大、响应稍慢,biz.js 体积小、先下载完了,于是先执行,biz.js引用base里的方法时自然是 undefined。
排查链路其实不复杂:先在 devtools 的 Network 面板里看请求的响应顺序,能明显看到 biz.js 先返回先执行;然后清缓存、模拟 slow 3G 网络,必现概率大幅提高;最后把 async 改成 defer,问题消失,顺序恢复稳定。
这个坑背后的原理前面已经讲清楚了:async 不管文档顺序,谁先下载完谁先执行。凡是有依赖关系的脚本,都必须用 defer 或普通同步标签。更稳妥的做法是不要再手写多个 script 标签挂依赖,而是用打包工具把它们合并成一个 chunk,让浏览器只需加载一个文件。
3.2 场景二:动态加载的脚本失败了,页面没有兜底
动态创建脚本最让人头疼的是失败场景。一个典型例子是地图 SDK 的加载:页面初始化时按需加载地图库,结果用户网络很差,脚本加载失败,地图区域直接空白,控制台只有一条Failed to load resource: net::ERR_CONNECTION_TIMED_OUT,用户也不知道发生了什么。
兜底方案至少要包含三层逻辑:
function initMap() { return loadScript('https://map-sdk.cdn.example.com/sdk.js') .then(() => window.MapSDK.init()) .catch(() => { // 第一层:提示用户 showErrorTip('地图加载失败,请检查网络'); // 第二层:尝试降级到静态图或简化版地图 return loadStaticMap(); }); }除此之外,要设置超时机制。某些情况下脚本请求会一直挂起,既不成功也不失败,迟迟不触发 onload 也不触发 onerror。我用过一种通用做法:Promise.race()把脚本加载和一个 10s 定时器赛跑,超时后主动 reject。这在弱网场景尤其重要,因为弱网下有些请求会转圈 30 秒甚至更久。
还有一类容易漏掉的“伪失败”:脚本加载成功了,但执行时抛异常,或者因为引入了某个全局变量污染导致后续逻辑失效。这类问题不体现在资源加载层面,而是运行时报错。处理思路是:动态加载的脚本尽量封装成模块,让加载和执行分成两步,各自有独立错误处理;大批量引入第三方脚本时,至少给自己的业务代码加一层 try-catch 隔离。
3.3 场景三:异步 CSS 带来的白屏闪烁(FOUC)
异步加载通常讲的是脚本,但 CSS 同样能做异步处理。很多优化方案把非关键 CSS(比如弹窗、页脚、折叠区域的样式)抽出来异步加载,这是对的,但不加处理地直接异步 CSS,会出现页面先以“裸样式”状态渲染、CSS 加载完成后再“啪”地跳一下的效果,专业术语叫FOUC(无样式内容闪烁)。
为什么会闪烁?因为异步 CSS 不会阻塞 HTML 解析,浏览器先把没有样式的 HTML 画了出来,等 CSSOM 构建完再重绘一次。用户看到的就是一个先丑后美、甚至布局跳动两次的页面。
两种解法比较常见。第一种是把首屏真正必需的样式内联到head里,异步加载只负责首屏之外的样式;第二种是用媒体类型 hack,把非关键 CSS 的media属性设成不匹配当前环境的假值,让浏览器低优先级加载,加载完再切回真实媒体值:
<link rel="stylesheet" href="secondary.css" media="print" onload="this.media='all'" />要注意,media="print"这个老 hack 在个别场景下会导致 CSS 加载完成后请求顺序依然偏低。我自己的经验是:如果首屏样式量不大,直接内联关键 CSS,剩下的放一个异步加载队列统一管理,比逐个标签打补丁要稳得多。
4. 真实项目里的资源优先级调度:预加载、预连接和构建层面的切割
4.1 先给资源做一个“关键性分级”
拿到一个项目,我不建议立刻开 preload 和 prefetch,而是先做一张资源清单表,把页面里的每个请求标记为“关键”或“非关键”。
| 资源类型 | 关键性 | 推荐策略 |
|---|---|---|
| 首屏核心 JS(路由入口、主体渲染逻辑) | 关键 | defer 或 module |
| 首屏 CSS | 关键 | 内联或 preload |
| 字体文件 | 可选 | preload + 字体子集化 |
| 图片(视口内) | 关键 | 正常加载,可加 fetchpriority="high" |
| 图片(视口外) | 非关键 | loading="lazy" + 占位高度 |
| 第三方 SDK(埋点、客服、ABTest) | 非关键 | 动态注入或延迟到空闲加载 |
| 下一页可能需要的资源 | 非关键 | prefetch |
分级完成后,优化方向就很明确了:关键资源要“短而快”,非关键资源要“晚而散”,未来资源要“空时拉”。这个表格不只是写给我自己的,我更建议把这张表挂在团队的 wiki 里,每次新增第三方脚本时先过一遍分级,防止优化成果一点点被蚕食。
4.2 preload 不是越多越好,要做“预算管理”
preload 能提高某个资源的加载优先级,但浏览器的网络带宽、线程资源是共享的。当一个页面里有十几个 preload,浏览器会按照优先级和依赖关系重新排队,真正关键的文件反而可能被前面的 preload 挤到后面。我见过一个页面,开发者把首屏前几十个资源全部标成 preload,结果 Google Fonts 和主业务脚本互相抢带宽,首屏反而比优化前更慢。
合理的做法是建立一个简单的预算表,比如“首屏 preload 不超过 3 个”,或者“preload 只用于首屏视口内的关键资源”。我在自己的项目里通常只 preload 这些类型:
- 通过 @font-face 但位置偏后的自定义字体(不 preload 会导致字体加载明显延迟)
- 首屏大图或关键渲染所需的 hero 图片
- 少数体积大、又不能合并到主 bundle 的关键脚本
4.3 连接预建:别小看 DNS 和 TLS 握手
很多优化方案把注意力都放在资源的传输体积上,忽略了连接建立的开销。当一个页面引用了多个不同域名的资源,浏览器要为每个新域名做 DNS 查询、TCP 握手和 TLS 协商,这在移动端高延迟网络下可能各浪费几十到几百毫秒。
用preconnect能提前把这些握手过程完成:
<link rel="preconnect" href="https://cdn.example.com" crossorigin />如果只是想做 DNS 预解析,而不需要提前建立 TLS 连接,用dns-prefetch更省资源:
<link rel="dns-prefetch" href="https://cdn.example.com" />实际什么时候用哪个?我的经验是:如果这个域名确定是首屏马上会用到的静态资源 CDN,直接 preconnect;如果只是页面上有链接、用户可能会跳转过去,用 dns-prefetch 就够了,因为提前建立完整 TLS 连接也有开销,不是免费的。顺手说一下,预期消费能力当中,“preconnect 所有第三方域名”也是滥用重灾区,只对真正高频的第三方连接做预建。
4.4 构建层面的异步分割:splitChunks 与动态 import
运行时做得再好,构建层不配合,很多优化都是空谈。Webpack/Vite 体系下的代码分割,本质是把一个巨大的 bundle 拆成“首屏必要”和“按需加载”两个部分。
动态 import 是最典型的按需加载:
// 用户点击弹窗时才加载编辑器组件 const handleOpenEditor = async () => { const { default: Editor } = await import('./editor/Editor'); setEditorMounted(new Editor()); };构建工具会把editor/Editor单独打成一个 chunk,浏览器只在import()被调用时才去下载它。这里有一个对性能影响很大的配置改动:Webpack 的splitChunks默认会把公共依赖提到vendorschunk 里,但如果拆分得过于激进,会产生几十个小文件,HTTP/2 下并行请求虽然快,但移动端弱网环境依然讨厌太多小请求。
我自己常用的一个标准是:首屏先保证入口 bundle 小于 200KB(gzip 后),非首屏的独立 chunk 按照“打开频率×体积”排序,用得勤、体积大的放前头。这不算什么高深理论,但能帮你避免两头发力过猛。
5. 用数据说话:性能指标的采集、对比与优化效果验证
5.1 用 Performance API 自己搭一个采集器
优化做得再好,没有靠谱的指标验证,说服不了别人,也说服不了自己。现代浏览器提供了一整套 Performance API,不必依赖第三方监控平台就能拿到关键数据:
window.addEventListener('load', () => { const nav = performance.getEntriesByType('navigation')[0]; const paint = performance.getEntriesByType('paint'); const fcp = paint.find((p) => p.name === 'first-contentful-paint'); const lcpEntry = new PerformanceObserver((list) => { const entries = list.getEntries(); const lcp = entries[entries.length - 1]; console.log('LCP:', lcp.startTime, lcp.loadTime || lcp.renderTime); // 上报到自己内部的数据平台 }); lcpEntry.observe({ type: 'largest-contentful-paint', buffered: true }); });重点读三个指标:
- FCP(First Contentful Paint):用户看到第一个内容的时间,衡量白屏期的长度
- LCP(Largest Contentful Paint):首屏最大元素渲染完成的时间,用户会感知为“页面真正出来了”
- TBT(Total Blocking Time):主线程被长任务阻塞的总时间,间接反映脚本执行对交互的影响
5.2 对比实验怎么做才靠谱
“优化前 2.8 秒,优化后 1.4 秒”这种结论看起来很漂亮,但如果你是在自己电脑、自己网络、自己缓存状态下测的,这种数据基本没有参考价值。我推荐一个简单的三轮验证法:
- 用无痕窗口,清空缓存,避免缓存和扩展干扰
- 在 DevTools 的 Network 面板开启网络节流(比如 Fast 3G 或 Slow 4G),同时用 CPU 4x 降速模拟中低端设备
- 同一配置下跑五次,取 P50(中位数)和 P75,不仅看平均值,还要看长尾
真实项目里,优化效果往往在弱网和低端设备上最明显。如果你只在实验室宽带下测,很可能 FCP 只改善了 200ms,但在 4x CPU 降速环境中,TBT 从 3000ms 降到 800ms,完全是一个天上一个地下。所以做性能验证时,永远把环境调到比你想象中最差的情况再低一档。
5.3 一个典型的改造过程复盘
我给一个内容站做过类似改造,初始情况是面向搜索引擎的落地页,首屏总大小约 1.8MB,脚本 700KB,图片 900KB。整个优化按优先级拆成了三轮。
第一轮只动脚本:把阻塞解析的同步脚本全部改成 defer,埋点 SDK 改成事件触发后动态加载,主逻辑按路由拆成三个 chunk。FCP 从 1.6s 降到 1.1s,LCP 从 3.4s 降到 2.6s。
第二轮动样式和字体:关键 CSS 内联约 40KB,剩余样式异步加载;字体子集化并把 woff2 用 preload 提前加载。LCP 进一步降到 2.0s。
第三轮动图片:首屏三张大图改成 WebP 并提前加载,视口外的图片用懒加载加占位。整轮完成时,LCP 稳定在 1.6s 左右,在 4x CPU 降速下 TBT 从 2.4s 降到 0.6s。
整个过程中最有价值的一步不是具体某个技术,而是第一轮只动脚本——它让我看清了脚本加载方式对渲染延迟的支配性影响。很多团队一上来就压缩图片,最后发现脚本才是瓶颈,白干一场。
5.4 把预算写进流程里,防止优化成果回退
性能优化不是一次性工作。我把“性能预算”写进了项目的持续集成流程:每次构建都跑一次 Lighthouse 或者用 WebPageTest 做对比,超过预算阈值就构建失败,提示前端团队回头检查最近合入的代码到底加了什么。
预算不必一开始定得很紧张,我的初始建议是三个硬性指标:“首屏请求数不超过 25 个”、“gzip 后首屏传输量不超过 500KB”、“LCP 目标不超过 2.5s”。这三个值可以随着项目成熟度逐步收紧,重点不是逼死所有人,而是让团队在优化方向上有共识,让“异步加载”这种原则真正落到每次代码评审里。
最后再分享两件事,我觉得比数据本身更重要。第一,遇到“加载变慢”的问题时,先别急着换手段,打开 DevTools 的 Performance 面板看一眼主线程,阻塞时间最集中的那段往往就是你的优化目标;第二,给每种异步手段写一条“适用边界”备注放在文档里,比如“本项目的 preload 只准加在字体和 hero 图上,其它需求先过评审”,这种约束条款在团队协作里比任何教程都管用。异步加载用好了,用户体感是质的飞跃;用错了,就是给线上埋雷。希望这篇原理篇能帮大家把这层窗户纸捅破,少走点弯路。