一、预加载扫描器的软肋:只认字面量

要理解虚拟滚动为什么是“降维打击”,先得弄明白浏览器的预加载扫描器到底有多勤快。

当你请求一个 HTML 页面时,主线程会开始解析 HTML。为了避免主线程被 JS 阻塞导致资源下载停滞,浏览器在后台启动了一个辅助线程,也就是预加载扫描器。它不建 DOM、不跑 JS、不解析 CSS,只做一件事:扫描 HTML 字节流,遇到 <img src="..."> 或 <link rel="stylesheet" href="..."> 等标签,立刻向网络线程发送请求。

这就导致了一个非常尴尬的局面:

  • 你用 opacity: 0 把图片藏在屏幕外,DOM 全在,扫描器照常发请求。

  • 你用 display: none 把轮播图隐藏,扫描器依然照发不误。

  • 你用 content-visibility: auto 跳过渲染,网络请求照样飞出去。

只要 src 写在 HTML 里,预加载扫描器就会“看见”,并毫不留情地抢占带宽。

二、虚拟滚动的降维打击:从物理层面消灭请求

虚拟滚动的终极奥义在于:不从 CSS 层面动手,而是直接从数据和 DOM 层面动手。

你可以想象一个智能仓储系统:传统模式是所有货物(DOM 节点)都堆在分拣传送带上,只是有的被布盖住了;而虚拟滚动则是,分拣线上只摆放当前需要处理的几件货物。当一批货物处理完毕,传送带向前移动,系统才会从仓库(数据数组)中拿出下一批货物,在分拣线上“组装”成新的 DOM 节点,而旧节点则被直接销毁回收。

在虚拟滚动中,视口内只有能放得下的几个 DOM 节点,加上上下多渲染一两个作为缓冲(overscan)。剩下的几千条数据,连 HTML 标签都不存在。 没有 <img> 标签,没有 src,预加载扫描器就算有透视眼也扫不到,因为字节流里压根就没写。

它位于屏外资源优化光谱的终点:

opacity隐藏 < display:none < content-visibility < 原生lazy < data-src < 虚拟滚动

(越往右越狠,虚拟滚动是真正的“物理不存在,真·没请求”)

三、SSR 与 CSR 的不同灾难

虚拟滚动虽好,但如果不分场景无脑用,同样会引发灾难。

1. CSR(客户端渲染):LCP 的陷阱

在 React/Vue 应用中,首屏 HTML 只有一个空壳 <div id="app"></div>。预加载扫描器扫完,什么都没有。随后 JS 执行,虚拟列表算出来视口能放 8 条,只 createElement 这 8 个 <img>——完美,只有这 8 张图发出请求。

但反直觉的代价是: 首屏没有大图元素,LCP(最大内容绘制)指标会变得很小(可能是一行字/骨架屏),看着 metrics 很好看,但用户实际看到的是白板/骨架屏,体感并不快。

铁律:首屏关键内容(Hero 元素)永远别进虚拟列表,要直出、要 eager、要 fetchpriority="high"。

2. SSR(服务端渲染):最隐蔽的灾难

为了 SEO,服务端一口气把 500 条列表全吐成 HTML。此时,预加载扫描器如获至宝:“谢谢老板,500 个 src 我全发了!”

结果就是:

  • 500 张图抢带宽。

  • 首屏那 8 张真正看得见的被挤到后面。

  • LCP 炸、TTFB 还行但可交互时间巨晚(因为 hydration 要管 500 个节点)。

SSR + 虚拟滚动的正确姿势: 服务端只直出首屏那 8~12 条(刚好填满视口的),剩下的数据作为 JSON 挂在 window.__INITIAL_DATA__ 里,客户端 hydrate 后自己 append(不写 src 直到滚到)。或者干脆用渐进式 hydration,非首屏块 content-visibility + 懒加载。

一句话总结:服务端别替客户端把虚拟列表的 DOM 全吐了,那是帮倒忙。

四、三大暗坑与避坑指南

坑 1:快速滚动导致请求反复取消(thrashing)

这是虚拟滚动最反直觉的坑。由于虚拟滚动复用 DOM 节点——比如池子里只有 15 个 <img>,滚动时不停改同一个节点的 src:

imgEl.src = newUrl; // 旧请求还没下完呢!

浏览器一看:这个元素要换 src 了,上一个请求还没完成,直接 abort。Network 面板里一堆红字(canceled)。用户来回滚两下,同一张图被取消三次、重发三次,缓存没进去,滚到位了图还是白板。

解法:

  • 拉大 overscan(多渲染几个,减少节点复用频率)。

  • 别复用同一个 img 节点,用 key 让它销毁重建(但别太频繁)。

  • 更稳的方案:预加载池。 滚的时候先 new Image().src = nextUrl 预热进内存缓存,等真正滚到位了再把真实 src 赋给 DOM 里的节点(这时候命中 304/内存缓存,秒出)。

坑 2:overscan 给太大,退化成“轮播翻车”

overscan 是视口上下额外多渲染的缓冲项。你怕白屏,一狠心给了 50。视口 8 条 + 上下各 50 = 108 个 DOM + 108 张图同时发请求。好嘛,又回到轮播那个问题了——本来是为了省,结果一下子全发了。而且因为还在滚动中,这些图很快又被滚出去、节点销毁、请求 abort。

经验值: overscan = 视口容量的 1~2 倍,别超过 3 倍。移动端可以更小。

坑 3:不定高导致滚动条抽风 + CLS

虚拟滚动要知道每项多高,才能算“滚到第几项”。图片没给宽高、没给 aspect-ratio:先插进去 → 高度 0 → 滚到位了图下载完 → 撑开 300px → 下面所有项往下挤 → 你还在滚,又挤回去 → 反复跳。这比 content-visibility 不加 contain-intrinsic-size 还惨(因为 DOM 是真的增删,不是跳过)。

解法: 图片必须 width + height 或 aspect-ratio,骨架先占位,图来了只是填进去,不撑高度。

五、与 content-visibility 怎么选?

维度

content-visibility

虚拟滚动

DOM 数量

全在

只有视口 + N 个

扫描器发不发图

发(除非配懒加载)

不发(没 DOM 就没 src)

滚动流畅度

跳过 layout

DOM 少,本身就不 layout

SEO/可访问性

全在文档里,好

屏外不在 DOM,屏幕阅读器/爬虫看不到

实现成本

CSS 二行

要算偏移、处理滚动、处理 resize

适合场景

静态长文/段落

成千上万条商品/消息列表

实际项目里可以混用: 列表本身用虚拟滚动,列表项内部复杂子组件再用 content-visibility: auto 跳过内部重布局——双保险。

六、终极组合拳(实战代码与 Checklist)

如果你要在生产环境搞一个高性能长列表,请收下这份 Checklist:

  1. 首屏关键 Hero:直出 HTML + eager + fetchpriority="high"。

  2. 虚拟列表容器:只直出首屏那几条(SSR)或空壳(CSR)。

  3. 列表项图片:写死 src 没问题(反正不在视口就不创建节点),但给当前视口内的加 fetchpriority="auto",别抢过首屏 Hero。

  4. overscan:1~2 倍视口量,别贪多。

  5. 图片占位:width/height 或 aspect-ratio,防 CLS。

  6. 快速滚动:new Image() 预热下一屏,别让复用节点反复改 src 取消请求。

  7. 滚停再加载:用 requestAnimationFrame + 防抖,别 scroll 每帧都改 DOM。

  8. 别把首屏 LCP 元素放进虚拟列表里(JS 没跑完它不存在)。

代码示例:预加载池防抖方案

// 简易的虚拟滚动预加载池思路
const imagePool = new Set();

function preloadImage(url) {
  if (imagePool.has(url)) return;
  const img = new Image();
  img.src = url;
  imagePool.add(url);
}

let scrollTimer = null;
function onScroll() {
  if (scrollTimer) cancelAnimationFrame(scrollTimer);
  scrollTimer = requestAnimationFrame(() => {
    // 1. 计算当前视口应该渲染的项
    const visibleItems = calculateVisibleItems();
    
    // 2. 预加载下一屏或缓冲区的图片,防止滚动时 src 频繁变更导致请求中止
    visibleItems.forEach(item => {
      if (item.imageUrl) {
        preloadImage(item.imageUrl);
      }
    });

    // 3. 更新真实 DOM 的 src,此时大概率已命中内存缓存
    updateDOMSrc(visibleItems);
  });
}

// 监听滚动,加上防抖
window.addEventListener('scroll', () => {
  if (scrollTimer) cancelAnimationFrame(scrollTimer);
  scrollTimer = requestAnimationFrame(onScroll);
}, { passive: true });

总结:从“背结论”到“能预判”

回顾整个屏外资源优化的光谱,我们其实一直在聊同一件事:浏览器太勤快了,看见就想要;你得在不同层级告诉它“别急”。

  • HTML 扫描阶段:loading="lazy" 给预加载扫描器踩刹车。

  • 布局阶段:原生 scroll-snap / rect 真的移出,让 lazy 算得准。

  • CSS 渲染阶段:content-visibility 跳过 layout/paint,不省网络。

  • JS 控制阶段:data-src + IntersectionObserver,扫描器彻底看不见。

  • 数据驱动阶段: 虚拟滚动,连 DOM 都不建,物理消灭请求。

  • 首屏定锚:fetchpriority="high" 告诉扫描器这张是 VIP。

强度递增,代价也递增。 虚拟滚动最省带宽最省 DOM,但实现最复杂、SEO 最差、对首屏 LCP 最不友好。没有银弹,只有选对场景:

  • 几屏内容 → scroll-snap 够了,别上虚拟滚动。

  • 几十项 → content-visibility + lazy 够了。

  • 几百上千项(聊天记录、商品流、后台表格)→ 虚拟滚动,没跑。

预加载扫描器是浏览器性能优化的“第一道门”——它只认 HTML 字面量。骗不了它、拦不住它,那就在不需要它的时候,压根不写那个 src,或者压根不建那个 DOM。懂了这三层,前端加载性能你就从“背结论”跨进“能预判浏览器会干什么”了。下次有人问你“这个列表怎么优化”,别张口就说“加 lazy”——先看它现在在哪个层级漏了,再选对应的工具。