1. 一次优化翻车复盘异步加载为什么反而变慢了1.1 接手代码时的第一印象去年年中我接手了一个中后台管理系统的迭代工作。前任维护者离职前做了一次性能优化核心思路很直接——把所有能异步的东西全部异步化组件异步加载、图片懒加载、路由懒加载、接口全部并发请求。从代码审查的角度看每一处改动都说得通每一项技术都是业界常见做法。但线上监控数据说得很残酷白屏时间从 800ms 涨到了 1.5sLCP 从 1.2s 涨到 2.8s连原先很稳定的 DOMContentLoaded 都出现了肉眼可见的抖动。最典型的翻车点在于图片懒加载。原来的列表页首屏有 12 张商品图图片是 CDN 上的 WebP平均单张 60KB。改造成 lazy 之后浏览器在首屏解析 HTML 时压根不会去请求这些图片DOMContentLoaded 确实快了但随之而来的问题是图片请求被推迟到了其他关键资源之后而中后台系统的首屏又有四五个接口在上报埋点、拉取配置、校验权限这些请求全部挤在同一个窗口期发出。浏览器对同一域名的连接数是有限制的HTTP/1.1 下通常是 6 个HTTP/2 下虽然没了连接数限制但带宽和服务器并发能力仍是瓶颈。图片被排在队伍末尾等前面的请求一个个结束才轮到它们。结果就是页面框架渲染得很快图片区域却一直空着用户感知到的不是快而是缺东西。1.2 优化本身没有错错的是对异步的理解复盘这次事故之后我最大的感触是很多人在做异步加载时默认把异步等价于快。实际上异步只做了一件事——把等待从当前执行流里摘出去让主流程可以先往下走。它并没有减少总的工作量甚至因为任务切换、额外的状态复杂度总耗时往往会增加。异步加载真正的价值在于感知性能的提升让用户先看到能看的东西把暂时不需要的东西放到后台慢慢准备。所以后来我再做性能优化第一件事不是动手改代码而是先画一张关键路径图从用户输入网址到页面完全可用哪些步骤是串行的、哪些可以并行、哪些可以延后、哪些可以完全砍掉。异步只是延后这个策略在时间维度的表达而延后的前提是你必须知道什么是最关键的东西。连关键路径都没搞清楚就全面异步化就像把所有货品都挪到仓库后面的临时货架出货口倒是空了但顾客等到的永远是空手。这段复盘放在文章开头不只是讲故事。它引出了本文的核心命题异步加载与性能优化如果要落到实处必须建立在三个底层理解之上——运行时的事件模型、资源加载的时间线、以及可量化的性能指标。下面逐层展开。2. 异步加载的底层模型主线程、事件循环与假等待2.1 单线程运行时为何能同时做很多事前端开发者讨论异步加载时绕不开 JavaScript 的单线程模型。很多人学过事件循环但理解停留在宏任务、微任务会排队这个层面。我想换个角度解释单线程意味着什么意味着在任何时刻代码只有一个执行栈在跑。你写的 fetch、setTimeout、addEventListener 回调本质上都不是并行执行的而是被塞进一个任务队列等当前执行栈清空之后依次被取出来执行。关键点在于等待和阻塞的区别。网络请求发出之后CPU 其实处在空闲状态它在等内核通知数据到了。如果你用同步方式写浏览器在等待响应期间整个主线程就卡住了用户的滚动、点击全部无响应。异步的核心就是把这段CPU 空闲等待网络的时间利用起来让事件循环去处理其他任务。这就是假等待——看起来像是多个任务在同时推进实际上只是 CPU 在等待期间没有闲着。挂到任务队列里的时机也很讲究。Promise 的回调走微任务队列会在当前宏任务结束之后立刻执行setTimeout 和事件回调走宏任务队列每一轮事件循环只取一个执行。所以你经常看到的把某段代码放进 setTimeout 里就能让渲染先走本质就是利用宏任务的排队机制把高优先级工作排到渲染这步之后。理解了这层你就能解释为什么用 setTimeout(0) 做伪异步有时候有效、有时候无效——它的执行时机取决于前面排了多少宏任务而不是你期望的立刻。2.2 不是所有任务都适合异步区分 I/O 密集与 CPU 密集明白了事件循环你就能理解异步的适用边界了。I/O 密集型的任务——网络请求、文件读写、数据库查询——是异步加载的天然主场因为瓶颈在外部资源CPU 本来就无事可做。而 CPU 密集型的任务比如大量数据排序、复杂的图表布局计算、图像处理算法它们长时间占住主线程异步对这个场景毫无帮助。你把它放进 Promise 里也好放进 setTimeout 里也好回调执行的时候照样把主线程堵死。在移动端和游戏领域这个区别尤其致命。QCandlestickSeries 那种几千根 K 线的蜡烛图每次数据更新都要重新计算坐标、布局、绘制路径如果把这些计算直接放在主线程上哪怕你用 RequestAnimationFrame 做了分帧帧率照样往下掉。正确的做法是把计算部分放到 Worker 线程里或者干脆做数据抽样——只渲染当前可视区域内的 K 线。我之前给一个行情图表做优化5000 根 K 线全量绘制时每帧耗时 26ms掉帧到 30fps 以下改成按可视区域切片渲染每帧只处理 80 根耗时直接降到 4ms。这里异步解决的问题不是让计算变快而是把计算挪出主线程。2.3 异步带来的隐性成本调度、上下文切换与内存异步不是免费的午餐。每个异步任务都有调度开销事件循环要维护任务队列Promise 链要管理状态机Web Worker 之间的消息传递要做序列化和反序列化。任务拆得越碎这些开销占比越高。我在一个页面里见过把一个大列表拆成每条数据一个 setState 的做法渲染性能反而比一次性 setState 更差因为每次状态更新都触发一次 reconcile。内存方面同样有坑。异步回调里的闭包会持有作用域链上的引用如果这个闭包被长期挂在事件监听器或者定时器上那些被它捕获的变量就永远无法被 GC 回收。做长列表时如果你在每一行绑定了异步监听并在组件卸载后忘了移除内存会一路涨上去。做移动端开发的朋友对这一点应该有深有体会Android 上泄漏的 Activity 引用往往就是从异步回调开始的界面已经销毁了回调却还在被某个单例持有。理解这些隐性成本之后你再来审视多异步就是多快的想法自然会多一分谨慎。3. 异步加载手段选型懒加载、代码分割、预加载的适用边界3.1 资源加载的决策框架把前端常见的异步加载技术放在一起看你会发现它们对应着资源的不同属性。我习惯用一个时机 × 必要性 × 体积的三维框架来判断该用哪种手段方案解决的问题典型场景需要注意动态 import首屏不需要的代码路由级代码分割不要切得过碎小心依赖共享IntersectionObserver 懒加载视口外的图片/媒体长列表图片预留占位高度避免布局抖动preload当前路由马上要用的资源首屏字体、大图用错了反而抢带宽prefetch下一个导航会用到的资源用户即将点击的页面有带宽成本移动端慎用Web Worker主线程内的重计算图表、加密、摘要消息传递有开销这张表的逻辑核心是当前需要、且是必要的资源应该出现在关键路径里用 preload 提前准备当前不需要、但很快可能需要的才适合懒加载或 prefetch当前需要、但不是必要的资源才值得考虑动态 import 或延迟处理。先分类再出方案而不是打开项目看到什么就异步什么。3.2 动态 import 的粒度控制一个真实的拆分教训动态 import 最常见的坑是拆得太碎。我见过一个项目把每个页面组件都拆成一个 chunk结果首屏加载一个路由居然要串行请求六七个 JS 文件。chunk 之间的依赖关系是拓扑的浏览器要等所有依赖都下载完才能执行首屏代码网络往返次数反而增加了。HTTP/2 的多路复用虽然能缓解一部分握手开销但每一个 chunk 仍有独立的解析和编译成本。合理的粒度通常是以路由为单位做一级分割把该路由下所有页面公共的东西留在主包如果某个页面内有一个特别重的模块比如富文本编辑器、WebGL 渲染器、Excel 解析库就单独拆出来在用户真正打开时才加载。我常用的做法是先用打包工具的依赖分析看 bundle 大小分布把超过主包 10% 的重模块单独拆出再把对应路由改成动态 import。改完之后首屏 JS 从 780KB 降到 310KBLCP 从 2.1s 降到 1.3s。关键不是用没用动态 import而是该拆的拆不该拆的别乱拆。3.3 图片懒加载的正确姿势从 loadinglazy 到 IntersectionObserver图片懒加载是异步加载里最容易被低估也最容易做砸的部分。原生 loadinglazy 属性确实好用但它的行为是进入视口前不加载浏览器对滚动方向有一定预取优先级也不是你能控制的。我之前在章节 1 里复盘的那个事故就是首屏图片被错误地加上了 lazy——正确的做法是首屏图片绝对不要加 lazy视口外的图片才懒加载而懒加载本身不要依赖 scroll 事件手写节流直接用 IntersectionObserver 监听占位元素进入视口。另外两个细节很多人会漏掉。第一图片占位必须预留宽高否则懒加载图片加载完成那一刻会引起布局偏移CLS 指标直接崩。你可以用 aspect-ratio CSS 或 padding-top 百分比方案来占位。第二懒加载要配合解码策略给视口内的图片加 decodingasync 能让图片解码不阻塞主线程渲染视口外的图片加 loadinglazy 避免提前下载。这些细节叠加在一起图片加载对页面性能的影响才能真正降到最低。3.4 预加载是异步的另一面把时间往前挪和懒加载相反preload 是把资源的加载时间提前。很多人不知道 preload 和普通标签加载的区别preload 告诉浏览器这个资源是这个页面马上要用的请以最高优先级下载并且下载完成不会阻塞渲染但会占住网络带宽。字体文件是最典型的用例——CSS 里的 font-face 往往在 CSS 解析后才知道要下载字体这会形成一段等待链用 preload 字体能绕过这个链让字体下载和应用提前。prefetch 则更激进它把下一次导航可能用的资源在当前空闲时间就缓存好。判断要不要 prefetch 的标准很简单下一个页面的被访问概率乘该页面的资源大小再乘当前网络状况。如果用户十有八九会跳转到详情页prefetch 就能省掉一整段加载时间如果只是可能会点那在移动端上做 prefetch 就是在烧用户流量。我自己的经验是prefetch 只给确定性强且便宜的资产用比如小体积图标、特定页面首屏的关键 CSS。这个分寸和数据加载的缓存策略本质上是一回事。4. 移动端启动与大数据量图表从异步到少做事4.1 Android 启动性能异步加载只是第一层移动端性能优化和前端有个本质区别你能调度的资源更有限CPU 核心数、内存带宽、电池余量都是硬约束。Android 启动优化就是一个典型战场。应用冷启动时系统要完成进程创建、Application 初始化、首帧渲染这么多步骤每一步都要抢时间。常规手段无非是Application 里不做耗时初始化把任务按优先级拆分非关键的 SDK 初始化放到子线程或者延迟到首帧之后。但很多人忽略了一点线程池本身不是免费的。你在 Application 里为优化启动创建了六七个线程去并行初始化每个线程都要去抢锁、分配堆内存、触发 GC主线程反而被 GC 卡住一两次。我之前在某项目里做过一次实验把 Application 里 12 个异步初始化任务砍到 5 个把最重的两个 SDK 直接推迟到用户进入主页后再初始化结果冷启动时间反而缩短了 120ms。原因很简单异步并发多了CPU 资源被切碎主线程的响应反而不稳定。启动优化的核心思路不是把所有事都异步而是让首帧只做必须的事其余全部移出关键路径。4.2 QCandlestickSeries 的优化思路从全量绘制到可视切片K 线图这种行情组件是性能优化里一个很有代表性的极端场景。QCandlestickSeries 如果喂给它 5000 根 K 线渲染线程要处理大量节点或绘制指令内存和 CPU 一起紧张。我处理过一个类似项目最初的实现是数据一变就全量重绘用户横向缩放时掉帧严重动画卡成了幻灯片。优化分三步走。第一步是数据抽样只取当前可视宽度加拖拽缓冲区域内能放下的 K 线其余数据不参与绘制第二步是防抖合并缩放和拖拽的输入事件以帧为粒度聚合同一个帧内只重绘一次第三步是把坐标变换和路径生成挪到 Worker 线程。这三步做完同样一套数据帧率从 20fps 提升到 55fps 以上。这里最核心的经验是图表类优化很少靠异步加载本身更多靠的是少做事——减少参与计算的数据量减少绘制次数减少主线程计算时间。异步只是配套手段帮你把不在主线程上的体验缺口填补起来。4.3 手游和移动端的通用原则帧率、功耗与加载节奏手游性能优化和图表渲染共享一套底层逻辑画面流畅度取决于单帧耗时而单帧耗时等于逻辑计算加渲染提交加等待垂直同步。手机游戏里常见的卡顿来源一是主线程上的资源加载比如纹理上传、音频解码二是 GC 抖动频繁分配临时对象引发的垃圾回收三是渲染负载过重比如粒子数量、模型面数超标。前两个问题都可以用异步解决资源加载放进单独的加载线程纹理上传配合帧循环分帧处理。但第三个问题异步解决不了只能靠降面数、合批次、降低粒子数这些少做事手段。再往深一层说移动端功耗是比帧率更隐蔽的指标。如果你用高优先级线程疯狂轮询或疯狂预加载帧率可能很稳定但电量会肉眼可见地往下掉。我做移动端性能评估时会同时盯三个数帧耗时、CPU 使用率和工作线程的唤醒频次。优化目标不是把 CPU 打满而是用最少的资源维持最稳定的体验。这个思路同样适用于小程序、桌面端乃至后端的异步任务调度——资源永远是有限的关键路径永远只有一个。5. 性能优化的可度量闭环指标、采集、归因与验证5.1 不要凭空优化先把关键指标打上任何性能优化都应该以数据开头、以数据结尾。前端可以先通过 Performance API 拿到 navigation timing 的数据再结合 PerformanceObserver 采集 LCP、CLS、INP 这些用户体验指标。移动端则可以用系统自带的 profiling 工具Android 里是 Perfetto 或 systraceiOS 里是 Instruments。没有这些数据支撑的优化就像凭感觉调音调完了也不知道是变好还是变坏。我自己习惯在每个关键页面埋一个性能版本号配合后端的灰度发布同时上线旧版和新版然后对比同一时段同一设备的指标分布。这样做的好处是能排除网络波动、机型差异带来的干扰。很多开发者只测自己机器上的结果开发机网络好、配置高优化效果根本测不出来。正确做法是选一台中低端 Android 手机加低速网络比如用开发者工具限速到 3G拿真实的 P75/P90 值来衡量。性能优化要是脱离了这个度量闭环就容易变成自我感动式地改代码。5.2 归因分析一个卡顿问题是如何定位的有指标才有归因。我举一个最近处理过的真实排查链路移动端某页面滑动卡顿Perfetto 采集的 trace 显示主线程每隔几百毫秒就有一次绿色长条对应的函数是某个图片的解码回调。顺着 callstack 往上查发现是列表里的图片没有做内存缓存每次滑回到可视区域就重新解码一次。修复方案是给图片缓存加 LRU命中率从 40% 提到 90%卡顿立竿见影地消失。这类归因分析最忌讳的是只看表象就猜原因。看到卡顿就加异步看到白屏就压缩图片这些都是在猜。正确链路是先用 profiling 工具找到最耗时的调用栈再沿着调用栈找到产生这个耗时的资源或数据然后才谈得上优化方案。前端可以用 Chrome DevTools 的 Performance 面板录制一段交互轨迹重点关注 Task 的耗时分布和红色长任务移动端则用 Perfetto 看 CPU 调度、锁竞争和 GC 次数。工具不一样方法论是通用的先量化再定位后修复最后用同样的工具验证修复效果。少了任何一环下次换个场景照样翻车。5.3 内存管理是性能优化里被忽视的半壁江山julia性能优化与内存管理这个组合很有意思Julia 的性能优化关键之一就是减少内存分配因为 GC 频繁触发会导致长暂停。这个道理搬到 JavaScript、Java、Kotlin 里完全成立。一次频繁的垃圾回收可能让主线程卡顿几百毫秒这比一个同步计算逻辑的耗时还要可怕。做前端性能优化时我会专门检查高频代码路径里的临时对象分配循环里有没有 new 对象、事件回调里有没有创建大数组、图表更新时有没有重复构建数据容器。内存问题的异步场景尤其多。异步加载进来的数据如果被保存在全局缓存、闭包或事件监听器里且没有对应的清理机制内存就会像漏水的桶一样慢慢涨。移动端的老开发者都有过这种经历内存水位越高GC 越频繁卡顿越明显然后误以为是异步任务太多其实是内存泄漏。排查泄漏的习惯要养成页面不停进出几次用 heap snapshot 对比前后两次快照看哪些对象没有释放。这项工作不性感但往往比加十个异步优化更有价值。5.4 A/B 验证的坑别被单一指标骗了最后是验证环节。优化做完指标到底看哪几个我最常提醒团队的一句话是不要只盯一个指标。有些优化能把 LCP 做得很好看但会把 CLS 搞砸有些优化让首屏极快但交互响应变差。真正的性能优化必须在多个指标之间做平衡。章节 1 里那个首屏图片懒加载的例子DOMContentLoaded 和 LCP 都改善了但用户体验却是下降的。这就是单一指标欺骗了判断的典型。我的做法是每次优化至少同时对比四个维度加载类LCP、布局类CLS、交互类INP/TTI、资源类总请求体积、请求次数。如果四个维度里至少三项变好、一项持平这个优化才算真正成立。既不要因为一个指标亮眼就上线也不要因为一个指标变差就全盘否定先看变差的指标是否在你的预期内、是否可以通过后续手段兜住再决定要不要推进。灰度期间的样本量也要注意至少取到几百个真实用户的分布单机测试结果根本不具备统计意义。6. 异步改造的五大暗坑与我的排查链路6.1 竞态条件请求响应的顺序可能和发出去的顺序相反异步加载最常见的暗坑是竞态条件。最经典的场景是搜索框用户输入苹果发出请求 A紧接着改成苹果手机发出请求 B。网络状况不可控B 的响应可能先回来A 后回来。如果代码直接把响应渲染到页面上最终显示的就是苹果的搜索结果而用户现在输入的是苹果手机。排查链路是这样的先在网络面板看请求的发出顺序和完成顺序确认存在乱序再在前端代码里找到渲染函数看它是否校验了当前响应是否属于最新请求。修复方案也很简单给每次请求生成一个自增序号响应回来时对比当前请求号不是最新就丢弃。前端框架里还可以用 AbortController 主动取消过期请求省掉没意义的网络流量。这条经验几乎是每次做异步搜索、异步分页都会踩到的值得写进团队的代码规范里。6.2 错误处理异步环境下的异常不会自己冒出来同步代码里的 try/catch 大家都熟但异步代码的错误处理经常被写成回调里 console.error 一下就没下文了。后果是加载失败时页面出现空白或永远停在 loading 状态用户不知道是网络问题、服务端错误还是代码 bug。排查的时候也很难复现因为错误被吞掉了。我的规范是每个异步加载入口必须有成功、失败、结束三态失败时要么展示重试 UI要么走降级方案绝不允许静默失败。Promise 链上要在尽头挂 catchasync/await 则用 try/catch 包住所有 await。这里有个小技巧在全局挂一个 unhandledrejection 监听只要发现未处理的 Promise rejection 就上报到监控平台线上问题才能第一时间被发现。这不算什么高深技术但能把很多隐性故障变成显性日志排查效率能翻好几倍。6.3 请求风暴与背压异步不等于无限并发异步加载做多了另一类问题是并发失控。页面有 50 个模块每个模块各自拉一个接口首屏一次性发出 50 个请求。服务器没被打挂但用户体验差到极点——带宽被占满关键请求反而排在后面。更隐蔽的是滚动加载的场景用户快速滚动长列表触发了 20 个分页请求全部发出之后前面的数据已经不在视野里了白拉了 15 个请求。解决这类问题需要背压思想——异步系统里应该有意识地限制在途请求的数量。前端常用方案是请求池维护一个并发上限比如 6超出上限的任务排队等待。滚动加载则要加节流只有用户停止滚动超过 200ms 才触发下一次加载并且已经发出的请求要配合 AbortController 做取消。后端异步任务调度同理线程池、信号量、消息队列本质上都是在做背压。理解了这一点你才会明白为什么全面异步化听上去很美但永远不应该作为默认方案。6.4 优先级反转低优先级的异步任务反噬关键路径优先级反转是从实时系统借来的词在异步加载里同样存在。一个典型的场景页面加载时你为了优化 LCP把所有普通资源都设为低优先级把首屏字体设为高优先级。结果字体文件体积很大网络拥塞时它占着带宽让原本很快能到的小图片也迟迟到不了。或者反过来低优先级的 prefetch 请求在下行带宽不足时和高优先级的首屏资源抢带宽首屏反而变慢。排查这种问题需要在网络面板里按资源的 priority 排序看有没有高优先级资源被低优先级资源挤占的迹象。修复不外乎三招调整 preload 的 as 属性让浏览器正确分类资源不要同时 preload 太多资源一般不超过 3 个给 prefetch 的任务窗口加空闲检测只有浏览器空闲时才发起。优先级不是越高越好关键是让不同优先级之间不要互相打架。做移动端时还要注意系统可能因为低内存而直接杀掉高优先级进程所以优先级的设定最好结合业务场景反复测量不要拍脑袋定。6.5 我的一套异步健康度例行检查清单最后分享一个我每次做异步加载改造时的例行检查清单。这套清单不一定适合所有项目但可以作为起点关键路径分析列出首屏必须完成的任务序列确认哪些可以延后、哪些必须同步。请求并发度数一下页面初始加载有多少个网络请求是否超过了合理阈值有没有请求可以合并、缓存或取消。主线程长任务用 Performance 面板记录首屏 3 秒内的长任务超过 50ms 的任务逐个归因。图片策略首屏图片无 lazy、有宽高占位、异步解码视口外图片懒加载加 IntersectionObserver。内存检查来回切换页面三次堆快照对比确认没有累积增长。弱网测试限速到 3G 模拟确认加载失败、超时、重试的交互是否符合预期。这套检查做完异步加载相关的大多数隐患基本能提前暴露。我自己已经在四五个项目上验证过虽说不至于让每个指标都拉满但至少能保证异步化改造不会变成异步化翻车。做性能优化这么多年我最大的体会是真正的性能高手不是懂得最多优化技巧的人而是知道什么时候不该用某个技巧的人。异步加载是一个强大的工具但它只是工具不是目的。每次想给页面加一层异步之前先问自己三个问题这里的关键路径是什么异步之后谁来兜底这个优化能用什么指标证明是有效的想清楚这三个问题你大概率能避开绝大多数我踩过的坑。