
1. 知道要优化什么才算真正开始做前端和移动端开发这几年我最大的体会是性能优化这件事80%的问题都出在“不知道瓶颈在哪”而不是“不知道怎么改”。异步加载是手段性能优化是目标但很多团队把这两件事搞反了——先上异步再谈优化结果代码拆得七零八落首屏该慢还是慢。先聊清楚异步加载到底在解决什么问题。一个页面从用户输入URL到最终可交互中间经历 DNS 解析、TCP 连接、HTTP 请求、HTML 解析、CSS 构建、JavaScript 执行、渲染合成等一长串步骤。任何一步如果阻塞了主线程用户看到的画面就会“卡住”轻则白屏几百毫秒重则完全无响应。异步加载的核心逻辑非常朴素把不紧急的任务往后排把阻塞主线程的任务挪出去让关键路径优先完成。但在实际落地时这个概念被严重滥用。有人把所有脚本都加上defer有人把初始化逻辑全塞进requestIdleCallback还有人干脆把所有资源都改成懒加载——这些做法表面上是“异步”实际上只是把问题往后拖甚至可能拖出新的问题比如可交互时间反而变长、长列表滚动掉帧、页面切换出现白屏闪烁。性能优化则更复杂它不是一个单一指标而是一组指标的平衡。首屏渲染速度要快交互响应要灵敏滚动要顺滑内存不能涨得太快电量不能消耗太多——这些目标之间往往存在冲突。把图片全换成懒加载首屏指标确实好看了但用户滚动时如果加载不及时体验反而更差把资源全部分包拆散缓存利用率下降二次访问的速度可能变慢。这篇文章我想从原理层面把异步加载和性能优化的关系彻底讲透再结合移动端启动优化、资源加载优化这些实际场景给出能直接落地的方案和踩坑记录。适合刚接触性能优化的前端开发、Android开发也适合已经在做优化但总觉得“差了点什么”的从业者。读完你会发现理解和选择往往比动手更重要。2. 异步加载的核心机制事件循环、任务队列与控制权转移2.1 事件循环JavaScript 是怎么“排队”的要用好异步加载首先得理解运行环境是怎么调度代码的。浏览器和 Node.js 的 JavaScript 引擎都基于事件循环模型。简单说主线程维护着一个调用栈和两个任务队列宏任务队列macrotask和微任务队列microtask。每次事件循环迭代引擎会先从宏任务队列里取出一个任务执行执行完毕后会清空微任务队列中的所有任务然后进行渲染更新再继续取出下一个宏任务。这个机制决定了异步代码的执行时机并不是“调用后就立刻执行”而是取决于它被放进哪个队列、前面还有多少任务在排队。理解这一点后就不难明白很多性能问题的根源大任务阻塞如果主线程上有一个执行时间很长的同步任务事件循环就一直卡在“执行宏任务”这一步后面的渲染、输入响应统统排队等待。我们看到的表现就是“点了按钮没反应”。微任务滥用Promise的回调属于微任务它会在当前任务结束前被清空。如果在循环里连续创建大量微任务它们会在同一轮事件循环里全部执行完可能阻塞渲染。看起来“异步”了其实还是同步阻塞。宏任务过重setTimeout回调属于宏任务执行时机比较靠后适合拆分重任务。但如果不加控制地分批抛宏任务可能把渲染帧之间的空闲时间全占掉导致动画掉帧。我在给团队做分享时常用一个例子异步不等于“不干活”只是“安排干活的顺序”。就像餐厅后厨炉火只有一个灶孔主线程先做凉菜关键路径任务把炖汤耗时任务放在另一个灶上慢慢煨Web Worker再让洗碗工在空闲时处理餐具空闲时间任务——如果所有菜都推给一个灶孔顾客只会等到饿死。2.2 从回调到 Promise 再到 async/await控制权的演进异步加载的代码形态演进过程也是一个控制权逐渐清晰的过程。早期我们用回调函数loadScript(a.js, function () { loadScript(b.js, function () { loadScript(c.js, function () { // 嵌套地狱出错难以追踪 }); }); });回调嵌套的问题不只是代码难看更麻烦的是你无法在回调之间共享状态很难控制异常更别提取消或超时。到了 Promise 阶段控制权明确了一部分loadScript(a.js) .then(() loadScript(b.js)) .then(() loadScript(c.js)) .catch(err console.error(err));但 Promise 链依然存在一个问题中间任何一个环节如果卡住后面全部等待且无法直观地看到全局流程。真正带来质变的是async/await它让异步代码看起来像同步代码逻辑顺序一目了然同时保留异步调度await loadScript(a.js); await loadScript(b.js); await loadScript(c.js);但这里有一个不少新人会踩的坑await是串行等待。如果a.js、b.js、c.js之间没有依赖关系串行加载会白白浪费大量时间。正确做法是用Promise.all并行加载await Promise.all([loadScript(a.js), loadScript(b.js), loadScript(c.js)]);控制权转移的本质是主线程把“等待结果”这件事交给运行时去处理自己先去执行其他任务。这背后涉及两个关键机制事件循环负责调度任务的执行顺序。微任务和宏任务队列决定了回调的触发时机。理解了这一层才能真正读懂后面所有的优化方案。3. 资源加载与渲染路径的优化关键渲染路径、代码分割与图片懒加载3.1 关键渲染路径先搞清楚什么被卡住了我经常问来请教性能问题的同事一个问题“你页面上最重要的那颗按钮从输入URL到你看到它中间经过了多少环节”大多数人都答不上来这很正常因为我们习惯性把性能优化当成“加缓存”“加CDN”这种监控项而不是当成一条需要逐环节排查的链路。这个链路就是关键渲染路径Critical Rendering Path。它的核心步骤是HTML 解析 - 构建 DOM - 加载 CSS - 构建 CSSOM - 合并为渲染树 - 布局 - 绘制 - 合成。任何一步的资源加载如果阻塞后续全部停下等待。这条路径上最常出现的阻塞问题有三个CSS 文件阻塞渲染浏览器必须加载完 CSS 才会开始首次绘制。如果 CSS 文件太大首屏白屏时间就会拉长。JavaScript 阻塞解析默认情况下script 标签会阻塞 HTML 解析。如果脚本文件在 head 里且没有 defer 或 async浏览器得下载并执行完这段脚本才能继续往下解析 HTML。字体文件阻塞文字渲染使用font-face加载字体时浏览器可能先使用默认字体渲染字体加载完后再次重绘。如果不配置font-display文字可能短暂不可见。解决思路通常分成两条线一是减少关键路径上的资源体积二是把非关键资源从关键路径上挪开。减少体积的手段很多CSS 和 JavaScript 压缩、去除无用代码、开启 Gzip/Brotli 压缩、图片转 WebP 和 AVIF 格式、合理配置缓存策略等。其中最容易立竿见影的是内联关键 CSS把首屏必需的样式直接写进 HTML外部 CSS 文件延迟加载。这样至少能省一次网络往返。把非关键资源挪出关键路径则用到我们这篇文章的主角——异步加载。比如把不是首屏必需的 JavaScript 脚本加上defer或async属性让浏览器先解析 HTML 和渲染页面defer脚本会异步下载但它在 HTML 解析完成后、DOMContentLoaded事件之前执行。多个defer脚本会按顺序执行。async脚本下载完成就会立即执行可能在 HTML 解析过程中执行。多个async脚本的执行顺序不固定适合完全独立的第三方统计脚本。实际选型时我的经验是需要保证执行顺序的用defer完全独立且不操作 DOM 的用async。把第三方统计、广告脚本、埋点脚本加上async能明显减少它们对首屏的影响。3.2 资源加载的“并行度”与“优先级”问题除了脚本的defer/async我们还要考虑加载并行度。浏览器对同一域名下的并发请求数量有限制HTTP/1.1 下通常只有 6 个左右。如果页面加载时 8 个资源同时请求排在后面的 2 个只能等前面的完成才能开始白白浪费一段带宽。解决这个问题的手段域名分片把资源部署到多个域名下突破并发连接数上限。但域名太多会增加 DNS 解析开销所以一般 2~3 个域名即可。升级到 HTTP/2 或 HTTP/3HTTP/2 的多路复用机制允许在一个连接上同时传输多个资源彻底解决了连接数限制问题。CDN 加速把静态资源部署到离用户更近的边缘节点缩短网络延迟。还有一个大家容易忽略的点资源加载优先级。浏览器在解析 HTML 时会给每个资源一个默认优先级比如 CSS 是最高优先级图片是低优先级脚本一般是中优先级。但在某些场景下默认优先级并不是最优的。我们可以用fetchpriority属性手动提示浏览器!-- 首屏最关键的图片可以提升加载优先级 -- img srchero.jpg fetchpriorityhigh !-- 首屏外的图片降低优先级 -- img srcbelow-the-fold.jpg fetchprioritylow我用fetchpriorityhigh优化过一组电商页面的主图加载首屏首图出现时间从 4.2 秒降到 2.8 秒。但要注意这个属性只是一个提示浏览器有最终的资源调度权不要在页面上给一大堆资源都标上high那样会失去意义。3.3 图片懒加载最典型的异步加载场景聊完原理用一个真实场景把概念串起来——图片懒加载。这是最常见、也最容易出错的性能优化手段。懒加载的思路是首屏只加载用户能看到的图片其余图片暂不加载等用户滚动到图片进入视口附近时再加载。最原始的实现在脚本里监听scroll事件判断图片位置和视口边界的关系再动态把src替换成真实地址const observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { const image entry.target; image.src image.dataset.src; observer.unobserve(image); } }); }); document.querySelectorAll(img[data-src]).forEach(img observer.observe(img));这段代码用了IntersectionObserverAPI它的性能远好于监听scroll事件——它由浏览器原生实现在元素进入视口时异步回调不会阻塞主线程。这是“异步加载 性能优化”一个很好的结合点。现代浏览器已经内置了loadinglazy属性可以直接使用img src... loadinglazy alt...不过原生懒加载有几个局限图片在页面里位置偏下时才生效且浏览器对loadinglazy的图片有一定预加载机制。在一些对图片加载时机特别敏感的场景比如要求图片进入视口前 200px 就提前加载自定义IntersectionObserver仍然是更可控的方案。懒加载的另一个升级方向是占位与渐进式加载。首屏图片加载时可以先用一张极小的模糊占位图通常是原始图片缩小到 20px 宽的版本体积只有几百字节真正的高清图加载完成后替换。这就是所谓 Low Quality Image PlaceholderLQIP技术。它的核心价值是让用户“感觉”图片加载得很快而不是真的把加载时间缩短了。3.4 代码分割让应用“按需执行”单页应用越做越大JavaScript 包体积从 1MB 涨到 5MB 甚至更大是常事。用户打开页面时浏览器要下载、解析、执行整个 bundle 的全部 JavaScript即使页面首屏只用了其中 10% 的代码。这个阶段的优化核心就是代码分割。代码分割的目标简单说就是只加载当前路由需要的代码其余代码等到用的时候再加载。主流打包工具都提供了动态导入能力// 以 React 为例使用动态 import 实现路由级代码分割 const Home React.lazy(() import(./pages/Home)); const About React.lazy(() import(./pages/About)); function App() { return ( React.Suspense fallback{Loading /} Switch Route path/ component{Home} / Route path/about component{About} / /Switch /React.Suspense ); }这段代码会把Home和About两个组件的代码拆成独立的 chunk用户访问根路径时只加载Home对应的 chunk访问关于页时才加载另一份。这就是“异步加载”在组件级别的应用。代码分割的粒度需要结合实际场景来设计。按路由拆是最稳妥的方案按组件拆可以更细粒度地控制首屏体积但拆得太细会导致请求数量暴增增加 HTTP 往返时间。我的建议是首屏路由按需拆基础组件库整体合并图表库等重型依赖用动态 import按需加载。4. 移动端启动性能优化异步初始化与主线程流水线4.1 Android 启动优化异步初始化到底怎么做移动端的性能瓶颈和 Web 相比多了两个大变量设备性能差异巨大和系统版本碎片化严重。低端安卓手机和中端手机在 CPU、内存、磁盘 IO 上的差距可能有 3~5 倍同一个优化方案在真机上的效果可能天差地别。这一节结合 Android 启动优化来聊异步加载的实际落地。Android 应用启动时Application 的onCreate会执行各类 SDK 初始化网络库、数据库、图片加载库、埋点库、推送库……如果这些全部在主线程同步执行启动时长很容易超过 2 秒表现为点击图标后界面长时间停留在白屏或启动页。常见的优化手段是把初始化任务分为三类必须在主线程且必须同步执行的如Activity生命周期相关逻辑、ContentProvider的初始化。可以放到子线程但必须尽快完成的如数据库初始化、文件缓存预热。可以延迟到空闲时再执行的如埋点 SDK 的初始化、用户行为统计模块。我在实际项目中常用的方案是建立一个小型的异步初始化框架核心思路是关键路径只做最少必要的事其余全部排到非主线程。class App : Application() { override fun onCreate() { super.onCreate() // 第一步主线程只初始化App 运行必需的核心模块 initCoreModules() // 第二步把非核心但耗时较长的初始化放到线程池 Thread { initNetworkSDK() initImageLoader() initDatabase() }.start() // 第三步把可延迟的初始化放到空闲时执行 IdleInitializer.delay { initAnalytics() initPushService() } } }这里面要特别注意一个点凡是放到子线程的初始化都要做好被动初始化兜底。比如网络库在子线程初始化期间用户在主线程发起了第一个网络请求这个请求不能因为网络库还没初始化完成就失败。做法是在请求开始前检查初始化状态如果初始化未完成就先等待或直接在主线程同步触发初始化。这里推荐用CountDownLatch或者协程的Mutex来做状态同步。Android 启动优化的另一个重点在于减少这个“Application 进程”本身的耗时。并不是所有组件都需要在启动时加载很多第三方 SDK 提供了“延迟初始化模式”可以把初始化放到Activity的onResume之后。我优化过一款日活过百万的 App光是把 4 个 SDK 从启动时延到进入主界面后启动耗时就从 1.8 秒降到 1.1 秒。还有一个常被忽视的优化点SharedPreferences 的同步写入。启动时如果调用SharedPreferences.commit()同步提交大块数据磁盘写入会把主线程卡住。在 Android 中这是非常隐蔽的启动性能杀手。建议启动阶段所有偏好写入改为apply()异步提交或者干脆在启动阶段避免写入任何偏好数据。4.2 主线程流水线能异步的别同步移动端性能优化的本质其实是管好主线程的时间片。用户所有的触摸响应、动画渲染、屏幕刷新都发生在主线程上。任何时候主线程被一个耗时任务占据界面就会卡顿。这里有一个很直观的类比主线程就是一条流水线的传送带只能通过一个零件。异步加载就是把那些不紧急的零件从传送带上拿下来放到旁边的仓库里等传送带空了再送过来。如果传送带上堆满了零件后面真正需要加工的零件就只能干等。我在实际调优中总结过一套“主线程时间片分配清单”线程可执行任务不可执行任务主线程UI 更新、响应用户事件、页面跳转、动画驱动大量 IO、复杂计算、网络请求、大 JSON 解析子线程线程池网络请求、图片解码、数据库读写、JSON 解析任何涉及 View 的操作空闲线程Idle埋点上报、预加载下一个页面的数据用户不可感知的预渲染操作这张表背后是一个关键原则线程是有限的资源每类任务必须明确自己该待在哪。很多性能事故都是因为开发者在主线程干了本该属于子线程的活——最常见的是主线程直接做 JSON 解析或者主线程读文件。如果要用一句话概括移动端性能优化的核心心法我会说让主线程只干它该干的事其余的都异步掉。这个原则看似简单真正贯彻执行需要代码评审、工具监控和长期坚持。5. 问题排查与工具化经验从线上事故到速查表5.1 一次线上事故的复盘误用异步导致的性能灾难讲一个我亲身经历的事故。某个版本我们为了提高“首屏速度”把某个统计 SDK 的初始化改成了异步延迟加载。用户启动后统计模块还没加载完成用户就点击了某个功能结果这个功能内部依赖统计 SDK 的某些字段触发了空指针直接崩溃。崩溃率从 0.1% 飙升到 2.3%整整花了三天才定位到原因。这个事故告诉我们异步加载必须伴随着依赖管理和兜底机制。把任务异步化的同时必须明确回答三个问题这个任务异步后有没有其他人依赖它的执行结果依赖方在任务还没完成时访问会不会出异常如果任务执行失败是否需要重试或补偿把这三个问题都回答清楚、写下设计方案之后再动代码。排查这类问题的工具链也很重要。Android 端我常用的有systrace和Perfetto它们能记录主线程上每个任务的执行时间线一眼就能看出哪些任务卡在主线程上。Web 端则用 Chrome DevTools 的 Performance 面板录制页面加载过程逐帧分析主线程的任务分布。工具有很多难的是把工具测出来的数据和代码逻辑对应起来——这需要在代码里加足够的日志和打点否则即使看到“主线程卡了 200ms”你也不知道是哪个模块干的。5.2 工具选型与确定优化方案的方法工具选型的思路不是“热门用什么我就用什么”而是先明确你要观测什么指标再选能拿到这个指标的工具。做性能优化最重要的不是工具而是建立一套可量化的指标基线。我的建议是至少关注四类核心指标首屏时间FCP / LCP用户看到第一帧完整内容的耗时。可交互时间TTI页面从加载到可以顺畅响应用户输入的时间。交互响应延迟INP用户每次点击或触摸后到界面响应的延迟时间。卡顿率滚动和动画过程中出现掉帧的比例。建立指标基线后再用工具排查“哪里耗时长”然后针对耗时的环节设计异步方案。优化的优先顺序是先解决阻塞问题再解决体积问题最后才是网络优化。顺序反了花了大力气却收效甚微。5.3 异步加载与性能优化常见问题速查表最后整理一份我在实际项目中反复遇到、几乎每次性能排查都会用到的问题速查表场景典型表现可能原因排查思路推荐方案首屏慢白屏 3 秒以上关键路径上有大体积 JavaScript 或 CSSDevTools Network 看阻塞资源耗时代码分割、内联关键 CSS、defer/async 脚本图片加载迟滚动到图片位置时短暂空白懒加载触发时机太晚检查 IntersectionObserver 的根边距配置设置 rootMargin 提前加载页面卡顿滚动时掉帧明显主线程有重任务或过度布局Perfetto 看主线程时间线把计算挪到子线程或分解成小块任务启动耗时长点击图标许久才进首页Application 主线程初始化过多systrace 看 onCreate 附近耗时异步初始化、延迟初始化内存增长操作一段时间后内存暴涨异步任务持有引用的对象未释放Memory Profiler 抓内存快照检查回调、清理监听器网络请求并发过高页面加载流量巨大资源拆得太细、请求数过多Network 面板看请求瀑布图合并请求、使用 HTTP/2 多路复用WebView 加载慢H5 页面白屏WebView 内核初始化耗时启动时预热 WebView预创建 WebView 实例或在空闲时初始化这张表覆盖了我这几年处理过的大部分性能问题。实际项目里性能优化不是一个“做一次就完”的任务而是需要持续监控、持续压测的过程。我特别习惯在每个版本上线前跑一轮核心页面的性能回归如果某个指标比上一个版本变差了 20% 以上就去查这个版本引入了哪些新代码、新依赖。6. 踩过的坑和沉淀下来的经验写到这里想起我刚入行时犯过的错误也一并分享出来。第一次负责性能优化时我把所有 JavaScript 资源全部加上了async觉得这样首屏加载一定最快。结果页面结构还没解析完脚本就执行了找不到 DOM 节点直接报错。后来才理解async和defer的区别才明白“异步”不是万能胶贴哪儿都行。第二次是给一个电商页做图片优化把所有图片都换成懒加载结果首屏主图因为loadinglazy延迟出现用户看到的是空白占位反而影响了下单转化率。后来给首图加上了fetchpriorityhigh问题才解决。第三次是在 Android 启动优化时把数据库初始化放到子线程结果某个功能在启动后被用户立刻触发查数据库时空指针崩溃。当时心里很沮丧——明明是按照异步加载的原理来做的为什么还是出问题复盘后才明白异步化的同时必须构建好“依赖同步”机制让依赖方在数据准备好之前不会去访问。这些经历让我总结出三条朴素的经验第一异步加载要搭配“依赖管理”和“兜底机制”。你无法控制用户会在哪一秒点击哪个按钮所以异步化的代码必须假设任务可能还没完成外部就已经发起了访问。正确的做法是提供“同步等待”或“临时降级”的路径宁可让用户多等一瞬不能崩溃或白屏。第二性能数据指标要有“事前基线”。每次优化前先记录当前的关键指标数据优化后对比同一场景的数据差异。没有基线的优化是在盲人摸象——你可能把首屏时间降了 200ms但把可交互时间拖长了 300ms不对比数据根本发现不了。第三优化永远不要以牺牲稳定性为代价。异步加载本质上是拿“确定性”换“速度”。本来同步执行一定没问题异步化之后增加了不确定性——多线程竞争、并发修改、生命周期错位都是潜在的炸弹。所以每次改动前都要想一想如果这个异步任务没有在预期的时间完成会发生什么想清楚这个问题再动手。做性能优化这些年最深的体会是它是一门平衡的艺术而不是一味地“快”。真正决定体验好坏的不是首屏到底有多快而是用户在关键时刻的感知是否流畅。异步加载给你提供了调度的手段但用得好不好取决于你对自己业务特性的理解深度。希望这篇从原理到实践的文章能让你少踩一些我踩过的坑少交一些我用事故换来的学费。