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

资讯详情

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

前端异步加载与性能优化:从原理到工程化落地全解析

前端异步加载与性能优化:从原理到工程化落地全解析 先说个我自己的真实经历。前两年接了一个后台管理系统的重构菜单十多个图表组件、富文本、Excel导出库全都塞在首屏一个 bundle 里打包出来 1.6MB用户每次打开都要等好几秒白屏测试妹子每次都吐槽这系统是不是卡死了。后来花了两天时间把路由改成异步加载、图表按需引入、图片全部懒加载首屏资源直接压到 300KB 出头DOMContentLoaded 从 4.8 秒降到 1.2 秒。整个过程没有删任何功能代码结构也没大改核心就一句话把用户当前不需要的东西从加载链路里拿走或者挪到后面去。这就是异步加载与性能优化最朴素的逻辑。这篇原理篇我会把异步加载的底层机制、实用手段和工程化落地从头到尾拆一遍不绕弯子直接讲清楚它为什么能优化性能以及你在真实项目里该怎么用、会踩哪些坑。1. 渲染引擎的解析流程白屏时间到底被谁吃掉了要理解异步加载为什么能优化性能先得知道浏览器打开一个页面时从收到 HTML 到画面呈现中间经历了什么。1.1 DOM 与 CSSOM 的构建顺序浏览器拿到 HTML 字符串后会交给 HTMLParser 逐行解析边解析边构建 DOM 树。与此同时只要遇到link relstylesheet或style标签解析器就会请求并解析 CSS构建出 CSSOM 树。DOM 和 CSSOM 两者都齐了才合并成 Render Tree然后进入 Layout计算每个节点的几何位置、Paint绘制像素、Composite合成图层最终呈现在屏幕上。这里有个关键点CSSOM 的构建会阻塞渲染。因为如果没有样式信息浏览器不知道一个div应该画多宽多高、是什么颜色渲染出来也是错乱的。所以 HTML 里head区域的 CSS 是渲染必经之路。1.2 脚本执行对解析器的阻塞机制JavaScript 的情况更特殊。当解析器遇到script src...标签时它会停下手中所有工作停止构建 DOM等待脚本下载然后执行脚本执行完再继续解析后面的 HTML。为什么这么设计因为在没有async和defer的远古时代脚本执行时可能会调用document.getElementById(xxx)来查 DOM、修改 DOM如果解析器不等脚本执行完就继续往下走脚本可能找不到它期望的元素。为了保证逻辑正确浏览器只能选择遇到即阻塞。网速正常时这个阻塞成本还不明显但一旦脚本体积大、网络慢阻塞时间就会被拉长。比如一个 300KB 的脚本在 3G 网络下要下载约 2 秒这 2 秒就是用户盯着的白屏时间。1.3 一个完整页面的耗时拆解实际操作中我会建议团队把首屏加载时间拆成四段看T1请求 HTML 并完成解析构建 DOM 骨架。这个阶段耗时取决于 HTML 大小和服务器响应速度。T2下载并解析 CSS构建 CSSOM。这是渲染必须等待的阶段。T3下载并执行同步脚本。这个阶段阻塞 DOM 构建是白屏的主要来源之一。T4完成 Layout / Paint / Composite真正把画面画出来。异步加载要优化的核心就是T3 以及 T2 里可以被延迟的部分。把脚本从同步阻塞变成异步加载浏览器就不用在 T3 阶段干等把非首屏必需的 CSS、图片、组件从立即加载变成按需加载T2 和 T4 的时间也能被压缩。注意async和defer其实不会减少脚本的总下载量它们改变的是脚本下载和执行时机对解析器的影响。真正的资源体积缩减要靠代码分割和按需加载。2. async 与 defer 的本质区别这两个属性的取舍实践很多初学者只记得加了 async 或 defer 脚本就不会阻塞了但真到项目里选哪个往往凭感觉。我在这里把两个属性的完整行为逻辑展开讲顺带把动态创建 script 标签也放一起对比。2.1 三种加载模式的行为对比给script标签加不同属性脚本的加载和执行时机完全不同模式下载时机执行时机执行顺序保证DOMContentLoaded 触发时机无属性同步解析到标签时阻塞下载下载完立即执行按文档顺序等所有同步脚本执行完async解析到标签时开始下载不阻塞解析下载完立即执行可能在解析中途不保证顺序不等 async 脚本可能在它执行前或执行后defer解析到标签时开始下载不阻塞解析等解析完、DOMContentLoaded 之前按顺序执行保证文档顺序等所有 defer 脚本执行完这里最容易踩的坑是 async 的执行顺序。因为 async 脚本下载完就执行两个 async 脚本哪怕在 HTML 里一个在前一个在后谁先下载完谁先执行没有任何顺序保证。如果你的脚本之间有依赖关系比如 a.js 定义了一个全局函数b.js 调用它给它们同时加 async 就是给自己埋雷。2.2 给实战选型的建议我平时在项目里做脚本加载策略基本遵循这几个原则和 DOM 结构强相关的业务脚本、需要操作首屏元素的脚本用defer。它保证按顺序执行又不会阻塞解析安全和性能两头都占。与业务无依赖的独立脚本比如埋点统计、A/B 测试 SDK、第三方鉴权脚本用async。它的执行时机不固定但因为脚本本身不依赖页面状态完全不关心什么时候跑。任何情况下都不建议让第三方脚本用同步模式加载尤其是那些动辄几百 KB 的 SDK。同步加载会让它们变成首屏白屏的帮凶。我在代码注释里写得很直白给团队留个模板!-- 业务核心依赖 DOM 顺序执行用 defer -- script defer src/js/app.core.js/script script defer src/js/app.ui.js/script !-- 第三方独立脚本下载完就跑早跑晚跑无所谓 -- script async srchttps://cdn.example.com/analytics.js/script2.3 动态创建 script 标签的异步场景除了在 HTML 里写死标签还有一种很常见的做法是运行时动态创建function loadScript(src) { return new Promise((resolve, reject) { const script document.createElement(script); script.src src; script.onload () resolve(script); script.onerror () reject(new Error(脚本加载失败: src)); document.head.appendChild(script); }); } // 业务里按需使用 async function initEditor() { await loadScript(/vendor/rich-editor.js); window.createEditor(#content); }动态创建的 script 默认就是异步行为浏览器会立刻发起下载但不会阻塞当前解析。这种做法的好处是灵活你可以在用户点了某个按钮之后再拉取对应功能的脚本真正实现用到才加载。代价是你要自己管理加载完成状态、失败重试、重复加载去重本质上是在实现一个简易版模块加载器。2.4 配合模块化系统的真实场景在原生 ES Module 时代动态加载这件事已经被import()语法天然解决了。它返回一个 Promise完美支持按需加载button.addEventListener(click, async () { const { openPreviewModal } await import(./previewModal.js); openPreviewModal(); });浏览器遇到import()时会动态请求对应的 JS 文件完全不影响当前页面。现代打包工具Vite、Webpack默认会把这个动态 import 的内容单独拆成一个 chunk天然形成代码分割。这也是目前做异步加载的首选方案比手动操作 script 标签靠谱得多。唯一的兼容性顾虑是较老的浏览器不支持动态 import但 2020 年之后的浏览器基本都没问题了如果你还在维护老项目可以加个 polyfill 或用构建工具降级处理。3. 延迟加载的具体打法从图片懒加载到组件按需加载异步加载不止针对脚本。图片、视频、组件凡是首屏看不到的资源都值得往后挪。3.1 图片懒加载的两种实现路径图片通常是页面里最大的资源类型一张高清图动辄几百 KB。首屏可见的图加载无可厚非但首屏之外的图也在页面初始化时一股脑下载就是纯浪费了。最省事的方案是给img加上原生loadinglazy属性img srcproduct-photo.jpg loadinglazy alt商品图这个属性从 Chrome 76 开始支持现在所有主流浏览器都认浏览器会自动判断图片是否进入视口附近进入才加载。对大部分项目来说这已经够了。如果你需要更精细的控制比如触发提前量、进入视口时的回调逻辑就用 IntersectionObserver 自己实现const lazyImages 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); } }); }, { rootMargin: 200px 0px, // 提前 200px 触发加载 threshold: 0.01 }); lazyImages.forEach((img) observer.observe(img));一个实用的技巧rootMargin设成200px 0px意味着图片还差 200px 进入视口时就开始加载既能提前准备又不至于过早下载。这样用户在快速滑动页面时图片已经加载好了不会出现滑到了还在转圈的尴尬。3.2 踩过的布局抖动坑图片懒加载最典型的问题是图片加载完成前高度为 0一旦加载触发页面内容突然往下跳用户体验非常糟。我踩过这个坑之后定了两条规矩固定宽高的图片在 HTML 或 CSS 里写死width和height属性。现代浏览器支持用width/height属性自动计算宽高比并预留空间不需要 CSS 额外处理。宽高不固定的图片比如响应式布局用一个固定宽高比的占位容器通常用aspect-ratio属性或 padding-top 百分比方案。顺带提醒一句背景图片CSSbackground-image不会触发原生懒加载需要 JS 配合 IntersectionObserver 去动态添加背景图类名应用场景更多的时候建议尽量用img标签承载图片。3.3 组件按需加载把轮播图之下的代码也拆出去图片是资源大头但不是唯一的大头。我接手过的页面里首屏代码里有大量首屏根本用不到的组件评论区富文本编辑器、数据报表图表库、视频播放器、地图组件这些个个体积惊人。用动态 import 可以按业务动作主动触发// 用户滚动到底部才加载评论区 async function loadComments() { const { CommentList } await import(./components/CommentList.js); // render... } // 用户第一次点击图表 Tab 才加载图表库 async function loadChartTab() { const { ChartPanel } await import(./components/ChartPanel.js); // render... }组件层面还有一个很实用的做法配合 IntersectionObserver 动态 import实现组件快要进入视口时才加载它。我在长页面里给第二屏的内容区都挂了这样的监听实测有效把首屏初始化时间砍掉了一截。需要注意懒加载并不总是越晚越好。如果用户交互很快比如点了一个按钮 200ms 后就要弹窗此时才去下载脚本用户会明显感觉到延迟。对这种场景更好的策略是提前预加载prefetch下面一节我会讲到。4. 资源优先级调度preload、prefetch 与 preconnect 的配合把不要的往后挪做了之后还可以更进一步把马上要用的提前拿。这里引入的是另一组工具——资源提示Resource Hints它们也属于异步加载范畴本质是调整资源的加载优先级和时机。4.1 三类资源提示的适用场景标签含义典型用法link relpreload浏览器应立即加载该资源优先级很高首屏必须的字体、关键 CSS、首屏大图link relprefetch浏览器在网络空闲时加载该资源优先级很低用户下一步大概率会用到的页面、组件link relpreconnect提前建立到源服务器的 TCP/TLS 连接第三方 API 域名、CDN 域名preload 我需要特别说一句它适合你确定马上要用的、而且等它等不起的资源。首屏如果没有 web font文字会从默认字体闪变成设计字体FOUT那这个字体就值得 preload。提前把字体下好能避免这个切换过程。4.2 preload 字体时容易忽略的 crossorigin这是我在实际项目里真的遇到过的坑。给字体文件加 preload 时如果漏了crossorigin属性浏览器会下载两次!-- 正确写法 -- link relpreload href/fonts/inter.woff2 asfont typefont/woff2 crossorigin !-- 错误写法会重复下载 -- link relpreload href/fonts/inter.woff2 asfont typefont/woff2原因很微妙字体文件默认是用 CORS 模式请求的而 preload 默认是 no-cors 模式。两者模式不匹配浏览器就会认为 preload 的资源没有被使用于是把 preload 的结果丢弃等真正用到字体时又重新请求一次。这个坑的神奇之处在于功能完全正常只是网络面板里会莫名多出一条字体请求不仔细看根本发现不了。4.3 移动端场景的优先级策略移动端的弱网络环境更容易暴露性能问题也需要更精细地掌控资源加载。我在移动端项目的实践中总结了一套组合策略首屏关键接口的域名通常是 API 域名用preconnect提前建连避免 HTTPS 握手那几百毫秒的等待。首屏图片如果可能用到的最大图特别大比如一个 banner 图 1MB我会把它 preload同时配合media属性做断点控制只在对应屏幕尺寸下才 preload。非首屏但点击率高的下一个页面在首屏加载完成、网络空闲后由 JS 触发prefetch确保用户真去点时秒开。这里要克制的是preconnect 和 preload 都是消耗预算的一个页面 preconnect 三四个域名已经是极限了preload 资源超过三四个也可能适得其反——浏览器会为这些资源排队导致真正关键的资源反而被排在后面。4.4 优先级策略和动态加载的配合更完整的思路是懒加载 空闲预取组合。比如用户登录后已知他大概率会进入订单列表页面但首屏是用户中心页那就可以在requestIdleCallback空闲回调中做预取if (requestIdleCallback in window) { requestIdleCallback(() { import(./pages/orderList.js); // 或者执行一个链接的 prefetch }, { timeout: 3000 }); }这样的好处是不干扰首屏加载但又能抢在用户点击前把资源准备好。异步加载不是只能晚也可以用空隙提前拿。5. 构建层面的异步加载落地代码分割与路由级懒加载前面讲的都是浏览器运行时的加载策略。但在现代工程化项目里异步加载最关键的落地手段其实发生在构建环节——代码分割。5.1 从一个 bundle到多个 chunkVite 和 Webpack 这类打包工具会把源码中的静态 importimport xxx from ./xxx.js统一打包成同一个 chunk。如果不做任何特殊处理整个应用变成一个巨大的 JS 文件。代码分割要做的就是把大 bundle 拆开。Webpack 提供了splitChunks配置来自动分割第三方库和公共代码但更精准的业务级代码分割靠的还是动态 import。看到这里你可能发现了在构建工具中import()的作用远不止运行时加载它同时也是一个构建指令告诉打包器这里要单独拆一个 chunk。// webpack.config.js 对应的产物逻辑简化示例 // 这个动态 import 会被拆成独立的 chunk async function openOrderDetail(orderId) { const { OrderDetailModal } await import(./OrderDetailModal.js); const modal new OrderDetailModal(orderId); modal.show(); }5.2 路由级懒加载的常见写法单页应用里收益最明显的代码分割点就是路由。因为每个路由对应一个独立页面天然是一个独立的加载单元。以 Vue 3 的写法为例const routes [ { path: /, component: () import(./pages/Home.vue) }, { path: /dashboard, component: () import(./pages/Dashboard.vue) }, { path: /user, component: () import(./pages/UserCenter.vue) } ];React 里对应的是React.lazy加Suspenseconst Dashboard React.lazy(() import(./pages/Dashboard)); const UserCenter React.lazy(() import(./pages/UserCenter)); function App() { return ( Suspense fallback{PageLoading /} Routes Route path/dashboard element{Dashboard /} / Route path/user element{UserCenter /} / /Routes /Suspense ); }写作模式上一点差异Vue 路由的component字段直接支持返回动态 import 的函数React 的React.lazy需要包一层且必须配合Suspense提供加载占位 UI。5.3 Webpack 魔法注释的命名技巧默认情况下动态 import 拆出来的 chunk 名字是数字 ID调试时很不友好。用 Webpack 的魔法注释可以给 chunk 命名const OrderDetailModal () import(/* webpackChunkName: order-detail */ ./OrderDetailModal.js);在控制台 Network 面板就能看到order-detail.js这个资源了。命名规范我建议直接对照路由路径来比如route-dashboard、route-user查问题的时候一眼能找到是哪个页面加载的。5.4 松耦合和依赖关系管理代码分割以后chunk 之间会形成依赖关系。比如Home.js依赖一个公共的utils.jsWebpack 会自动把utils.js提取成公共 chunk并在 Home chunk 加载时自动先加载公共 chunk。这块工具链都处理好了我们要注意的主要是不要让动态 import 的模块反过来静态依赖很多页面级模块否则会破坏分割效果。一个实用的检查方法构建完直接看 dist 目录里的文件数量和体积。如果动态 import 的组件拆出来的 chunk 特别大比如里面藏了整个图表库说明这个组件可能不适合作为懒加载单元需要再往下拆一层。6. 衡量异步加载效果的指标与验证方法做了这么多优化总得有办法证明它真的生效了。我能理解为什么很多团队做了性能优化但说不清效果因为没有建立可对比的衡量体系。6.1 核心指标怎么选性能优化不是玄学核心看几个指标就行LCPLargest Contentful Paint视口内最大的内容元素渲染出来的时间。这个指标衡量用户感知的加载速度异步加载优化的主要受益指标。资源加载完成时间DOMContentLoadedDCL和 Load 事件时间代码里可以直接打点取数。首屏可交互时间JavaScript 执行完、页面能响应用户操作的时间和 FID/TIN 相关。体积与请求数优化的直接产物是首屏资源体积KB/MB和请求个数变化。单资源到达时间某个关键脚本从请求发起到执行完成的时间。我在做验证时会用 Performance API 写一段通用打点window.addEventListener(load, () { const nav performance.getEntriesByType(navigation)[0]; console.log(DNS耗时:, nav.domainLookupEnd - nav.domainLookupStart); console.log(TCP耗时:, nav.connectEnd - nav.connectStart); console.log(请求总耗时:, nav.responseEnd - nav.requestStart); console.log(DOM解析完成:, nav.domContentLoadedEventEnd - nav.startTime); console.log(页面加载完成:, nav.loadEventEnd - nav.startTime); });这不只是一个数字更是定位问题、验证优化效果的直接依据。6.2 优化前后的实测对照我之前有一个真实项目改成路由懒加载后指标变化是这样一组数据指标优化前优化后变化首屏 JS 体积1.6MB380KB下降 76%首屏请求数4223下降 45%DOMContentLoaded4.8s1.2s下降 75%LCP3.9s1.5s下降 62%可以看出异步加载的收益不只是心理上快了一点而是实打实几十倍的加载链路缩短。另外我也记录了弱网下的表现Chrome DevTools 的 Slow 4G 模拟优化前白屏憋到 8 秒以上优化后 3 秒内能出首屏这个差异对用户留存是决定性的。6.3 怎么用 Performance 面板验证加载时机Chrome DevTools 的 Performance 面板是验证异步加载行为的好帮手。拿 async 和 defer 来说录制加载过程后可以看到同步脚本所在位置有一段明显的解析暂停时间轴Task 区块。async 脚本的下载和解析并行进行执行阶段不固定。defer 脚本的执行统一出现在Parse HTML 结束和 DOMContentLoaded 之间。Network 面板里也有一列叫 Priority可以去看看 preload/prefetch 资源是否按预期给了不同的优先级High/Low。如果 preload 的资源显示为 Low很可能是资源位置或者关联方式写错了。6.4 线上监控与回归防护开发阶段验证是一回事线上持续监控是另一回事。我会建议团队至少做两层在 CI 构建时跑一次 Lighthouse或 PageSpeed把 LCP、TBTTotal Blocking Time写进构建报告超过阈值直接挂红灯。这个过程自动化程度很高构建环境集成插件就能跑。生产环境接入 RUMReal User Monitoring上报采集真实用户设备上的性能数据通常一个 SDK 几十行代码就能完成上报。没有真实用户数据支撑的性能优化基本算盲人摸象。附几个提升异步加载收益的小细节按惯例最后再把我在实操中总结的几个看似不起眼但很影响效果的细节抛出来异步加载不是越狠越好。把每个组件都拆成独立 chunk、每个操作都异步加载会让页面交互出现明显延迟用户点击按钮后要等网络往返才响应。我个人的标准是首次交互前能完成加载的内容才算需要预加载其余以用户会等一等的低频交互为边界判断该不该懒加载。图片懒加载的兜底策略。有些爬虫和老的浏览器不支持原生loadinglazy这张图就永远加载不出来。我会在服务端渲染或构建阶段给关键图片一个必须加载的标记或者用noscript兜底。实际线上环境里这个兼容问题比想象中更容易被忽略。异步加载出来的样式要跟着走。动态 import 的 Vue/React 组件如果自带样式文件CSS Modules 或style块打包工具通常会把样式拆到对应 chunk 里JS 加载时自动带上 CSS这个行为不用担心。但如果你的页面有大量全局 CSS 没有做按需处理那代码分割的收益会被 CSS 的体积拖后腿。网络空闲的判定。requestIdleCallback的触发时机和页面是否正在滚动、是否有动画都有关系不是任何情况下都可靠。我会在关键场景里设置一个超时兜底比如{ timeout: 2000 }避免空闲回调一直不执行导致预取胎死腹中。Chunk 体积的颗粒度控制。拆得太细会导致请求数爆炸HTTP/1.1 时代尤其要命连接数上限都只有 6。好在现在都走 HTTP/2 甚至 HTTP/3 了请求数的约束放宽了很多但每个 chunk 控制在 50KB 到 200KB 之间仍然是比较理性的区间太小了传输效率低太大了首屏收益差。异步加载看起来是一堆浏览器的小机制堆出来的但它的核心思想其实一句话就能说透把用户当前最需要的东西放到最快的路径上把暂时用不上的东西挪到用户看不见的时机去加载。只要理解了浏览器解析、渲染、下载、执行这条链路上每一个环节的阻塞点和时机性能优化就不再是套模板而是你手里真正可以自由组合的工具。下次再有人问你的页面为什么这么快你能清楚地告诉他是异步加载在背后一件一件把资源安排得明明白白。
返回列表