
上周给一个电商活动页做性能优化光是把首屏 JavaScript 从 1.2MB 压到 400KB线上 LCP 中位数就从 4.8 秒掉到了 2.1 秒。真正起作用的不只是压缩代码更重要的是把非关键逻辑全部改成了异步加载。异步加载这四个字听起来没什么稀奇但想在自己的项目里用得明白、用得稳你得先搞清楚浏览器解析一份 HTML 时处理器到底在忙什么。这篇文章我会把异步加载的原理、不同实现方式的差异、性能指标的度量方式、以及完整落地流程串起来讲一遍最后集中分享这几年踩过的坑和排查思路。适合刚开始接触性能优化的前端工程师也适合在移动端、游戏加载、本地应用启动这类场景里寻找优化思路的开发者——异步优化的底层逻辑其实是通用的。1. 为什么要异步先搞清楚浏览器解析页面的完整过程1.1 浏览器处理 HTML 时的“流水线”浏览器收到一份 HTML 文档后并不是一次性把整个页面画完。完整的过程大致是接收响应字节流按编码解码成字符做词法分析生成标签 Token构建 DOM 树遇到style和 CSS 资源时构建 CSSOM两者合并成渲染树再做布局和绘制。这个过程很像一条工厂流水线。工人 A 组装外壳工人 B 安装主板工人 C 做质检。如果某个环节必须等一批零件从仓库送到才能继续整条流水线就得停下来干等。浏览器也一样DOM 构建、CSS 计算、脚本执行、样式布局全部都在主线程上排队任何一步阻塞后续所有步骤都只能等着。关键点在于HTML 解析在主线程上JavaScript 执行也在主线程上它们共享同一条执行通道。所以渲染页面最昂贵的不只是 CPU 计算本身更是“主线程被占住、其他任务全部排队”的这段空白时间。很多性能问题看起来是资源太大、网络太慢本质上却是一长串任务挤在同一根时间线上互相拖累。1.2 同步脚本为什么是“阻塞之王”当浏览器解析到普通的script src...标签时会立刻暂停 HTML 解析先去下载脚本文件再完整执行一遍最后才继续解析后面的 HTML 内容。这个过程包含两个费用网络请求费用和执行费用。网络这一段在移动端尤其吓人。一个没有提前做连接预热的跨域脚本可能需要经历 DNS 查询、TCP 握手、TLS 握手在弱网环境下随随便便就是几百毫秒到几秒的开销。哪怕脚本只有 10KB只要它被同步放在head里首屏就白白少了好几百毫秒的执行窗口。更麻烦的是用户在等待期间什么都看不到页面就那样白着。执行费用也经常被低估。现代前端框架打包出来的 JS 动辄几百 KB浏览器下载完之后还要经历解析、编译、执行的完整过程。注意gzip 压缩只能减少传输体积不能减少解析和执行成本该做的计算一样都不会少。所以业内有条不算严格的规律减少主线程上的同步 JS就是减少页面从请求到可交互之间那段最长的空白。这也是异步加载出现的最直接理由。1.3 异步的本质推迟而不是删除这里想先厘清一个概念。异步加载并不是“不做这些事了”而是“把这些事安排到更合适的时间去做”。一种做法是让某个脚本不阻塞 HTML 解析解析完成后再执行或者干脆等浏览器空闲了再执行另一种做法是某些模块根本不着急等到用户真正需要时才下载执行这也就是按需加载。这样做的收益可以归结为三点。第一首屏更早可用因为关键渲染路径上的同步阻塞减少了第二主线程更通畅长任务被拆散用户点击、输入这类高优先级响应能更快被处理第三带宽和内存更节约毕竟用户没访问的功能就不需要传输和解析。理解到这个层面之后再去看 defer、async、动态 import 这些具体手段就会发现它们其实都是在回答同一个问题哪部分工作必须现在就干哪部分可以往后放往后放的时候怎么保证不乱、不丢、不晚。2. 异步加载的几种主流实现方式与原理差异2.1 defer 与 async两个属性两种语义几乎每个前端工程师都见过这种写法但不少人把 defer 和 async 当成同一个东西其实两者语义差别很大。script srcapp.js defer/script script srcanalytics.js async/script带 defer 的脚本下载过程与 HTML 解析并行进行但执行被推迟到 HTML 解析完成之后、DOMContentLoaded 事件触发之前多个 defer 脚本严格按出现顺序执行。如果你有一段代码需要操作 DOM、依赖前面的库defer 是安全的选择。async 就不一样了。脚本同样是并行下载但下载完成就会立刻抢占主线程执行不管此时 HTML 解析到哪个位置。执行期间解析依然会被打断而且多个 async 脚本的执行顺序完全由下载完成时间决定谁先到谁先执行跟标签顺序没有半毛钱关系。所以选择逻辑其实很清晰脚本之间没有依赖关系、也不依赖 DOM 结构比如埋点、统计、AB 实验这类独立逻辑用 async 最合适脚本依赖 DOM、依赖其他库、需要保证执行顺序必须用 defer。如果拿不准默认用 defer 比用 async 更稳妥。还有一种容易被忽略的写法script typemodule默认就是 defer 语义而且会触发 CORS 校验加载 ES Module 时天然更严格。现代项目如果直接在浏览器里使用原生模块这个行为会直接影响你的资源和脚本组织方式。2.2 动态 import() 与代码分割真正意义上的按需加载defer 和 async 解决的是“下载后不阻塞”但脚本最终还是要全部传下来并执行。如果希望“用户没用到某段功能就连代码都不传”那就得上动态import()。// 路由级代码分割进入订单页才加载订单模块 const OrderPage React.lazy(() import(./pages/Order)); // 交互触发式加载用户点开图表才加载图表渲染库 async function showChart(container) { const { renderChart } await import(./libs/chart); renderChart(container); }配合 webpack、Vite、Rollup 这类打包工具动态 import 会被自动拆成独立的 chunk 文件。首屏只需加载核心入口的 chunk其他功能模块在路由切换、弹窗打开、按钮点击时才请求对应文件。这个机制带来的收益有两个层面字面上看是首屏体积变小了深层看是浏览器在主线程上需要解析、编译的代码量也变少了。但动态 import 不是越多越好。如果一个只有 2KB 的小工具也被单独拆成 chunk首屏的请求数会从 3 个变成 20 个每个请求都有固定耗时。HTTP/2 能缓解这个问题但请求过多仍然会在弱网环境造成明显延迟。拆分的颗粒度要结合模块实际体积来定一般建议单个异步 chunk 至少在 10KB 以上否则合并进主包反而更划算。2.3 资源级别的异步图片懒加载、preload 与 prefetch前面主要讲 JS其实图片、CSS、字体这类资源同样要面对异步问题。图片是首屏体积的一大来源好在现代浏览器已经支持原生懒加载img srcbanner.png loadinglazy width1200 height600 alt活动横幅浏览器会根据图片相对视口的位置来决定何时加载元素接近视口时才发起请求。需要兼容更老浏览器时可以用 IntersectionObserver 自己实现一套效果一样只是多写一点判断逻辑。但注意视口内的首屏图片不要加loadinglazy否则 LCP 会被明显拖后这个坑在第五节会详细展开。再往下一层是资源提示。preload表示“这个资源当前页面马上要用请优先加载”prefetch表示“这个资源以后可能用空闲时请悄悄加载”preconnect表示“请提前建立到这个跨域源头的连接”。它们不会加快单次请求本身但能缩短关键资源的排队和握手时间。CSS 同样存在阻塞问题因为 CSSOM 构建完成之前浏览器不会渲染页面。业内有一种做法是给非关键样式表做异步加载先让页面以mediaprint加载样式加载完成后再切回正常 media或者用 JS 在window.load之后再挂载 stylesheet。这些偏门技巧平时用到的机会不多但真遇到单页应用首屏样式瓶颈时很管用。2.4 一张表理清各种异步加载方式实现方式是否阻塞解析执行时机顺序保证典型场景同步script是遇到即执行是首屏必需的少量内联逻辑script async是执行时暂停下载完成即执行否埋点、独立工具脚本script defer否解析完成后、DOMContentLoaded 前是依赖 DOM 的普通业务脚本typemodule否同 defer 语义是原生 ES Module 项目动态import()否代码调用时不适用路由、弹窗等按需场景loadinglazy图片不适用接近视口时不适用长页面非首屏图片preload/prefetch不适用按资源优先级提示不适用关键字体、下一路由资源这张表里的每一行都值得存下来。实际项目排期中大多数异步化改造都能从这张表里找到对应方案。不过要提醒一句静态语义只说明脚本“什么时候执行”真正影响用户体验的是脚本到底在哪个时间点占用了主线程。defer 脚本虽然不阻塞解析但如果一堆重逻辑集中在 DOMContentLoaded 前执行仍然可能把页面可交互时间拖得很晚。3. 性能优化怎么做才算数指标、预算与监控3.1 先定度量标准再谈优化没有一套客观指标优化很容易变成“我觉得变快了”。合理做法是从两个维度看数据实验室数据和线上真实用户数据。实验室数据由 Lighthouse、WebPageTest 这类工具提供可以在统一环境下快速对比不同版本之间的差异线上数据靠 RUM真实用户监控采集能看到不同网络、不同设备上的真实分布。两者缺一不可实验室数据用于开发期发现问题RUM 用于上线后跟踪成效。常用核心指标大致就这几个FCP首次内容绘制反映白屏是否消失LCP最大内容绘制反映首屏主要内容是否完成INP交互到下一次绘制反映用户交互后界面响应速度CLS累积布局偏移反映页面稳定性。这些指标都有推荐阈值但团队协作时最好再约定一个百分位比如“LCP 在 p75 低于 2.5 秒”因为只看中位数容易掩盖长尾用户的问题。3.2 异步加载是怎么影响这些指标的异步加载和这些指标的关系非常直接。首屏如果同步加载一个 200KB 的脚本FCP 会被明显拖后因为 HTML 解析被阻塞渲染无从谈起把脚本改成 defer 之后解析不再被阻塞FCP 往往立刻就有改善。LCP 的影响更微妙。假如首屏最大元素是一张大图而它恰好被错误地加上了loadinglazy浏览器会等元素接近视口才加载导致 LCP 记录到的时刻比预期晚很多。所以首屏图片第一原则是视口内图片绝不懒加载必要时还要 preload 提前抢网络。INP 则和长任务有直接关系。主线程如果被一段 300ms 的解析执行占住用户点击按钮就得等 300ms 才有响应。异步加载和代码拆分把大段任务拆散就是给高优先级交互让路。举个我改过的真实例子一个管理后台首页原本把表格、图表、富文本编辑器全量打包进主 bundle首屏同步执行整体时间约 480ms改造后只在用户打开对应面板时才加载这些模块首屏主线程长任务从 480ms 降到了 90msINP 从 520ms 降到 180ms体感上完全是两个页面。3.3 性能预算把优化变成团队纪律一次优化做完很容易难的是防止它悄悄回归。给项目设性能预算是比较有效的做法相当于给代码定一条“红线”。预算可以设几个维度首屏 JS 体积预算比如 gzip 后不超过 170KB、请求数预算、核心指标时间预算。工具层面可以用webpack-bundle-analyzer定期检查打包体积用size-limit把体积预算写进 CI 流水线体积超了就构建失败。RUM 侧接上 web-vitals 这类库把线上采集到的指标上报到监控平台至少给自己留一个查看历史趋势的入口。预算的意义不在于让人少写代码而是让每次提交都意识到性能成本把性能当成需求的一部分去评估。一旦确立了预算后续任何异步化改造是好是坏拿数据说话就行不用争论。4. 一个完整优化案例从分析到落地的全过程4.1 第一步先做性能体检找到真正瓶颈假设你接到一个线上活动页的优化需求我的做法是先不急着改代码。第一步是用 Chrome DevTools 的 Performance 面板录制一次页面加载再配合 Lighthouse 跑一遍主要看三点主线程上有哪些长任务、哪些请求占掉了最多时间、FCP/LCP 落在什么位置。我做过的一次体检结果很典型首屏 JS 总大小 3.2MB其中有 1.8MB 是图表库和富文本编辑器主线程解析执行时间接近 700ms首屏图片 40 张全部设置了懒加载但最大的一张 hero 图也在其中。两个问题指向同一个结论该异步的没异步不该懒加载的却懒加载了。体检报告一定要写成可对照的基线数据不要只写“页面很慢”。要记录具体的指标值、请求瀑布图、长任务分布这些是后续验证优化效果是否达标的唯一依据。没有基线对比后面所有优化动作都说不清楚有没有意义。4.2 第二步把资源分优先级确定异步策略拿到基线之后我按照“首屏是否可见、用户是否必然使用”把页面资源分成三档。第一档是首屏渲染必需的包括页面骨架组件、核心样式、hero 图片这一档保持同步或者内联但严格控制体积第二档是业务功能但非首屏必需比如滚动到下方才出现的模块全部走动态 import第三档是无关紧要的辅助资源比如埋点脚本、聊天入口、辅助工具库全部加 async 并后置加载。各模块方案定下来之后我列了一张表格模块名、体积、当前加载方式、目标方式、预期收益。表格本身不重要重要的是每一项都写清了“为什么这么改”而不是直接照搬网上经验。比如聊天组件虽然体积不大但它在绝大多数用户会话里都不会被打开所以用动态 import 的收益最稳定。移动端还要多考虑一个因素网络切换频繁。对弱网用户来说一个延迟到交互时才触发的动态 import可能比预加载多花 2 秒。所以我给下一个路由的 chunk 加了 prefetch利用首屏空闲时间去拿用户跳转时几乎无感。4.3 第三步落地实现与结果验证落地过程其实是按方案表逐项替换。代码层面的关键主要是两件事把功能组件改成动态导入并处理好 loading 状态给非首屏图片补上宽高属性和loadinglazy首屏 hero 图去掉懒加载并加上 preload。改完后我用同样版本的 Lighthouse 重新测了一遍并跑了两轮 WebPageTest 模拟 3G 网络。对比结果FCP 从 2.8 秒降到 1.2 秒LCP 从 4.8 秒降到 2.1 秒主线程长任务从 700ms 降到 160ms。在真机连上 DevTools 的 CPU 6 倍降速再录一次 Performance 面板交互响应手感和优化前完全是两个页面。但实验室数据只能证明理论达标真正决定成败的是线上。上线后我盯了三天的 RUM 数据确认 LCP 的中位数确实稳定在目标值附近才在复盘报告里写下“优化已验证”。这是我在实践中养成的习惯线上指标至少稳定三天再宣布优化成功避免发布当天流量结构和平时差异带来的误判。5. 绕不开的坑常见问题与排查思路实录5.1 async 脚本执行顺序导致的功能崩溃一次改造中我把某个工具库脚本加上了 async结果线上部分页面报出 “xxx is not defined”。原因是另一个功能脚本依赖这个工具库而 async 脚本的执行顺序不受控制工具库下载得稍微慢一点依赖它的脚本就先执行了直接报错。这种问题的排查思路很明确凡是存在依赖关系的脚本一律别用 async如果某些第三方脚本之间确实独立才可以用 async。更稳妥的做法是从设计上避免“两个独立请求的脚本之间存在隐式依赖”依赖关系只应该存在于打包后的模块内部。另外还要警惕一种情况有人说“我只用了一个脚本加 async 没关系”。但如果这个脚本内部又通过document.write或者其他方式动态引入别的资源执行时机不可控的问题会重新出现。动态加载的脚本默认是异步的这一点经常引起连锁错误。5.2 懒加载图片引发的 CLS 与 LCP 迟到把首屏大图加上loadinglazy是我见过新手最容易犯的错我自己也犯过。浏览器对懒加载的处理是“等元素接近视口才加载”首屏图片本来就该在视口内这种做法只会让 LCP 时刻大幅延后。更隐蔽的问题是懒加载图片造成布局偏移。图片没有预留空间时加载完成后会突然把页面往下撑开CLS 分数瞬间变差。解决办法是给 img 设置固定的width和height属性或者使用 CSSaspect-ratio让浏览器在图片加载前就知道它的占位尺寸。定位这一类问题时可以打开 DevTools 的 Network 面板看请求时间轴确认图片是否是在接近滚动时才发起请求再看 Elements 面板里图片有没有明确尺寸。两处一对比问题基本就能锁定。5.3 低端安卓机上的“伪异步”陷阱异步加载优化做完了低端安卓机上仍可能出现“看起来卡、点起来没反应”的现象。这不是优化失效而是设备 CPU 和内存太弱哪怕一个 30KB 的 chunk解析和执行也要比高端机多花好几倍时间。低端设备上要注意几个细节动态 import 的 chunk 不要拆得太碎否则频繁的请求和解析会让主线程一直忙个不停长列表页面不要一次性渲染所有数据配合虚拟滚动才能真正控制住内存图片不要同时发起太多请求给图片加载队列设并发上限避免短时间内内存暴涨。这类问题的排查比功能问题难因为它不报错只表现为掉帧和卡顿。我常用的方式是直接在 DevTools 里模拟低端设备把 CPU 降速到 4 倍或 6 倍然后重复用户主路径操作看 Performance 面板里的长任务分布。排除掉网络因素之后如果长任务仍然很多就说明主线程上的计算量确实超了需要进一步压缩或错开那些任务。5.4 异步思路在其他领域的相通之处原理篇最后多说一句异步加载和性能优化的思路并不只属于浏览器。优化 Android 启动性能做得同样是关键路径分析把启动时不需要的初始化逻辑往后挪减少首帧前的同步工作游戏性能优化里的关卡资源异步流式加载也是为了让玩家先进场景再慢慢补资源做科学计算的人关注内存管理和 GC 停顿本质上也是在减少性能关键路径上的不可控开销。所以这套方法论换个壳几乎处处适用。关键始终是那几步画出关键路径找出阻塞点把可后置的工作异步化再用数据去验证。我个人这几年的体会是性能优化真正难的从来不是某个 API 的用法而是你能不能在自己的场景里做出那句判断什么必须现在做什么可以往后放。把一个页面的加载过程分解成一张优先级表比记住所有优化技巧都更重要。最后分享一个工作习惯每次改完代码都顺手记录一次 Lighthouse 分数和关键指标截图并标注当时的版本号。这个习惯帮我在一次线上回滚中快速锁定了回归点省掉了好几个小时的排查时间。性能优化做得越久越会发现数据和记录才是最大的护身符。