ARTICLE DETAIL

资讯详情

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

异步加载实战:前端性能优化如何将首屏从3.2秒降到1.1秒

异步加载实战:前端性能优化如何将首屏从3.2秒降到1.1秒 “首屏加载 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 异步编程范式的演进简单捋一遍。最初是纯回调。比如动态加载一个 SDKfunction 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 记录已经加载或正在加载的 URLvar 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配合Suspenseconst Detail React.lazy(() import(./views/Detail));这种做法对首屏最友好的点在于用户访问首页时只加载首页代码只有真正跳转到某个路由时才加载对应的 JS。需要特别注意的是动态 import 返回的是 Promise如果你的页面里对懒加载组件做了“即时渲染”在 chunk 还没下载完成时会触发 Suspense 的 fallback如果 fallback UI 没写会直接白屏。JavaScript 里动态 import 的错误也需要捕获特别是懒加载失败之后不能一点反馈都没有。2.4 图片懒加载与预加载的配合图片是页面体积的大头有时候一张高清图比整个 JS 还大。图片生性能优化最简单的一条路就是不要一口气全加载。原生loadinglazy属性已经不需要任何库浏览器自动判断图片进入视口附近才加载兼容性足够用。不过原生属性的触发时机没有做精细控制想要“提前一点加载”或者“做骨架占位”用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 relpreload hrefcritical.css asstyle link relprefetch hrefdetail-page-chunk.jspreload 适合急用资源告诉浏览器“你现在就下优先级调高”但注意别滥用首屏同时 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执行耗时 612msapp.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。像日期范围选择器这种只有列表页筛选栏用到的东西再也不用出现在登录页的加载链路里。第四步是图片懒加载。首屏之外的轮播图、长列表封面图全部加loadinglazy对需要精确控制位置的图片使用 IntersectionObserver 方案设置 200px 的提前量保证用户滚动时图片已经悄悄加载了不会出现“加载一半卡住”的视觉断层。3.3 优化效果与关键参数对照改造完成后用同样的网络环境Fast 3G 模拟跑了一遍数据指标改造前改造后首屏完全可交互时间3.2s1.1s首屏 JS 体积未压缩1.2MB260KBTCP 连接数2312LCP最大内容绘制3.0s1.2sLighthouse 性能分4286其中 LCP 这个指标特别值得关注因为它反映的是“用户看到主要内容”的时间。首屏图片和标题渲染明显提前了因为不再被一串巨大的脚本卡在最后。这个改造过程有两点经验值得说。第一拆包之后的体积收益要配合服务端 gzip 才有最终效果我们同时开了 gzip260KB 的包实际传输只有 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再跑一遍然后到真机低端机上试一圈。很多优化在强网环境下看起来区别不大一到弱网环境就原形毕露。场景越恶劣异步加载做得好的页面优势越明显。把弱网下的表现作为验收标准你优化出来的页面才是真的能打。
返回列表