做了多年前端,我越来越觉得“异步加载”这件事被很多人低估了。一提到性能优化,大家第一反应往往是压缩图片、上CDN、开HTTP/2,却忽略了最基础也最决定成败的一件事:怎么把资源“按时按需”地交给浏览器。异步加载的底层逻辑,是把每个关键资源的请求路径变成一条“非阻塞队列”,让脚本不再卡住页面渲染,让图片不再抢占首屏带宽,让初始化逻辑不再全部堆在启动阶段。这篇原理篇我想从浏览器的工作机制出发,把异步加载与性能优化的关系彻底拆一遍,再给出可以直接落到项目里的操作步骤和踩坑记录。
这篇文章适合三类人:一是写业务页面但总觉得首屏慢、又定位不准原因的开发者;二是刚接触性能优化、对async/defer/懒加载只有模糊概念的初学者;三是在做移动端或桌面端应用、想理解“为什么异步能省下这么多时间”的工程师。后面所有内容我都会从原理讲到实操,尽量不让你看完之后只会敲几个API,而是能自己判断在什么场景下选什么方案。
1. 为什么说异步加载是性能优化的基石
1.1 同步执行下被阻塞的页面
我们把浏览器想象成一个只有一条流水线的工厂。HTML是生产图纸,CSS是配色方案,JavaScript是负责加工的动作指令。如果流水线上的某个工位必须等机械臂完成全部指令才能动起来,那后面所有工位就只能干瞪眼。这就是同步加载的处境:浏览器在解析HTML时,一旦遇到<script>标签,它就会停下解析动作,先去下载并执行完这个脚本,再继续往下走。
下载之外还要执行,执行的代价可能比下载更高。哪怕脚本很小,只要它里面有一段循环或者DOM操作,引擎照样会占用主线程,期间用户滑动页面、点击按钮都得不到响应。我实测过一个极端例子:一个页面在head里放了一个1.2MB的第三方统计脚本,首屏等了将近两秒才出现,但其实页面自身渲染只需要200毫秒。这份脚本不是首屏必需资源,却因为同步加载把整个渲染流程堵死了。这就是性能优化首先要解决的问题:不是程序不够快,而是资源到达的时机不合理。
所谓时机不合理,可能体现在三个维度。第一,资源本身可以在空闲时加载,但同步写法让浏览器只能串行处理;第二,资源可以由多个请求并行去取,但HTML解析被一句句打断,并行度被白白浪费;第三,资源对首屏根本不重要,却占住了设备和网络资源,导致真正要紧的图片和样式延后。“异步加载”要动的就是这三个维度,把资源从“必须立刻到达”和“可以和其它请求同时到达”这两种状态中解放出来。
1.2 异步加载的本质:让出主线程
异步加载的核心不是“晚点加载”,而是“不阻塞主线程”。浏览器的主线程要同时负责解析HTML、计算样式、执行JavaScript、绘制像素和响应用户输入。任何一件工作占用的时间过长,都会造成卡顿。异步加载的目的,就是让资源的下载过程离开主线程,交给浏览器内部的网络线程去处理;让脚本的执行时机推迟到合适的时间点,避免打断解析过程;让长任务分化成更细粒度的时间片,降低卡顿感。
拿图片来说,<img>标签本身并不会阻塞HTML解析,图片下载是在网络线程异步完成的。如果把图片改成同步方式去读取文件流再插入DOM,页面就会频繁卡死。浏览器天生就提供了异步的能力,我们需要做的是把这种能力用对。脚本异步加载的思路也一样:async或defer属性把“下载”和“执行”解耦出来,浏览器可以在后台下载,同时继续解析DOM,等下载完成后再决定何时执行。
理解了这个本质,你会发现很多优化手段是相通的。代码分割(Code Splitting)是让某段逻辑在用户需要时才执行,本质是加载时机优化;懒加载是让视口之外的内容先不下载,本质是资源优先级优化。它们都属于异步加载这个范畴,因为它们的共同点是:不阻塞主线程,把加载动作延后到收益最大的时间点。
1.3 性能优化金字塔:异步在其中的位置
把性能优化的常见手段排个金字塔,最底层是网络与传输优化,包括减少请求数、启用HTTP/2、合理设置缓存;中间一层是资源体积优化,包括代码压缩、Tree Shaking、图片格式转换;最上层才是运行时性能优化,包括异步加载、长任务拆分、渲染节流。这里我想强调一个多数人忽略的事实:异步加载并不只属于最上层,它同时影响了底层和中层。
异步加载可以减少首屏无用资源的请求数(底层),它本身不减少体积,但能把体积大的资源从关键渲染路径上挪走(中层),它还可以把耗时的初始化逻辑拆成多个阶段执行(上层)。所以我把异步加载称作纽带型优化。它和缓存策略配合,可以给出“构建期精准命中缓存”的方案;和代码分割配合,可以把路由级的首屏JavaScript削减一半以上;和渲染调度配合,可以让动画不再受大数据请求影响。
很多团队在写性能优化规范时,第一件事就是规定所有脚本必须加上异步加载标记,这个规定其实很合理。因为只要资源还是同步进入页面,后面再大的压缩率、再好的缓存策略,都可能被一个不合理的脚本标签打回原形。
2. 浏览器渲染原理与加载时机
2.1 从HTML解析到首次渲染
要真正用好异步加载,必须先知道浏览器是怎么把HTML变成像素的。简单来说,浏览器拿到HTML后,先生成DOM树;同时,CSS的解析会生成CSSOM树;两者合并成渲染树;再经过布局计算和绘制,最终在屏幕上出现内容。关键点在于,CSSOM的生成可能被样式表阻塞,而DOM的生成会被脚本阻塞。
这里有个细节:CSS不仅会阻塞样式计算,还会阻塞脚本执行。当浏览器遇到一个普通脚本,而它前面的样式表还没加载完时,浏览器必须等待样式表加载完毕再执行脚本,因为脚本里可能查询元素的样式。这也是为什么性能规范里强调“CSS放头部、脚本放尾部”或“脚本异步化”的原因。头部同步脚本可能是最差的选择,它既阻塞解析又依赖前置样式表,整个页面都会等它。
首次渲染的时间不是等HTML完全解析完才开始的,浏览器会在解析到一定量内容后提前绘制,这被称作“渐进式渲染”。但这个机制有个前提:主线程没有被阻塞。一旦解析中途卡在脚本下载上,面板就再也无法提前渲染。异步加载的目的之一,就是保证主线程始终有空闲去执行“解析→布局→绘制”这条链路。
2.2 脚本、样式表与阻塞行为
浏览器处理一个普通<script src="...">的流程是这样的:下载脚本时,HTML解析被暂停;脚本下载完成后,执行脚本,解析继续。整个过程主线程处于等待状态。如果把脚本标签放入<head>且不带任何异步属性,就是最彻底的同步阻塞,并且脚本之间是串行的,一个接一个执行。
样式表的阻塞比较微妙。<link rel="stylesheet">本身不会阻断HTML解析,但会阻断脚本执行和渲染。在浏览器渲染流程里,只有样式表加载完成后才能生成最终的渲染树,因此样式表延迟越久,首次绘制越晚。这里就牵出一个常见认知误区:“我把脚本全部放到body底部就行了。”实际上如果body前面有尚未加载完成的样式表,底部脚本一样要等待那份样式表。异步加载不能解决样式表本身的耗时,只能避免脚本与样式表互相叠加的二次阻塞。
| 资源类型 | 是否阻塞HTML解析 | 是否阻断脚本执行 | 是否阻断渲染 | 建议加载位置 |
|---|---|---|---|---|
| 同步script | 是 | 是 | 是 | body末尾或异步化 |
| CSS样式表 | 否 | 是 | 是 | head区域,尽快加载 |
| async script | 否 | 不适用 | 否 | 任意位置,独立执行 |
| defer script | 否 | 不适用 | 否 | 任意位置,DOM解析后执行 |
提示:上面“建议加载位置”是经验值,不代表绝对规则。以CSS为例,把它放在head是为了尽早加载、尽早阻塞脚本执行前的等待;而异步脚本放在哪里问题不大,因为下载和解析是并发进行的。
这张表值得贴在工位上。多数性能问题都来自对这张表理解不到位:要么在脚本里想当然地操作DOM导致白屏,要么把脚本全部堆到head里以为是并行加载,结果反而延长了等待。
2.3 async、defer、动态创建:三种标准的取舍
async和defer常被混为一谈,但语义完全不同。简单理解:async是“下载完能执行就立刻执行,不等也不让路”,它的执行时机取决于网络速度和脚本大小,因此多个异步脚本之间没有执行顺序保证;defer是“下载完成先等着,直到HTML解析完再按文档顺序执行”,它把执行推迟到了DOMContentLoaded之前,既保证不阻塞解析,又保留了脚本顺序。
| 属性 | 下载时机 | 执行时机 | 顺序保证 | 典型场景 |
|---|---|---|---|---|
| 无(同步) | 遇到标签时 | 下载完成立即执行 | 有 | 首屏关键逻辑 |
| async | 遇到标签时 | 下载完成立即执行 | 无 | 独立统计脚本、广告 |
| defer | 遇到标签时 | DOM解析完成后按序执行 | 有 | 需要DOM的页面脚本、依赖顺序的业务脚本 |
| 动态创建 | 创建并插入时 | 插入后下载完成执行 | 可设置async | 按需加载的模块 |
动态创建脚本是更灵活的方式:通过document.createElement('script')再放到页面中,脚本不会阻塞当前解析,因为它是用户自定义时机触发的。它在SPA(单页应用)里用得最广,路由切换后需要加载下一屏的代码模块时,往往就是动态创建script标签或直接使用import()。
选型时我的经验是:不依赖DOM的第三方统计脚本用async;依赖DOM、同时关心脚本顺序的用defer;按按钮点击或滚动事件触发的模块用动态创建;首屏核心脚本既不能异步也不能延后,但要尽量小,因为它必须提前执行。
3. 前端资源异步加载的落地步骤
3.1 脚本异步加载:async与defer的正确选择
落到项目里,第一步先把所有不是首屏必需的内联脚本和外部脚本检查一遍。一个快速判断方法:问自己,“如果这个脚本晚3秒执行,页面核心功能会不会受影响?”如果不会,它就应该异步化。
具体操作步骤:
- 列出页面所有的
<script>标签,用浏览器网络面板按阻塞时间排序。 - 把必须立即执行的脚本(如首屏渲染逻辑、关键样式兜底逻辑)保持同步,但尽量压缩体积。
- 把统计、埋点、客服弹窗、A/B测试等独立脚本改成
defer或async。 - 把依赖DOM结构的业务脚本统一改为
defer,确保在DOMContentLoaded前按序执行。 - 用性能面板对比改造前后的白屏时间与可交互时间。
这里有个容易翻车的细节:async脚本的执行顺序不可控,所以如果业务脚本之间有依赖关系,比如先加载埋点模块、再加载基于埋点数据的业务模块,就不能都加async。defer保留了顺序,但执行时间都被推迟到解析完成后,要小心别把“应该尽早执行的首屏逻辑”也放进defer里,否则首屏虽然不卡DOM,却可能延迟了首屏事件绑定。
我实际见过一个团队把jQuery库也用了defer加载,结果文档解析完成之后才执行jQuery,所有依赖jQuery的DOM事件绑定被打到后面,页面交互出现了半秒到一秒的空白。归根结底,defer适合“解析完成后执行即可”的脚本,不适合“越早越好”的关键逻辑。
3.2 图片与水塘懒加载:Intersection Observer实战
图片是异步加载最典型的受益者。传统的懒加载方案会监听滚动事件,通过计算元素相对视口的位置来判定是否进入可视区,同时还要处理节流、兼容性等一堆问题。现在推荐直接用Intersection Observer API,它由浏览器原生实现,不占主线程计算,还能精确判断元素与视口的交叉比例。
实现一个图片懒加载的代码量其实非常少:
<img>const lazyImages = document.querySelectorAll('.lazy-img'); const observer = new IntersectionObserver((entries, obs) => { entries.forEach(entry => { if (!entry.isIntersecting) return; const img = entry.target; img.src = img.dataset.src; img.classList.add('loaded'); obs.unobserve(img); // 加载后不再观察,避免重复回调 }); }, { rootMargin: '200px 0px', // 提前200px开始加载,减少滚动等待感知 threshold: 0.01 }); lazyImages.forEach(img => observer.observe(img));这段代码的核心不是API本身,而是两个细节。第一,rootMargin设一点预加载距离,用户滚动到图片边缘之前就已经开始下载,体感更顺滑;第二,加载完成后一定要unobserve,否则图片即使离开视口再回来,也会不断触发回调,造成无效计算。
还有一类容易忽略的场景是“水塘图片”,也就是页面下方大量非关键图片。不要一次性给所有图片都加懒加载,可以按离首屏的距离分批绑定observer,或者直接给距离较远的图片加上loading="lazy"属性。原生loading也走浏览器调度,成本更低,但要注意loading="lazy"在部分浏览器里对JavaScript脚本不生效,只适用于iframe和img这类资源。水塘图的体验优化还需要配合占位符和宽高比预留,否则懒加载真的能带来更大的布局抖动。
3.3 动态import与路由级代码分割
用构建工具(Webpack、Vite、Rollup)做代码分割时,动态import()是异步加载的另一种落地形态。以前我们把第三方库直接写进主包,现在通过import()可以让某个模块在用户触发特定页面或操作时才加载。
以一个React/Vue项目为例,路由切换对应的页面组件改成动态导入,主包的体积可以明显下降:
// 之前:一次性加载所有页面 // import HomePage from './pages/HomePage'; // import AboutPage from './pages/AboutPage'; // 之后:按路由异步加载 const HomePage = () => import('./pages/HomePage.vue'); const AboutPage = () => import('./pages/AboutPage.vue');构建工具会把动态导入的组件拆成单独chunk,用户进入首页时只会加载首页相关代码。这个方案的收益肉眼可见:中大型应用首屏JavaScript体积往往能减少40%到60%。代价是需要配置好chunk命名、预加载策略和服务端缓存,否则会出现“点击路由时白屏半秒”的现象,因为chunk正在网络下载。
为了缓解这个体验问题,可以配合prefetch:在浏览器空闲时提前拉取可能用到的chunk。Webpack配置里写入magic comments即可:
const LoginPage = () => import(/* webpackPrefetch: true */ './pages/LoginPage.vue');这样当用户浏览首页时,浏览器会在空闲时间下载登录页代码,用户真点击登录时,几乎没有等待时间。prefetch的副作用是消耗额外带宽,所以只对“很可能被访问”的页面使用,不要给全站页面都加。
3.4 预加载与预连接:preload、prefetch、preconnect
有人会把“异步加载”和“不预加载”划等号,这是个误区。异步加载的本质是调度资源到达的时机,而不是一律延迟。浏览器提供了几类“主动加载”能力:
<link rel="preload">:提前加载当前页面确定需要的资源,且不阻塞渲染。<link rel="prefetch">:提前加载用户下一步可能需要的资源,优先级最低。<link rel="preconnect">:提前与目标服务器建立网络连接,省去后续请求的DNS和TCP握手时间。<link rel="dns-prefetch">:只做DNS预解析,开销更小。
| 类型 | 优先级 | 典型用途 | 注意事项 |
|---|---|---|---|
| preload | 高 | 首屏字体、关键图片、动态脚本 | 用完即释放,避免资源重复下载 |
| prefetch | 低 | 路由级chunk、下一页数据 | 慎用,防抢带宽 |
| preconnect | 中 | 第三方域名的已知请求 | 最多连3个域名,过多无意义 |
| dns-prefetch | 低 | 域名解析较慢的CDN | 开销小,收益有限 |
preload有一个容易被忽视的坑:如果你preload了一个图片,之后页面里又正常引用了这张图,浏览器会实际请求一次;如果preload的URL和最终使用URL不一致,甚至会造成双倍下载。所以preload的URL必须和最终资源URL严格匹配。
4. 性能度量的关键指标与优化验证
4.1 从FCP到LCP
异步加载做了那么多之后,怎么知道它到底有没有用?靠体感不靠谱,得看指标。Web性能核心指标里,和异步加载关系最紧密的是FCP(First Contentful Paint,首次内容绘制)、LCP(Largest Contentful Paint,最大内容绘制)和TTI(Time to Interactive,可交互时间)。
FCP是浏览器第一次绘制出任何内容的时间点,异步加载减少了阻塞后,FCP通常会有明显提升。LCP测量的是最大可见元素的渲染时间,它更贴近用户“页面打开了吗”的真实感受。如果你把首屏图片放到了defer脚本后才插入DOM,即使FCP很快,LCP也可能很慢,因为最大元素直到脚本执行完才出现。
TTI衡量的是页面完全可交互的时间。异步脚本如果太多,虽然不阻塞解析,但大量脚本会在DOMContentLoaded后集中执行,这段时间用户能看见页面,却不能点击,TTI就会很高。好的异步设计应该让脚本执行窗口尽量分散,避免“渲染快但交互慢”的局面。
4.2 用Performance API做异步资源测量
想量化每次异步加载的收益,可以在浏览器控制台里用Performance API直接拉数据。比如查看所有资源的加载耗时:
const resourceTimes = performance.getEntriesByType('resource') .filter(item => item.initiatorType === 'script') .map(item => ({ name: item.name.split('/').pop(), duration: item.duration.toFixed(2) })); console.table(resourceTimes);从中能看到每个脚本的download时长和execute时长,帮助你定位是网络慢还是执行慢。还可以用performance.mark()在异步回调前后打点,计算模块加载到执行结束的总体耗时,特别适合验证动态import的收益。
提示:本地开发环境的性能数据容易失真,建议用生产构建、开启无痕窗口、关闭网络节流的状态下测基线,然后再测优化后数据,对比才有意义。
4.3 Lighthouse与真实环境验证
Lighthouse是常用的一站式性能检测工具。跑一遍之后,要看它给出的Opportunities列表,那里面会直接指出哪类资源可以被延迟加载。但Lighthouse的数据基于模拟的慢网络,代表的是“理想环境下的优化空间”,最终还是要回到真实用户体验采集(如Performance面板、Web Vitals)去验证。
我的做法是:先用Lighthouse看方向,再用Performance面板的录制功能抓真实加载过程,最后用Performance API拉一次长列表对比优化前与优化后的资源耗时。优化是否有效,不只看单一指标,还看用户操作感受。把SDK脚本异步化后,经常出现FCP和LCP不变、但TTI变短的情况,这在真实用户体验里就是“页面可以更快点击了”,值得记录和肯定。
5. 异步加载中最容易踩的坑
5.1 异步脚本的次序失控
最常见的问题就是根本不理解async的执行时机,把两个有依赖关系的脚本都加上了async。两个脚本并行下载,短的先执行,长的后执行,业务逻辑如果依赖前置脚本的全局变量,轻则报错,重则页面白屏。解决思路有两类:一类是把有依赖关系的脚本合并成单一脚本;另一类是改用defer,让它们在解析完成后按顺序执行。
还有一种隐蔽场景:动态创建的script保持了插入顺序,但如果设了async=true,执行顺序也不确定。我建议动态脚本尽量不设async,由浏览器保持插入顺序,除非你明确知道这个脚本不依赖任何其他脚本。
5.2 懒加载导致的布局抖动
懒加载最典型的副作用是页面高度变化。图片未加载时高度为0,滚动到附近开始加载图片,图片撑开高度,页面内容往下跳,用户明明在滚动,却突然被弹回了上面的位置。这比不优化还糟糕。
要根治必须在图片外层容器上预留宽高比。现代CSS可以用aspect-ratio属性,简单有效:
.lazy-img-wrapper { aspect-ratio: 16 / 9; overflow: hidden; } .lazy-img-wrapper img { width: 100%; height: 100%; object-fit: cover; }aspect-ratio搭配object-fit: cover能保证图片加载前容器占位正确,加载后图片也能统一裁剪比例。兼容老项目可以退回到padding-top百分比占位法,但原理是一样的:容器必须有固定比例,不能等图片加载完再计算高度。
5.3 双倍请求问题
双倍请求在异步加载场景里挺常见。一是preload与正常请求重复下载同一资源;二是懒加载图片与浏览器预加载策略冲突,导致同一张图被请求两次;三是动态创建的script标签,如果页面上同时存在静态script引用,也会重复请求。
排查方法:打开网络面板,按名称排序,如果某个资源出现两条记录,就逐条对比发起者。preload导致的双倍请求要在HTML或服务端模板里删掉preload标签;懒加载导致的重复则要检查是不是代码里先设了src又设置了>
深度学习边缘检测模型实战:从源码数据集到训练推理避坑指南
简介:这份资源面向计算机相关专业的在校学生、教师及企业员工,提供一套基于深度学习的边缘检测模型完整实现,适合作为毕设项目、课程设计、大作业或初期项目立项演示,也便于对深度学习感兴趣的小白入门进阶。压缩包共34个文件&…
MAPPO多智能体强化学习实战:共享Critic与独立Actor设计
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
嵌入式Linux SPI NOR Flash调试全解析:以W25Q128为例
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
uni-app HBuilderX与手机端SDK版本不匹配排查修复
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
YOLOv8手势检测实战:数据集转换、训练调参与部署
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
Python深度学习文本相似度检测系统:从源码解压到实战复现
简介:面向毕业设计及深度学习实践者的完整项目包,实现基于BERT模型的文本相似度检测系统。系统综合欧氏距离、余弦相似度、曼哈顿距离等算法,并配备文件管理模块:支持创建文件夹、按指定目录上传、批量删除/下载、搜索及收藏&…