看到“异步加载”这四个字大多数人第一反应是给 script 加个 async 属性或者把路由改成懒加载。但如果你只做到这一步说明还停留在工具层面。真正理解异步加载要回答的是浏览器在加载页面时为什么必须同步异步到底改变了哪一段关键路径从 FCP 到 INP哪些指标会因为你把一段逻辑延后而变好哪些指标反而会恶化这篇文章是原理篇不讲某个具体框架的配置只讲机制、取舍和排查思路。适合那些已经写过 async/defer/懒加载但想知道“为什么”的前端开发者也适合做移动端、手游性能优化的同学对照参考。1. 浏览器天然是同步的性能瓶颈的来源在聊异步加载之前先把最根本的问题说透浏览器在加载网页的时候为什么非要“同步”你打开一个 HTML 页面浏览器拿到第一块字节之后就开始解析边下边解析这个过程虽然是增量的但每一步都有明确的前后依赖。这个依赖关系是所有性能问题的起点。1.1 HTML解析、样式计算与JavaScript执行是怎么串成一条链的浏览器渲染一个页面大约要做这几件事HTML 解析成 DOM 树CSS 解析成 CSSOM 树两者合成渲染树再进行布局和绘制。关键点在于JavaScript 可以同时修改 DOM 和 CSSOM。你说 document.createElement(div)解析器就得停下你说 el.style.width100px样式树也要重算。这意味着什么一个没有特殊标记的 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 底部或者加 deferdomInteractive 的数值会差很多。第一次看到这种差距的人往往会惊讶一个脚本居然能拖住整个页面的渲染。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关系无修饰发现即阻塞下载下载完立即执行按文档顺序阻塞DOMContentLoadedasync并行下载下载完立即执行不保证顺序可能阻塞也可能不阻塞defer并行下载HTML解析完成后执行保证文档顺序在DOMContentLoaded之前执行选型经验法则很简单如果多个脚本之间有依赖关系老老实实用 defer因为它既有并行下载的优势又保证执行顺序。如果是一段完全独立的脚本比如埋点统计、AB实验、广告脚本用 async 没问题谁先回来谁先执行业务上不依赖彼此。typemodule 的脚本默认就是 defer 行为这也是现在 ESM 项目可以直接用原生的原因。但要注意defer 脚本会在 DOMContentLoaded 之前执行它依然占用主线程。如果你把一堆重量级脚本全部 deferDOMContentLoaded 还是会被拖慢只是不再有“下载断档”那么明显。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哪个指标吃异步加载的红利衡量异步加载的收益不能靠感觉得落回到指标上。我经常用三个指标来判断FCPFirst Contentful Paint页面上第一个内容出现的时间。FCP 对渲染阻塞脚本极其敏感。把非关键的脚本从 head 里拿掉、改成 defer 或 asyncFCP 通常能明显提前。LCPLargest Contentful Paint最大内容的渲染时间。LCP 受异步加载的影响是间接的如果 LCP 元素是图片脚本阻塞解析可能会延迟图片请求的发出如果把非 LCP 区域的图片 lazy 了释放了带宽LCP 可能改善。但如果把首屏图片也 lazyLCP 反而会恶化。INPInteraction 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);捕捉长任务可以用 PerformanceObserverconst 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移动端则记启动耗时和帧率。没有基线就不存在优化你可能改完自我感觉良好一上线用户反馈照样卡。另一个体会是取舍要果断。异步加载和性能优化做到最后大多是在做减法把关键路径上没必要的资源一个个请走把关键路径上真正需要的资源安排明白。删的时候要果断加回去的时候要谨慎。真能把控住这个节奏页面性能通常不会差。