
做了这么多年性能优化我见过太多团队在“伪优化”上浪费时间——压缩了图片、删了没用的依赖、上了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 之前否按文档顺序typemodule遇到即下载支持依赖解析类似 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 typemodule现代浏览器的天然异步ES Module 在 script 标签里使用typemodule时默认行为跟 defer 很接近下载不阻塞解析执行在文档解析完成后进行。但它比 defer 多做了一件事可以分析模块依赖图提前并行加载内部依赖。script typemodule src/js/app.js/scriptapp.js 里可能又import了三个模块浏览器拿到 app.js 后会做一次静态分析把依赖列出然后尽可能并行下载不需要你手动去写多个script标签。这在模块化工程里非常方便Vite 和现代打包工具的开发模式基本都走这条链路。但要注意typemodule天然受到 CORS 限制文件必须通过 HTTP 访问直接用file://打开会报跨域错。另外在旧浏览器上它完全不执行——如果项目里还有老设备用户就需要搭配nomodule属性做降级script typemodule src/js/app.js/script script nomodule src/js/legacy-app.js/script2.4 懒加载不到用的时候绝不下手懒加载的本质不是“加载得更快”而是“加载得更少、更晚”。图片和 iframe 的懒加载最佳实现是用浏览器原生属性加上 IntersectionObserverimg srcplaceholder.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));两点实操心得。第一懒加载的图片一定要预留占位高度。不预留的话图片进入视口时才开始请求请求完成后高度撑开页面布局被顶下去用户的滚动位置会跳动体验反而更差。占位可以按已知宽高比算也可以用一个固定容器。第二loadinglazy是原生属性但它只对图片和 iframe 生效别指望它帮你懒加载自定义组件或复杂业务代码。另一个容易翻车的地方是某些电商页面把首屏下方的图片也懒加载了用户往下滚的时候能看到“加载中”的占位图。这其实是过度懒加载——如果这些图片在首屏流量里本来就要被很快看到不如直接让浏览器提前加载省掉 IntersectionObserver 的触发延迟。懒加载的目标是“距离视口还有一段距离的资源”不是所有不在首屏的东西。2.5 preload 和 prefetch不是加载得更快而是加载得“更早”preload 和 prefetch 都算“预加载”但它们针对的资源生命周期完全不同preload告诉浏览器“这个资源是本页面马上要用的”请求优先级会提高适合字体、首屏关键图片、关键脚本。prefetch告诉浏览器“这个资源是未来某个页面可能用的”浏览器会在空闲时间悄悄下载适合链接指向的下一个页面、可能打开的弹窗内容。link relpreload href/fonts/inter.woff2 asfont typefont/woff2 crossorigin / link relprefetch href/next-page.js asscript /这里有个极常见的坑字体文件用 preload 时必须带crossorigin属性哪怕字体和页面同源。因为字体请求默认用匿名 CORS 模式缺了这个属性浏览器会把 preload 当成跨域请求处理导致加载失败。另外最重要的一条铁律preload 只加载不执行。有些同学以为 preload 一个脚本它就会自动执行其实不会。你 preload 完之后页面上还需要另一个真的script引用或动态加载它去执行否则就白白浪费了一次网络请求。3. 异步加载最容易翻车的三个场景顺序依赖、错误兜底和首屏白屏3.1 场景一依赖顺序的脚本用了 async线上偶发 undefined去年一个内部系统遇到过这样的问题入口页引入了一个基础库base.js下面紧接着引入业务代码biz.js源码里写得很清楚script srcbase.js/script script srcbiz.js/script后来为了让首页更快把两个标签改成了script async srcbase.js/script script async srcbiz.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 relstylesheet hrefsecondary.css mediaprint onloadthis.mediaall /要注意mediaprint这个老 hack 在个别场景下会导致 CSS 加载完成后请求顺序依然偏低。我自己的经验是如果首屏样式量不大直接内联关键 CSS剩下的放一个异步加载队列统一管理比逐个标签打补丁要稳得多。4. 真实项目里的资源优先级调度预加载、预连接和构建层面的切割4.1 先给资源做一个“关键性分级”拿到一个项目我不建议立刻开 preload 和 prefetch而是先做一张资源清单表把页面里的每个请求标记为“关键”或“非关键”。资源类型关键性推荐策略首屏核心 JS路由入口、主体渲染逻辑关键defer 或 module首屏 CSS关键内联或 preload字体文件可选preload 字体子集化图片视口内关键正常加载可加 fetchpriorityhigh图片视口外非关键loadinglazy 占位高度第三方 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 relpreconnect hrefhttps://cdn.example.com crossorigin /如果只是想做 DNS 预解析而不需要提前建立 TLS 连接用dns-prefetch更省资源link reldns-prefetch hrefhttps://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 小于 200KBgzip 后非首屏的独立 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 }); });重点读三个指标FCPFirst Contentful Paint用户看到第一个内容的时间衡量白屏期的长度LCPLargest Contentful Paint首屏最大元素渲染完成的时间用户会感知为“页面真正出来了”TBTTotal 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.1sLCP 从 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 图上其它需求先过评审”这种约束条款在团队协作里比任何教程都管用。异步加载用好了用户体感是质的飞跃用错了就是给线上埋雷。希望这篇原理篇能帮大家把这层窗户纸捅破少走点弯路。