“首屏加载 3.2 秒”,这几个字摆在我面前的时候,产品经理表情是平静的,我心里是拔凉的。当时的页面不算重,但打包出来一个 1.2MB 的整包 JS,里面混着编辑器、图表库、日期组件,首屏用到的其实只占五分之一。整轮优化做下来没上什么奇技淫巧,核心思路就一个词:异步加载。把不是首屏必须的东西全部往后挪,能晚加载就晚加载,能分片加载就分片加载,最终首屏压到 1.1 秒。这篇文章把这些内容整理成一套可以直接抄走的方案,覆盖基础原理、常见手段、真实改造过程和排错技巧,适合手里有页面加载慢、白屏时间长、想做性能优化又不知道从哪下手的同学。
1. 异步加载到底在解决什么问题
先不急着上工具,把原理层的东西聊透。很多人用了很多年 async、defer、动态 import,但你说不清它们到底改变了什么。性能优化最怕的就是“照着别人的配置抄了一遍,出了问题不知道怎么调”,所以这一部分把异步加载的底层逻辑拆开讲。
1.1 浏览器解析 HTML 时的“卡壳”现象
浏览器从服务器拿到 HTML 文档之后,渲染主线程会从头到尾扫描并解析它。解析过程中遇到一个普通的<script>标签(没有加 async 或 defer),浏览器会立刻停止解析 HTML,先发送请求把这段脚本下载下来,然后执行完,再继续解析后面的内容。这个行为叫“解析阻塞”(Parser Blocking)。
为什么它是性能的大敌?因为下载是网络操作,执行是 CPU 操作,两个都是耗时大户。假如你有一个 400KB 的脚本放在<head>里,在 3G 网络下下载可能需要 2 秒,这 2 秒里用户看到的就是一个白屏,页面连第一行文字都渲染不出来。而页面需要用户尽快看到东西,每一百毫秒的延迟都会让用户觉得“这个网站很慢”。
可以打个比方:你在厨房里做饭,每切一个菜都要停下等帮厨把下一个食材递到手上,而且这个帮厨动作很慢。明明你有五个菜要做,大部分时间却耗在等待上。异步加载想做的事情很简单——把“等待”从主流程上摘出去,让做菜的流水线不要停。
1.2 主线程单线程与渲染阻塞的必然性
这里还有一个很多人忽略的问题:为什么浏览器非要用同步的方式执行脚本,让页面卡住,而不是“脚本慢慢下载,我先渲染页面”呢?
因为 JavaScript 可以修改 DOM 结构,比如document.write()可以直接往文档里写内容,也可以删除节点、改样式。如果浏览器一边解析 HTML 一边执行脚本,两边同时对 DOM 做操作,状态就会乱套。所以浏览器做了一个硬性规定:解析和脚本执行必须互斥,主线程一次只能干一件事。这是保证页面行为一致性的前提,牺牲的就是“速度”。
理解了这一点,你就能明白异步加载的本质:它不是把脚本执行的耗时抹掉了,而是把脚本执行的时机调度到“不需要阻塞渲染”的时间点上。比如 defer 会把脚本推迟到整个文档解析完之后再执行,这样首屏文本、图片可以先出来,用户先看到东西,脚本在后台执行,再对页面做增强。这是“感知性能”的巨大胜利。
1.3 异步代码范式的演进:从回调到 async/await
异步加载不只是“脚本标签加属性”,做工程化的时候我们经常需要动态控制脚本的加载时机和依赖关系。这个过程绕不开 JavaScript 异步编程范式的演进,简单捋一遍。
最初是纯回调。比如动态加载一个 SDK:
function loadScript(url, callback) { var script = document.createElement('script'); script.src = url; script.onload = callback; document.head.appendChild(script); } loadScript('sdk-a.js', function () { loadScript('sdk-b.js', function () { initApp(); }); });代码不复杂,但一旦 SDK 数量变多、依赖层级变深,就进入“回调地狱”,很难维护。而且并行加载两个脚本的时候,必须手动计数,写起来非常啰嗦。
后来有了 Promise,配合Promise.all可以实现并行加载并且统一处理结果:
function loadScript(url) { return new Promise(function (resolve, reject) { var script = document.createElement('script'); script.src = url; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } Promise.all([loadScript('a.js'), loadScript('b.js')]) .then(function () { return loadScript('c.js'); }) .then(initApp) .catch(function () { console.error('脚本加载失败'); });再后来就是async/await,代码读起来跟同步一样直观:
async function init() { await loadScript('a.js'); await loadScript('b.js'); render(); }这套演进对性能优化的意义在于:你可以用非常清晰的方式控制“什么时候加载什么资源”,而不是依赖文档里 script 标签的排列顺序。异步加载不是“内容变少了”,而是“命令的组织方式更可控了”。
2. 常见异步加载方案与选型要点
现在到了动手环节。异步加载的手段非常多样,每一套都有它的适用场景和坑。这一节把主流的方案逐一说清楚,并用选型视角告诉你在什么情况下选哪一种。
2.1 script 标签的 async 与 defer
这是最基础、也是使用频率最高的一对属性。两者都让浏览器“边解析 HTML 边下载脚本”,不阻塞解析,但执行时机差别很大。
| 属性 | 下载时机 | 执行时机 | 顺序保证 | 典型场景 |
|---|---|---|---|---|
| async | 边解析边下载 | 下载完成后立即执行 | 不保证,谁先下载完谁先执行 | 独立统计脚本、广告脚本,不依赖其他资源 |
| defer | 边解析边下载 | 文档解析完成后、触发 DOMContentLoaded 之前 | 按文档中的顺序执行 | 需要操作 DOM、依赖执行顺序的脚本 |
注意几个容易翻车的点。如果你有两个脚本,b.js 依赖 a.js 里声明的函数,用 async 就可能报错——因为 a.js 体积大、下载慢,b.js 先下载完先执行,调用一个不存在的函数,直接 ReferenceError。defer 就没这个问题,它保证按顺序执行。
另外一个细节是多脚本场景下 defer 的执行顺序是文档顺序,但它们会在DOMContentLoaded事件之前统一执行。意味着你可以在“解析完但还没触发事件”的时候做初始化工作。这类脚本适合放业务代码;async 脚本适合埋点、监控、AB 实验这类完全独立、加载完跑一下就不管的东西。
2.2 动态脚本注入与按需加载
有些场景下,脚本不在 HTML 里,而是用户触发某个动作之后才需要。比如用户点击“帮助中心”按钮之后才需要打开客服 SDK;用户把页面滚动到某个区块才需要加载图表渲染库。这时候动态注入脚本是更精准的手段。
基础写法一句话就能概括:
function loadScript(url) { return new Promise(function (resolve, reject) { var script = document.createElement('script'); script.src = url; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); }实现层面有两个实际问题值得注意。第一是防重复加载:如果用户反复点击按钮,脚本会被重复注入,浪费流量不说还会带来重复执行副作用。一般用一个 Map 记录已经加载或正在加载的 URL:
var loadingMap = {}; function loadScript(url) { if (loadingMap[url]) { return loadingMap[url]; } loadingMap[url] = new Promise(function (resolve, reject) { var script = document.createElement('script'); script.src = url; script.onload = resolve; script.onerror = function () { delete loadingMap[url]; reject(new Error('加载失败: ' + url)); }; document.head.appendChild(script); }); return loadingMap[url]; }第二是错误处理:网络抖动、CDN 挂了都会导致加载失败,如果 Promise 没有 catch 住,控制台会报 Unhandled Promise Rejection。生产环境里最好在 catch 里给用户一个轻提示,同时允许重试。
2.3 打包器视角:代码分割与动态 import
现代前端项目里,手动创建 script 标签已经不多了,更主流的是“代码切割 + 动态 import”的组合。以 Webpack 或 Vite 为例,只要代码里写了动态 import,打包器就会自动把这个模块拆成一个独立的 chunk,浏览器在运行到 import 语句时才发起请求。
最简单的例子是路由懒加载。以 Vue 为例:
const routes = [ { path: '/detail', component: () => import('./views/Detail.vue') } ];React 同类场景用React.lazy配合Suspense:
const Detail = React.lazy(() => import('./views/Detail'));这种做法对首屏最友好的点在于:用户访问首页时只加载首页代码;只有真正跳转到某个路由时才加载对应的 JS。需要特别注意的是,动态 import 返回的是 Promise,如果你的页面里对懒加载组件做了“即时渲染”,在 chunk 还没下载完成时会触发 Suspense 的 fallback,如果 fallback UI 没写,会直接白屏。JavaScript 里动态 import 的错误也需要捕获,特别是懒加载失败之后不能一点反馈都没有。
2.4 图片懒加载与预加载的配合
图片是页面体积的大头,有时候一张高清图比整个 JS 还大。图片生性能优化最简单的一条路就是不要一口气全加载。
原生loading="lazy"属性已经不需要任何库,浏览器自动判断图片进入视口附近才加载,兼容性足够用。不过原生属性的触发时机没有做精细控制,想要“提前一点加载”或者“做骨架占位”,用IntersectionObserver更稳:
var observer = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { var img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: '200px 0px' }); document.querySelectorAll('img[data-src]').forEach(function (img) { observer.observe(img); });rootMargin 设置成200px 0px表示图片进入视口下方 200px 范围时就开始加载,这样用户滚动到图片附近时它已经基本加载完,不会有明显的刷新感。
预加载(preload/prefetch)则是异步加载体系里的“先手”:
<link rel="preload" href="critical.css" as="style"> <link rel="prefetch" href="detail-page-chunk.js">preload 适合急用资源,告诉浏览器“你现在就下,优先级调高”,但注意别滥用,首屏同时 preload 十几个资源会让网络通道拥挤,得不偿失。prefetch 则是“如果你有空,帮我下载未来会用的资源”,行为更温和,适合路由级 chunk。
3. 实战记录:一个 3.2 秒页面的完整改造
原理和方案讲了这么多,最终还是要落到真实项目里。记录一个我经手过的典型优化,把过程和数据都摆出来,你可以照着思路复现。
3.1 改造前的病灶诊断
项目是一个后台管理系统,技术栈是 Vue 2 + Webpack,入口文件在 main.js 把几乎所有业务页面需要的公共依赖都 import 了一遍,再加上全量引入了几个第三方库。打包产物是 vendor.js + app.js,总共 1.2MB(未压缩)。
在 Chrome DevTools 的 Network 面板里看瀑布图,问题非常直观:
- HTML 文档下载只用了 80ms;
- 但 vendor.js 下载耗时 904ms,执行耗时 612ms;
- app.js 下载 486ms,执行 523ms;
- 这两段 JS 从下载到执行拖了 2.5 秒多,期间首屏一直白屏;
- 加上 CSS 和几张首屏大图的加载,首屏完全可交互的时间稳定在 3.2 秒以上。
还有更糟的:登录页用得极少的地图组件也在主包里面,用户根本没打开过相关页面,代价却是每次进系统都要白下载几百 KB 代码。
3.2 四步改造路径
第一步是拆包。把第三方依赖拆成 core 和 extras 两拨。core 只保留 Vue、Vue Router、公共状态管理这类任何页面都必须的东西;extras 包括图表库、富文本编辑器、地图 SDK,全部改成动态 import。
第二步是路由懒加载。系统有登录、列表、详情、设置四个主路由,每个路由组件改成() => import()写法,打包器自动拆成独立 chunk。改完之后首页分包从 1.2MB 缩到 260KB,这是首屏时间大幅下降最直接的原因。
第三步是组件级按需引用。以前为了省事在全局 components 里注册了一堆 UI 组件,现在改成页面内单独 import。像日期范围选择器这种只有列表页筛选栏用到的东西,再也不用出现在登录页的加载链路里。
第四步是图片懒加载。首屏之外的轮播图、长列表封面图全部加loading="lazy",对需要精确控制位置的图片使用 IntersectionObserver 方案,设置 200px 的提前量,保证用户滚动时图片已经悄悄加载了,不会出现“加载一半卡住”的视觉断层。
3.3 优化效果与关键参数对照
改造完成后,用同样的网络环境(Fast 3G 模拟)跑了一遍数据:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 首屏完全可交互时间 | 3.2s | 1.1s |
| 首屏 JS 体积(未压缩) | 1.2MB | 260KB |
| TCP 连接数 | 23 | 12 |
| LCP(最大内容绘制) | 3.0s | 1.2s |
| Lighthouse 性能分 | 42 | 86 |
其中 LCP 这个指标特别值得关注,因为它反映的是“用户看到主要内容”的时间。首屏图片和标题渲染明显提前了,因为不再被一串巨大的脚本卡在最后。
这个改造过程有两点经验值得说。第一,拆包之后的体积收益要配合服务端 gzip 才有最终效果,我们同时开了 gzip,260KB 的包实际传输只有 90KB 左右,网络损耗进一步降低。第二,chunk 文件名要加 contenthash,否则用户浏览器缓存还是拿旧文件,等于白优化。
4. 常见问题与排错技巧实录
异步加载用上之后,新问题也会跟着来。这里整理几个出现频率最高的坑和排查思路,基本覆盖日常开发的常见雷区。
4.1 异步脚本执行顺序错乱
用 async 加载两个互相依赖的脚本,运行时报xxx is not defined,这种问题在上线大厅调试时非常尴尬。排查思路很直接:先看脚本的依赖方向,b.js 依赖 a.js 的函数,那不能给这两个脚本都用 async;可以改成 defer,或者用动态加载的方式通过 Promise 控制顺序。
另外注意一个细节:defer 严格按文档顺序执行,但它执行的时候文档已经解析完毕。如果你有一个脚本想“越早执行越好”,又需要操作 DOM,那么脚本位置放<body>底部配合 defer,比放<head>里更合理。
4.2 懒加载导致图片容器塌陷
图片懒加载的时候,如果图片没有显式高度,容器高度就是 0,等图片加载完成后容器突然被撑高,页面视觉上会“跳一下”。尤其是列表页,每跳一下用户都得重新定位阅读位置,体验非常差。
解决办法是两个:给图片容器固定宽高,或者使用 CSS 的aspect-ratio属性预设比例。再配合一点 background-color 占位,加载过程基本无感。
4.3 预加载过度导致首屏变慢
新手容易把 preload 当成“万能加速器”,一口气把首屏所有图片、字体、 chunk 都塞进 preload。结果是浏览器网络通道被塞满,真正关键的脚本和样式反而排在后面,首屏时间不减反增。preload 的合理使用范围是首屏关键资源(比如最大那张首屏图、关键字体、首屏必需样式),其余未来资源一律用 prefetch 或者不预加载。
4.4 动态 import 的 chunk 请求地址 404
项目部署上线后,点击某个按钮触发懒加载,Network 面板里看到请求 chunk 文件 404。常见原因是 Webpack 的 publicPath 配置与 CDN 实际目录不一致。排查路径:先看 Network 面板里请求的完整 URL,再对照 CDN 上的实际文件路径,最后检查构建配置里的 publicPath。另一个隐蔽问题:某些 CDN 回源慢导致 chunk 首次访问超时,重试一下能好,那就要在加载失败的重试逻辑上做文章。
4.5 性能优化过程中容易忽略的隐藏项
很多人优化完 JS 和图片,发现首屏还是慢,转头一看才发现是字体文件拖后腿。字体加载默认是“阻塞换行渲染”的,也就是字体文件没加载完,浏览器不会渲染使用了该字体的文本。最通用的解法是font-display: swap,让文本先用替代字体展示,字体加载完成后切换。虽然会有极短时间的字体跳变,但比起白屏等字体,这个代价完全可以接受。
还有缓存策略。异步加载的资源文件带 contenthash 之后可以设置很长的 Cache-Control 过期时间,用户二次访问时直接走本地缓存,不再请求服务器。这一步优化对“往返访问”场景的提速效果比任何加载方案都明显。
5. 写给正在做性能优化的你
异步加载这套组合拳打下来,我最深的感受是:性能优化不是“把代码变少”,而是“把代码出现的时间线重新排列”。用户感知到的速度,取决于“关键内容出现在屏幕上的时间”,而不是“所有资源都加载完的时间”。认清这一点,很多优化决策会变得非常清晰——凡是首屏用不到的,统统往后排;凡是用户马上要用的,优先级拉满;凡是有依赖关系的,顺序必须可控。
最后再分享一个小技巧:优化完成之后,不要只盯着本地的快速网络数据看。打开 DevTools 的 Network 面板,把网络节流调到 Slow 3G,再跑一遍,然后到真机低端机上试一圈。很多优化在强网环境下看起来区别不大,一到弱网环境就原形毕露。场景越恶劣,异步加载做得好的页面优势越明显。把弱网下的表现作为验收标准,你优化出来的页面才是真的能打。