13、资源加载与解析:HTML 与子资源加载、预加载扫描器(Preload Scanner)、脚本阻塞与 async/defer、图片解码与懒加载
这一章我们聊聊浏览器最核心的“搬砖”过程——资源加载。你想想看,用户输入一个网址,浏览器拿到的是HTML文本,但最终屏幕上出现的是图文并茂的页面。这中间发生了什么?说白了,就是一场资源调度的交响乐。
我个人习惯把HTML解析比作“一边读菜谱一边做菜”。浏览器不能等整本菜谱读完才开始切菜,那样太慢了。它得边读边做,甚至提前预判下一步要什么食材。这就是我们今天要讲的核心。
13.1 HTML解析与DOM构建
浏览器拿到HTML字节流后,第一步是解码成字符,然后通过词法分析器(Tokenizer)切成一个个Token。比如<div>是一个开始标签Token,</div>是结束标签Token。接着这些Token被送入树构建器(Tree Builder),按照HTML规范生成DOM节点。
这个过程是流式的。什么意思?就是浏览器不需要等整个HTML下载完才开始解析。它收到一部分数据,就解析一部分。我在项目中遇到过一个问题:某个页面首屏特别慢,后来发现是因为服务器把HTML分成了很多小chunk发送,但每个chunk之间间隔太长。浏览器解析到一半卡住了,DOM树迟迟不能闭合。
关键点:HTML解析是边下载边解析的。解析器遇到CSS和图片会继续往下走,但遇到脚本就不一样了。
13.2 预加载扫描器(Preload Scanner)
这里有个重要的优化机制——预加载扫描器。它是HTML解析器的一个“小弟”,专门负责提前发现资源。
主解析器还在慢慢构建DOM树的时候,预加载扫描器会快速扫描原始字节流,找出所有<img>、<link>、<script>等标签的URL,然后提前发起网络请求。这样一来,等主解析器真正处理到这些标签时,资源可能已经下载好了。
我曾经调试过一个性能问题:页面里有个很大的背景图片,加载很慢。检查后发现,这个图片是通过CSS的background-image引用的,预加载扫描器看不到它。因为扫描器只认HTML标签里的URL,不认CSS里的。后来我把这个图片改成了<img>标签,首屏加载速度明显提升。
避坑指南:如果你有重要的首屏资源(比如Logo、关键图片),尽量用HTML标签直接引用,不要藏在CSS里。预加载扫描器只扫描HTML,不扫描CSS。
预加载扫描器的工作流程可以用下面这张图来理解:
13.3 脚本阻塞与async/defer
脚本是HTML解析的“拦路虎”。默认情况下,遇到<script>标签,解析器会停下来,等脚本下载并执行完,再继续解析后面的HTML。这就是所谓的“脚本阻塞”。
为什么会这样?因为脚本可能用document.write()修改DOM,或者查询尚未解析的元素。浏览器不敢冒险,只能暂停。
但这样太慢了。于是有了async和defer两个属性。
| 属性 | 下载时机 | 执行时机 | 执行顺序 |
|---|---|---|---|
| 无(默认) | 遇到标签时下载 | 下载完成后立即执行 | 按文档顺序 |
| async | 遇到标签时下载 | 下载完成后立即执行 | 不保证顺序 |
| defer | 遇到标签时下载 | HTML解析完成后执行 | 按文档顺序 |
我个人习惯这样用:如果脚本之间没有依赖关系,用async;如果脚本需要按顺序执行,或者需要操作完整的DOM,用defer。比如第三方统计脚本,用async就很好,谁先下载完谁先跑。但如果是jQuery和依赖jQuery的脚本,必须用defer保证顺序。
注意:async脚本可能在HTML解析完成前执行,此时DOM可能不完整。如果你的脚本要操作DOM元素,最好用defer或者放在页面底部。
我曾经遇到过一个线上bug:某个广告脚本用了async,但它执行时依赖的DOM节点还没生成,结果广告位一直空白。排查了半天才发现是执行时机的问题。后来改成defer就正常了。
13.4 图片解码与懒加载
图片加载是个“重活”。浏览器下载完图片数据后,还要解码成位图才能渲染。解码本身很耗时,尤其是大尺寸的JPEG或PNG图片。
在WebKit中,图片解码默认是异步的。下载完成后,解码任务会被丢到后台线程去处理,不阻塞主线程。但解码完成后,把位图数据传回主线程这个过程,还是有一定开销的。
懒加载是另一个重要优化。通过loading="lazy"属性,可以让图片只在接近视口时才加载。这个属性在Chrome 76之后得到了广泛支持。
<!-- 原生懒加载 -->
<img src="large-image.jpg" loading="lazy" alt="大图" />
<!-- 配合解码提示 -->
<img src="photo.webp" decoding="async" loading="lazy" alt="照片" />
decoding属性有三个值:sync(同步解码)、async(异步解码)、auto(浏览器决定)。我个人建议非首屏图片都用decoding="async",让解码不阻塞渲染。
小技巧:对于首屏的图片,不要用loading="lazy"。因为懒加载需要计算元素位置,首屏图片用懒加载反而会延迟加载时机。首屏图片直接用loading="eager"或者不加loading属性。
我在项目中做过一个优化:一个图片列表页,有上百张缩略图。最初全部直接加载,页面卡得不行。后来做了三件事:
- 缩略图用
loading="lazy",只加载视口附近的 - 图片格式从PNG换成WebP,体积缩小60%
- 加上
decoding="async",解码不阻塞主线程
优化后,页面加载时间从4.2秒降到了1.1秒。嗯,效果还是很明显的。
13.5 资源加载的整体流程
把上面这些串起来,整个资源加载流程是这样的:
- 浏览器下载HTML字节流
- 主解析器开始解析,构建DOM树
- 预加载扫描器同时扫描,提前发现并下载资源
- 遇到CSS:继续解析,CSS下载不阻塞DOM构建(但阻塞渲染)
- 遇到普通脚本:暂停解析,下载并执行脚本
- 遇到async/defer脚本:继续解析,脚本异步下载
- 遇到图片:继续解析,图片异步下载并解码
- HTML解析完成后,触发DOMContentLoaded事件
- 所有资源加载完成后,触发load事件
你想想看,这个流程里每一步都可能成为性能瓶颈。预加载扫描器能提前发现资源,但发现不了CSS里的图片。async/defer能避免脚本阻塞,但用错了顺序会出bug。懒加载能节省带宽,但首屏图片用了反而慢。
嗯,这里要注意:没有银弹。每个优化手段都有适用场景。理解底层原理,才能做出正确的选择。
总结:资源加载是浏览器性能优化的核心战场。预加载扫描器、async/defer、懒加载,这三个工具用好了,页面加载速度能提升一个量级。但前提是——你得知道它们各自在干什么。