前端老鸟都知道,页面加载慢的锅,一半以上是脚本加载方式不对砸下来的。很多萌新刚接触 async 和 defer 时,只记住“这俩都能让 script 不阻塞页面”,但真要上手调优或者面试被追问,立刻懵住:到底选哪个?为什么 defer 能保证顺序而 async 不能?放在 head 里和 body 底部有什么区别?这篇文章不整虚的,直接把浏览器是怎么解析脚本的、async 和 defer 的底层行为差异、不同场景下怎么选、怎么验证优化效果,全部掰开揉碎讲清楚。没有前端基础也能看懂,看完你就能自己上手优化页面加载速度,面试被问到这块也能对答如流。
我先说个实际案例。之前给一个内部管理系统做体检,首屏加载耗时 8.2 秒,Performance 面板一眼扫过去,罪魁祸首是 17 个 render 阻塞脚本,全部裸挂在 </body> 之前。把它们全部改成 defer 之后,首屏时间直接砍到 3.1 秒——没删一行业务代码,没压缩一个字节,只改了标签属性。这就是搞懂这两个属性的价值所在,优化成本极低,收益却立竿见影。
1. 先搞清楚浏览器是怎么“读”页面的,不然你理解不了 async 和 defer
1.1 解析 HTML 像流水线,script 是流水线上的“验货关卡”
你在浏览器地址栏敲下网址按回车,服务器把 HTML 响应回来之后,浏览器干的第一件事不是“画页面”,而是解析 HTML 字符串,逐个构建 DOM 节点。从 <html> 开始,遇到 <div> 建一个 div 节点,遇到 <p> 建一个 p 节点,过程像一条流水线:一边读一边产,把文档结构还原成内存里的 DOM 树。
这条流水线有一个很讨厌的特性——单线程,而且边解析边渲染。HTML 从上往下读,读到一半如果发现有 link rel="stylesheet" 或者 script 标签,就得停下来处理。为什么要停下来?因为后续的 HTML 结构、样式应用、JS 执行,全都依赖前面这些资源提供的信息。就好比你组装一台电脑,说明书还没看完,但提示你“必须先看第 3 页的驱动安装说明”,那你只能先把组装流程暂停,翻到第 3 页读完再继续。
这个“停下来处理”的动作,专业术语叫 parser blocking。一旦发生阻塞,浏览器就暂停构建 DOM,页面呈现给用户的内容就卡在当前位置,用户看到的可能是白屏、可能是半截页面,反正不完整。
1.2 浏览器遇到 script 标签的默认行为:先取货、再拆包、最后才继续干活
我们平时什么都不加,直接写一个 <script src="xxx.js"></script>,浏览器会执行三个步骤:
- 暂停 HTML 解析(好比你组装电脑时停下手中的活);
- 下载脚本文件(网络请求,可能是本地静态资源,也可能是 CDN 上的远程文件);
- 执行脚本内容(把 JS 跑完,包括访问 DOM、绑定事件、修改样式等);
- 重新继续解析后续 HTML。
问题就出在第二步和第三步。如果脚本文件很大,或者服务器响应慢,下载这一个文件可能耗时几百毫秒甚至几秒,而这段时间用户什么都做不了。页面内容明明已经下载到浏览器了,就因为一个 script 标签,后面的 </div>、<img>、<p> 全都解析不了。
注意:
script标签分为两种,内联脚本(直接写在<script>和</script>之间的代码)和外部脚本(通过src引入)。内联脚本没有“下载”这一步,所以解析器遇到它时,立刻执行就完事,没有 async 和 defer 的说法——后文讲的都是外部脚本。
你可以打开一个网页,按 F12 打开开发者工具,切到 Network 面板,再把 CPU 降速(DevTools 的 Performance 面板里有个 CPU throttling 选项),然后刷新页面观察。你会发现蓝色的 HTML 请求时间线里,经常出现一段空白——那就是解析被阻塞的时间。
1.3 阻塞到底有多“贵”?一个简单的实验感受一下
我教你一个三分钟就能验证的方法。随便创建一个 HTML 文件,里面放一个需要 2 秒下载的脚本(可以用本地服务模拟,或者干脆用一个网速慢的 CDN 资源):
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>阻塞实验</title>
</head>
<body>
<h1>页面上方内容已渲染</h1>
<script src="slow.js"></script>
<h1>页面下方内容待渲染</h1>
</body>
</html>
slow.js 里放一行 console.log('script executed'),并用工具模拟 2 秒延迟。打开页面,你会看到什么?页面一片空白,等 2 秒过后,上面、下面的标题和中间脚本的输出内容“唰”地一起出现在眼前。这是因为浏览器解析慢脚本时被阻塞了,整个页面的渲染都得等它跑完。
这就是为什么很多老项目的首屏性能一塌糊涂——脚本全部裸写、没有加任何异步属性,浏览器就像被胶水粘住一样,一个脚本卡两秒,五个脚本卡十秒,用户早跑了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. async 和 defer:两种“不阻塞”的方案,行为差别可大了
2.1 defer:排队等 HTML 解析完再执行,像“下班后再统一处理快递”
defer 的意思是“延迟执行”。给外部脚本加上这个属性后:
html复制<script src="app.js" defer></script>
浏览器遇到这个标签时,会立即发起下载请求,但不会暂停 HTML 解析,继续往下读、继续构建 DOM,下载好的脚本会先放在一边,等整个 HTML 文档解析完毕后,再按顺序执行所有带 defer 的脚本。
我用一个生活类比帮你记:你把快递包裹(脚本文件)放在门卫室,不等它,继续做自己的事(解析 HTML),等下班后(HTML 解析完毕),再按包裹上写的编号顺序(脚本在文档中的出现顺序)一个个拆开处理。
关键点有三个:
- 下载与 HTML 解析并行,不阻塞 DOM 构建;
- 执行在 HTML 解析完成后,执行时 DOM 已经完整可访问;
- 多个 defer 脚本按文档中的出现顺序依次执行。
第三点是 defer 最容易被忽略但也最实用的特性。只要脚本之间存在依赖关系(比如 A 脚本里定义了 B 脚本要调用的函数),用 defer 就能保证 A 在 B 之前执行。
2.2 async:下载完马上执行,谁先到谁先跑,像“外卖送到就吃”
async 的意思是“异步”。同样给外部脚本加上:
html复制<script src="analytics.js" async></script>
浏览器同样会立即下载,不阻塞 HTML 解析,但它和执行 defer 有一个本质区别:脚本下载完成后,立即执行,不管此时 HTML 解析到哪个位置。如果 HTML 还没解析完,它停下手头的解析,先把脚本跑完,再回头继续解析。
类比你点外卖:你正在忙自己的事(解析 HTML),外卖什么时候送到什么时候吃(下载完就执行),送到时哪怕你正在开会(解析中途),也得先放下会议把饭吃了,吃完再继续开会。
关键点也有三个:
- 下载与 HTML 解析并行,不阻塞 DOM 构建;
- 执行时机不可控,完全取决于何时下载完成;
- 多个 async 脚本之间不保证执行顺序,谁下载完谁先执行。
这就带来一个风险:如果两个 async 脚本之间有依赖关系(比如 B 使用了 A 定义的全局变量),当 A 体积大、B 体积小,很可能 B 先下载完、先执行,一执行就报 ReferenceError,因为 A 还没跑完。
2.3 一张表格看懂两者的核心区别
| 对比维度 | 默认(什么都不加) | defer | async |
|---|---|---|---|
| 是否阻塞 HTML 解析 | 是 | 否 | 否 |
| 脚本下载时机 | 触达标签时、阻塞时下载 | 触达标签时异步下载 | 触达标签时异步下载 |
| 脚本执行时机 | 下载完立即执行 | HTML 解析完成后 | 下载完成后立即执行 |
| 多个脚本执行顺序 | 按文档顺序 | 按文档顺序 | 不保证顺序 |
| 执行时 DOM 是否就绪 | 不一定 | 一定就绪 | 不一定,可能还在解析 |
| 适用场景 | 极少数同步需要 | 依赖 DOM 的业务脚本 | 无依赖的独立脚本,如统计代码 |
你仔细看这个表格,重点抓住“顺序”这一行——面试官最常挖的坑就在这里。他会给你两段代码:
html复制<script src="a.js" async></script>
<script src="b.js" async></script>
问你执行顺序是什么?正确答案是“不一定”。a 和 b 谁先下载完谁先执行。但如果把 async 换成 defer:
html复制<script src="a.js" defer></script>
<script src="b.js" defer></script>
这就稳定是 a 先、b 后。我见过不少人栽在这个常考问题上。
2.4 关于 DOMContentLoaded 事件,这俩的执行时机也不一样
这里还藏着一个很容易被忽略的细节:defer 脚本和 async 脚本对 DOMContentLoaded 事件的影响不同。
defer脚本在 HTML 文档解析完成后、DOMContentLoaded事件触发之前执行。所以你在defer脚本里监听DOMContentLoaded,是能正常触发的,但如果你在DOMContentLoaded回调里等着读某个defer脚本设置的数据,就得注意脚本是否已经执行完——实际上因为 defer 脚本先于事件触发,所以没问题。async脚本执行时机和DOMContentLoaded没有固定先后关系。可能脚本在DOMContentLoaded之前执行,也可能在之后。如果你的业务依赖DOMContentLoaded事件,并且用了async,建议在脚本内部自己判断document.readyState,避免想用的 DOM 节点还没解析出来。
提示:
document.readyState有loading、interactive、complete三种状态。如果脚本执行时readyState是complete,说明页面已经加载完,此时可以直接操作 DOM;如果不是,则可以给document.addEventListener('DOMContentLoaded', handler)挂一个回调。
3. 实际项目里到底怎么选?我直接给你一套“抄作业”方案
3.1 三条铁律:独立脚本用 async,业务脚本用 defer,什么都别加的老代码慎改
我自己在项目里遵循的选型原则非常简单粗暴:
第一,统计类、埋点类、广告类脚本用 async。 这类脚本通常不依赖其他代码,也不操作页面 DOM,早执行晚执行对你都没影响。用 async 能让它们尽快下载尽快执行,把影响降到最低。经典例子:百度统计、GA、第三方监控脚本。
第二,业务逻辑脚本、需要操作 DOM 的脚本、存在依赖关系的模块脚本用 defer。 比如你页面里的主入口 app.js,它大概率要查 DOM、绑定事件、初始化组件,这些操作必须等 HTML 解析完才安全。defer 保证执行时机在解析完成后,同时不阻塞解析过程,两全其美。
第三,老项目里裸奔的脚本,改造时优先加 defer,不要贸然加 async。 老项目脚本之间往往存在你都不知道的全局依赖。比如 a.js 里 window.utils = {...},b.js 里用了 utils.formatDate()。如果你给它们都加上 async,a 还没执行而 b 先执行,直接爆炸。但加 defer 就安全得多,因为执行顺序仍然按文档顺序来。我接过一个 Vue 老项目,里面把 vue.js、vue-router.js、app.js 全部并列放在 index.html 里,没有任何属性。我先把它们改成 defer,然后逐个分析依赖链,确认某一个脚本完全独立后才升级成 async。慢慢来,稳。
3.2 具体代码示例:一个典型页面的优化前后对比
优化前(典型萌新写法,一堆脚本全在 body 底部裸奔):
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>优化前</title>
</head>
<body>
<div id="app"></div>
<script src="https://cdn.example.com/lib/vue.js"></script>
<script src="https://cdn.example.com/lib/vue-router.js"></script>
<script src="js/config.js"></script>
<script src="js/utils.js"></script>
<script src="js/app.js"></script>
</body>
</html>
页面加载流程:浏览器解析到 vue.js 时,暂停、下载、执行,再去下载 vue-router.js,再暂停、下载、执行……一个接一个,全部串行。五个脚本,每个 100ms 网络延迟加 100ms 执行时间,总耗时至少 1 秒,期间白屏。
优化后(合理使用 defer 和 async):
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>优化后</title>
<!-- 统计脚本独立,用 async,不阻塞任何操作 -->
<script src="https://cdn.example.com/analytics.js" async></script>
<!-- 核心库与业务脚本有依赖关系,用 defer 并按顺序引入 -->
<script src="https://cdn.example.com/lib/vue.js" defer></script>
<script src="https://cdn.example.com/lib/vue-router.js" defer></script>
<script src="js/config.js" defer></script>
<script src="js/utils.js" defer></script>
<script src="js/app.js" defer></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
优化后的加载流程:HTML 解析遇到 analytics.js,立即下载、不等待;遇到 vue.js 等脚本,各自并行下载、不等待。HTML 解析完毕,浏览器按顺序依次执行 vue.js、vue-router.js、config.js、utils.js、app.js。无论是总耗时还是白屏时间都大幅缩短。
3.3 内联脚本怎么处理?没有 async/defer,但你可以用动态加载或者直接改造
有的萌新会问:内联脚本(直接写在 <script> 里的代码)不能加 async/defer 吗?
答案是:不能。async 和 defer 仅对带 src 属性的外部脚本生效。内联脚本遇到就会立即执行。那内联代码中有耗时的初始化逻辑怎么办?两个方案:
方案一:把关键内联逻辑包进 DOMContentLoaded 回调里。
html复制<script>
document.addEventListener('DOMContentLoaded', function() {
// 初始化代码
});
</script>
这样代码会等 HTML 解析完成后执行,相当于手写了一个“defer”。
方案二:用动态创建 script 标签的方式加载外部脚本。 把内联代码提取成独立的 .js 文件,然后用 JS 动态插入:
javascript复制const script = document.createElement('script');
script.src = '/js/init.js';
script.defer = true;
document.head.appendChild(script);
这里有个隐藏知识点:动态创建并插入 document 的 script 标签,默认加载行为是 async 的,但这和写在 HTML 里的 async 属性行为不完全一样。细节我放到第 4 节展开讲。
4. 进阶:动态脚本、type="module" 和 async/defer 的纠缠关系
4.1 动态创建的 script 标签,默认就是 async 行为
很多前端老手都可能忽略这一点。用 document.createElement('script') 创建出来的脚本,只要你把它插入到页面中(appendChild 或 insertBefore),它默认就以 async 方式加载——也就是说下载不阻塞解析,但下载完就立即执行,不保证顺序。
javascript复制// 动态加载脚本,默认 async
const s = document.createElement('script');
s.src = '/js/lazy.js';
document.body.appendChild(s);
如果你想让它表现成 defer,需要手动设置:
javascript复制const s = document.createElement('script');
s.src = '/js/lazy.js';
s.defer = true; // 手动设置为 defer
document.body.appendChild(s);
但这里有个坑:defer 属性对动态插入的脚本不一定生效。不同浏览器对动态脚本的 defer 处理并不完全一致——有的浏览器依然把它当 async 处理。所以如果你需要控制动态脚本的加载行为,最稳妥的做法是判断 readyState 或在脚本内部自己处理依赖,而不是依赖属性行为。
另外,实际项目里常有一种“按需加载”场景:用户点击某个按钮后,才需要加载某个大模块。此时用动态创建 script 是标准做法,因为它天然不阻塞页面,又是“用到才加载”,性能上非常理想。但你得接受它的执行时机不可控这一现实,不能在这类脚本之间建立同步依赖。
4.2 type="module" 天生带 defer 效果,而且支持更现代的依赖管理
ES Module(type="module")是 ES6 时代引入的浏览器原生模块方案。凡是写成下面的形式:
html复制<script type="module" src="main.js"></script>
它的加载和执行行为默认就等价于 defer——HTML 解析不阻塞,多个模块按依赖关系加载执行。你甚至可以在模块脚本上再显式加 async,那就变成了“下载完就执行”的模块模式。
html复制<script type="module" async src="main.js"></script>
type="module" 和传统 script 还有一个重要区别:模块脚本内部默认是严格模式,并且可以 import 其他模块。它天然解决了传统脚本时代“靠全局变量通信”的依赖问题。所以,如果你的项目可以上 ES Module,我建议新代码就全部用 type="module",async/defer 这些属性就不需要手动操心了——浏览器都替你安排好了。
但要留意兼容性和部署问题:type="module" 的脚本只能从 http/https 协议加载,你用 file:// 直接打开本地 HTML 文件时会报 CORS 错误,这个坑够萌新踩一下午的。调试模块代码时,建议本地起一个静态服务,比如用 npx serve 或 python -m http.server。
4.3 用 Performance API 和 DevTools 实测 async/defer 的效果
讲了这么多理论,怎么验证你的优化真的有效?两个最常见的工具我说一下。
第一个:Chrome DevTools 的 Performance 面板。 录制页面加载过程,在“Summary”标签页能看到不同颜色区块:蓝色是 HTML 解析,黄色是脚本执行,紫色是样式计算,绿色是渲染。重点看蓝色的解析时间线上有没有被黄色脚本执行打断——打断次数越多、每次持续时间越长,阻塞就越严重。把脚本加上 defer 后,你会发现黄色的脚本执行全部集中到了解析结束之后,蓝色变成整块不间断的,这就是优化生效的铁证。
第二个:Performance API 中的 performance.getEntriesByType('resource')。 它可以列出页面加载的所有资源,每个资源的 name、duration、initiatorType 都清晰可见。结合 PerformanceNavigationTiming 的 domContentLoadedEventEnd 和 loadEventEnd 时间,可以量化优化前后的指标:
javascript复制window.addEventListener('load', () => {
const nav = performance.getEntriesByType('navigation')[0];
console.log('DOMContentLoaded 时间:', nav.domContentLoadedEventEnd - nav.startTime + 'ms');
console.log('页面 load 时间:', nav.loadEventEnd - nav.startTime + 'ms');
});
优化前测一遍,优化后再测一遍,数值变化一目了然。我自己习惯把每次优化的数据记录在表格里,方便复盘。比如:
| 优化动作 | DOMContentLoaded | Load | 首屏白屏感受 |
|---|---|---|---|
| 优化前(全部裸脚本) | 3.7s | 4.9s | 明显白屏 |
| 加了 defer | 2.1s | 2.3s | 无白屏感 |
| 再优化脚本体积后 | 1.4s | 1.5s | 几乎秒开 |
实测下来,最直观的感受就是页面不再“一闪白”了。很多用户感知到的“打开快”,其实就来自于这种白屏时间的缩短。
4.4 给浏览器提前“打预防针”:preload 和 preconnect 与 async/defer 的配合
聊到页面加载优化,不可能不提 preload 和 preconnect,因为它们和 async/defer 配合起来效果更好。
preconnect用来提前和第三方域名建立连接,省去 DNS + TCP + TLS 握手时间。如果你的统计脚本来自cdn.example.com,可以在 head 里加上<link rel="preconnect" href="https://cdn.example.com">。preload用来告诉浏览器“这个资源很关键,提前下载”。比如一个放在页面底部的主要脚本,你可以提前预取资源,但通过defer控制执行时机:
html复制<link rel="preload" href="js/app.js" as="script">
<script src="js/app.js" defer></script>
注意不要对所有脚本都加 preload,那会抢带宽,导致真正关键的 CSS 或首屏图片变慢。有选择地 preload 1~2 个核心脚本就够了。
5. 面试高频题和真实项目里的坑,一次性讲透
5.1 面试官最爱问的 5 个 async/defer 问题
前端面试题库里,async 和 defer 属于基础但高频的知识点。我整理了最常见的 5 个问法,附带标准答案背后的原理。
问题一:async 和 defer 的区别是什么?
回答思路:都是让脚本不阻塞 HTML 解析,但执行时机不同。defer 在 HTML 解析完成后按文档顺序执行,async 是下载完成立即执行,不保证顺序。
问题二:如果页面有多个 defer 脚本,它们按什么顺序执行?
回答思路:按 HTML 中出现的先后顺序执行,和下载完成的先后无关。因为浏览器会把所有 defer 脚本放进一个队列,解析结束后依次执行。
问题三:async 脚本会阻塞 DOMContentLoaded 事件吗?
回答思路:不一定。如果 async 脚本在 DOM 解析完成前下载完毕,它会执行,然后继续解析,此时它阻塞了被它打断的那部分解析。但如果 async 脚本在 DOMContentLoaded 之后才下载完,那就不影响。defer 脚本则一定在 DOMContentLoaded 之前执行。
问题四:为什么说 async 适合统计脚本、defer 适合业务脚本?
回答思路:统计脚本通常与业务无关、独立执行、不需要操作完整 DOM,越早执行越好,所以用 async;业务脚本依赖 DOM 和其他脚本执行结果,需要保证执行时机和顺序,所以用 defer。
问题五:如果脚本既不操作 DOM 也没有依赖,用 async 还是 defer?
回答思路:优先 async。因为它能更早执行,耗时更短。注意,这里的前提是“绝对没有依赖”,如果你自己都不能百分之百确认,稳妥起见还是 defer。
5.2 常见问题速查表:从报错现象反推原因
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
页面报 xxx is not defined |
async 脚本先于定义依赖的脚本执行 | 换 defer,或把有依赖的脚本合并成一个文件 |
Failed to load module script 报错 |
type="module" 脚本跨域或 MIME 类型不对 | 用本地静态服务器调试;检查服务器是否正确返回 JS 的 MIME 类型 |
| 脚本明明加载了,但 DOM 操作取不到元素 | 脚本执行时 DOM 还没解析到目标节点 | 改用 defer,或监听 DOMContentLoaded 后再操作 |
| 一个脚本多次触发,行为不可控 | 脚本里注册了全局事件,且脚本被重复加载 | 给脚本加缓存策略,或保证只加载一次 |
| 页面首屏白屏很久,Network 里某个脚本排队时间特别长 | 前面有大量同步脚本阻塞,或带宽被大资源占满 | 给脚本加 defer/async,配合 preload 优化关键资源 |
| 加 defer 后,脚本反而报错 | 脚本依赖它之前执行的全局变量被延后了 | 检查依赖链;如果依赖是同步的,谨慎调整脚本加载顺序 |
5.3 独家避坑经验:我踩过的几个“看起来没问题”的坑
第一个坑:把 async 加在带有 DOM 操作的内联代码外链脚本上。 我以前做过一个活动页,首屏插了一个倒计时组件,脚本文件写在 head 里并加了 async。本地开发一切正常,上线后偶尔有人反馈倒计时不显示。排查半天,发现原因是有时候脚本下载太快,在 HTML 解析到 <body> 里的倒计时容器节点之前就执行了,脚本里 document.getElementById('countdown') 取到 null,初始化失败。后来我把脚本改成 defer,问题彻底消失。教训就是:只要脚本要操作 DOM,就别用 async,至少要用 defer。
第二个坑:老项目里把 defer 加在依赖 document.write 的脚本上。 document.write 在解析期调用是有效的,但在解析结束后调用会清空整个页面。把这种脚本从默认改成 defer 之后,执行时机延后到解析完成,结果页面直接白屏。遇到这种情况,别挣扎了,老老实实重构掉 document.write 吧,这 API 在现代浏览器里已经是过街老鼠了。
第三个坑:CDN 脚本用 async,但第三方服务不稳定导致页面 show 不出来。 第三方统计脚本如果挂了,async 不会阻塞页面渲染,这是优势,但如果是第三方脚本是业务核心(比如登录 SDK),那就得做好加载失败的重试和降级处理,不能指望 async 帮你兜底。
第四个坑:多个依赖脚本分别来自不同域名,且用了 defer。 defer 只保证“这些脚本按文档顺序执行”,但不保证第一个脚本的下载不耗时。如果 a.js 来自国外慢速 CDN,b.js 来自本地服务器,理论上 b.js 已经下载完,但 a.js 还在漂洋过海,那 b.js 就得干等 a.js 下载完才能开始执行。这个等待时间虽然不阻塞 HTML 解析,但会拖延 DOMContentLoaded。所以核心依赖脚本最好放在同一个域名或同一个 CDN,减少这种排队等待。
5.4 从“能用”到“会优化”:如何体系化地持续做性能优化
把 async 和 defer 搞清楚,只是页面加载优化的第一步。真正体系化的前端性能优化,还涉及这几个方向,你可以按顺序往下深入:
- 资源体积压缩:JS/CSS 压缩混淆、图片 WebP 化、字体按需加载;
- 请求合并与拆分:HTTP/2 下不等于盲目合并所有 JS,按路由拆包、按需加载更合理;
- 缓存策略:静态资源长缓存 + 文件名 hash 刷新,让用户二次访问秒开;
- 渲染路径优化:关键 CSS 内联、非关键 CSS 延迟加载、图片懒加载;
- 服务端渲染与静态化:首屏 HTML 直接输出内容,把 JS 降级为增强交互。
我建议你在做优化前,先用 Lighthouse(DevTools 里 Audits 面板)给页面打一次分,记录 Performance 指标,然后一项一项改。每改完一项重新测,看分数变化。这样你能清楚知道哪些手段对你的页面最有效,而不是盲目套用网上的优化清单。
我个人在实际操作中的体会是:async 和 defer 属于“零成本+高收益”的入门级优化手段,但它并不只是两个属性而已,背后是浏览器解析机制、脚本执行机制、网络加载机制的交织。你把这一小块吃透了,后面再学模块化加载、按需加载、微前端、Web Worker 这些更高级的概念,就不会觉得是空中楼阁,因为它们本质上都是在解决同一个问题——怎么让脚本的出现不拖累用户体验。
最后分享一个小技巧:如果你在改造老项目时担心脚本加了 defer 之后有隐藏问题,可以分两步走。第一步先加 defer,同时用 window.addEventListener('error') 全局捕获脚本错误上报,观察线上错误率。第二步确认稳定后,再把那些无依赖的独立脚本升级成 async。这样既控制了风险,又能把性能优化做到位。步骤不复杂,收益却很实在,值得你花半天时间在自己的项目上亲手试试。
