尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

异步加载与前端性能优化:从关键渲染路径到工程实践

异步加载与前端性能优化:从关键渲染路径到工程实践 如果你翻过任何一份性能优化方案八成会看到这么一条结论把非关键的脚本改成异步加载。这句话看起来人畜无害只有实际操作过的人才知道坑有多深。异步加载不是给 script 标签加个 async 就完事它牵扯到浏览器解析机制、资源优先级、依赖顺序、以及你拿什么指标衡量收益这一整套东西。这篇文章我想把异步加载和性能优化这件事从原理层面一次性聊透为什么同步会慢异步到底快在哪里工程上怎么落地以及最常见的几个翻车现场。这篇内容适合正在做前端性能优化的同学也适合刚接触性能优化、想知道为什么大家都说 async 和 defer 很重要的人。我会尽量用大白话拆原理再给出可以直接抄走的方案保证你看完能直接用起来。1. 从渲染关键路径说起同步脚本为什么一夫当关1.1 浏览器遇到 script 时到底在等什么想理解异步加载你先把浏览器解析 HTML 的过程在脑子里过一遍。浏览器拿到 HTML 字节流之后要经过字节 → 字符 → 词法标记 → 节点 → DOM 树这么几条流水线。这个过程叫解析默认是边读边构建的读到一张图片标签最多发个请求出去不会停下来等图片返回。但读到 script 标签时情况完全不一样。普通不带任何属性的 script 标签无论是外链还是内联一旦被解析器碰到解析就会立刻暂停。外链脚本要等文件下载完、再解析、再执行完才能继续往下读 HTML。为什么会这样因为脚本在执行时可能会查询或者修改 DOM比如getElementById、appendChild、document.write这类操作。如果解析器不等脚本执行完就继续往下构建脚本拿到的 DOM 就是不完整的逻辑很容易出错。这是浏览器为了保证脚本语义正确而做的强制让步。更冷门的一点是脚本执行前还要等 CSSOM 构建完成。因为脚本同样可能读取样式比如getComputedStyle、offsetWidth。CSSOM 没构建好脚本执行也会出问题。所以一个放在 head 里的普通外链脚本实际阻塞的范围涵盖了 HTML 解析、CSSOM 构建、DOM 构建几乎是一夫当关万夫莫开。1.2 文件不大为什么也那么致命很多人有个错觉只要 JS 压缩后不到 100KB应该很轻了吧账不是这么算的。压缩后的 100KB真实代码体积可能膨胀到 600KB 到 800KB。即便把传输时间压下来了解析和编译时间也压不下来。V8 引擎在普通中端手机上解析加编译一兆左右的 JS耗时大约要一两百毫秒甚至更慢。注意这还只是解析编译不算脚本内部业务逻辑的执行时间。如果这个脚本正好在首屏 HTML 的头部那么用户看到的白屏时间就是网络下载时间 解析编译时间 执行时间的总和。我实测过一个商务页面一个放在 head 里的 200KB 压缩脚本把首屏时间拉慢了近一个秒。不是脚本本身写得多烂而是它放的位置和加载方式决定了它在最关键的路径上。这也解释了一个反直觉的现象为什么 Google 建议首屏渲染所需的 JS 尽量控制在 20KB 左右甚至更小。不是为了抠门而是因为首屏 JS 的每一字节都直接乘以移动设备的解析成本体现在用户等待上。1.3 关键渲染路径的完整链路值得刻进脑子所谓性能优化本质就是优化关键渲染路径。这条链路由这几环组成请求并拿到 HTML、解析 HTML 构建 DOM、解析 CSS 构建 CSSOM、两者合成为渲染树、执行布局、最后绘制到屏幕。任何阻塞这一链条的资源都会推迟首次渲染。同步脚本阻塞的是解析 HTML这一环同步 CSS 阻塞的是构建 CSSOM这一环。你可能会问CSS 不是默认就放 head 里吗对CSS 被认为是渲染必须的资源所以正常业务里我们不会让 CSS 异步化但这不代表没有例外后面我会讲 critical CSS 的技巧。理解了这条链路再看异步加载就顺了异步加载的本质是让脚本的下载和解析不要停留在关键路径上把主线程的空闲留给更重要的首屏渲染。所有性能优化的手段要么是缩短这条链路的长度要么是把非关键环节挪出这条链路仅此而已。2. 异步加载是怎么绕过去的defer、async 与动态插入2.1 defer 与 async 的死磕对比这两个属性是前端入门的必修课但很多人只是背了结论没有真正理解它们的区别。我把两者的行为完整列一下特性deferasync下载时机HTML 解析期间并行下载HTML 解析期间并行下载执行时机解析完成后、DOMContentLoaded 前下载完成后立即执行是否阻塞解析下载不阻塞执行时解析已完成下载不阻塞但执行时可能打断解析执行顺序按照文档顺序不保证顺序谁先下载完谁执行适用场景依赖 DOM、依赖其他脚本的功能完全独立的统计、埋点、广告脚本defer 的英文原意是推迟。它告诉浏览器你正常往下解析我在后台把脚本下载好等你解析完 HTML、建立好 DOM再按顺序执行我。所以 defer 脚本执行时DOM 一定是齐的而且多个 defer 脚本之间保持顺序很适合放那些有依赖关系的库和业务代码。async 的英文原意是异步。它同样不阻塞下载但脚本下载完成后会立刻抢占主线程执行不管此时解析器在干什么。这个抢占过程虽然短但严格来说已经打断了解析。更重要的是async 脚本之间不保证顺序。两个 async 脚本如果存在依赖关系几乎必然在某台设备上翻车。我在实际项目里的纪律很简单凡是需要操作 DOM 的、有依赖关系的、对首屏有用的业务脚本一律用 defer凡是完全独立的埋点、AB 测试、第三方监控才用 async。这句话你可以直接抄进团队规范里。2.2 动态插入脚本与执行顺序陷阱除了标签属性还有一种常见的异步加载姿势用 JavaScript 往页面里动态插入 script 标签。const script document.createElement(script); script.src https://example.com/plugin.js; document.head.appendChild(script);这里有一个隐含规则很容易被忽略动态插入的脚本默认开启 async 行为也就是说它会异步执行并且不保证顺序。如果你动态插入了 a.js 和 b.js而 b.js 依赖 a.js 里的全局变量你大概率会在线上收到一堆报错。解决办法有两个。第一个是显式关掉 asyncconst script document.createElement(script); script.src https://example.com/a.js; script.async false; document.head.appendChild(script);async false会让这个动态脚本进入类似 defer 的队列保持加载顺序。第二个办法是不管顺序手动用 Promise 封装加载逻辑保证依赖关系。这在后面排查章节我会单独讲。动态插入的优势是灵活可以在用户触发某个动作时再加载真正实现用到才下。劣势是它对搜索引擎和部分性能工具的识别不那么友好而且如果写不好很容易演变成一堆不可控的 setTimeout。2.3 module script新时代的默认值如果你用的是现代前端框架大概率不会直接手写 script 标签而是打一个打包后的 bundle。但 ES Module 本身也是一种异步方案。script typemodule默认是 defer 语义的而且模块之间通过import声明依赖浏览器会自己管理依赖图谱。更灵活的是动态import()它返回一个 Promise真正实现了运行时按需加载。button.addEventListener(click, async () { const { showChart } await import(./chart.js); showChart(data); });这段代码的精髓在于chart.js 的下载、解析、执行全部发生在用户点击按钮之后完全不影响首屏。这就是工程化异步加载最常见的一种形态后面的代码分割也是基于这个 API 实现的。3. 从加载不阻塞到按需加载工程化异步优化3.1 代码分割把大 bundle 拆成时间片手动给 script 加 defer只是异步加载第一层。现代前端构建体系里真正的重头戏是代码分割。思路很简单不要把所有代码打进一个 bundle 里而是按路由、按组件、按功能拆成多个 chunk在用户真正需要时再加载。以 Vite 或 Webpack 为例路由级懒加载写起来非常统一// Vue const routes [ { path: /dashboard, component: () import(../views/Dashboard.vue) } ]; // React const Dashboard lazy(() import(../pages/Dashboard));构建工具看到import()会自动把 Dashboard 拆成一个独立的 chunk。用户访问首屏时只会下载首屏所需代码其他路由的代码零开销。这里有一个关键点要注意代码分割不是为了拆而拆是要把首屏不需要的代码移出关键路径。如果一个 chunk 虽然独立但它在首屏就会被 import那拆了等于没拆。拆分时参考这样几个经验首屏路由只保留渲染必需的组件超过一定体积的第三方库我一般以 30KB gzip 为线单独拆成 vendor chunk并设置长效缓存业务代码按访问频率拆高频模块常驻低频模块懒加载。拆完之后去构建产物里看一眼每个 chunk 的大小大的要么拆要么优化。3.2 资源提示别让关键资源等网络代码分割解决的是首屏代码体积问题但有时候体积已经很优化了关键资源依然慢。原因在于浏览器对资源的加载优先级未必和你设想的一致。这时候需要资源提示上场。有三个属性值得写进你的优化清单属性作用典型场景preload提前加载当前页面必需的资源首屏字体、LCP 图片、关键脚本prefetch提前加载用户下一步可能用到的资源下一个路由的 chunkpreconnect提前建立跨域连接省去 DNS/TLS 时间第三方 API 域名、CDN 域名preload 和 prefetch 的区别一句话概括preload 是给现在要用的资源加速prefetch 是给未来要用的资源做准备。preload 的资源浏览器会以最高优先级处理所以不能滥用。如果你 preload 了七八个资源优先级互相打架反而是灾难。我举一个真实案例。一个活动页的 LCP 图片加载慢找来找去发现是浏览器把它识别成了低优先级资源而我用loadinglazy给它做了懒加载——这等于亲手把首屏最关键的东西降了级。解决办法就是取消懒加载并加上一条提示link relpreload asimage hrefhttps://cdn.example.com/hero.jpg如果是脚本还可以配合fetchpriority属性来控制加载优先级。Chrome 支持fetchpriorityhigh让关键脚本或图片抢占网络通道和 preload 配合使用效果明显。3.3 图片与 iframe 的异步化脚本之外的异步加载很多人会忽略图片和 iframe。图片的异步化有两个维度一个是加载时机一个是解码时机。loadinglazy是原生的懒加载方案现代浏览器已经支持得很好了但有一个使用前提图片必须有明确的宽高或者预留好空间。否则懒加载图片到位后会引发较大的布局位移CLS 指标直接崩掉。另一个属性decodingasync可以告诉浏览器别在解码图片时阻塞主线程对长列表里的图片收益很明显img srcphoto.jpg loadinglazy decodingasync width600 height400 alt示例如果你需要更精细的控制IntersectionObserver 是懒加载的更底层实现const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: 200px }); document.querySelectorAll(img[data-src]).forEach((img) observer.observe(img));这里rootMargin: 200px的意思是图片距离视口还有 200 像素时就开始加载。这个提前量非常关键它可以避免用户快速滚动时看到白图闪烁。我踩过坑首屏第一张图也加了懒加载结果导致 LCP 数字直接翻倍后来通过fetchpriorityhigh单独拉回优先级才救回来。iframe 同理可以用loadinglazy特别是页面底部的视频嵌入、地图嵌入加一个属性就能省下不少网络耗时。3.4 首屏关键 CSS 与异步化的关系CSS 默认是渲染阻塞资源但阻塞不是罪过关键 CSS 本来就该阻塞渲染。真正的问题是你把整站的大样式表都放在首屏阻塞路径上其中只有一小部分对首屏有用。业内成熟的方案是 critical CSS把首屏需要的样式内联进 HTML剩下的样式文件用异步方式加载。异步加载外部 CSS 有个经典技巧link relstylesheet hreffull.css mediaprint onloadthis.mediaall这个技巧利用了mediaprint的浏览器行为打印样式表不会被当作渲染阻塞资源浏览器会以低优先级下载它。下载完成后通过 onload 事件把 media 改成 all让样式生效。缺点是一旦 JS 失效样式就永远不生效了所以更稳妥的做法是用现代框架或构建插件自动抽取 critical CSS。我自己通常在优化中只对两种场景用 critical CSS一种是活动页线上页单页需求简单另一种是首屏结构稳定且内容重要值得为了 FCP 指标专门做一次抽取。普通后台系统或者内容站与其做 critical CSS不如先压缩整体 CSS 体积并去掉无用样式。4. 效果怎么验证指标、工具与量化4.1 先盯住这三个核心指标异步加载做得好不好不能凭感觉要看指标。Web 性能领域目前公认的核心指标是这三个FCP首次内容绘制页面第一次画出了文本、图片等有效内容代表用户看到了东西。LCP最大内容绘制首屏最大元素一般是主图或标题渲染完成的时间代表页面主体内容可用。INP到下一次绘制的交互延迟用户点击、按键后页面响应的时间代表页面能不能及时互动。INP 是 2024 年取代 FID 的新指标。异步加载直接影响 FCP 和 LCP你把非关键脚本移出关键路径主线程更早空闲浏览器更早渲染出首屏。INP 则更多和长任务有关一次执行超过 50ms 的 JS 任务都会损害 INP异步加载恰好可以把长任务拆散避免主线程被长时间占用。这里提醒一点不要只盯着 Lighthouse 的分数。实验室数据只能反映测试环境下的情况真实用户环境千变万化。有条件一定要接真实用户监控也就是 RUM 数据看 p75 甚至 p95 分位的指标。4.2 Performance 面板和 Lighthouse 的配合Chrome DevTools 的 Performance 面板是我每次做优化必开的工具。操作流程是打开面板点录制刷新页面等页面完全加载后停掉然后看瀑布图。重点看两个东西一是有没有主线程被占用的长任务区段二是每个脚本的加载启动位置在哪里。瀑布图里每一根条都有自己的颜色脚本加载任务用紫色表示。如果你的页面里某个 500KB 的脚本在 HTML 解析的中段完成下载并执行主线程上出现一大片紫色块这就说明它阻塞了关键路径。顺着这根条点进去就能看到脚本来源、发起者定位非常方便。Lighthouse 更适合做全局体检。它有一个专门的Opportunities板块会直接告诉你哪些脚本有机会被延迟加载哪些图片该加宽高哪些资源该 preload。不过 Lighthouse 给出的建议只是建议每一项都要结合业务场景判断。它说移除未使用的 JavaScript你还要分析这个脚本到底是首屏不需要还是当前页面确实用不到。4.3 一个可复制的验证流程我个人的优化验证流程固定是五步分享给你建立基线优化前先跑一遍 Lighthouse同时记录 Performance 面板的瀑布图保存截图。定目标挑一个指标做优化对象比如把移动端 FCP 从 2.4 秒降到 1.8 秒。目标要具体不然你会迷失在细枝末节里。做单项修改一次只改一个点比如把某个第三方脚本从同步改成 defer。重新测量同样的网络节流、同样的设备模拟重新跑 Lighthouse 和 Performance。对比回归看目标指标的变化同时留意有没有其他指标被波及。最典型的是 FCP 降了但 CLS 涨了这时候需要权衡。这一步里最容易犯的错是贪多。同时改五个优化点最后指标确实变好了但你根本不知道是哪一项起了作用下次做类似优化依然没有依据。一次只改一个变量哪怕慢一点积累的经验才是可复用的。如果你需要在自己的代码里自动化采集指标web-vitals 库是最省事的import { onLCP, onFCP, onINP } from web-vitals; onFCP((metric) console.log(FCP:, metric.value)); onLCP((metric) console.log(LCP:, metric.value)); onINP((metric) console.log(INP:, metric.value));把这些值上报到自己的埋点平台长期监控比临时开一次 Lighthouse 有用得多。5. 常见问题与排查实录5.1 异步加载后样式闪烁FOUC症状页面先显示一段无样式的纯文本或乱排版的界面然后突然啪一下变成完整样式整个过程可能只有几百毫秒但视觉体验很糟。原因CSS 被异步加载了而 HTML 已经解析完成并开始渲染浏览器在没有完整样式表的情况下先画了一版内容。传统 async CSS 技巧会在 JS 不可用时直接失效也是 FOUC 的高发原因。解法最根本的办法是保证首屏关键 CSS 始终同步内联或同步加载异步化的只是非关键样式。如果你用了mediaprint技巧务必补一个noscript兜底让无 JS 环境下样式正常加载。5.2 依赖顺序失控症状线上偶发某个功能不可用报错类似xxx is not defined刷新后又好了。原因两个 async 脚本同时加载快的先执行了慢的依赖方还没准备好。动态插入脚本时默认 async 行为也会触发这个问题。这种问题在弱网环境下出现概率极高因为两个文件的下载时间差被放大。解法有依赖关系的脚本统一用 defer动态加载时显式保证顺序或者把依赖封装成 Promise 链。我在团队里的硬性规定是页面业务脚本一律 defer只有统计类、监控类脚本允许 async。这个规定帮我省掉了大量排查线上的时间。5.3 懒加载太懒导致体验断层症状用户快速滚动页面图片区域先是一块纯色或占位滚过去了图才慢慢加载出来。还有一种更隐蔽的情况首屏图片加了loadinglazyLCP 指标变得忽高忽低。原因懒加载的触发距离设得太小或者错误地把首屏关键资源也懒加载了。浏览器对首屏资源的识别并不总是智能特别是在图片是异步插入的情况下。解法遵循两个原则。第一首屏关键图片绝不懒加载给fetchpriorityhigh而不是loadinglazy。第二视口外图片的懒加载要给足够提前量IntersectionObserver 的rootMargin至少设置 200px 到 500px。同时图片必须有宽高属性否则懒加载会带来 CLS 飙升。5.4 点击模块时 chunk 还没下完症状用户点了某个按钮页面没有任何反应过了好几秒才跳出内容甚至直接白屏报错。原因动态 import 的 chunk 是一张网络请求需要时间。用户点击时请求还在路上。如果你没有做 loading 状态和处理失败逻辑用户就会觉得页面卡死了。解法动态加载交互型代码时务必包一层状态管理。简单做法async function loadFeature() { const container document.getElementById(feature); container.textContent 加载中...; try { const { init } await import(./feature.js); init(container); } catch (e) { container.textContent 加载失败请重试; // 上报错误日志 } }更进一步预判用户行为比如鼠标悬停到某个菜单上时就用 prefetch 把对应 chunk 拉下来让用户真正点击时资源已经就位。这是游戏行业里常说的预加载放到 Web 上同样适用。5.5 一个简单的排查速查表现象优先怀疑排查手段首屏白屏时间长同步脚本阻塞解析Performance 面板看主线程长任务FCP 很快但 LCP 慢首屏大图优先级低或懒加载Network 面板看图片加载时序交互卡顿主线程被长任务占满Performance 面板找超过 50ms 的任务异步脚本执行顺序错乱使用了 async查看 Network 面板资源完成顺序页面闪烁CSS 异步加载对比有无 JS 场景下的渲染表现6. 最后再分享几条经验异步加载这件事我有两点很深的体会。第一点是别把异步加载当成万能药。异步加载解决的是脚本横在关键路径上的问题但如果你把宝贵的首屏资源异步化了反而会帮倒忙。一切优化的前提是先定位瓶颈而不是看到优化建议就往上堆。第二点是性能优化是持续性工作不是一次性活动。我接手过一些项目平时没人管性能上线前突击优化结果改完一版又退回去。正确的做法是把核心指标接进监控平台设定预算线比如 LCP 超过 2.5 秒就告警让性能问题暴露在平时。异步加载只是工具真正值钱的是这套持续观测的方法。如果你刚开始做这个方向的优化建议从最小的一步开始找一个首屏不需要的第三方脚本给它加上 defer对比一下前后的 FCP。亲手看到变化比读一百篇原理文章都更有感觉。后面再逐步深入代码分割、资源提示、渲染层面的异步化你会有自己的判断。
返回列表