拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

前端性能优化核心:彻底搞懂异步加载的defer、async与动态导入

前端性能优化核心:彻底搞懂异步加载的defer、async与动态导入

1. 异步加载的本质:从“排队检票”到“多窗口办理”

这两年无论是做前端还是做客户端,大家挂在嘴边最多的词一定有“性能优化”。可聊得多了,反而容易把最基础的东西忽略掉。这个章节想聊的异步加载,恰恰是性能优化体系里最底层、最核心的一块基石,也是我做了多年性能优化之后觉得最值得反复琢磨的原理。

先说一个最常见的误区。很多人觉得“异步加载”就是把<script>标签往body末尾一扔,或者给脚本加上async属性就算完事。这种理解不能说错,但距离“原理”还差得远。异步加载真正的价值在于,它改变了浏览器处理资源的顺序模型。

什么是顺序模型?我常用一个生活化的类比来解释。早期的浏览器处理网页资源,就像是火车站只有一个售票窗口,所有人必须排成一队,前面的乘客买完票,后面的才能往前走。传统<script>标签放在<head>里,就是这个效果——浏览器碰到脚本就必须停下来,下载、解析、执行,这期间整个页面的渲染全部冻结,后续哪怕已经下载完毕的HTML和CSS都得原地等待。这个“排队”的过程,专业术语叫渲染阻塞(Render Blocking)。

异步加载干的活,相当于把那个唯一的售票窗口改成多个窗口,甚至让用户提前把票买好,到站直接进。浏览器遇到带有async或defer属性的脚本,不会停下渲染流程,而是在后台把脚本文件拉下来,等空闲时机再执行。这么一改,页面的白屏时间、首屏可交互时间都会肉眼可见地缩短。

从原理层面拆解,浏览器有一个主线程,所有的JavaScript执行、DOM解析、样式计算都发生在主线程上。同步加载的脚本会直接“霸占”主线程,谁来了都得排队。而异步加载的本质,是利用浏览器底层的事件循环(Event Loop)机制,把资源下载和执行拆成两件独立的事情:下载可以丢给网络线程池并行进行,执行再回到主线程排队。这就是为什么异步加载能在不破坏代码逻辑顺序的前提下,大幅缩短关键渲染路径。

我见过很多项目,优化做了半年,图片压缩、CDN加速、代码混淆全部都上了,但效果始终不理想。最后定位下来,问题根本不在资源大小,而是head里那七八个同步脚本硬生生把首屏拖到了三秒以后。这说明一个道理:性能优化的排序里,加载策略的优化优先级要高于资源体积优化。资源再小,只要是同步链路上的阻塞节点,它的代价都会指数级放大。

不管你是做Web、小程序还是Hybrid App,理解异步加载的原理都能帮你找到性能瓶颈的“七寸”。下面这些内容,我会把原理讲透,再给出可以直接抄作业的实操方案。

1.1 浏览器的关键渲染路径

要理解异步加载为什么管用,先得搞清楚浏览器从拿到HTML到屏幕上出现画面的完整链路。这条链路叫关键渲染路径(Critical Rendering Path),由五步构成:

  1. HTML被解析成DOM树
  2. CSS被解析成CSSOM树
  3. DOM和CSSOM合并生成Render Tree(渲染树)
  4. Layout布局:计算每个节点的几何位置
  5. Paint绘制:把像素画到屏幕上

这里面有个细节值得注意:DOM的解析过程是可以被JavaScript中断的。为什么?因为浏览器不知道脚本里会不会有document.write()这样的操作,也不知道脚本会不会通过getElementById之类的方法查询当前已经解析出来的节点。为了保证数据的完整性,碰到同步脚本只能暂停解析,等脚本跑完再继续。

这就是阻塞的本质。它不是网络慢导致的,而是浏览器为了“确定性”付出的代价。

CSS也阻塞渲染,但阻塞方式和JS不一样。CSS不会中断DOM解析,但它会阻断Render Tree的构建。换句话说,CSS没加载完,DOM构好了也没用,画不出来。理解了这一点,就能明白为什么业界总说“CSS要尽量提前内联,JS要尽量异步加载”——它们阻塞的环节完全不同。

1.2 同步加载的代价到底有多大

我用一个具体场景说明白这个代价。假设某个页面的head里有这样一个脚本:

<script src="https://cdn.example.com/lib-util.js"></script>

再假设这个脚本文件体积是200KB,网络往返时间(RTT)是100ms,带宽是2Mbps。粗算一下,下载这个文件需要至少800ms。再加上解析执行JavaScript的时间——V8引擎解析200KB源码大约需要50-100ms——整个流程至少白费了1秒。

这1秒只是表象。真正的放大器在后面:浏览器是流式解析HTML的。如果遇到脚本时DOM才解析到一半,后半段HTML的解析工作全部搁浅,那么依赖这些DOM元素的渲染、布局、绘制全部顺延。一个200KB的脚本,可能最终拖慢了所有内容呈现,前后加起来两三秒的额外延迟。

更闹心的是,咱们前端有个“1秒定律”——用户等待超过1秒,流失概率就会大幅上升。同步加载浪费掉的恰恰是最昂贵的首屏时间。

有了这个认知基础,再来看异步加载的两种主流手段,很多问题就能自己想明白了。

2. 异步加载的三大主力方案:defer、async与动态导入

异步加载听起来是个笼统的词,实际落地的时候就三板斧:defer、async、动态import()。很多人分不清defer和async的区别,面试被问倒。这不能怪大家,因为这俩属性从字面上看确实很像。但原理和适用场景差异很明显,我尽量用大白话讲透。

2.1 defer:把脚本推迟到DOM解析完成之后

defer这个属性的行为,官方定义是“表示脚本将在文档解析完成后、发出DOMContentLoaded事件之前执行”。它的执行流程是这样的:

HTML解析开始 → 遇到defer脚本,立即开始下载(不阻塞解析) → HTML解析完成 → 所有defer脚本按文档顺序执行

从这里能看出defer的两个特征:

  1. 下载不阻塞解析:脚本下载和HTML解析同时进行
  2. 执行不抢时机:所有defer脚本耐心等DOM解析完,再按顺序执行

所以defer特别适合那些依赖完整DOM结构、且要求内部执行顺序的脚本。比如页面底部要用的功能库、需要操作全站节点的统计脚本、商品页的交互逻辑,用defer最保险。

我举一个实战中踩过坑的例子。曾经有个团队把埋点脚本加上了async属性,结果发现同一个页面里埋点数据的上报顺序经常是乱的。排查了半天才想起来,async不保证执行顺序,两个都标了async的脚本,谁先下载完谁先执行。行为埋点A依赖埋点B先上报用户身份信息,结果B晚到了,链路直接断了一半。后来把两个脚本改成defer,顺序稳定了,问题才彻底消失。

2.2 async:下载完立刻执行,不等待任何人

async的行为模式和defer正好相反。它的执行流程是这样的:

HTML解析开始 → 遇到async脚本,立即开始下载(不阻塞解析) → 下载完成,立刻暂停解析,执行脚本 → 执行完成,继续解析HTML

看出区别了吗?async的执行时机是“下载完就执行”,没有等待、没有排序、没有保证。如果两个async脚本同时下载,先回来哪个就先执行哪个。DOM可能已经解析完了,也可能还没解析完。

所以async只适合那些不依赖DOM结构、也不依赖其他脚本顺序的独立功能。典型场景包括:

  • 第三方广告脚本(独立运行,晚一点执行问题不大)
  • 数据上报/埋点脚本(不操作DOM)
  • 图表库懒加载(等容器出现之后加载,但自身逻辑独立)

注意:async脚本执行时依然会阻塞HTML解析。只是它把“下载”这个最耗时的环节从主线程剥离开了。下载虽然并行,执行仍然抢占主线程。所以async脚本不要放太多,否则执行阶段照样卡主线程。

2.3 defer与async的选择:一张表说清楚

我总结过一张表格,团队里的人每次拿不准的时候就看这个:

对比维度deferasync
下载是否阻塞解析否否
执行是否阻塞解析否(等解析完)是(下载完就执行)
执行顺序按文档顺序不保证
执行时机DOM解析完成后,DOMContentLoaded之前下载完成立即执行
适用脚本依赖DOM、有顺序要求独立、无依赖、允许延迟

一句话总结:拿不准的时候用defer,明确独立且可以乱序的时候用async。这个原则应对绝大多数业务场景都够用。

2.4 动态import()与代码分割:异步加载的高级形态

defer和async解决的是“单个脚本文件什么时候加载”的问题。但在现代前端工程里,我们面对的不是一个文件,而是几百个模块。理想状态是:首屏只加载首屏需要的代码,其他代码等用户真正用到某个功能时再加载。这就轮到动态import()登场了。

// 传统静态导入 import { reportData } from './analytics'; // 动态导入(按需加载) const handleClick = async () => { const { reportData } = await import('./analytics'); reportData('button', 'click'); };

动态import()是ES2020正式标准化的语法,底层原理返回了一个Promise。当这段代码执行到import()那一步,浏览器才会去发起网络请求,加载对应的模块文件。配合Webpack、Vite这些构建工具,import()会被编译成“代码分割”(Code Splitting)的语法,每个动态导入的模块自动打包成独立的chunk文件。

我见过最夸张的优化案例:一个后台管理系统,首屏JS从4.2MB优化到1.1MB,核心动作就是给路由改成动态导入。原本是把十几个业务模块全量打包成一个bundle,浏览器得一次性下载4.2MB,优化后首屏只下载登录页和布局框架的代码,其余模块跳到对应路由才加载。首屏加载时间从6.8秒降到2.3秒,用户的体感天差地别。

这个优化的前提是框架支持路由级懒加载。以Vue为例,写法是这样的:

// 路由配置中 const routes = [ { path: '/dashboard', component: () => import('../views/Dashboard.vue') }, { path: '/user-management', component: () => import('../views/UserManagement.vue') } ];

React生态对应的是React.lazy和Suspense:

const Dashboard = React.lazy(() => import('../pages/Dashboard')); function App() { return ( <Suspense fallback={<Loading />}> <Dashboard /> </Suspense> ); }

这些写法本质都是异步加载在应用层的高级运用。核心思想就一个:把不必要的代码从首屏加载链路中剥离出去。

3. 性能优化实战:从指标拆解到优化落地

说完了异步加载的原理,很多人会问:那我具体怎么判断哪里该异步、哪里不该异步呢?这得有方法论支撑,不能拍脑袋。这章我拆解一套自己在实战里反复验证过的流程。

3.1 先定指标,再谈优化

优化之前必须建立衡量标准。不然改动之后说不清效果,也没法验证得失。前端的核心性能指标现在基本统一到Web Vitals上了,三个关键数字,即所谓的核心网页指标,每一项目对应不同的用户感知:

  1. LCP(Largest Contentful Paint):最大内容绘制,衡量首屏主要内容出现的时间。目标是低于2.5秒。这个指标直接跟“首屏资源加载顺序”挂钩。
  2. INP(Interaction to Next Paint):衡量用户交互到画面反馈的延迟。约等于“操作跟不跟手”,低于200毫秒算优秀。页面上的同步长任务会严重拖垮它。
  3. CLS(Cumulative Layout Shift):衡量页面布局抖动程度。低于0.1算良好。图片未预留空间、异步内容插入导致位移,会直接拉爆这个指标。

这三大指标里,异步加载改善最明显的是LCP和INP。LCP好了,用户看到主要内容的时间快了;INP好了,交互卡顿少了。有了这些指标作为标尺,优化的方向就清楚了。

3.2 资源加载优先级:把带宽让给最重要的资源

有了指标,接下来要做的是给资源排优先级。很多人把优化等同于“减小体积”,但实际项目中,更常见的问题是重要资源没有优先加载,次要资源反而抢占了网络通道。

浏览器有一个资源加载优先级的调度机制,通常会按资源类型和位置给不同请求打上不同优先级的标记。图片、脚本、CSS、字体,优先级各不相同。这本来没问题,但业务代码往往把规则搞乱了。我见过的一个典型场景:首屏有一张主视觉大图,而head里又插了两三个第三方统计脚本。浏览器会自动给脚本分配较高优先级,结果重要的大图反而排队等待,导致LCP拉胯。

解决的思路通常两步走:

  • 给重要资源提高优先级:比如首屏大图用fetchpriority="high"属性
  • 给非关键资源降低优先级:第三方脚本全部异步加载,并且加上loading="lazy"(懒加载)属性
<!-- 首屏主图,优先加载 --> <img src="hero-banner.jpg" fetchpriority="high" alt="主视觉" /> <!-- 非首屏图片,懒加载 --> <img src="detail-img.jpg" loading="lazy" alt="详情图" />

fetchpriority和loading="lazy"这两个HTML原生属性,是近几年浏览器性能优化体系里补上的重要工具。原理层面,fetchpriority直接影响浏览器内部的请求调度器,让高优先级的请求更早出发网络请求;loading="lazy"则告诉浏览器在资源出现在视口附近之前,不要发起请求。

这两行属性的成本几乎为零,收效却很直接。我自己的项目里,给首屏大图加了fetchpriority之后,LCP从2.8秒优化到1.9秒,就改了一行代码。

3.3 CSS的异步加载:别把所有CSS都全量阻塞

前面说CSS会阻塞渲染,很多人以为那把CSS全改为异步加载就行了。这个思路是错的。关键CSS必须同步渲染,否则首屏会闪出无样式内容(FOUC,Flash of Unstyled Content)。

正确的姿势是把CSS拆成两块:

  • 关键CSS(Critical CSS):覆盖首屏需要的样式,体积通常很小,直接内联进HTML的<head>里
  • 非关键CSS:其余样式,通过异步方式加载

异步加载CSS有好几种做法。最可靠的方式是用JS动态创建<link>标签,等页面空闲了再插入:

// 把非关键CSS放在页面加载后期再加载 window.addEventListener('load', () => { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = '/css/non-critical.css'; document.head.appendChild(link); });

还有更讲究的做法是用浏览器原生的media属性切换技巧:先用一个浏览器必然匹配的media值,让CSS变成非阻塞加载,加载完再切换回正常值。不过说实话,日常开发用JS插入的方式就够用了,原理简单,维度清晰。

3.4 实操示例:一个页面从3.5秒到1.2秒的完整路径

这里分享一个我近期改过的真实案例。某营销落地页,原始加载时间3.5秒左右(实验室环境,模拟4G网络),主要的性能瓶颈有三个:

  1. head里有4个同步JS文件,合计320KB
  2. 首屏图片没有设置优先级,被脚本请求抢占了带宽
  3. 某个体积较大的图表库在首屏根本用不到,却全量加载了200KB

优化步骤按优先级排列:

第一步:改造脚本加载方式把4个同步JS分析一遍,其中一个统计数据上报脚本独立无依赖,改成async;另外三个是页面交互逻辑,依赖DOM结构,改成defer。

<!-- 修改前 --> <script src="/js/tracker.js"></script> <script src="/js/app.js"></script> <script src="/js/UI-lib.js"></script> <script src="/js/analytics.js"></script> <!-- 修改后 --> <script async src="/js/tracker.js"></script> <script defer src="/js/app.js"></script> <script defer src="/js/UI-lib.js"></script> <script defer src="/js/analytics.js"></script>

第二步:给首屏图设置优先级

<img src="/img/kv.jpg" fetchpriority="high" />

第三步:图表库改为动态导入原本这个图表库在统计图表区域用到,但该区域位于页面第二屏。改成只在滚动到该区域时才加载。

const loadChart = async () => { const { renderChart } = await import('./heavy-chart-lib'); renderChart(); }; // 用IntersectionObserver监听图表容器进入视口 const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { loadChart(); observer.disconnect(); } }); observer.observe(chartContainer);

改完以后在同样的模拟环境下做A/B对比,结果很直观:

指标优化前优化后变化
LCP2.9s1.1s下降62%
页面完全加载3.5s1.8s下降49%
首屏网络请求数2311减少52%
总传输体积1.8MB1.1MB下降39%

重点不是数字本身,而是优化路径的排序:先解决阻塞加载,再解决资源优先级,最后做按需加载。这三步的性价比是逐级递减的,但每一步都很必要。

4. 常见性能问题与排查实录

顺着实战流程走,总会遇到一些理论解决不了的坑。我把自己做性能优化时高频踩到的问题整理成查版本的速查表,按问题现象、根因、解决路径三个维度列出来,方便大家排查时对号入座。

4.1 首屏白屏时间过长

  • 现象:页面打开一片空白,等很久才出现内容

  • 根因:大多数情况是同步脚本或未内联的关键CSS阻塞了渲染

  • 排查思路:DevTools Performance面板录制加载过程,查看Main线程上有没有长条形任务占据大量时间,检查哪些网络请求发起的顺序靠前,凡是早于首屏内容的请求,都要逐一确认是否真的需要那么早加载

  • 解决路径:

    • 全部脚本检查一遍,能异步就异步
    • 首屏CSS内联,剩余CSS异步
    • 字体文件用font-display: swap属性,避免字体加载阻塞文本渲染

4.2 图片延迟加载但首屏大图反而不出来

  • 现象:加了loading="lazy"属性之后,首屏大图反而加载得很慢
  • 根因:loading="lazy"的工作原理是“接近视口时加载”,但某些浏览器对“接近”的判断比较保守,首屏图片在初始化时并没有真正接近视口,导致加载被推迟
  • 解决路径:首屏图片不要加loading="lazy",改为fetchpriority="high";只有非首屏图片才加懒加载

4.3 动态导入导致首屏反而多了额外请求

  • 现象:把路由改成了懒加载,第一屏却发起了很多chunk文件请求,总请求数暴增
  • 根因:路由懒加载粒度拆太细了,一个页面拆了几十个chunk,浏览器同时请求大量小文件,握手开销超过了体积收益
  • 解决路径:用体积阈值控制分割粒度。一般模块小于30KB的,不值得单独拆成chunk。构建配置里可以把小模块聚合在一起,把真正的大模块独立出来

4.4 第三方脚本把页面拖垮了

  • 现象:线上页面慢,点开Network面板发现一堆第三方域名请求
  • 根因:广告、统计、客服、AB测试,各种第三方脚本全部同步加载,有的还重复执行
  • 解决路径:
    • 评估每个第三方脚本的必要性,可砍就砍
    • 不能砍的全改为async,并放在页面最底部
    • 高优场景可以给第三方脚本加defer并延后触发,页面核心交互完成后再加载它们

4.5 性能优化后代码逻辑出现运行顺序错乱

  • 现象:改了异步加载之后,原来能跑的功能故障了,报错“某某变量未定义”
  • 根因:原本同步执行的脚本之间,存在隐式的执行顺序依赖。改成异步之后,这种顺序依赖被打破了
  • 排查思路:全局搜索报错变量,查看它在哪些脚本里被定义、哪些脚本里被使用。如果存在跨文件的变量引用,说明两个文件之间有强耦合,需要合并成一个文件或用import显式管理依赖

我在这里额外强调一点:性能优化做之前,先给现有脚本之间的依赖关系理一遍。有依赖关系的脚本,优先考虑合并或使用defer;完全没有依赖的,再考虑async。不要为了追求极致的异步效果,把老代码的顺序逻辑打乱,那是得不偿失的。

4.6 测试环境优化效果明显,线上却失效

  • 现象:本地测试LCP在1秒内,线上却是4秒
  • 根因:线上环境多了CDN回源、网络波动、并发带宽争抢等变量
  • 解决路径:
    • 用Lighthouse或WebPageTest这类工具,设置模拟4G网络和多设备环境测试
    • 对比不同地域节点的性能数据,查找网络分层的问题
    • 所有优化效果验证不要只看本地一次,连续测3轮取平均值

5. 移动端与多端场景的异步加载差异化实践

很多优化方法论是围绕桌面浏览器生态建立的,但放到移动端和各类小程序容器里,情况会变一套。这章单独拿出来说,就是因为异步加载在多端环境里的“坑”足够多,值得单独交代。

5.1 移动端的网络和内存约束

移动端跟桌面端最大的不同是网络不稳定、内存和CPU有上限。4G和5G环境下DNS解析耗时、TCP连接建立成本、带宽波动,都比有线网络严重得多。再加上手机浏览器后台会自动回收标签页内存,这会直接影响异步加载的体验。

一个我实测过的结论:移动端HTTP/1.1下同域名并发连接数限制是6个,HTTP/2下虽然能多路复用同一条连接,但移动网络的丢包重传代价很高。所以在移动端做异步加载,通常比桌面端更激进——能拆就拆,能懒就懒,能预判就预判。

另外移动端的硬件解码资源有限,大量图片同时解码很容易造成帧率下降、掉帧卡顿。异步加载在这里的体现是:图片懒加载不只是为了省流量,也是给浏览器的解码和渲染管线减负。

5.2 小程序环境下的异步加载

小程序(以微信小程序为例)的架构和浏览器不同,它运行在双线程模型中:逻辑层(JS引擎)和渲染层(WebView)各自独立,通过原生桥接通信。这个特性让异步加载在策略上有针对性调整:

  • 分包加载:微信小程序原生的“分包”机制,本质就是把不常用页面和代码拆分到单独的分包包体中,进入对应页面时才下载。同Web里的代码分割原理完全一致
  • 首包体积控制:小程序主包上传体积限制是2MB,超了就得拆。合理拆分分包,能让主包体积掉到1MB以内,进入体验明显加快
  • 异步API调用:小程序提供了wx.request等异步API,数据请求不要阻塞页面渲染。首屏页可以用骨架屏占位,数据到了再异步刷新

5.3 Hybrid App里的异步加载策略

混合开发(Hybrid)领域也有自己的性能特点。WebView加载页面的过程跟浏览器内核基本一致,但多了“离线资源包”的概念。实践中的方案是:把业务JS和CSS提前打包进App安装包,运行时从本地加载,网络只负责拉取增量更新。这个策略的本质还是异步加载——本地资源加载不依赖网络,自然消除了网络等待给异步加载带来的不确定性。

6. 打好异步加载这一层地基,才能继续往上层走

从这个章节一路讲下来,不知道你有没有一个整体的感觉:异步加载在性能优化体系里,不是孤立的一个技巧,而是整个资源调度系统的基础。没有这层基础,后面做再多的体积压缩、缓存优化、CDN加速,效果都会打折扣。

我在实际项目中最大的体会是,性能优化不是一个“做了就好”的动作,而是一个持续迭代的过程。今天解决了脚本阻塞,明天可能又冒出来第三方插件的性能问题;这周做完了资源优先级排布,下周新的业务功能加进来,可能又把优先级搅乱了。所以我建议团队把性能优化的检查做成常规流程,每次版本发布之前,用性能预算(Performance Budget)的概念卡一道红线——超过规定阈值的改动不允许上线。这样异步加载和性能优化的成果才能保持住,不会随着迭代慢慢回退。

再分享一个小技巧收尾。做性能优化的时候,手边常备一个“对比清单”,每次改动之前把改动前后的性能数据记录下来。不要只记最终的数字,中间的排查过程、修改方案、最终效果,都按时间线写清楚。我自己踩过几次坑之后发现,整理这些笔记的过程本身,就是对原理理解加深的过程。很多说不清道不明的性能问题,写多了自然就通了。

返回列表