
打开管理后台的报表页白屏 2.5 秒后才出现框架期间点任何按钮都没反应切到监控大屏图表拖进去要等 1 秒才渲染手机端打开 H5 活动页首屏图一张一张蹦出来滚动起来一顿一顿的。如果你也被这些场景折磨过那这篇文章就是写给你的。异步加载与性能优化这两个词听着像八股文实际做起来是纯体力活加脑力活。它不是让你背几个 API 名字而是要你把浏览器、JavaScript 运行机制、网络协议、渲染管线这一整条链路想明白然后才能知道哪些优化是真有效哪些只是安慰自己。我从大量真实项目里踩出来的经验是性能问题基本都有一个核心瓶颈找不到它优化全是白费找到它解决掉数字立刻好看。下面我会把异步加载从资源加载、数据请求、渲染调度三条线拆开讲穿插事件循环原理和真实调优案例尽量让你看完就能在自己的项目里动手。1. 异步加载到底在解决什么问题——先从阻塞说起1.1 一个卡顿页面的真实现场先说一个我印象很深的案例。某运营后台的订单明细页面路由切换后要渲染一个包含 2000 行数据的表格每行还带着状态标签、操作按钮和金额格式化逻辑。第一次打开时页面白屏时间到了 3 秒而且白屏期间用户滚动页面完全没有响应像死掉了一样。拿 Performance 面板一看主线程上有一根巨大的黄色长任务占了 1.8 秒。点进去展开调用栈排在最前面的耗时有三个一个同步加载的dist/vendor.js里面是图表库、日期库、Excel 导出库等一大堆从未用过的依赖一个表格组件初始化时对 2000 行数据做的双层循环格式化还有一个是图片懒加载插件在首屏一次性创建了 80 个 IntersectionObserver 实例。这其实是很多卡顿页面的共同解剖结果——不是某一个动作特别慢而是同步加载的资源太多、初始化时同步做的事太多导致主线程一直被占着。用户点击、页面渲染、滚动响应全部排在后面等它释放。1.2 正经解释为什么同步加载必然卡要理解为什么同步加载会卡得先接受一个事实浏览器的渲染主线程是单线程的。主线程要负责执行 JavaScript、解析 DOM、计算样式、布局、绘制、处理用户输入事件。你写的全部 JS 代码包括同步引入的第三方库初始化逻辑都在这一条线程上跑。当浏览器解析 HTML 遇到一个普通的script src...标签时它会暂停 HTML 解析等脚本下载并执行完再继续往下解析。CSS 也一样遇到link relstylesheet时它要先等 CSSOM 构建完成否则后面的 JS 可能取不到样式。这就是经典的同步阻滞。多个同步脚本按顺序排队每个都让页面干等着它。我常用排队打饭来类比同步加载就是食堂只有一个窗口排队的人必须停在原地等后面的人不能做任何事。异步加载等于多开了几个窗口你点了餐之后先去找座位坐下菜做好了叫你取等待的时间里你可以看菜单、聊天、干别的。核心不是总时间变短而是你不需要一直站在原地等。1.3 异步不是快而是不让别人等你这是一个很多人没绕过来的点异步加载并不会减少总加载时间它改变的是时间的分布方式。一个 500KB 的脚本不管你怎么异步化下载时间还是那么多。但如果你让主线程在等待下载的同时先渲染出页面框架、响应用户操作用户在感知上会觉得页面能用了这就是性能优化里最核心的感知性能。更深一层异步加载的意义在于把等待时间从关键路径上彻底拿掉。关键路径是指从输入 URL 到用户可交互这期间必须按顺序完成的所有事情。如果资源 A 不阻塞页面渲染那它就不在关键路径上如果接口 B 不阻塞首屏内容展示那它也不在关键路径上。性能优化的本质就是不断压缩关键路径把尽量多的操作变成旁路任务。理解了这一点后面所有具体的异步方案——defer、async、动态 import、懒加载、分片渲染——全都是同一逻辑的变体让当前不需要的东西靠边等优先让用户看到和用到核心内容。2. 浏览器是怎么做到边下边执行的——事件循环与任务队列的底层逻辑2.1 事件循环浏览器的心脏浏览器实现异步的核心机制是事件循环Event Loop。主线程不能停下来等一个耗时的网络请求返回于是浏览器把这个操作交给其他线程网络线程等等结果回来后通过一个任务队列告诉主线程活干完了你处理吧。每轮事件循环大致是这样的主线程从任务队列里取出最早的任务执行执行完一个任务后主线程会去清空所有的微任务队列然后判断是否需要渲染浏览器一般会按刷新率合并渲染60Hz 屏幕大约 16.7ms 一帧之后再取下一个宏任务。有个经典问题可以验证你是否真正理解事件循环setTimeout(() console.log(timeout), 0)和Promise.resolve().then(() console.log(promise))谁先输出答案是promise先因为.then回调是微任务当前宏任务结束后、下一个宏任务开始前微任务队列会被一次性清空。这个机制对性能优化有什么意义关键点在于任何塞进任务队列的操作都不可能立刻执行它必须等前面所有任务清空而且微任务里如果再产生微任务会一直执行到队列空了为止。我曾经遇到过一个 Bug某个 Promise 回调里又递归调用了大量微任务导致渲染一直被打断页面长时间白屏。这就是微任务被滥用带来的隐性风险。2.2 宏任务与微任务的微妙关系宏任务包括整体脚本执行、setTimeout、setInterval、I/O、UI 渲染等微任务包括 Promise.then、MutationObserver、queueMicrotask。一个关键区别是渲染的时机通常是在宏任务结束之后两帧之间的间隙但微任务会在每次宏任务结束后立即执行。举个例子你在一段同步代码里依次创建 100 个 Promise 并立即 resolve然后在每个.then里做大量计算。这段微任务链会在当前宏任务结束后一口气跑完主线程在此期间的渲染请求全部被卡住。页面看起来就是卡死了但代码还在跑。想验证这个原理很简单在浏览器里跑一个很大的微任务链你会在 Performance 面板看到主线程被拉成长长的绿色块。实际项目里的教训是微任务适合做轻量的状态更新、数据修正不适合做 CPU 密集的重活。如果你有一个大列表要在数据到达后渲染别把渲染逻辑整体放进.then里一次做完应该用分片、requestAnimationFrame 或者把渲染逻辑切成多个宏任务让浏览器有机会在中间插入渲染和交互处理。2.3 async/await 不只是语法糖——Promise 的硬核本质很多同学以为async/await只是让 Promise 代码更顺眼其实它的运行机制决定了你的代码会在哪些时机被切断。await后面的代码会被包装成微任务继续执行这一点非常关键。看个简单的例子async function test() { console.log(start); await Promise.resolve(); console.log(end); } test(); console.log(outer); // 输出顺序start → outer → endawait之后的内容不会同步执行而是进入微任务队列。所以在性能调优中不要以为把所有逻辑包到 async 函数里就万事大吉——await只是切分执行时机不是免除执行代价。如果你在await后面写了一个耗时的同步 for 循环它依然会把主线程堵死只是堵的时间点往后挪了。正确的做法是把 CPU 密集的任务拆小每个小片里只做有限工作然后用await让出主线程比如中间穿插requestAnimationFrame或setTimeout(0)让浏览器有机会处理输入事件和渲染。这个思路在后面分片渲染部分会再细说。3. 资源加载的实战维度脚本、样式与图片的异步策略3.1 script 标签的 defer 与 async优先级和顺序完全不同普通script、defer、async三种方式很多人只知道都是异步加载但实际差别巨大用错了会直接踩坑。先看一张对比表加载方式下载时机执行时机执行顺序适用场景普通 script遇到即阻塞下载下载完立即执行阻塞解析按文档顺序极少用除非必须首屏同步执行defer解析过程中并行下载文档解析完成后、DOMContentLoaded 之前按序执行保证按顺序业务主逻辑、多个有依赖关系的脚本async解析过程中并行下载下载完立即执行随时可能打断解析不保证顺序独立无依赖的脚本埋点、监控、客服组件并行的意思是浏览器在解析 HTML 的同时空闲的网络连接会去下载这些脚本不需要等解析完成。但这不等于随心所欲defer脚本之间如果 A 依赖 B只要你在标签里把 B 写在 A 前面执行顺序就是 B 先 A 后可以放心。async则完全随缘谁先下载完谁先执行所以千万不能让async脚本之间有依赖关系。我试过一次在项目里用async加载数据上报 SDK结果 SDK 内部需要先定义一个全局函数某几个页面在 SDK 还没执行完时就开始调用这个函数直接报undefined is not a function。排查了很久最后把async改成defer才解决。教训很直接无依赖的独立脚本才用 async有初始化顺序要求的脚本一律 defer。3.2 动态注入与代码分割按需加载的正确姿势除了静态的 script 标签现代前端更常用的是动态import()配合构建工具做代码分割。Vite 和 webpack 都支持把动态导入的模块单独打成一个 chunk在路由切换或组件挂载时按需拉取。拿 React 项目举例import { lazy, Suspense } from react; // 路由级代码分割只在进入该路由时才下载这个组件的 JS const OrderDetail lazy(() import(./pages/OrderDetail)); function App() { return ( Suspense fallback{PageSkeleton /} OrderDetail / /Suspense ); }这样做以后首屏包体积从 1.2MB 降到 350KB用户第一次打开的速度提升非常明显。路由级的代码拆包是收益最高、成本最低的优化优先做这个。还有一个很多人忽略的点动态 import 的 chunk 在浏览器里依然要走网络请求。如果你的 JS 文件部署在 CDN 上而页面里用到了跨域字体、接口那你就需要preconnect提前建连。加上这个就能省掉 DNS 查询和 TCP/TLS 握手时间link relpreconnect hrefhttps://cdn.example.com crossorigin实测下来这个预处理在弱网环境能省 200-400ms 的加载延迟几乎零成本。3.3 图片懒加载与解码优化别让像素卡住渲染图片是页面体积的大头。首屏之外的图片我建议直接使用浏览器的原生懒加载属性img src... loadinglazy decodingasync alt...loadinglazy让浏览器在图片即将进入视口时才加载decodingasync让图片解码不阻塞 DOM 渲染。首屏图片千万不要加 lazy也别用decodingasync否则会影响 LCP最大内容绘制成绩这一点下面第 5 部分会说到。如果你需要更精细地控制加载时机用 IntersectionObserver 自己写一个懒加载组件也可以。但要注意一个坑懒加载不应阻止预加载。实际项目中我曾把首屏下方不远的一张关键图片设成懒加载用户快速滚动时图片区域滚动到了视口边缘才触发加载结果图片显示出来时已经晚了很多。对于距离视口不远且尺寸较大的图片建议用link relpreload asimage href...主动预取让它在后台提前下载。图片解码也是一个常见性能杀手。移动端一个 2000x3000 的图片解码耗时可能达到几十毫秒。如果首屏有 5 张大图同时解码渲染主线程会直接被占住 200ms 以上。解决思路是能压缩到目标尺寸就压缩不要为了清晰度上传原图需要使用大图时提前用Image()对象在空闲时期预热解码把解码时间挪出关键路径。4. 数据与渲染层面的异步流水线4.1 骨架屏与流式渲染先给轮廓再补细节资源加载异步化之后下一个瓶颈往往出现在数据请求和渲染环节。数据到达前的等待期很多页面是纯白屏或死等 loading这其实是感知性能的巨大浪费。React 18 提供了 Suspense 和流式 SSR可以在服务端先把页面框架、可静态化的部分吐给浏览器数据等到了再补上动态部分。前端页面里即使不用 SSR也可以用同一套思路先渲染骨架屏把布局、占位区域、可预先知道的文案全部展示出来数据到了再逐块填充。这种渐进式填充的感知效果提升非常明显。我做过对比测试同样的接口耗时 800ms用整页 loading 旋转图标和用骨架屏用户对快慢的主观感受差了接近一倍。骨架屏让页面看起来已经开始工作了而不是还不知道要等多久。另外一个思路是利用 React 的startTransition把非紧急的渲染标记为低优先级。比如用户在搜索框输入时搜索列表的更新可以被标记为 transition输入框的实时响应优先级更高这样即使列表渲染很重也不会让输入框卡顿。这背后同样是主线程时间片的调度思想。4.2 接口并发与请求竞态的工程化处理数据层面的异步不只是记得用 fetch。我在真实项目中踩过最深的坑是请求竞态。当时有个联想搜索接口用户每输入一个字发一次请求后端偶尔会慢导致一个常见现象先发出去的请求比如输入苹果响应比较慢后发出的输入苹果手机反而先回来了。结果界面先展示了苹果手机的结果等前一个慢请求回来后又把结果覆盖成苹果用户看到搜索内容在乱跳。解决竞态有两个做法用户触发新搜索时用AbortController取消上一个未完成的请求或者用请求序号判断响应是否是最近一次。推荐两者结合let searchController null; let searchSeq 0; async function search(keyword) { if (searchController) searchController.abort(); searchController new AbortController(); const seq searchSeq; try { const res await fetch(/api/search?q${keyword}, { signal: searchController.signal }); const data await res.json(); if (seq searchSeq) { // 只渲染最新一次请求的结果 renderList(data); } } catch (err) { if (err.name AbortError) return; // 被主动取消忽略 throw err; } }还有一个工程化细节并发限制。页面初始化时如果同时发 20 个接口请求连接数打满关键接口反而会被挤在后面。我遇到过一个案例登录后仪表盘一次性发起 30 个数据请求结果慢的那个拖到 8 秒才完成整个页面一直在 loading。后来改成并发限量器把请求压到 4 条一组排队执行再配上超时控制整体完成时间反而大幅缩短。原因是浏览器对同域名的并发连接数是有限的HTTP/1.1 约 6 条HTTP/2 虽多但也会有限制超出后会排队排队的等待时间才是真正的瓶颈。4.3 分片与增量渲染大列表不再白屏如果一次性拿到 10000 条数据直接把全部 DOM 节点插到页面上主线程几乎必然要卡掉几百毫秒到几秒。正确思路是时间分片把一个大渲染任务切成很多小任务每执行一小片就主动让出主线程让浏览器插进渲染和交互处理。下面这个写法是实际项目里验证过多次的批次渲染模式function renderInChunks(items, chunkSize 50) { let index 0; function processChunk() { const batch items.slice(index, index chunkSize); // 这里做真实 DOM 插入建议配合 DocumentFragment 减少回流 batch.forEach((item) renderItem(item)); index chunkSize; if (index items.length) { requestAnimationFrame(processChunk); // 每帧之间让主线程喘息 } else { console.log(全部渲染完成); } } processChunk(); }每渲染 50 条就让出主线程滚动、点击等其他任务可以插空执行。实际效果是10 万行数据不再让页面白屏卡死而是一帧一帧地刷出来用户甚至可以边滚动边看到下面的内容在生成体验完全不在一个级别。这里有个细节值得说透为什么用requestAnimationFrame而不是setTimeout因为 rAF 的回调时机和浏览器绘制是在同一帧内调度的渲染比较规律不会乱插队。setTimeout最小延迟不固定且和 rAF 的时机不一回事在性能优先的场景下不如 rAF 顺滑。另外分片大小也需要根据设备性能调整桌面端 100 条一片没问题低端安卓机上 20-30 条一片更稳妥。5. 性能优化的度量与调优闭环——不能靠感觉5.1 关键指标LCP、INP、TTFB 到底在说什么做性能优化第一步不是改代码而是建立度量体系。没有数字支撑的优化都是玄学。目前业界最常看的核心 Web 指标有三个指标全称含义目标值优化方向LCPLargest Contentful Paint视口内最大元素的渲染时间≤2.5s资源压缩、预加载、关键资源优先INPInteraction to Next Paint用户与页面交互到下次绘制的时间≤200ms减少主线程长任务、调整事件处理器TTFBTime To First Byte浏览器收到首个响应字节的时间≤800ms后端接口、CDN、缓存策略这些指标里最容易忽略的是 INP。以前大家爱提 FID首次输入延迟但 FID 只管第一次交互INP 管的是整个页面生命周期中的所有交互延迟。这更真实地反映了用户体验——页面某个按钮在加载长任务时点击没反应用户会直接认为页面坏了。拿到这些指标后要形成一个调优闭环先记录基线数据 → 找到瓶颈 → 针对性优化 → 再测对比 → 回归上线。每次改动都必须有数据佐证不要边改边猜。5.2 Performance API 与性能面板的配合打法浏览器自带的 Performance 面板是你最趁手的工具但除了肉眼盯火焰图你还应该用代码把关键数据采集下来。PerformanceObserver可以帮你自动监听长任务和指标事件// 监听长任务Long Task const observer new PerformanceObserver((list) { for (const entry of list.getEntries()) { console.log(长任务耗时, entry.duration, ms); } }); observer.observe({ type: longtask, buffered: true }); // 监听 LCP const lcpObserver new PerformanceObserver((list) { for (const entry of list.getEntries()) { console.log(LCP 时间, entry.startTime, ms); } }); lcpObserver.observe({ type: largest-contentful-paint, buffered: true });把这些数据上报到你的监控平台你就能拿到真实用户的性能分布。我强烈建议你在页面上线前先跑一轮 Performance 面板记录基线和长任务分布上线后再用 RUM真实用户监控持续观察。很多优化在测试环境看不出来到了低端机、弱网环境差别会非常明显。5.3 一次真实调优案例从 2.8s 到 1.1s拿前面提过的运营后台来复盘那次调优完整走了一遍闭环具体步骤可以给你参考。基线是首屏可交互时间 2.8sLCP 是 2.1s。第一步拆包。用打包分析工具看产物分布发现 80% 的体积集中在 vendor 包里的图表库和 Excel 导出库。这些库只有少数页面用到却全部跟着首屏加载。处理后改成路由级动态 import首屏体积直接掉了 700KB。第二步预加载。把首屏真正要用的核心 CSS 优先内联到 HTML head 里关键接口的域名加上preconnectCDN 上的业务 chunk 加上preload提前拉取。第三步异步化。把组件初始化时对 2000 行数据的双层循环格式化拆成时间分片每 100 行插一帧埋点脚本从同步 script 改成 async。第四步处理竞态。报表页的筛选条件切换时上一个请求会被中止避免旧响应覆盖新数据。最终结果可交互时间从 2.8s 降到 1.1sLCP 从 2.1s 降到 1.0s而且 Performance 面板上的长任务从原来的 8 个降到 2 个最长单个任务从 1.8s 降到 230ms。整个过程没有做任何花哨的操作全都是围绕移除关键路径上的等待这个核心逻辑。6. 异步加载的常见陷阱与排查思路6.1 顺序错乱异步不等于无序异步加载最容易出的事故就是顺序控制。我见过一个页面两个模块分别动态引入了一个 SDK 脚本A 模块的脚本需要在 B 模块的脚本之前执行但因为是异步下载B 先回来了A 后回来导致 B 在初始化时调用了 A 还没有定义的全局方法报错一片红。排查这一类问题思路是先看报错堆栈找到谁在调用谁再在 Network 面板看脚本加载顺序和完成时间确认是顺序错乱后要么用defer保证文档序要么把两个模块的初始化逻辑合并到一个入口方法里用 Promise 串起来。串行化有时候会损失一点并行下载的收益但换来了确定的执行顺序。实际上需要严格顺序的模块往往本来就有逻辑耦合合并进同一个 chunk 反而更合理。6.2 内存泄漏与失效请求异步的隐性成本异步加载的代码很容易产生内存泄漏。最常见的是组件已卸载但之前的异步请求返回后还调用了 setState定时器没有在卸载时清掉事件监听器挂在 window 上从未移除。React 组件里标准写法是借助 useEffect 的清理函数useEffect(() { let mounted true; fetchData().then((data) { if (mounted) setData(data); // 组件卸载后不再更新 }); return () { mounted false; clearTimeout(timer); }; }, []);检查内存泄漏不需要高深工具打开浏览器开发者工具的 Performance Monitor盯住 JS Heap Size 的趋势线——正常操作的页面积累一段时间后堆内存还在持续上升不回落基本就是泄漏了。配合 Memory 面板抓几次堆快照对比能快速定位是谁在持有这些对象。6.3 节流与防抖控制触发频率的最后一道闸异步加载做完了还有一个隐藏性能问题事件触发频率。滚动加载更多、搜索实时联想、拖拽缩放这些高频交互如果不做频率控制抗压能力再强的服务端也被打怕。区别一个细节防抖debounce是停止操作后才执行适合搜索输入这种需要等用户停下来的场景节流throttle是固定时间最多执行一次适合滚动加载这种希望持续有反馈但要限速的场景。我见过有人把这两个概念搞混滚动加载用了防抖用户快速上下滚动时数据一直不加载松手后才突然加载一大批——既慢又浪费。滚动加载还有一个容易踩的坑滚动到底部判断条件用了scrollTop clientHeight scrollHeight在移动端由于页面缩放比例不同经常触发不准确导致要么加载不了要么疯狂触发多次请求。稳妥做法是加个触底缓冲区比如距离底部 100px 时就触发加载同时配合一个loading标志位防止重复请求let isLoading false; window.addEventListener(scroll, throttle(() { const remain document.documentElement.scrollHeight - window.scrollY - window.innerHeight; if (remain 100 !isLoading) { isLoading true; loadMore().finally(() { isLoading false; }); } }, 200));异步加载与性能优化做到最后你会有一种感觉它不是某个算法的炫技而是对人机交互节奏的深刻理解。每一个性能瓶颈背后都是一段用户等待的时间被错误地放到了关键路径上。你能做的是把等待从用户的感知里一点点挪走挪得越干净页面就越轻快。而衡量这件事的唯一标准不是代码里用了多少新特性而是用户实际体感数字。希望这篇原理与实战结合的梳理能让你在下次遇到卡顿问题时不再是盲目改代码而是有一套清晰的排查和优化思路。