揭秘 CSS content-visibility 机制:如何提升页面渲染性能
如果一个页面里有 150 个内容密集型的卡片,但用户只能看到前几张,会发生什么?
你可能觉得浏览器只会关心当前可见的内容。但实际情况并非如此。
即使内容位于视口下方数千像素处,浏览器可能仍需为其执行渲染工作。
因此,我想尝试一种新思路:如果我们能直接告诉浏览器:
你不需要现在渲染所有内容。对于那些用户还看不到的内容,直接跳过相关处理即可。
CSS 有一个属性,只需一行代码就能帮助我们实现这一目标:
.card {
content-visibility: auto;
}
这也自然勾起了我的好奇心:它究竟能带来多大的性能提升?
所以我没有止步于查阅文档,而是构建了一个包含 150 个内容密集型卡片 的页面,打开 Chrome DevTools,并进行了实测。
结果远超我的预期。但与此同时,它也引入了一个新问题。
让我们先从 content-visibility 究竟在要求浏览器做什么开始。
本文涵盖内容:
前置要求
要跟随这篇文章进行实践,你需要:
对 HTML 和 CSS 有基础了解
使用现代浏览器,例如 Chrome
熟悉 Chrome DevTools 的基本操作
你无需了解任何框架。实验仅使用原生 HTML、CSS 和 JavaScript,以便我们将焦点集中在浏览器本身的渲染行为上。
content-visibility: auto 究竟做了什么?
content-visibility 属性用于控制元素是否渲染其内部内容。
在本实验中,我们重点关注一个取值:
.card {
content-visibility: auto;
}
当设置为 auto 时,如果元素当前对用户不重要(例如位于视口很远之外的地方),浏览器就可以跳过渲染该元素内部内容的工作。
关键在于,我们讨论的是渲染。
该元素并未从 DOM 中移除。content-visibility 也并不是主要用来告诉浏览器不要去下载资源的。
我们是在给浏览器一个机会,让它避免做那些目前还用不上的渲染工作。
这依赖于 CSS 包含性(CSS containment)。正如 web.dev 指南 所述,content-visibility: auto 会应用布局、样式和绘制包含。当内容对用户不重要时,浏览器可以跳过该子树中更多的处理工作。
因此,在没有 content-visibility 的情况下,浏览器仍可能对视口下方很远处的内容执行渲染工作。而有了它,其中部分工作可以在内容变得重要之前被跳过。
注意这里的关键词是可以(can)。
content-visibility: auto 并不保证视口外的每个元素其渲染工作都会被跳过。由浏览器来判断内容是否对用户相关,以及是否可以跳过其渲染。
听起来很有用。但到底有用多少?
是时候去测量了。
我搭建了一个包含 150 张卡片的页面
我不想在一个小的演示中测试,那样差异可能会淹没在测量噪声里。
所以我故意把页面做得有点“离谱”。它包含了150 个内容繁重的卡片。
每个卡片包含:
一个固定大小的图片占位符
一个标题和标签
八个段落
十个相关条目
数字 150 本身并没有什么特殊含义。
一个页面不会因为跨越了某个元素数量阈值,就突然变成 content-visibility 的理想候选对象。
例如,150 个简单的 <div> 元素可能让浏览器几乎没有昂贵的工作可以跳过。
但这 150 个包含嵌套布局、文本、列表、图片等大量渲染工作的区块,正好能制造出明显的测试效果。
对于这个实验来说,这正是我想要的。
我也没有用 React 或其他框架,而是直接用原生 HTML、CSS 和 JavaScript。
这是有意为之的。
我们要测试的是 CSS 渲染优化,如果引入框架执行,就会多出一个不必要的变量。
生成卡片的 JavaScript 代码如下:
const CARD_COUNT = 150;
const PARAGRAPHS_PER_CARD = 8;
const RELATED_ITEMS_PER_CARD = 10;
const cards = [];
for (let i = 1; i <= CARD_COUNT; i++) {
cards.push(`
<article class="card">
<div class="card-image-placeholder"></div>
<h2>Product ${i}</h2>
${Array.from(
{ length: PARAGRAPHS_PER_CARD },
(_, index) => `
<p>
Product ${i}, paragraph ${index + 1}.
This is sample content used to make
each card more expensive to render.
</p>
`
).join("")}
<ul>
${Array.from(
{ length: RELATED_ITEMS_PER_CARD },
(_, index) => `
<li>Related item ${index + 1}</li>
`
).join("")}
</ul>
</article>
`);
}
document.querySelector("#feed").innerHTML = cards.join("");
我考虑过使用真实图片,但那会让实验变复杂。
网络延迟、缓存和图片解码都可能影响测试结果。
所以每张卡片改用 CSS 占位块:
.card-image-placeholder {
height: 320px;
background: linear-gradient(
135deg,
#e5e7eb,
#f3f4f6
);
}
每组测试我都保持了相同的浏览器环境和视口尺寸,并且在录制首屏加载时不滚动页面。
我还多次重复测试,而不是挑最好看的一次结果。
如果你想复现这个实验,完整示例已发布在 GitHub 上。
其中包含与下文测量数据相同的测试页面和配置,你可以在自己的浏览器和设备上运行并对比结果。
现在我们有东西可以测量了。
先看基准测试
在添加 content-visibility 之前,我使用 Chrome DevTools 的 Performance 面板对页面进行了三次录制。
Performance 录制的 Rendering 活动数据如下:
| 运行次数 | 渲染耗时 |
|---|---|
| 1 | 39 ms |
| 2 | 44 ms |
| 3 | 42 ms |
| 中位数 | 42 ms |
我选择的是中位数,而非最快的那次运行。
这里有个重要的澄清:这些数字代表的是 DevTools Performance 录制中报告的 Rendering 活动。
它们并非页面总加载时间、Core Web Vitals 指标,也非用户感知性能的直接度量。
Chrome DevTools 将 Rendering 作为 Performance 录制中活动拆解的一个类别来报告,因此我将始终特指 Rendering 活动,而不是笼统地说页面"在 42 ms 内完成渲染"。
所以我们的基准线是:
Rendering 活动中位数:42 ms
基准 Performance 录制截图。上述的 42 ms 中位数是通过三次独立运行计算得出的。
接着,我只改变了一个变量:
.card {
content-visibility: auto;
}
然后再次进行实验。
| 运行次数 | 渲染耗时 |
|---|---|
| 1 | 20 ms |
| 2 | 21 ms |
| 3 | 20 ms |
| 中位数 | 20 ms |
好的,效果很明显。
我们从:
42 ms → 20 ms
即:
(42 - 20) / 42 × 100 ≈ 52%
在这个实验中,添加 content-visibility: auto 与 Chrome DevTools 报告的 Rendering 活动减少约 52% 相关。
但这个数字需要谨慎看待。
这并不意味着 content-visibility 能让网站快 52%,甚至也不代表整页加载快了 52%。
我们只是在 Chrome DevTools 中,针对一个测试环境下特定页面的某类活动做了测量。
实际效果会受以下因素影响:
视口外的内容量
这些内容的渲染成本
浏览器类型
设备性能
视口大小
页面结构
我们的实验基本是让 content-visibility 有大量工作可以跳过。
因此,有用的结论不是:
“
content-visibility让网站快 52%。”
而是:
对于包含大量屏外内容的页面,让浏览器跳过不必要的渲染工作,可以带来可测量的性能提升。
web.dev 用类似的自研密集内容演示验证了同一思路,并报告了显著的性能提升。
但他们的数据属于他们的测试,我们的数据属于我们的。
这两个百分比都不应该直接套用到你的应用中,除非你自己做过测量。
不过,我们的实验还没结束。开始滚动后,出现了另一个问题。
渲染工作省了,但布局出问题了
想想我们刚告诉浏览器的话:卡片远在视口下方,所以其内容可以跳过。
这很合理。但页面仍需要布局。
于是出现一个棘手的问题:在浏览器知道卡片正常渲染尺寸之前,屏外卡片应该占用多少空间?
如果浏览器最初推算的尺寸和卡片实际大小不一致,那么当卡片进入渲染范围、内容真正显示时,布局就会发生调整。
这正是 contain-intrinsic-size 的用武之地。
我们可以给浏览器提供一个回退的固有尺寸:
.card {
content-visibility: auto;
contain-intrinsic-size: auto 900px;
}
其中的 900px 就是回退值:当内容被跳过、又没有记录下来的渲染尺寸可用时,浏览器就用它。
而 auto 这个前缀让事情变得更有意思。
假设卡片还没被正常渲染过,浏览器没有之前的尺寸可以复用,这时 900px 就充当回退值。
之后,卡片滚动到足够接近视口的位置,被正常渲染了。此时浏览器已经记下了它的实际渲染尺寸。
如果这张卡片再次变为可跳过状态,浏览器就会复用记录下来的尺寸,而不是退回 900px。
所以:
contain-intrinsic-size: auto 900px;
并不意味着浏览器知道卡片高度就是 900px。
它的真正含义是:
这个属性和 content-visibility 搭配使用也非常有价值。
既然我们要跳过内容渲染,就得保证页面在内容渲染前仍有合理的几何尺寸。
回退值应该设多大?
我的第一个疑问是:回退值设大一点还是小一点,会不会明显影响首次 Rendering 指标?
于是我试了三个值:
| 回退值 | Rendering |
|---|---|
| 100px | 12 ms |
| 900px | 10 ms |
| 2000px | 11 ms |
结果非常接近。
在这种量级下,差异小到不足以证明某个回退值比其他的更快。
我们不能据此得出结论:
"固有尺寸越小越快。"
同样也不能得出:
“与真实尺寸估算越接近,渲染性能通常越好。”
但这并不是 fallback 的职责所在。
更有意义的问题是:布局会发生什么变化?
如果你的 fallback 设为 100px,而实际卡片高度远超此值,当内容渲染完成时,页面可能需要调整几何结构。
反过来,如果 fallback 远大于实际内容,同样会出现问题。
因此,你需要的是一个合理的近似值,而不是某个神奇的性能数值。
不过在测试过程中,我发现了一个意料之外的现象。
仅使用
.card {
content-visibility: auto;
}
之后我进行了三次测量:
20 ms
21 ms
20 ms
Median: 20 ms
接着添加了:
.card {
content-visibility: auto;
contain-intrinsic-size: auto 900px;
}
结果如下:
10 ms
9 ms
12 ms
Median: 10 ms
没错,在这个特定实验中,添加显式 fallback 确实带来了 Chrome DevTools 中 Rendering 活动的进一步下降。
容易得出的结论是:
contain-intrinsic-size = 2× faster
不对。我们的测量结果只说明了这次实验中发生了什么。
它并未确立 contain-intrinsic-size 的通用性能特征。
它的职责是在尺寸包含生效时提供有用的固有几何信息,包括在无法获取正常渲染尺寸时提供 fallback。
对这些数字还需谨慎对待。最初的基线对比用了三次运行,而这些后续探索性测量也用了三次运行。
对于实验中的观察而言,这没问题。但这并非用于宣称某种配置普遍快于另一种受控方法论。
因此,10 ms 的结果保持原样:它是这次实验中一个有趣的观察结果,而非浏览器承诺。
我们是否只是把工作转移到了滚动阶段?
初始加载基准测试无法回答另一个问题。
如果我们跳过了屏幕外卡片的工作,那么当用户滚动时,其中一些卡片最终会变得相关。
工作并没有神奇消失。部分工作被推迟到浏览器判定内容相关时再执行。
我们究竟改善了整体体验,还是只是把部分工作转嫁到了别处?
这次实验没有基准数据能直接回答这个问题。
我没有记录受控的滚动测试,所以不会把手动滚动时的直观感受转化为新的性能论断。
要回答这个问题,需要一项独立的实验,重点关注滚动过程、卡片进入相关区域、布局偏移,以及页面滚动时的帧行为。
目前,我们的测量结果只能说明一个更窄范围的事实:
在此特定测试中,
content-visibility: auto减少了初始 Rendering 阶段的活动量。
这并不意味着浏览体验的每个环节都加速了 52%。这是解读当前数据时一个重要的界限。
等等,这不就是懒加载吗?
到这里,content-visibility 听起来确实和懒加载很像。
两者的目的都是避免无谓的开销,但它们针对的通常是不同的工作。
懒加载主要问的是:
我现在需要加载这个资源吗?
而 content-visibility 问的是另一回事:
我现在需要渲染这些内容吗?
以图片为例:
<img
src="/product.jpg"
loading="lazy"
alt="Black running shoes"
>
原生的图片懒加载可以推迟加载资源,直到它接近被需要的时候。
但使用:
.product-card {
content-visibility: auto;
}
元素可能已经存在于 DOM 中,其资源也早已加载完毕。
此时我们在询问浏览器,是否需要对这部分内容执行渲染工作。
因此,这两种优化未必是互斥的。
你可以在同一个页面上同时使用它们。
一种机制有助于避免过早加载资源。
另一种机制有助于避免当前不必要的渲染开销。
但可访问性怎么办?
content-visibility: auto 有个容易被忽略的细节。
被跳过渲染的屏幕外内容依然留在 DOM 中,并且仍可能出现在无障碍树(accessibility tree)里。
这对性能是好事,但也划清了一条重要界限:content-visibility: auto 只是一种渲染优化,不是语义上的隐藏机制。
如果你想让辅助技术无法访问某些内容,别指望 content-visibility,应该使用合适的 HTML、CSS 和无障碍语义来做这件事。
还有一个值得注意的边界情况。web.dev 指出,被跳过的子树中的内容仍可能出现在无障碍树里,即使其中某些内容本应被 display: none 或 visibility: hidden 之类的样式隐藏。
所以,如果你把 content-visibility 应用到复杂或可交互的区块上,请实际测试键盘操作和辅助技术的体验,不要想当然地认为渲染优化不会影响无障碍性。
那么,什么时候才真正值得用 content-visibility?
经过这一番测量,答案其实没那么激动人心——不是"加一个 CSS 属性就能让网站变快"。
这多半是件好事。
当页面在视口外有大量渲染开销较高的内容时,content-visibility: auto 才真正有价值。比如:
长文章或社交媒体信息流
大型商品列表
章节繁多的文档页面
超长的数据面板
复杂的首屏以下内容
如果页面本身很小、几乎一打开就能看完全部内容,那基本没什么渲染工作可以省。
在投入生产环境之前,还剩一个实际问题:浏览器兼容性能靠得住吗?
就现代浏览器而言,支持范围已经很广。content-visibility 已纳入 Baseline 2024,只要项目不需要支持老旧浏览器版本,兼容性问题已经远不像从前那么让人担心了。
因此,并非每个页面都需要使用 content-visibility。但当页面包含大量昂贵的屏幕外内容时,它能为浏览器提供一项宝贵选项:干脆不渲染用户还看不到的部分。