做了这么多年Web前端和移动端性能优化我越来越觉得“异步加载”这四个字就是所有性能优化的地基。很多人一提到性能优化第一反应就是压缩图片、上CDN、换框架这些当然有用但都更像是给表面症状贴膏药——如果没搞明白脚本和资源到底是怎么被加载、怎么被解析、又是怎么把页面卡住的那优化永远只能在猜。这篇就专门把异步加载这层窗户纸捅破从原理角度说清楚浏览器为什么会被一个脚本阻塞、defer和async到底差在哪、动态加载和preload/prefetch应该怎么配合以及移动端和游戏场景里异步思想是怎么落地成启动速度和帧率的。文章偏原理但每一步都会配实操案例和可复现的代码适合对性能优化有基础认知、想彻底搞懂里面门道的同学。1. 异步加载的本质从“排队结账”到“分窗口办理”1.1 浏览器“单线程排队”是如何拖慢页面的先问一个问题当你在地址栏输入一个网址浏览器拿到HTML之后它是怎么把页面展示出来的很多人答不上来细节但性能优化恰恰就藏在这些细节里。浏览器解析HTML的过程可以粗略理解成一个流水线一边读HTML文本一边把它转换成DOM树。读到script srcxxx.js的时候事情就变得麻烦了——浏览器必须停下来等这个脚本下载完再执行完然后才能继续往后解析HTML。也就是说一个普通的同步脚本会让整个HTML解析进程“卡住”而且下载和执行这两个阶段都不能跳过。这个行为是早期浏览器设计时定下的规矩原因是脚本可能会通过document.write修改HTML内容如果不先执行完后续解析到的DOM可能就是错的。这个模型我用一个生活化类比来解释早期浏览器相当于一个只有一个收银台的超市每个顾客都得排队。你前面那个人买了一大堆东西光扫码和装袋就要三分钟后面所有人只能干等着。对应到页面上这个“大顾客”就是一个体积很大的同步JS文件它一站到“收银台”前面后面的DOM节点、图片、样式表全都没法被解析和处理。用户感受到的直观结果就是白屏、卡顿、首屏内容迟迟出不来。不仅仅是脚本会阻塞CSS也会。你可能会说CSS不是用来渲染的吗怎么还会拖慢速度答案是CSS文件会阻塞渲染树的构建。浏览器必须把CSS全部下载并解析成CSSOM才能把DOM和CSSOM合并成渲染树。如果CSS长时间不加载完即使HTML解析完了页面也不会绘制出来白屏时间照样拉长。搞懂这个“排队模型”之后异步加载的意义就非常清楚了所谓异步加载本质上是把这些会阻塞主流程的资源从“唯一收银台前的排队队列”里挪走让它们走“另外的窗口”或者干脆错峰处理。1.2 关键渲染路径异步加载的主战场在性能优化领域“关键渲染路径”这个词你一定会经常听到。它的含义是从拿到HTML到屏幕上出现画面中间必须要经历的那条路径。路径上的每一步缺失页面就多白屏一秒。典型的关键渲染路径长这样下载HTML文档。解析HTML构建DOM树。下载并解析CSS构建CSSOM树。把DOM和CSSOM合并生成渲染树。计算每个节点的位置和大小也就是布局Layout/Reflow。把渲染树绘制到屏幕上Paint。“关键”里的每一步都有一个共同特点主流程必须等到它完成才能继续下一步。而异步加载优化的核心思路就是审视这条路径上每一个资源问自己三句话这个资源是首屏必须的吗能不能晚点加载有没有办法让它在后台先准备好我拿一个真实优化案例来说。之前有个项目首屏有一个大Banner图图片本身就800KB同时页面上还引入了一个全站通用的数据统计脚本体积有200KB。优化之前浏览器解析HTML时先遇到统计脚本被强制等它下载执行等HTML解析完了又发现Banner图是从另一个慢接口拿地址的于是又等图片。整个首屏的LCP最大内容绘制时间拉到了4秒以上。优化动作很朴素统计脚本改成异步加载Banner图地址直接埋进HTML并用preload提前下载。这两个动作做完LCP掉到了1.8秒。这个案例说明了关键路径优化的一个基本原则能被移出关键路径的资源一律移出去不能被移出去的就让它尽快到达。异步加载就是第一种手段preload就是第二种。2. 手写一套异步加载方案从defer/async到动态注入2.1 defer、async到底差在哪在HTML里给script标签加defer或async属性是最同步、最直接的异步化手段。很多人知道这两个属性但真正问起来“执行时机”“执行顺序”“对DOMContentLoaded事件的影响”能答全的人不多。我把两者的区别整理成一张对照表对比维度deferasync下载阶段解析HTML的同时后台下载不阻塞解析解析HTML的同时后台下载不阻塞解析执行时机等待HTML解析完成后按文档顺序执行下载完成后立即执行不分先后顺序执行顺序多个defer脚本按自上而下顺序执行多个async脚本谁先下载完谁先执行DOMContentLoaded事件在DOMContentLoaded之前执行不保证可能在之前也可能在之后适用场景对顺序有要求的脚本比如依赖关系明确的功能模块完全独立的脚本比如统计、埋点、广告代码上的区别最直观!-- 阻塞解析下载完立即执行 -- script srca.js/script !-- 不阻塞解析HTML解析完后按顺序执行 -- script defer srcb.js/script !-- 不阻塞解析下载完立即执行顺序不保证 -- script async srcc.js/script你仔细看表格会发现defer和async虽然都不阻塞HTML解析但它们对“执行秩序”的态度完全不同。defer是“等所有人都到了按编号进场”async是“谁跑得快谁先进没有先来后到”。因此如果脚本之间有依赖关系比如b.js需要用到a.js里定义的函数这时候两个脚本都用defer是安全的但如果用async就可能会因为下载速度差异导致b.js先执行然后报“XXX is not defined”。另外有个小细节很容易被忽视defer属性在内联脚本也就是script代码直接写在标签里/script这种形式上是不生效的它只对外部资源文件有效。同样async属性对内联脚本也没意义因为内联脚本没有“下载”这个过程。2.2 动态脚本加载最灵活的异步化手法除了标签属性动态创建script节点也是一种非常经典、非常可控的异步加载方式。原理很简单用JS创建一个script元素设置src然后把它塞进document.head浏览器就会异步去加载它并且不会阻塞当前页面的解析。一个最基础的实现长这样function loadScript(src, callback) { const script document.createElement(script); script.src src; script.onload () callback(null, script); script.onerror () callback(new Error(加载失败: src)); document.head.appendChild(script); } // 使用示例 loadScript(https://example.com/sdk.js, (err, script) { if (err) { console.error(err); return; } // SDK加载完成后再调用里面的方法 window.SDK.init(); });在实际工作中我更推荐把回调改成Promise代码的复用性和维护性都会好很多function loadScript(src) { return new Promise((resolve, reject) { const script document.createElement(script); script.src src; script.onload resolve; script.onerror () reject(new Error(加载失败: src)); document.head.appendChild(script); }); } async function init() { await loadScript(a.js); await loadScript(b.js); // 这里可以安全地使用a.js和b.js暴露的方法 }这其实就形成了一个轻量的“依赖加载器”先加载底层库再加载功能模块最后再执行业务代码。整个过程完全不阻塞页面渲染同时执行顺序又被Promise串起来了。需要提醒一句动态插入的script默认是异步的也就是它不会等前面的脚本执行完也不会等HTML解析完。如果你动态插入两个有依赖关系的脚本仍然要小心顺序问题。我通常会先把第一个脚本await完成再插入第二个而不是一次性把两个都appendChild进去。2.3 细粒度的“提前量”preload、prefetch与modulepreload异步加载解决的是“不阻塞”但资源下载本身还是要花时间的。如果想要更进一步让浏览器在空闲时间就把未来要用到的资源提前拉下来就需要preload和prefetch出场了。preload的语义是这个资源当前页面就一定用得到而且很快就要用请你现在就去下载不要等我解析到再开始。典型场景就是首屏背景图、首屏关键字体以及某些需要提前加载的脚本。用法很简单!-- 提前加载首屏大图 -- link relpreload hrefimages/hero.jpg asimage !-- 提前加载首屏必需的字体 -- link relpreload hreffonts/main.woff2 asfont typefont/woff2 crossorigin !-- 提前加载首屏必需的脚本 -- link relpreload hrefcritical.js asscriptprefetch则相反它的语义是这个资源当前页面用不到但用户接下来很可能会跳转到某个页面那边要用。浏览器会在网络空闲的时候去下载它相当于提前做功课。典型场景就是提前拉取下一个路由的JS分包、详情页的图片数据等。modulepreload是ES Module场景下的preload。当你用import引用模块依赖时浏览器需要先下载模块文件再解析它的import语句再一层层下载依赖。modulepreload可以告诉浏览器把模块及其依赖一次性都提前拉好避免运行时的串行等待。这里有一个非常大的坑我单独拎出来说preload是一个“高优先级”的请求如果加错了资源不仅不会优化反而会拖慢页面上真正关键资源的下载速度。比如你把一个非首屏的轮播图设置成preload它就会跟首屏字体抢带宽结果首屏LCP反而更差。所以preload的原则是只给关键渲染路径上的资源用而且数量要克制。另外preload字体的时候必须加上crossorigin属性哪怕字体资源跟页面同源。因为字体加载走的是CORS流程没有这个属性的话字体文件会被浏览器拒绝使用等于白下载了。2.4 HTTP/2带来的新思考合并还是拆分理解异步加载之后你可能会走上另一个极端把所有脚本全部拆成小文件全都异步化是不是就最优了这个想法在HTTP/1.1时代确实会坑到你。HTTP/1.1对同一域名下的并发连接数有严格限制不同浏览器普遍是6个左右。如果你把本来一个100KB的文件拆成100个1KB的小文件浏览器在同一时间只能并发发起6个请求剩下的94个都在排队。整个加载过程反而比一个大文件更慢。HTTP/2解决了这个问题。它引入了多路复用同一个域名下的所有请求可以共享一条TCP连接并行传输互相不排队。在这种场景下拆小文件、按需加载就变得非常划算我可以精确地只加载当前路由需要的代码走异步加载用户跳转后再加载其他部分这就是现代前端打包工具里“代码分割Code Splitting”的底层逻辑。所以我的经验判断是如果你的站点还在HTTP/1.1时代国内部分老旧网络环境仍然存在优先走“合理合并按页面拆分”不要追求碎成渣的颗粒度。如果站点已经上了HTTP/2大胆做代码分割把首屏之外的所有模块异步化并用prefetch预取下一个路由的资源。判断是不是HTTP/2打开DevTools的Network面板看协议列显示h2就是显示http/1.1就不是。3. 异步加载在移动端与游戏场景的落地3.1 Android启动性能冷启动场景下的异步加载实践热词里有一个“优化android启动性能”这正好是异步加载思想在移动端最典型、也最见效果的应用场景。Android应用的冷启动简单说就是用户点开图标系统创建进程Application执行onCreate然后创建MainActivity、渲染第一帧。这段过程里Application.onCreate是重灾区。很多应用会在这里做各种SDK初始化、数据库打开、图片缓存预热、网络框架配置其中任何一个操作是同步的、耗时的都会让用户感觉“点图标之后白屏好久”。我之前优化过一个应用Application.onCreate里同步初始化了四个SDK单这一个方法的耗时就接近900毫秒。整个启动过程用户看到的静止画面时间极长特别难受。异步加载在这里的思路就是能后台做的绝不占用主线程能晚点做的绝不提前做。最直接的改造方式是并行初始化public class App extends Application { Override public void onCreate() { super.onCreate(); // 线程池异步执行不阻塞主线程 ExecutorService executor Executors.newFixedThreadPool(4); executor.execute(() - initImageLoader()); executor.execute(() - initAnalyticsSDK()); executor.execute(() - initDatabase()); // 主线程只做必须同步完成的活 initCrashHandler(); } }这里有一个极其关键的细节异步化不等于乱序化。如果某个模块B必须在模块A初始化完成后才能用那么你在executor.execute里同时丢两个任务、但不做任何等待就是在埋雷。比如分享功能依赖登录模块的数据登录模块还没初始化完用户在首页就点了分享那就会崩溃。这种场景要么用带依赖关系的异步框架比如AndroidX里的App Startup库要么在业务调用处做“懒加载”——第一次真正使用时再初始化并且做好状态保护。Android官方其实提供了一个非常好的方案叫App Startup库。它的做法是把初始化逻辑放到一个InitializationProvider里通过ContentProvider的机制在应用启动早期自动初始化而且支持你指定哪些组件需要同步、哪些可以异步、哪些还需要依赖关系排序。如果你现在还在手写线程池做初始化调度建议看看这个库能让代码干净不少。另外Android里还有一类非常典型的启动优化不要在onCreate里同步加载大资源。比如首屏要用到一张高分辨率启动背景图如果直接在onCreate里读文件、解码成Bitmap这个过程是很重的。正确做法是用异步方式预解码或者先用低分辨率占位图显示等图片加载完再替换。3.2 手游性能优化异步加载与分帧加载手游性能优化里的异同其实比Android启动场景更复杂一些。游戏的帧率要求是16.6毫秒一帧60帧一旦主线程在某帧内做了超过这个时间的事情玩家就会感觉到掉帧、卡顿。游戏里最常见的卡顿来源之一就是资源加载。进入新场景的时候需要加载模型、贴图、音频、预制体如果这些都在同一帧里同步加载轻则这一帧卡死几十毫秒重则直接内存爆涨、触发系统杀进程。解决思路是把“一次性大加载”变成“切片加载”我总结成四个字异步、分帧、预算、缓存。异步很好理解资源加载全部放到后台线程主线程只负责接收“加载完成”的消息。分帧则更进一步即使后台加载完成了把资源真正实例化进场景的操作也不应该一帧内全部做完而是每帧只处理几个让CPU和内存的负担被摊开。内存预算是什么意思就是加载新场景之前先算一算当前已用内存还剩多少超过阈值就先释放或者压缩旧资源再加载新的。缓存就不用多解释了同一个模型、同一张贴图第二次使用的时候直接从内存缓存里拿而不是再从磁盘读。举一个简单例子一个战斗场景需要加载20个角色模型。如果一次性加载可能瞬间需要600MB内存低端机会直接闪退。按分帧加载的思路我一般把它化简成一个队列每次处理2个每帧只处理一次队列总共分10帧来做。这样内存峰值被控制住了每一帧的耗时也不会突破预算。用户看到的loading进度条本质上就是这个队列的进度反馈。这类方案的底层逻辑和前端异步加载的“不阻塞主流程”是完全一致的。游戏引擎里的Resources.LoadAsync、Addressables.LoadAssetAsync这类API本质上就是把资源加载从主线程剥离出去。3.3 跳出WebJulia性能优化与内存管理里的异步影子热词里出现了“julia性能优化与内存管理”很多人会觉得Julia跟浏览器异步加载八竿子打不着。但实际上你把“加载耗时工作”这几个字抽象出来会发现它们的底层心法是一模一样的。Julia是个科学计算语言它在性能优化上的一个大痛点是“首次运行慢”——因为Julia是JIT编译函数的代码会在第一次被调用时编译然后被缓存。如果你在程序主流程里第一次调用一个重量级函数用户感受到的就是卡顿。于是Julia社区的常见优化手段包括提前预编译、把编译好的缓存存起来、或者在程序后台用任务并行预热那些后面才会用到的函数。这个思路跟前端用preload提前拉资源、跟Android用一个后台线程预初始化SDK本质上说的都是同一件事。在内存管理上Julia强调减少不必要的内存分配因为大量临时对象的分配和回收会引发GC压力造成“世界暂停”。这和手游里的内存预算控制也如出一辙都是把“某个时间点突然需要大量资源”的这个尖峰削平让程序运行得更平滑。所以我想表达一个观点异步加载不是一个HTML标签、不是一个浏览器API它是一套通用的工程思维。无论是Web脚本、Android初始化、游戏资源还是科学计算语言的JIT编译优化的方向永远是把耗时操作从关键路径上移走要么提前做要么推迟做要么并行做最差的选择才是堵在主流程里硬等。4. 性能优化不靠感觉指标体系与度量方法4.1 到底该看哪些指标异步加载做得对不对不能靠“感觉快了”。优化做得好不好一定要有数据支撑。Web领域我日常工作主要盯这几个核心指标。FCPFirst Contentful Paint首次内容绘制页面上第一次出现文本、图片、画布等内容的时间点。它反映的是“白屏结束没有”。LCPLargest Contentful Paint最大内容绘制页面中最大可见元素通常是首屏大图或标题被渲染出来的时间。LCP是用户感知“页面加载完成”的关键指标也是目前Web性能审核里权重最高的指标之一。CLSCumulative Layout Shift累计布局偏移页面加载过程中元素发生意外位移的累计程度。异步加载如果没做好占位图片加载完把布局挤了一下CLS就会飙升。TBTTotal Blocking Time总阻塞时间从FCP到页面可交互之间所有长任务阻塞主线程时间的总和。这个指标和异步加载的关联最紧密——脚本执行时间越长TBT越高。TTITime to Interactive可交互时间页面能够稳定响应用户输入的时间点。不用背定义你只需要记住一个判断FCP看白色LCP看主体CLS看乱不乱TBT看卡不卡。异步加载优化的核心目标就是让FCP变早、LCP提前、TBT降下来同时别因为加载顺序问题把CLS搞上去。4.2 用工具和代码量化异步加载的效果Chrome DevTools是最常用的度量工具它内置了Performance面板和Lighthouse。但我要特别推荐一个更贴近工程实践的方式用PerformanceObserver在代码里直接监听指标把它上报到监控平台。这样你就能看到真实用户在真实网络环境下的表现而不是只在开发机上看着爽。监听LCP的代码是这样new PerformanceObserver((entryList) { const entries entryList.getEntries(); const lastEntry entries[entries.length - 1]; console.log(LCP:, lastEntry.startTime, lastEntry.renderTime || lastEntry.loadTime); }).observe({ type: largest-contentful-paint, buffered: true });监听长任务对应TBT的代码是这样new PerformanceObserver((entryList) { for (const entry of entryList.getEntries()) { // entry.duration 就是这个long task的阻塞时长 console.log(Long Task:, entry.duration, entry.startTime); } }).observe({ type: longtask, buffered: true });移动端也一样。Android的启动耗时可以用Debug.startMethodTracing()配合Profile工具查看更精细的做法是用Systrace或者Perfetto抓trace看Application、Activity、布局、绘制各阶段各自花了多少时间。拿到数据之后再定位是同步初始化耗时高还是布局复杂耗时高才不会眉毛胡子一把抓。这里有一个经验度量的代码一定要早一点挂上最好在HTML head里就用内联脚本把监听器注册好否则你监听器还没注册指标已经发生了这段数据就丢了。4.3 从指标反推优化动作不同指标异常对应的异步加载优化方向完全不同我把常见对应关系整理如下指标表现大概率原因异步加载相关优化动作FCP慢CSS阻塞渲染、关键资源下载慢内联首屏关键CSS非关键CSS延迟加载LCP慢首屏大图加载太晚、关键脚本阻塞图片加preload阻塞脚本改async/deferTBT高同步JS执行过长、长任务太多大脚本异步加载/代码分割长任务拆片CLS高图片/广告异步加载后没占位给容器设置宽高占位或者用aspect-ratio移动端启动慢Application同步初始化多初始化异步化、懒加载、App Startup库这条规则反过来也成立你每次做了优化都应该回过去看对应指标有没有变化。没有变化的优化要么是没做到位要么是根本没打中要害。5. 常见问题与排查技巧实录5.1 异步加载完成了但调用时报“undefined”这是一个非常经典的坑。你把某个功能脚本改成了异步加载页面看起来不卡了但用户快速点击页面上的按钮时发现调用的函数是undefined——因为脚本还没加载完。排查思路很清晰任何依赖异步脚本的逻辑都必须放在脚本加载完成的回调之后执行。不要在HTML里直接内联调用这个SDK的方法也不要在DOMContentLoaded里假设它已经存在。更稳妥的做法是用我前面说的loadScript封装把所有依赖逻辑包进Promise的then里或者在脚本初始化完成时主动派发一个自定义事件业务方监听这个事件再干活。5.2 defer和async混用执行顺序乱成一锅粥如果你页面上同时存在defer脚本、async脚本以及普通同步脚本要非常谨慎。async脚本的执行时机完全不可预测它可能在任何时间点执行。如果async脚本依赖defer脚本里的内容那基本必炸。我的建议是有依赖关系的脚本统一用defer保证顺序完全独立、没有依赖的才允许用async。如果项目复杂到今天这个底部依赖明天那个框架直接上模块化工具和打包器让工具去处理依赖关系和加载顺序不要手工裸写一堆script标签。5.3 preload用错了反而把首屏拖慢preload的本质是“插队”它把一个请求的优先级提到了前面。如果你把首屏不需要的资源preload了它就会排到关键资源前面占用带宽和连接最后关键字体的下载被延后LCP反而变差。所以我的实操准则是preload只给首屏真正关键、且你确定马上要用的资源。拿不准的资源一律不用preload宁可让它晚个几百毫秒也不能让它插队害了别人。prefetch则恰恰相反只用于“未来可能用”它的优先级最低浏览器会在空闲时才下载安全得多。5.4 拆包拆得太碎Http请求排队前面讲过HTTP/1.1的6连接限制。如果你在HTTP/1.1环境下把代码拆成上千个小碎片浏览器会陷入严重的排队等待其表现是瀑布图里大量请求都在“Queued”状态实际下载时间反而不如一个大文件快。遇到这种情况先看协议是HTTP/2就放心拆是HTTP/1.1就适当合并。另外不管什么协议都要注意别把“体积很小的公共依赖”拆出来比如几个工具函数只有几十行单独拆成文件反而增加请求开销不如直接打包进bundle里。5.5 一套可复制的排查方法最后分享一个我实际用了很久的排查流程。我不喜欢一次性把所有优化都上了因为那样你根本不知道是哪个改动起了作用。我的步骤是先用Performance面板录制一次完整加载拿到优化前的指标基线。看瀑布图里每个资源的阻塞时间重点找“让下一个请求等了很久”的资源。从耗时最长的阻塞资源下手只做一件事把它异步化或者preload掉。重新录制对比指标。如果指标有明显改善保留如果没有回滚换下一个方案。重复以上过程直到优化目标达成。这套“单变量实验”的做法本质上是把性能优化变成一门可复验的工程而不是靠运气调参。我在实际优化过程中体会最深的一点是异步加载真正难的不是写代码而是想清楚每一份资源的核心优先级以及用什么方式加载它才不会挡住主流程。我见过太多项目把async、preload、代码分割一股脑全加进去结果性能面板反而更难看了。原理吃透再做单变量实验这套方法放Web、Android还是游戏场景都走得通。最后再分享一个见效最快的小组合首屏外的JS默认全部异步加载同时把首屏最大图片用preload提前下载。这个组合在我优化过的多个项目里都拿到了最稳定的收益你可以从这一步开始试。