看到“异步加载”这四个字,大多数人第一反应是给 script 加个 async 属性,或者把路由改成懒加载。但如果你只做到这一步,说明还停留在工具层面。真正理解异步加载,要回答的是:浏览器在加载页面时为什么必须同步?异步到底改变了哪一段关键路径?从 FCP 到 INP,哪些指标会因为你把一段逻辑延后而变好,哪些指标反而会恶化?这篇文章是原理篇,不讲某个具体框架的配置,只讲机制、取舍和排查思路。适合那些已经写过 async/defer/懒加载,但想知道“为什么”的前端开发者,也适合做移动端、手游性能优化的同学对照参考。
1. 浏览器天然是同步的:性能瓶颈的来源
在聊异步加载之前,先把最根本的问题说透:浏览器在加载网页的时候,为什么非要“同步”?你打开一个 HTML 页面,浏览器拿到第一块字节之后就开始解析,边下边解析,这个过程虽然是增量的,但每一步都有明确的前后依赖。这个依赖关系,是所有性能问题的起点。
1.1 HTML解析、样式计算与JavaScript执行是怎么串成一条链的
浏览器渲染一个页面,大约要做这几件事:HTML 解析成 DOM 树,CSS 解析成 CSSOM 树,两者合成渲染树,再进行布局和绘制。关键点在于,JavaScript 可以同时修改 DOM 和 CSSOM。你说 document.createElement('div'),解析器就得停下;你说 el.style.width='100px',样式树也要重算。
这意味着什么?一个没有特殊标记的 script 标签出现在解析器面前时,浏览器只能选择停下来等它。因为脚本可能在文档流中间输出 HTML,也可能把已经构建好的 DOM 全删了。如果不听,后面的解析就是建立在错误前提上。所以 HTML 规范规定了解析时遇到普通脚本必须暂停。这个设计是历史包袱,也是所有性能问题的根源。
可以用厨房做菜来打比方:洗菜、切菜、炒菜的顺序不能乱来,你不可能边切边炒还没洗净的菜。浏览器解析 HTML 也是一条流水线,脚本就像是流水线上的一个关卡,它来了,上游所有步骤都得等它干完。
很多人把“异步加载”当成一种优化技巧,其实它是在对抗这个历史包袱。理解了这条依赖链,你才会明白为什么脚本是渲染关键路径上最昂贵的资源。
1.2 用Performance导航计时看一个阻塞案例
空谈原理没意思,我拿一个真实场景说。之前接过一个老项目,页面 head 里引入了 jQuery 和几个插件,走的 CDN,文件大小大概 300KB。看起来不算大,对吧?但页面在 4G 网络下,DOMContentLoaded 要 1.2 秒。
当时我用 DevTools 的 Performance 面板看瀑布流,发现 HTML 解析到 jQuery 那个 script 时就断掉了。水蓝色那条 HTML parsing 在脚本下载期间是断的,下载完、执行完,才继续解析。整个页面在脚本就绪之前,什么都渲染不出来。
你可以用 Navigation Timing 自己量一下:
const nav = performance.getEntriesByType('navigation')[0]; console.log({ htmlParse: nav.domInteractive, domContentLoaded: nav.domContentLoadedEventEnd, load: nav.loadEventEnd, });把脚本放在 head 和放到 body 底部,或者加 defer,domInteractive 的数值会差很多。第一次看到这种差距的人往往会惊讶:一个脚本居然能拖住整个页面的渲染。
1.3 为什么“加async”不等于“快”:异步只是不阻塞解析
现在你给 script 加了 async 属性,会发现一个很有意思的现象:脚本下载并行进行了,HTML 解析不中断了,但如果你去看 Performance 面板,脚本执行的那一刻,解析还是会被打断。
async 的语义是:下载过程并行,下载完成后如果 HTML 还没解析完,就立即执行,执行期间解析照样暂停。所以 async 解决的是“下载阻塞”,不解决“执行阻塞”。如果你的脚本本身要跑 100ms,这 100ms 依然卡在主线程上,绝不会因为加了 async 就消失。
这就是很多优化方案“明明加了 async,但 FCP 没怎么变”的原因。脚本执行开销依然在关键路径上,除非你把它拆出去,让它延后到用户真正需要的时候再执行——这就引出了动态 import 和懒加载的核心思路。
记住一句话:异步加载的本质,不是把任务删除,而是把任务从关键路径上移走。任务还在,只是不再挡路。接下来要聊的三条技术路线,都是围绕这句话展开的。
2. 三条异步路线:defer、async、动态import背后的机制
2.1 async和defer的规范差异,以及选型依据
很多人分不清 async 和 defer,其实差别就两个维度:执行时机和执行顺序。
| 属性 | 下载方式 | 执行时机 | 顺序保证 | 与DOMContentLoaded关系 |
|---|---|---|---|---|
| 无修饰 | 发现即阻塞下载 | 下载完立即执行 | 按文档顺序 | 阻塞DOMContentLoaded |
| async | 并行下载 | 下载完立即执行 | 不保证顺序 | 可能阻塞也可能不阻塞 |
| defer | 并行下载 | HTML解析完成后执行 | 保证文档顺序 | 在DOMContentLoaded之前执行 |
选型经验法则很简单:如果多个脚本之间有依赖关系,老老实实用 defer,因为它既有并行下载的优势,又保证执行顺序。如果是一段完全独立的脚本,比如埋点统计、AB实验、广告脚本,用 async 没问题,谁先回来谁先执行,业务上不依赖彼此。
type="module" 的脚本默认就是 defer 行为,这也是现在 ESM 项目可以直接用原生的原因。但要注意:defer 脚本会在 DOMContentLoaded 之前执行,它依然占用主线程。如果你把一堆重量级脚本全部 defer,DOMContentLoaded 还是会被拖慢,只是不再有“下载断档”那么明显。
2.2 动态import()到底是什么:Promise与脚本注入的封装
如果说 async/defer 是“把任务延后到加载阶段”,那动态 import() 就是真正把任务延后到了运行时。你写:
button.onclick = async () => { const { default: editor } = await import('./editor.js'); editor.open(); };这个语法在 Babel 时代就被广泛使用,但很多人不清楚它在浏览器里到底做了什么。简单说,动态 import() 是浏览器原生 script 加载逻辑之上的 Promise 封装。当构建工具把它拆成一个单独 chunk 后,浏览器执行到 import() 那行时,会动态地在 document 上插入一个 script 或 module 标签,发起请求,请求成功后用运行时把模块注册进模块表,然后 resolve 对应的 Promise。
收益非常直接:首屏代码里根本没有这个 chunk 的请求,直到用户点了某个按钮,才去下载和执行。这能缩小首屏 JS 体积,间接缩短解析、编译、执行的时间,对 FCP 和 TBT 都友好。
但代价是,原本静态 import 可以做的依赖分析,在动态 import 下只能靠构建工具在编译期做。另外,动态 import 的模块如果依赖了全局对象,你需要保证那个全局对象在用户点击时已经就绪。这个问题后面翻车实录里细说。
2.3 preload/prefetch/prerender:提前做哪些事才算高效
异步加载不只是“延后”,还有一种思路是“提前”。preload、prefetch、preconnect 就属于这一类。
- preload:告诉浏览器我正在为当前页面准备一个马上要用的资源,给它高优先级提前下载。典型场景是首屏字体、首屏大图、关键 CSS。
- prefetch:告诉浏览器这个资源将来可能用到,在空闲时下载,优先级很低。典型场景是下一页的资源、用户大概率会点的功能模块。
- preconnect:提前建立到某个域的连接,减少后续请求的握手延迟。
这三者用好了是提速,用错了是拖后腿。最典型的坑是把 preload 用在一堆非关键资源上,结果高优先级请求把真正的关键资源挤到了后面。设备网络带宽是有限的,你把资源提前拉下来,另一段关键资源的下载就被延迟了。
所以优化里常说“做减法比做加法难”。你在把一个资源延后之前,先问自己两个问题:这个资源用户真的马上需要吗?如果现在不加载,会不会影响下一个关键交互?搞清楚这两个问题,比记住再多的优化技巧都重要。
3. 从指标出发衡量异步优化的真实收益
3.1 FCP、LCP、INP:哪个指标吃异步加载的红利
衡量异步加载的收益,不能靠感觉,得落回到指标上。我经常用三个指标来判断:
- FCP(First Contentful Paint):页面上第一个内容出现的时间。FCP 对渲染阻塞脚本极其敏感。把非关键的脚本从 head 里拿掉、改成 defer 或 async,FCP 通常能明显提前。
- LCP(Largest Contentful Paint):最大内容的渲染时间。LCP 受异步加载的影响是间接的:如果 LCP 元素是图片,脚本阻塞解析可能会延迟图片请求的发出;如果把非 LCP 区域的图片 lazy 了,释放了带宽,LCP 可能改善。但如果把首屏图片也 lazy,LCP 反而会恶化。
- INP(Interaction to Next Paint):从用户交互到下一次可视反馈的延迟。长任务直接影响 INP,而 JS 减负、异步加载、代码分割都会减少主线程长任务,INP 自然变好。
你会发现,异步加载对不同指标的作用方向并不总是一致。这也正是性能优化的难点:你得先明确自己到底在优化哪个体验。如果只是想让页面更快出字,优先处理 FCP;如果用户要频繁点击按钮,优先处理 INP 和 LCP。
3.2 Performance API做AB测量的实操方法
优化前后要对比,别“我猜应该快了”就上线。我习惯用 Performance API 写一点小脚本,在本地跑,再配合 DevTools 的 Performance 面板做交叉验证。
performance.mark('start'); // 要测量的代码 const el = document.getElementById('content'); el.innerText = 'hello'; performance.mark('end'); performance.measure('textRender', 'start', 'end'); const items = performance.getEntriesByName('textRender'); console.log(items[0].duration);捕捉长任务可以用 PerformanceObserver:
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log('Long task:', entry.duration, entry.startTime); } }); observer.observe({ entryTypes: ['longtask'] });提醒一句:本地开发环境没有网络延迟,数据参考意义有限。要测就测生产环境的空缓存状态,或者用 Lighthouse 的模拟节流做一次对照。A/B 测量时保持同样的设备、同样的网络条件,变量只保留你要改的那一个。
3.3 优化不是越异步越好:收益递减的边界
我见过最极端的“优化”是把一个页面拆出 80 个 chunk,首屏加载了 40 多个小脚本。结果不但没有快,反而更慢。原因很简单:每个 chunk 都是一次 HTTP 请求,HTTP/1.1 下浏览器对同一域名只有 6 个并发连接,40 个请求要排队;即便用 HTTP/2 多路复用,请求管理和服务端处理也有额外开销。
异步化的收益曲线不是一直向上的直线,更接近先陡升、后平缓、再回落的弧线。从“完全同步”到“拆分出 5~10 个异步 chunk”通常收益最大;再继续拆分,边际收益就很小,甚至出现负收益。
判断标准还是回到关键路径:哪些资源不在首屏关键路径上?把它们延后。哪些资源用户马上要用?把它们放前面。关键路径思维,比任何一种具体的异步技巧都重要。
4. 异步加载翻车实录:竞态、顺序与错误处理
4.1 依赖未就绪:一个线上白屏的排查链条
有一次,我把页面上一个“编辑器”模块改成了动态 import,按钮点击后才加载。上线当天就有人反馈:点按钮没反应,控制台报错,报的是某个全局变量找不到。
排查时我先看网络面板,发现编辑器 chunk 确实加载成功了。再看代码,发现问题出在依赖顺序:编辑器模块内部直接用了 window.SDK,而这个 SDK 之前是页面同步加载的;我改成动态 import 之后,SDK 的注入脚本还在后面,导致编辑器模块执行时 SDK 还没就绪。
这个坑的本质是:动态 import 只是把一个模块的加载延迟到了运行时,它不保证模块运行前的所有依赖都就绪。要保证依赖顺序,有几种改法:
- 把 SDK 改回同步脚本,但放在 head 里,用 defer 保证它在 DOMContentLoaded 之前完成;
- 在动态 import 里显式先 await SDK 的初始化 Promise;
- 用构建工具的 externals 配置,把 SDK 作为外部依赖,同时用稳定方式提前注入。
我最后选了第二种,因为改动最小:把 SDK 初始化封装成单例 Promise,编辑器模块加载前先 await 它。从那以后,我再做异步改造,第一件事就是列出这个模块的所有依赖,跟主应用的生命周期对齐。
4.2 竞态下的用户可见结果:如何用AbortController兜底
另一个高频翻车点是竞态。我做过一个搜索框,输入关键词后请求联想列表,用户快速切换搜索条件,两个请求几乎同时发出。网络差的时候,旧请求可能比新请求晚返回,结果列表显示了旧条件下已经过期的数据,用户以为系统出 bug 了。
这在异步加载里一样会出现,尤其是动态 import 的资源到达顺序不确定时。解决办法是用 AbortController 取消旧请求:
const controller = new AbortController(); fetch('/api/search', { signal: controller.signal }) .then(...) .catch(err => { if (err.name === 'AbortError') return; // 其他错误正常处理 }); // 新请求发起时: controller.abort();动态 import 本身不支持 AbortController,但你可以封装一层:记录当前要加载的 chunk 版本,回调时校验版本号,旧版本直接丢弃。核心思想一致:异步操作完成后,先确认它是不是最新一次发起的,再更新 UI。
4.3 错误处理和加载失败的补偿机制
异步加载的另一面是:失败率比同步高。同步脚本挂在 HTML 里,CDN 挂了浏览器直接白屏,错误很显眼。异步脚本(特别是动态 import)失败时,页面主体可能已经渲染出来,只是某个功能区没有任何反馈,用户感觉“按钮点了没反应”。
所以动态 import 一定要写错误处理:
button.onclick = async () => { try { const { default: editor } = await import('./editor.js'); editor.open(); } catch (e) { fallbackEditor(); } };如果失败是网络抖动,可以再加一层重试:重试两次,间隔 1 秒、3 秒。但不要无限重试,会增加服务端压力。另外,chunk 文件上线后会带内容哈希,发布后用户停留在旧页面,动态 import 的旧 chunk 可能已经 404,这时要监听错误并触发整页刷新,或者提示用户刷新。
实际项目里,我会把 chunk 加载错误上报到监控平台,配上用户操作的上下文。等真遇到白屏、功能不可用时,这些错误日志能帮你快速定位到底哪个资源没加载出来。
5. 移动端与Android启动性能:异步加载在原生与手游场景的变体
5.1 移动网络下并发连接限制与HTTP/2的关键作用
前面讲的都是浏览器里的异步加载,但移动端环境和桌面端差异很大。最直观的一个差异是网络。移动网络 RTT 往往在 50ms 到 200ms,甚至更高,一次 TLS 握手可能就要几百毫秒。HTTP/1.1 下浏览器对同一域名只有 6 个并发连接,把首屏拆成 20 个小资源,瀑布流会非常长,首屏时间会被排队拖垮。
这就是为什么移动端页面反而要尽量控制首屏请求数,哪怕用大文件,也比一堆小文件好。HTTP/2 的多路复用解决了同一连接上的并发请求问题,但它不改变服务端处理逻辑,也没法消除大 RTT。所以在移动端做异步加载,更要注意“合并还是拆分”的平衡:合并能减少请求,拆分能按需加载,具体取舍取决于项目形态。
preload 在移动端的价值尤其大,因为首屏图片和字体的加载时机往往可以通过 preload 提前。但 preload 资源如果超过 1 到 2 个,收益就明显下降,有限带宽被预加载消耗了。移动端优化更讲究“只预加载最关键的一两个资源,其它交给浏览器默认的优先级调度”。
5.2 Android启动过程中的异步化:线程调度与异步初始化
原生 Android 应用启动性能和“异步加载”是很强的对应关系。Application.onCreate 是很多第三方 SDK 初始化的集中地,很多团队把初始化逻辑全堆在主线程里,冷启动就会出现一长串长任务,用户点开 App 几秒钟都没有画面。
优化思路和前端异步加载如出一辙:把主线依赖之外的任务挪到异步线程执行。比如把埋点 SDK、图片缓存、网络库预热放到 IO 线程,主线程只保留 UI 框架、业务首屏真正需要的数据和组件初始化。实现上有几种做法:
- Executor / Handler:把任务分发到后台线程,完成后再切回主线程更新 UI;
- 启动器框架(如 AndroidX Startup、App Startup):用有向无环图调度初始化任务,声明依赖关系,让不相关的初始化并行执行;
- 启动时段监控:Debug 下用 StrictMode、BlockCanary 抓主线程耗时调用栈。
但这里有个和前端一样的规矩:异步初始化依然要保证依赖顺序。有些库之间看似无关,其实内部都写了一个 shared prefs,后初始化的会覆盖前初始化的值。如果不在启动器里显式声明依赖,线上就会冒出各种怪异的偶发问题。
5.3 手游性能优化视角:异步加载并非银弹,关键是帧预算
手游性能优化里的“异步加载”也经常被提起,比如资源异步加载、场景异步加载。但手游和网页最大的不同是有一个 16.6ms 的帧预算:渲染一帧的时间超过它,画面就会卡顿。异步加载可以把资源加载放到子线程,避免卡住主线程,但子线程加载完成后,资源使用、贴图上传、场景对象实例化这些操作最终还是要回到主线程。
如果异步加载做得不好,你会看到帧耗时图上出现一个尖峰:加载完成瞬间,大量资源同时提交到 GPU,那一帧的时间暴涨到 50ms 甚至更高。这和前端“异步脚本下载完后集中执行,执行期间卡顿”是同一个道理。
手游做法一般在异步加载之外再加一层调度策略:限制每帧加载的资源数量,把资源加载分散到多帧,或者预加载下一场景,而不是等切换时才一次性加载。核心不是纠结“是否异步”,而是“异步回来的活儿不要同一帧挤爆主线程”。
6. 一些实际操作中的体会
我自己在性能优化上踩过不少坑,最后想分享一个最容易被忽略的经验:每次改动前,先记录三项数据——FCP、LCP 和 INP,移动端则记启动耗时和帧率。没有基线就不存在优化,你可能改完自我感觉良好,一上线用户反馈照样卡。
另一个体会是取舍要果断。异步加载和性能优化做到最后,大多是在做减法:把关键路径上没必要的资源一个个请走,把关键路径上真正需要的资源安排明白。删的时候要果断,加回去的时候要谨慎。真能把控住这个节奏,页面性能通常不会差。