ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

异步加载实战:从浏览器原理到移动端性能优化

异步加载实战:从浏览器原理到移动端性能优化 1. 从一次深夜优化事故说起为什么异步加载能救命三个星期前的凌晨一点我盯着性能分析面板上那个刺眼的红色FCP值第一次认真思考要不要把项目里那个塞满首屏的图片轮播组件拆出去。那是一个非常典型的移动端活动页——顶部轮播、中部商品列表、底部新人专享券逻辑不复杂但首屏 JavaScript 体积超过了 680KB。这个数字意味着什么按常规 4G 网络下 1.2MB/s 的下载速度计算光下载脚本就要花近 0.55 秒再加上解析、编译、执行的时间用户真正看到可交互页面之前至少要经历 3 秒以上的空白期。在转化率以毫秒计的电商场景里每延迟 100ms流失率就上升 1.5%这种代价没人扛得住。那次事故之后我花了整整两个晚上复盘整个加载链路最后的优化清单里八成改动用的是异步加载。所以这篇博文我不想聊虚的直接把我理解的异步加载原理、实战中用过的代码分割方案、以及移动端场景下那些踩过坑才能总结出来的性能优化细节一次讲透。适合谁看如果你正被首屏加载速度困扰、想做性能优化却不知道从哪里下手、或者看过不少教程但依然搞不懂 async/defer/dynamic import 实际该怎么选这篇内容应该能帮你省下一大段摸索时间。2. 异步加载的技术拆解从浏览器底层机制到代码层面的三种执行方式2.1 浏览器渲染链路里的同步阻塞问题要理解异步加载为什么能优化性能先得搞清楚浏览器在做什么。当你输入一个 URL 并按下回车浏览器拿到 HTML 后开始从上往下解析构建 DOM 树。HTML 里如果出现一个script srcindex.js浏览器会立刻停下 DOM 解析去下载这个脚本下载完再执行执行完才继续解析后续的 HTML。这个过程叫同步阻塞。阻塞的时间由两部分组成下载时间加执行时间。下载时间取决于网络速度和服务端响应这个你没法在客户端代码里彻底规避但执行时间纯粹由脚本体积和代码复杂度决定。这就是为什么同样的页面有人用 4G 网络首屏秒开你上班用办公 WiFi 也依然卡顿——当脚本体积足够大时即使带宽再充裕主线程解析和执行 JavaScript 的时间也会成为瓶颈。更糟糕的是同步阻塞会连带阻塞渲染。假设你的首屏背景图和某个交互脚本绑定在一起那个脚本下载得越慢整张页面出现得就越晚。异步加载的本质就是把“下载解析执行”和“DOM 渲染”这两件事解耦让脚本不再成为渲染链路上的单点故障。核心手段包括给 script 标签加 async/defer 属性以及在代码运行层面使用动态 import。2.2 async 与 defer两个属性背后的执行时机差异经常有人问我async 和 defer 到底有什么区别先记住一个简单的使用原则如果你的脚本之间没有依赖关系用 async如果脚本依赖 DOM 就绪或者多个脚本之间有先后执行顺序要求用 defer。原因要从它们的加载和执行时机说。普通脚本HTML 解析遇到就开始下载下载完立即执行执行期间阻塞解析。defer 脚本HTML 解析过程中遇到就开始下载异步下载下载完成后不执行等 HTML 全部解析完成、DOMContentLoaded 触发之前按顺序执行所有 defer 脚本。async 脚本HTML 解析过程中遇到就开始下载异步下载下载完成后立即执行执行时如果文档还没解析完就停一下先执行脚本再继续解析。多个 async 脚本之间不保证顺序。用一个时间线对比来说明加载时机执行时机是否会阻塞解析适用场景同步脚本遇到即下载下载完立即执行是defer遇到即异步下载DOM 解析完成后按顺序执行否async遇到即异步下载下载完成后立即执行可能2.3 代码运行时层面的异步Promise、async/await 与微任务队列上面说的是浏览器在解析 HTML 阶段对脚本加载方式的控制但异步加载在代码运行层面还有另一层含义——不阻塞主线程的异步操作。这就得说到事件循环机制。JavaScript 是单线程语言同一时间只能执行一个任务。浏览器内部维护着调用栈、任务队列和微任务队列。宏任务包括 script 整体代码、setTimeout、setInterval、I/O 事件中的回调微任务包括 Promise.then/catch/finally、MutationObserver 回调等。事件循环的规则是每次调用栈清空后优先把微任务队列里的任务全部执行完再取一个宏任务执行。这个机制对你做性能优化有什么用实际场景非常典型你向服务器请求了一份用户数据需要 300ms 才能返回如果这 300ms 内主线程闲等用户点击按钮就没有任何响应。用 Promise 包裹请求后主线程可以继续执行其他渲染任务、处理用户交互等数据返回后再通过微任务队列把回调塞回主线程。这就是“异步加载”在运行时线程层面的核心意义把 IO 等待时间从主线程的执行时间里剥离出去。async/await 本质上仍是 Promise 的语法糖只不过写法更贴近同步逻辑读起来更舒服但记住它不会让执行速度变快只是把异步流程的代码组织方式变得更可控。3. 方向判断异步加载如何同性能优化指标挂钩3.1 性能指标里哪些最依赖异步化改造做性能优化不能凭感觉先盯指标。目前业界主流的 Web 性能指标有三类——加载类、交互类和视觉稳定性类。异步加载主要影响的是加载类和交互类指标。FCPFirst Contentful Paint首次内容绘制指页面上第一次出现文字、图片等内容的时刻。资源加载顺序和是否阻塞对 FCP 影响巨大首屏关键 CSS 和少量内联脚本应同步其余资源全部改为异步加载。LCPLargest Contentful Paint最大内容绘制指页面主体内容加载完成的时刻通常对应首屏最大图片或大标题块的渲染完成时间。LCP 优化核心是让首屏资源尽快拿到非首屏组件必须延迟加载。TTITime to Interactive可交互时间指页面从加载完成到用户可以顺畅交互的时间。长任务Long Task是拉高 TTI 的元凶而长任务中相当一部分来自同步执行的大体积脚本。把非关键逻辑拆成异步加载后主线程才能尽早把控制权交还给渲染和交互。TBTTotal Blocking Time总阻塞时间主线程被长任务占用、无法响应用户输入的总时长。异步加载对它的优化作用立竿见影。如果你公司有内部性能平台建议先拉四个指标再看具体优化动作的优先级。一般而言FCP 靠资源加载顺序优化LCP 靠首屏资源直出和非首屏延后TTI 和 TBT 靠脚本拆分和主线程降载。3.2 拆包粒度怎么定从体积守恒到体验守恒异步加载的本质是做延迟不是做删除。代码总量没变只是把一段集中执行的体积按时间轴切成了多段。所以拆包粒度特别关键——拆得太碎每个分包都有额外 HTTP 请求开销移动端环境下握手和首字节时间反而拖慢速度拆得太粗体积损耗又体现不出来。一个既能落地又不太费劲的拆包原则按路由拆再按重要度排序。路由级别的拆是最自然的拆。用户从首页跳转到详情页详情页的脚本可以等跳转时再加载没必要放进首屏包里。Vue Router 的懒加载和 React Router 配合 React.lazy 都是这个思路。路由拆完之后如果某个页面内部还有体积特别大的独立组件再按组件拆。举个例子活动页首屏只要一个 banner 和一个商品卡片列表那这个页面包应该只包含渲染这两块所需的代码。点击“查看用户评价”时才弹出来的低概率使用组件丢进动态 import 里。3.3 体积阈值参考用数据驱动你的判断我整理了一份自己常用的体积参考区间不一定适用所有项目但可以帮你建立一个大致的感觉。分包类型体积参考加载时机首屏核心包小于 150KBgzip 后立即同步加载路由级分包50-200KBgzip 后路由切换时动态加载组件级分包20-60KBgzip 后组件实际渲染时动态加载工具库分包可单独拆出或合并进公共包依赖到对应功能时加载这里的核心逻辑是把用户“几乎一定会看到”的代码放进首屏包把“可能看到但概率不高”的代码交给异步加载时机控制。虽然拆包写起来就是一个 import 的事情但选择在哪个位置插入动态 import才真正考验对业务的理解程度。4. 实操演示从 680KB 到 198KB 的异步加载改造过程4.1 改造前的数据采集与问题定位我先交代一下项目背景这是一个面向移动端的促销活动页技术栈是 Vue 3 Vite用的是组合式 API。首屏包含顶部轮播、8 个商品卡片、新人弹窗以及一个非常大的新人礼包接口。改造前我先用 Lighthouse 跑了一轮移动端模拟测试核心数据如下指标优化前FCP2.6sLCP4.1sTBT750ms首屏 JS 体积680KBgzip 前从 performance 面板的 Main 火焰图里我看到首屏阶段有一串连续的长任务每段都超过 100ms最长的能到 230ms。点击率最高的耗时堆栈指向三个模块分别是轮播组件Swiper.js、图片懒加载工具内含 polyfill、以及新人弹窗的校验逻辑需要引入一个加密签名计算的库。三个模块里轮播和新人弹窗虽然体积大但用户首屏不一定马上能看到——轮播如果预先请求了图片资源但用户还没滑动到轮播图这笔开销就成了浪费新人弹窗则通常要等接口数据返回后才决定是否弹出提前执行校验逻辑同样没意义。于是这三个模块就成了异步化改造的首批目标。4.2 改造步骤实录构建配置与代码写法在 Vite 工程里做异步加载改造不需要额外装插件核心就是动态 import。我先说结论Vite 会把动态 import 的部分自动拆成独立 chunk并在真正执行到那行代码时才发起网络请求。轮播组件的改造前写法是// 改造前 import Swiper from swiper/vue;改造后// 改造后 import { ref, onMounted } from vue; const SwiperComponent ref(null); onMounted(async () { const module await import(swiper/vue); SwiperComponent.value module.default; });看起来很简单但有个坑必须提前说清楚如果你直接把 Swiper 放到模板里然后又依赖v-if来控制它的渲染组件会有一个短暂的空隙时间——接口数据还没回来组件还没挂载页面看起来像少了块内容。正确做法是先渲染一个占位容器异步模块加载完并完成状态赋值之后再让 Swiper 真正渲染。占位容器我给了一个高度为 300px 的灰色块轮播一旦挂载就会把占位块替换掉用户体验上不会感知到额外等待。新人弹窗的改造路径也类似。新人礼包有单独的接口/api/benefit/check这个接口会返回用户是否满足领券条件满足条件才展示弹窗。弹窗内部又引用了券码加密签名库crypto-js这个库压缩后依然有 88KB 左右首屏完全没必要加载。我的改造思路是先调普通页面接口等页面主体渲染完成后再异步调 check 接口根据返回值动态 import 弹窗组件和加密库。async function loadBenefitModal() { const [modalModule, cryptoModule] await Promise.all([ import(/components/BenefitModal.vue), import(crypto-js) ]); // cryptoModule 传给 modal 内部使用 modals.value modalModule.default; }这样改动之后首屏代码体积直接少了 280KB因为这些逻辑被拆到独立 chunk 里浏览器只在真正需要的时机才发起请求。页面主体渲染完成后主线程从长任务的总时间里释放出至少 300ms 的空闲TTI 和 TBT 都得到明显改善。4.3 参数选型背后的逻辑为什么我不把所有代码都异步化你可能会问既然异步加载这么好为什么不把首屏所有代码都改成动态 import原因很简单——过度异步化会导致 FOUC无样式内容闪烁和交互延迟。比如说按钮的点击事件绑定如果绑定逻辑也是异步加载用户看到按钮后立即点击事件还没注册上点击行为就丢失了。异步改造的目标是把“用户大概率用不上的逻辑”延后而不是把“用户一定会用上的逻辑”也延后。首屏的基础布局、核心交互逻辑、以及视口内的静态资源必须保证同步或接近同步加载完成。具体判断标准是任何一个用户进入页面后一定会触发的逻辑必须留在首屏包用户不一定触发、或者触发时间点大概率在首屏完成之后的逻辑逐项异步化。这里我还顺带解决了一个请求优先级的问题。浏览器对资源加载有个默认优先级策略一般是 CSS 和字体最高其次是首屏图片和脚本但某些场景下浏览器定的优先级不符合你的期望。比如轮播图里的三张 banner 图片虽然本身是异步加载但图片资源较大优先级不够高时可能被排在后面。我的做法是用link relpreload声明首图的地址设置fetchpriorityhigh让性能面板里的资源加载顺序更符合业务权重。这个技巧对 LCP 指标优化非常有帮助实测能让 LCP 从 4.1s 降到 2.8s。4.4 改造后的数据对比异步加载的量化收益改造整个过程大概用了两天第一天拆分包调整顺序第二天做数据回归和兼容性测试。最终数据如下指标优化前优化后变化幅度FCP2.6s1.8s下降 31%LCP4.1s2.8s下降 32%TBT750ms180ms下降 76%首屏 JS 体积680KB198KB下降 71%这个结果再次验证了异步加载对性能收益的直接贡献尤其 TBT 的降幅非常明显几乎等于把主线程的“拥堵路段”全部拆走了。后续我还把同一个优化方案沉淀成了团队的脚手架模板新页面启动时自动生成懒加载配置和分包预设。5. 移动端场景下的异步加载与性能优化补充5.1 网络与设备约束下的移动端专项优化同样的优化在 PC 端和移动端是完全不同的效果。移动端用户通常使用蜂窝网络带宽不稳定DNS 解析和 TCP 连接的时间占整个请求耗时的比重更高。这意味着移动端不能激进地拆出太多小体积分包——每个分包都意味着一次新的连接、TLS 握手和请求往返。我见过一个改造过度的案例分包数量从 3 个变成 41 个结果页面加载总量反而增加了 12%因为 HTTP 请求本身的握手时间开销远远大于省下来的解析时间。连接成本 体积成本这在移动端网络下尤为明显。所以移动端的策略通常比 PC 端更保守公共依赖尽量合并优先保证关键资源尽快到达。另外有条件的情况下尽量利用服务端 HTTP/2 的多路复用能力它能减少连接建立次数带来的损耗但前提是你得先控制分包数量。5.2 IntersectionObserver 实现图片与组件懒加载除了 JavaScript 分包移动端另一个性能大头是图片。首屏区域内的图片要尽量提前加载首屏以下的图片必须延迟到接近视口时才加载。最早的做法是监听 scroll 事件计算图片距视口顶部的 offsetTop这套方法有一个问题——滚动事件本身很频繁如果不做节流会在主线程上产生大量计算反倒是另一种性能负担。现在推荐统一用 IntersectionObserver 实现懒加载。function lazyLoadImages() { const imageList document.querySelectorAll(img[data-src]); const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; img.removeAttribute(data-src); observer.unobserve(img); } }); }); imageList.forEach((img) observer.observe(img)); }IntersectionObserver 的回调是异步触发的不会阻塞主线程渲染而且浏览器内部对观察对象做了优化回调频率远低于 scroll 事件触发的频率。实测效果是一个包含 30 张商品图的列表页首屏图片请求数从 30 个降到 5 个LCP 维持不变总流量消耗下降 70% 左右。除了图片我还会用 IntersectionObserver 来触发底部组件的异步加载。比如列表滚动到接近底部时动态 import 底部推荐模块的脚本这样用户快速滑动时体验不会中断同时可以把这部分资源的加载延后到真正需要的时刻。5.3 requestIdleCallback 与空闲时间窗口利用另一个我在移动端经常用到的 API 是requestIdleCallback。它的机制是在主线程空闲时安排执行低优先级任务不会抢占用户交互的关键时间窗。我一般用它来加载统计脚本、上报日志、初始化不紧急的埋点逻辑。if (requestIdleCallback in window) { requestIdleCallback(() { import(/utils/reporter).then((module) { module.default.init(); }); }, { timeout: 2000 }); }这个写法有一个很实用的场景统计分析脚本一般体积不小又不直接作用于用户体验放在同步加载里会拉长主线程占用时间。扔进 requestIdleCallback 后它会在浏览器空闲时例如用户浏览首屏内容、阅读文案的时候悄悄加载。但注意timeout参数必须设置否则在长期不空闲的页面里比如一直有动画在跑这个任务可能永远不会执行统计数据就丢了。还有一个容易踩坑的地方低端安卓机比如运行内存只有 2GB 的千元机对多个并发异步请求的处理能力有限浏览器同时发起的请求数会被限制在 6 个左右。如果异步加载的模块之间没有主次之分建议用一个队列控制最大并发量避免排队等待时间过长。我一般控制移动端首屏阶段的并发请求数不超过 4 个页面主体渲染后再放开并发限制。6. 常见问题与排查技巧我踩过的那些坑6.1 首屏白屏和 FOUC 问题异步加载改造最常见的直接结果就是首屏内容出现短暂空白或闪烁。这通常不是异步加载本身的问题而是控制渲染时机的逻辑没有处理好。比如你用 v-if 控制异步组件渲染模块加载完成前组件压根不渲染于是渲染结果为空。我的排查思路是先看 performance 面板的 Network 时间线确认动态 import 的模块请求是什么时候发出的。如果请求发出到模块执行之间有明显空隙说明动态 import 的时机过早或过晚需要调整触发节点或者像我之前那样增加一个占位结构让页面在等待期间依然有内容可看。6.2 组件加载顺序与接口数据的竞争问题还有一类问题出在组件加载和数据请求的并行竞争上。假设一个弹窗组件需要等接口 A 返回数据后再渲染但接口 A 的耗时比组件加载更长这时候你把组件设为异步加载反而会浪费主线程的等待时间。我的做法是并行发起组件加载和数据请求用Promise.all等待两者都完成后统一渲染。这样做的额外收益是接口数据请求和脚本加载可以同时进行总耗时只取决于较慢的一方而不是两个任务串行相加。实测这个改动让新人弹窗的展示时间从 1.2s 降到了 700ms。6.3 缓存策略与版本号混乱频繁改动异步分包后浏览器缓存策略可能导致线上旧版本资源和新版本逻辑混用。如果你用 Vite 默认的 hash 命名这个风险比较小但如果你们项目是接的自定义构建流程一定确认每个分包文件名的 hash 会随内容变化。我见过一个项目chunk 文件名固定为vendor.js异步加载的组件代码更新后没能及时替换缓存线上出现了“点击后模块执行报错”的怪异现象。排查了整整一个下午最后发现是服务端缓存头没有设置正确的 ETag。解决方案是规范构建产物的文件名命名规则并且给 HTML 入口文件设置no-cache。6.4 核心的排查工具使用顺序最后总结一下我排查异步加载问题时的工具使用顺序。第一步看 DevTools 的 Network 面板确认资源加载的顺序和耗时判断是否该加载的没加载、不该加载的提前加载了第二步看 Performance 面板的火焰图识别长任务和主线程的空闲段确认异步加载是否真正释放了主线程第三步看 Lighthouse 的 Performance 报告量化优化前后的指标变化判断是否还需要继续调整第四步用真实移动设备在弱网环境DevTools 里的 Network Throttling连接改为 Slow 4G重复测试确保加速方案不会在真实网络环境下打折扣。这个流程基本能覆盖九成以上的异步加载性能问题。
返回列表