长列表卡不动?先别急着虚拟滚动,浏览器自己会偷懒
一个信息流页面,200 张卡片,每张卡片里塞了头像、标题、三行摘要、一张图、点赞评论栏。滚动帧率掉到 30fps 以下,肉眼可见地卡。
第一反应:上虚拟滚动。
等等。
虚拟滚动确实是长列表优化的经典方案。但卡片高度不固定、内部有图片懒加载、还有展开收起交互------这种场景下虚拟滚动的实现成本直接爆炸。你要手动算每张卡片的高度,处理动态变化,还得管滚动锚定。搞了两天,代码量翻三倍,bug 也翻三倍。
有没有一种方案,不用自己管 DOM 的增删,让浏览器自己决定哪些东西该渲染、哪些跳过?
有。CSS 里就藏着这个能力。
浏览器渲染一个元素到底在干嘛
一个 DOM 元素从存在到你看见它,浏览器做了不少活。
粗略讲,四步:
- Style --- 算最终样式
- Layout --- 算位置和大小
- Paint --- 画像素
- Composite --- 合成图层,送上屏幕
200 张卡片,每张卡片内部几十个子元素。滚动时触发 layout,浏览器得把这几千个元素全算一遍。
那问题来了:屏幕外的元素,有必要算吗?
没必要。但浏览器默认不知道哪些可以跳过。你得告诉它。
CSS Containment:给浏览器画隔离区
contain 这个 CSS 属性,干的事就是告诉浏览器:"这个元素内部的变化不影响外部,你放心跳过。"
css
.card {
/* layout: 内部布局变化不影响外部元素位置 */
/* paint: 内部绘制不会溢出到外面 */
contain: layout paint;
}
给浏览器一个承诺:这个盒子是自洽的,不用担心它影响别人。
打个比方------一栋大楼里每间房是独立隔间,装修一间房不用疏散整栋楼。没有隔间?改一面墙,整栋楼的承重都得重新算。
contain 有四个值可以组合:
css
contain: layout; /* 布局隔离 */
contain: paint; /* 绘制隔离,超出部分直接裁掉 */
contain: size; /* 尺寸隔离,不依赖子元素算大小 */
contain: style; /* 计数器、引号等样式不泄漏,用得少 */
/* 常见组合 */
contain: layout paint; /* 推荐起步 */
contain: strict; /* = size layout paint style,最激进 */
contain: content; /* = layout paint style,不含 size */
contain: strict 最猛,但你必须给元素写死宽高。卡片高度不固定?那就别用 size,否则直接塌成 0。
踩过这坑。真塌了。
Content-Visibility:浏览器级别的懒渲染
contain 解决的是"减少连锁反应"。但屏幕外的元素,它依然走完整的渲染流程。
content-visibility 更狠。
css
.card {
content-visibility: auto;
contain-intrinsic-size: auto 320px; /* 必须给预估高度,不然滚动条会跳 */
}
两行 CSS,效果惊人。浏览器自动判断:这元素在视口外?那 layout 和 paint 都跳过,只保留一个占位高度。
等元素滚进视口附近,浏览器再渲染出来。滚出去,又跳过。
听着像虚拟滚动?
不一样。
DOM 还在。 所有元素都挂在 DOM 树里,querySelector 照样能找到,事件监听器还活着,状态不丢。虚拟滚动是真的把 DOM 节点删了再造,这个只是告诉渲染引擎"你可以偷懒"。
跑个对比
ts
function measureRenderCost() {
const cards = document.querySelectorAll('.card')
// 强制触发全量 layout(读取 offsetHeight 会 flush)
console.time('layout-all')
cards.forEach(card => void card.offsetHeight)
console.timeEnd('layout-all')
}
// 不加 content-visibility → layout-all: ~45ms
// 加了 content-visibility: auto → layout-all: ~6ms
// 差了将近 8 倍
45ms 意味着一帧的预算都不够(16.6ms)。6ms 就舒服了。
落地踩坑记录
滚动条疯狂跳
contain-intrinsic-size 的预估高度跟实际差太多,滚动条就会抽搐。往下滚的时候忽长忽短,体验直接崩。
css
/* ❌ 随便写个高度,实际卡片有的 200px 有的 600px */
.card {
content-visibility: auto;
contain-intrinsic-size: auto 100px; /* 差太远 */
}
/* ✅ 取接近平均值的高度 + auto 关键字 */
.card {
content-visibility: auto;
contain-intrinsic-size: auto 350px; /* auto 让浏览器记住渲染过的真实高度 */
}
那个 auto 关键字很关键。意思是:元素第一次渲染后,浏览器记住真实高度,下次跳过渲染时用真实值而不是预估值。 不加 auto,每次都用预估值,滚回去滚动条又跳。
Ctrl+F 搜不到内容
被跳过渲染的元素,文本不参与页面内搜索。用户按 Ctrl+F 搜关键词,搜不到屏幕外的卡片。
这个目前没有完美解法。Chrome 在改了,但截至目前行为不一致。场景强依赖浏览器搜索的话,得权衡。
锚点跳不准
<a href="#card-150"> 跳转?跳不准。因为前面的卡片没渲染,高度是预估的,锚点位置算错了。
解法------跳转时先强制渲染目标区域:
ts
function scrollToCard(id: string) {
const target = document.getElementById(id)
if (!target) return
// 临时关掉 content-visibility,让浏览器算真实位置
target.style.contentVisibility = 'visible'
requestAnimationFrame(() => {
target.scrollIntoView({ behavior: 'smooth' })
target.style.contentVisibility = 'auto'
})
}
丑吗?丑。管用。
IntersectionObserver 时序打架
同时用 content-visibility: auto 和 IntersectionObserver 做图片懒加载,时序会乱。浏览器判定元素"即将进入视口"的时机,和 Observer 的回调时机未必一致。
建议:用了 content-visibility: auto,图片懒加载直接上原生 loading="lazy",别再自己搞 Observer。两套"懒"逻辑叠一起只会互相干扰。
跟虚拟滚动怎么选
| 维度 | 虚拟滚动 | content-visibility |
|---|---|---|
| DOM 节点数 | 只保留可见区域 | 全量保留 |
| 实现成本 | 高 | 极低,两行 CSS |
| 状态管理 | 需要外部保存滚出元素状态 | 天然保留 |
| 内存占用 | 低 | 较高,DOM 都在 |
| SEO | 差,内容不在 DOM 中 | 好,DOM 完整 |
| 无障碍 | 需要额外处理 | 天然支持 |
| 适用量级 | 万级以上 | 百到千级 |
两者适用场景不一样。
1 万条以上,虚拟滚动依然是王道------你不可能让浏览器 hold 住 1 万个 DOM 节点。但 200~2000 条的复杂卡片流?content-visibility 够了,代价极低。
两行 CSS 扛不住怎么办
content-visibility 和 contain 是第一层。扛不住,还能叠。
css
.card {
content-visibility: auto;
contain-intrinsic-size: auto 350px;
will-change: transform; /* 提示浏览器提前分配图层 */
}
/* 对卡片内部子区域也做 containment */
.card__body {
contain: layout paint;
}
.card__comments {
content-visibility: hidden; /* 不是 auto,是完全不渲染 */
}
.card__comments.expanded {
content-visibility: visible;
}
content-visibility: hidden 这个值很多人不知道。它跟 display: none 的区别:元素还在布局流里占位,只是不渲染内容。 切到 visible 不会触发整页 reflow。折叠面板、Tab 切换拿它替代 display: none,能省掉不少布局抖动。
怎么量效果
别猜。量。
ts
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`Long task: ${entry.duration}ms`, entry)
}
}
})
observer.observe({ entryTypes: ['longtask'] })
Chrome DevTools 还有个更直接的:打开 Rendering 面板,勾 "Highlight areas outside content-visibility"。被跳过渲染的区域会高亮,一眼就能看到生没生效。
用这个检查过一次发现:有些卡片压根没被跳过。查了半天,原因是它们的祖先元素设了 overflow: visible,破坏了 containment 的前提条件。坑。
糊一个通用方案
能做通用,但别过度封装。
ts
function applyAutoContainment(selector: string, estimatedHeight = 300) {
const style = document.createElement('style')
style.textContent = `
${selector} {
content-visibility: auto;
contain-intrinsic-size: auto ${estimatedHeight}px;
}
`
document.head.appendChild(style)
return () => style.remove()
}
// 一行搞定
const cleanup = applyAutoContainment('.feed-card', 350)
想更完善可以加 ResizeObserver 动态更新预估高度。但说实话大多数场景下,一个固定预估值加 auto 关键字就够了。别过度工程化。
React 或 Vue 接入成本几乎为零------纯 CSS,不侵入组件逻辑,不用改数据流。
兼容性
截至 2025 年底:Chrome 85+、Edge 85+、Firefox 125+、Safari 18+。
移动端 iOS 18 的 Safari 支持了,安卓 Chrome 和 WebView 早就没问题。
要兼容老浏览器也不慌,content-visibility 本身就是渐进增强------不支持的浏览器直接忽略这条 CSS,页面照常渲染,只是没有性能优化。零风险。
什么时候该用,什么时候别碰
适合:
- 信息流 / 卡片流,200~2000 条
- 卡片内部结构复杂,子元素多
- 卡片高度不完全一致但差异不极端
- 不需要精确的浏览器内搜索
别用:
- 数据量过万,DOM 节点本身就是瓶颈
- 列表项高度完全一致且结构简单------这种场景虚拟滚动实现成本很低,直接上
- 用户强依赖 Ctrl+F 搜全部内容
两种方案也不互斥。见过一种混合打法:外层虚拟滚动控制 DOM 数量,每个渲染出来的卡片内部再用 contain: layout paint 做 containment。两层叠加,效果拉满。
渲染跳过这件事的底层逻辑
把问题抽象一下。
浏览器渲染管线是全量计算模型 :任何节点变化,默认要检查所有相关节点。contain 和 content-visibility 干的事,是把全量计算变成分区计算------每个区域独立,变化不跨区传播。
跟数据库分区、微服务边界、React 的 memo 一个思路:声明边界,缩小影响范围。