无法接入虚拟滚动时,怎样减少长列表的屏外渲染
一个不断加载内容的列表,滚得越久,页面里的卡片就越多。理想的处理方式是让列表组件只挂载视口附近的节点,也就是虚拟滚动。但有时列表由第三方组件或现成页面管理,我们只能给已有 DOM 加样式和少量脚本,碰不到它的数据源、分页状态与节点复用逻辑。
这种约束下,仍然可以减少一部分浏览器工作:保留卡片及其占据的空间,让浏览器暂时跳过远离视口的内容布局和绘制。负责这件事的是 content-visibility: auto。它不负责清理列表数据,因此也不能代替虚拟滚动。
先把两种成本分开
长列表变慢,至少可能来自两层成本。一层是 DOM 越积越多,组件实例、事件监听、业务状态和内存跟着增长;另一层是浏览器需要对大量节点做样式计算、布局与绘制。虚拟滚动通常通过控制挂载节点数量处理前一层,同时也减少后一层。content-visibility 主要针对后一层:元素仍在 DOM 中,页面脚本仍可访问它,卡片内部的计时器、请求或业务计算不会因为这条 CSS 自动停止。Chrome 的机制说明把它描述为让浏览器跳过屏外子树的渲染工作,而不是删除子树。
直接对读过的卡片设置 display: none 也能让它们不再渲染,却会把它们从布局里拿走。上方几十张卡片突然消失,滚动位置、滚动条长度和触底加载条件都可能跟着变化。这里需要的是「保留位置,少做渲染」。

图形表示机制差异,不是性能测试。content-visibility 是否跳过某个子树,由浏览器结合可见性和用户相关性决定。
先试两条 CSS
如果列表卡片有比较稳定的高度,可以先给每张卡片加上:
.feed-card {
content-visibility: auto;
contain-intrinsic-block-size: auto 300px;
}content-visibility: auto 让浏览器按元素是否接近视口、是否与用户当前操作相关来决定何时跳过其内容。接近视口时,它会恢复内容的布局和绘制;不必另外写一个 IntersectionObserver 来模拟浏览器自己的可见性判断。CSS Containment 规范也明确把视口附近与用户相关性纳入判断。
跳过内容后,浏览器仍要知道卡片在列表里占多大空间。contain-intrinsic-block-size 给出占位用的块轴尺寸;在常见的横排文字、纵向滚动页面里,块轴就是高度。示例中的 300px 是第一次尚无真实尺寸时的估算值,不是给卡片设置固定高度。前面的 auto 表示浏览器曾正常渲染过该元素后,可以优先使用它记住的真实尺寸。MDN 对这个逻辑属性的说明列出了这两种尺寸的使用时机。
这一步值得先单独试。若卡片高度接近,估算值与实高的差异不大,纯 CSS 就足够简单。若卡片有短文本、长文和图片混排,统一填 300px 可能让尚未看过的卡片在进入视口时突然变高或变矮。即使浏览器之后会记住真实尺寸,第一次经过它时仍可能出现滚动条长度变化;MDN 的示例也展示了估算尺寸差得太多时的跳动。
高度差异大时,只处理已经读过的卡片
如果拿不到未来卡片的准确高度,就不要提前给它们猜尺寸。让未读卡片照常渲染;卡片完整离开视口上方后,记录它此时的真实高度,再给它加屏外渲染样式。回滚到卡片附近时,撤掉这层样式。这样占位只发生在已经有实测值的卡片上。
.feed-card.is-deferred {
content-visibility: auto;
contain-intrinsic-block-size: auto var(--measured-block-size);
}下面是核心判断的简化示意,假设页面使用横排文字、纵向滚动。IntersectionObserver 负责通知卡片离开和重新接近视口;业务代码只决定何时启用这条 CSS,实际跳过哪些渲染工作仍由浏览器决定。
function deferPastCard(card: HTMLElement, rect: DOMRectReadOnly) {
if (rect.height <= 0 || rect.bottom >= -window.innerHeight) return;
const style = getComputedStyle(card);
const paddingAndBorder = [
style.paddingTop,
style.paddingBottom,
style.borderTopWidth,
style.borderBottomWidth
].reduce((sum, value) => sum + (Number.parseFloat(value) || 0), 0);
const contentHeight = rect.height - paddingAndBorder;
if (contentHeight <= 0) return;
card.style.setProperty('--measured-block-size', `${contentHeight}px`);
card.classList.add('is-deferred');
}
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const card = entry.target as HTMLElement;
if (entry.isIntersecting) {
card.classList.remove('is-deferred');
card.style.removeProperty('--measured-block-size');
} else {
deferPastCard(card, entry.boundingClientRect);
}
}
}, { rootMargin: `${window.innerHeight}px 0px 0px` });getBoundingClientRect().height 包含 padding 和 border,而内在占位尺寸对应内容盒,所以示意代码减去了这两部分。零高度卡片也跳过:它可能原本就被隐藏,没必要凭一个不存在的尺寸造占位。rect.bottom < -window.innerHeight 表示卡片已经完整越过视口上沿,并多留了约一屏缓冲。rootMargin 用像素写这一屏很重要;Intersection Observer 规范规定百分比按根矩形的宽度解析,100% 不是「一屏高」。

分区只表示位置关系,不代表卡片固定高度。回到附近时恢复正常渲染,再按当前内容和容器宽度更新尺寸。
这段代码只展示状态切换,不是完整的列表接入脚本。实际接入还要观察新追加的卡片、清理被移除的节点,并在路由或列表实例切换时断开观察器、撤销旧卡片的 class 与内联尺寸。快速跳过整张卡片或在页面已滚动时才开始观察,也值得在滚动停下后做一次节流补查;补查只读尚未隔离的候选卡片,避免每次滚动都遍历并测量整页 DOM。
这里默认滚动根是浏览器视口。如果列表在独立滚动容器里,观察器的 root、一屏缓冲距离和滚动兜底都应改用那个容器。视口或容器宽度变化时,除了重算缓冲区,还要考虑重新测量卡片:文字换行、图片加载和折叠内容变化都可能让旧占位过期。接近卡片时恢复正常渲染并更新尺寸,比持续读取已经跳过的子树更稳妥。
这里即使写了 auto,也仍然每次启用前提供实测的回退值。浏览器的「记住上次尺寸」依赖该元素保有相应的 auto 属性状态;动态撤掉再加回样式时,不应把跨状态的尺寸记忆当成占位保证。CSS Sizing 规范把记忆值附着、更新与清除的条件写得更细。
这条路容易踩到哪里
选择器要落在重复卡片本身,不要一口气加到整张列表、资料头或页内弹窗上。浏览器需要跳过的是卡片的子树;范围过大时,视口里仍需要的局部内容也可能受影响。
content-visibility: auto 会带来布局和绘制包含关系,即使内容没有被跳过也可能改变定位子元素的包含块。卡片里若有吸顶操作栏、绝对或固定定位的浮层、向外溢出的菜单,必须检查打开、滚动和关闭三个状态;这类风险来自 CSS 包含关系本身,MDN 的包含块规则也将 content-visibility: auto 列为可能改变定位参照的条件。对有复杂浮层的卡片,宁可缩小作用范围,或者只在远离视口时启用并在靠近前撤掉。
保留 DOM 也意味着无障碍语义仍要保留,不能为了屏外优化给整张卡片加 aria-hidden。页面内查找、键盘焦点和辅助技术对屏外内容的处理,应当随同视觉效果一起验证。另一个容易忽略的反向成本是脚本主动读取被跳过子树的精确布局:这种读取可能迫使浏览器补做原本省掉的工作。Chrome 的说明特别提醒要审视会强制渲染的 DOM API。
怎么知道它确实有用
用同一组卡片、同样的滚动路径分别录制启用前后的 Performance trace,比较主线程里的 Layout、Paint 和长任务,而不是只凭「感觉更顺」。验证期间尽量固定卡片数量和图片加载状态,否则无限加载产生的新 DOM 会混进对比。再记录列表 DOM 数量与 JS heap:它们没有明显下降是符合机制预期的,不应把渲染时间改善写成内存优化。
最后从列表中部快速滚到底、缓慢回滚到顶部,并打开卡片里的菜单、搜索页内文字、切换列表或路由。观察卡片高度、页面 scrollHeight、滚动位置、焦点和浮层是否正常。若这些检查过关,再考虑扩大选择器范围;若瓶颈主要是 DOM 数量、业务脚本或组件实例,屏外渲染就只能算小幅缓解,真正的解法仍要回到列表本身。