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。

预加载扫描器的工作流程可以用下面这张图来理解:

预加载扫描器工作流程 HTML字节流 主HTML解析器 预加载扫描器 提前发现资源URL 发起网络请求 (img/link/script等) 网络请求队列 资源已就绪,直接使用 预加载扫描器比主解析器快一步,提前发现并加载资源 主解析器处理到对应标签时,资源可能已经下载完成

13.3 脚本阻塞与async/defer

脚本是HTML解析的“拦路虎”。默认情况下,遇到<script>标签,解析器会停下来,等脚本下载并执行完,再继续解析后面的HTML。这就是所谓的“脚本阻塞”。

为什么会这样?因为脚本可能用document.write()修改DOM,或者查询尚未解析的元素。浏览器不敢冒险,只能暂停。

但这样太慢了。于是有了asyncdefer两个属性。

属性 下载时机 执行时机 执行顺序
无(默认) 遇到标签时下载 下载完成后立即执行 按文档顺序
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属性。

我在项目中做过一个优化:一个图片列表页,有上百张缩略图。最初全部直接加载,页面卡得不行。后来做了三件事:

  1. 缩略图用loading="lazy",只加载视口附近的
  2. 图片格式从PNG换成WebP,体积缩小60%
  3. 加上decoding="async",解码不阻塞主线程

优化后,页面加载时间从4.2秒降到了1.1秒。嗯,效果还是很明显的。

13.5 资源加载的整体流程

把上面这些串起来,整个资源加载流程是这样的:

  1. 浏览器下载HTML字节流
  2. 主解析器开始解析,构建DOM树
  3. 预加载扫描器同时扫描,提前发现并下载资源
  4. 遇到CSS:继续解析,CSS下载不阻塞DOM构建(但阻塞渲染)
  5. 遇到普通脚本:暂停解析,下载并执行脚本
  6. 遇到async/defer脚本:继续解析,脚本异步下载
  7. 遇到图片:继续解析,图片异步下载并解码
  8. HTML解析完成后,触发DOMContentLoaded事件
  9. 所有资源加载完成后,触发load事件

你想想看,这个流程里每一步都可能成为性能瓶颈。预加载扫描器能提前发现资源,但发现不了CSS里的图片。async/defer能避免脚本阻塞,但用错了顺序会出bug。懒加载能节省带宽,但首屏图片用了反而慢。

嗯,这里要注意:没有银弹。每个优化手段都有适用场景。理解底层原理,才能做出正确的选择。

总结:资源加载是浏览器性能优化的核心战场。预加载扫描器、async/defer、懒加载,这三个工具用好了,页面加载速度能提升一个量级。但前提是——你得知道它们各自在干什么。


公众号:蓝海资料掘金营,微信deep3321