为什么 <img> 明明不阻塞首屏渲染,我们却还要加 loading="lazy"?为什么放在 <head> 里的同步脚本有时反而让后面的图片更早发出?为什么内联一大坨 JS 会拖慢一切资源发现?本文把 HTML 流式解析、预加载扫描器(Preload Scanner)、同步脚本的两种阻塞方式串成一张完整决策图,给你一份拿来就能用的加载优化心智模型。
<img> 不阻塞首屏渲染 = 浏览器不需要等这张图片下载完,就能把页面第一帧画出来。即:不卡白屏、不卡 FCP、不等下载。简单说就是,当预处理器扫到 <img> 的时候,解析器只做两件事:把节点插进 DOM,把请求丢给网络栈——然后头都不回继续往下解析。它不会像遇到同步 <script src> 那样停下来等下载+执行,也不会像遇到 <link rel="stylesheet"> 那样让渲染树等 CSSOM 拼完才画。所以哪怕图片还在慢慢传,页面该出字出字、该出背景出背景,不会停在白屏上干等这张图。这就是“不阻塞”的本意。
一、起点:<img> 不阻塞解析,那 loading="lazy" 图啥?
很多人背过一句结论:图片不会阻塞 DOM 解析,不影响首次渲染(FP/FCP)。所以很自然会产生一个疑问:既然不阻塞,默认行为已经够好了,为什么还要加 loading="lazy"?
问题出在"不阻塞 ≠ 没代价"。
浏览器里有个叫预加载扫描器(Preload Scanner / Lookahead Scanner)的机制。它不等 DOM 构建完、不跑 JS、不解析 CSS,只要从 HTML 字节流里扫到 <img src>,立刻就发请求。哪怕这张图在第 10 屏、用户大概率不会滚到那里,只要 HTML 下到了,请求就飞出去了。
50张图?50个请求同时(或排队)在路上。代价是:
抢带宽:你的首屏 Hero 大图(LCP 元素)、关键 CSS 正和一堆屏外小图抢同一条网线的带宽,LCP 被拖慢。
浪费流量:用户看了两段就走了,下面 30 张配图白下了。
占并发:HTTP/1.1 下同域 6 个连接,屏外图把队列占满,关键资源反而排在后面等。
loading="lazy" 做的事很简单:告诉预加载扫描器——看到我别发请求,等我快进视口了再发。 它是专门给这个"抢跑选手"踩刹车的。
反例:首屏能看见的图、尤其是 LCP 图,千万别加 lazy。否则浏览器要等布局算完才发现"哦它在视口里",反而推迟了这张决定体验的大图请求。
二、预加载扫描器扫的不只是图片
它只干一件事:盲扫 HTML 原始字节流里所有字面量写的 URL,见一个登记一个请求。
它能扫到(立刻发请求) | 它扫不到(得等别的阶段) |
|---|---|
<img src>、srcset | CSS 里的 background-image: url() |
<link rel="stylesheet" href> | @font-face { src }(字体要手动 preload) |
<script src> | CSS 里的 @import |
<iframe src>、<video poster> | JS 动态创建的 img.src = x、动态 import() |
<link rel="preload"> | data-src 这种非标准写法 |
它存在的核心使命不是"早点下图片",而是:主解析器被同步外链脚本卡住等下载的时候,它还能趁这个空窗期继续往后摸 HTML,把后面的资源提前塞进网络队列。
三、HTML 是"流"过来的,不是整份一次给的
这是理解一切的前提。
服务端通过分块传输(chunked)一段一段往外吐字节。浏览器网络进程收到一段,就往渲染进程推一次 append(chunk)。解析器和扫描器都是边收边吃,谁也不等整份文档到齐。
所以任意时刻 t,预扫描器只能扫到已经交付给缓冲区的那部分前缀字节。网络上还没挤进来的后半截 HTML,谁都看不见——这跟主解析有没有卡住无关,是流式传输本身的属性。
四、它和主解析是"并行"的吗?
宏观上你感知到的是并行——主解析在构建 DOM,扫描器在抠 URL 发请求。
但实现上:Chromium 的预加载扫描器跑的是一套 轻量 tokenizer(不建 DOM、不跑 JS、不展开 <template>、不解析 CSS),在主线程上随着 append(chunk) 被调用时增量工作。真正脱离主线程的是 网络 I/O(走浏览器进程的网络服务)。
关键闸门叫 isWaitingForScripts():
撞到外链同步脚本 → 需要等下载 → 闸门打开 → 新来的 chunk 只扫不发 DOM → 预扫描偷跑。
没在等外部资源 → 正常走 tokenizer 建 DOM。
五、最反直觉的坑:两种"卡住",天差地别
这是线上性能最容易做反的地方。
1. 外链同步脚本(<script src="x.js">)
主解析撞到外链 → 等下载(I/O 等待期)
→ 主线程空闲,能接新 append task
→ 每来一段新 HTML,只跑预扫描,不发 DOM
→ 后面的 <img>、<link> 全部提前飞出去
→ 脚本下载完执行完 → 恢复建 DOM,资源已在路上等下载的这段时间,恰恰是预扫描最高效的空窗期。
2. 内联脚本(<script>一大坨同步逻辑</script>)
撞到内联脚本 → 不需要下载外部文件 → 不走 waiting 状态
→ 直接调 V8 执行,主线程 100% 被占
→ 新到的 HTML chunk 只能排在 task queue 里
→ append() 没机会被调用 → 预扫描连被唤醒的机会都没有
→ JS 跑完才处理排队,资源发现全推迟了Long Task 不仅卡交互,还顺带掐死了资源发现。 内联大 JS 看起来"省了一次请求",实际上让后面所有资源晚发了几百毫秒。
阻塞类型 | 预扫描有空窗吗 | 结果 |
|---|---|---|
外链同步脚本(等网络) | 有 | 趁等下载偷跑后面资源 |
内联脚本跑 CPU | 没有 | 主线程被占,扫描停工 |
外链 CSS(渲染阻塞) | 有 | 等 CSS 期间扫后续 |
document.write() | 直接废掉 | 扫描器不认运行时插入的字节 |
六、卡住时它会把后面所有资源全发了吗?不会
三道过滤拦着它:
只扫已到字节——没传到网卡的 chunk,谁也看不见。
只认静态字面 URL——JS 里动态拼的、CSS 里的,一律不认。
策略排除——loading="lazy" 在 token 阶段就被跳过;已命中缓存的不会重复发;有去重;有优先级排队(CSS/阻塞 JS > 首屏 img > 普通 img)。
所以它不是无脑全发,是在已到字节的前缀里,对静态可识别、没被 lazy 拦住的 URL 提前登记。
七、实战性能清单(直接照着用)
场景 | 怎么做 |
|---|---|
首屏 Hero / LCP 图 | 别 lazy,加 fetchpriority="high" |
首屏以下的图片 | 全加 loading="lazy" |
字体 | 预扫描扫不到,手动 <link rel="preload" as="font" crossorigin> |
<head>里的大逻辑 | 拆成外链——等下载时空窗能让预扫描偷跑;别塞一大坨内联 JS |
屏外 iframe | loading="lazy" (同理给扫描器踩刹车) |
路由懒加载 chunk | 预扫描扫不到动态 import,用 <link rel="modulepreload"> 或 preconnect 手动开绿灯 |
任何场景 | 别用 document.write(),它会直接销毁当前文档的预扫描器 |
首屏 Hero 轮播图实战
1. 手动翻页型轮播
<!-- 第1张:用户睁眼就看到,LCP 元素 -->
<img src="slide-1.jpg"
fetchpriority="high"
decoding="sync"
alt="">
<!-- 第2、3张:手动翻才看得到,放心 lazy -->
<img src="slide-2.jpg" loading="lazy" decoding="async" alt="">
<img src="slide-3.jpg" loading="lazy" decoding="async" alt="">
<img src="slide-4.jpg" loading="lazy" decoding="async" alt="">2. 自动播放型轮播
<!-- 当前 -->
<img src="slide-1.jpg" fetchpriority="high">
<!-- 下一张马上要切的,别 lazy -->
<img src="slide-2.jpg">
<!-- 再往后的 -->
<img src="slide-3.jpg" loading="lazy">
<img src="slide-4.jpg" loading="lazy">隐藏加分项:decoding
<img src="hero.jpg" fetchpriority="high" decoding="sync"> <!-- 首图尽快上屏 -->
<img src="lazy.jpg" loading="lazy" decoding="async"> <!-- 非首图丢给后台线程解码,不卡主线程 -->结论
浏览器的资源加载不是"看见 src 就下"那么简单,而是一条精密的管线:
默认行为是预加载扫描器无差别抢跑——勤快但没脑子,会把带宽分给用户根本不看的东西。
loading="lazy" 是给抢跑选手踩刹车,把带宽留给刀刃上的关键资源。
外链 I/O 等待期是预扫描唯一能"偷跑"的空窗期——所以合理拆分外链反而比全内联更快。
内联长任务是双杀——既卡主线程交互,又掐死资源发现。
HTML 是流式的——扫描器只吃已到字节,永远在追赶网络,不会瞬发全集。
理解了"谁在什么阶段、因为什么、能发现什么资源",你才算真正摸到了前端加载性能的门把手。剩下的就是对着这张心智模型,往对的地方踩油门、往错的地方踩刹车。
如果你要排一次 Lighthouse 性能分,先别急着加 CDN、别急着拆包——打开 Network 面板看看:首屏那 200ms 里,是不是有十几张第 8 屏的图正在跟你的 LCP 抢带宽。那是预加载扫描器在替你"勤快"呢。