长列表卡不动?先别急着虚拟滚动,浏览器自己会偷懒

长列表卡不动?先别急着虚拟滚动,浏览器自己会偷懒

一个信息流页面,200 张卡片,每张卡片里塞了头像、标题、三行摘要、一张图、点赞评论栏。滚动帧率掉到 30fps 以下,肉眼可见地卡。

第一反应:上虚拟滚动。

等等。

虚拟滚动确实是长列表优化的经典方案。但卡片高度不固定、内部有图片懒加载、还有展开收起交互------这种场景下虚拟滚动的实现成本直接爆炸。你要手动算每张卡片的高度,处理动态变化,还得管滚动锚定。搞了两天,代码量翻三倍,bug 也翻三倍。

有没有一种方案,不用自己管 DOM 的增删,让浏览器自己决定哪些东西该渲染、哪些跳过?

有。CSS 里就藏着这个能力。


浏览器渲染一个元素到底在干嘛

一个 DOM 元素从存在到你看见它,浏览器做了不少活。

粗略讲,四步:

  1. Style --- 算最终样式
  2. Layout --- 算位置和大小
  3. Paint --- 画像素
  4. 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: autoIntersectionObserver 做图片懒加载,时序会乱。浏览器判定元素"即将进入视口"的时机,和 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-visibilitycontain 是第一层。扛不住,还能叠。

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。两层叠加,效果拉满。


渲染跳过这件事的底层逻辑

把问题抽象一下。

浏览器渲染管线是全量计算模型 :任何节点变化,默认要检查所有相关节点。containcontent-visibility 干的事,是把全量计算变成分区计算------每个区域独立,变化不跨区传播。

跟数据库分区、微服务边界、React 的 memo 一个思路:声明边界,缩小影响范围。

相关推荐
蜡台2 小时前
Node Vue 项目开发常见问题解决
前端·javascript·vue.js·git·node
嘉琪0012 小时前
Day1 完整学习包(var/let/const + 作用域)——2026 0310
前端·javascript·学习
Moment2 小时前
2026 年 Next.js 站点的 SEO 优化指南
前端·javascript·面试
小小仙。2 小时前
IT自学第三十二天
服务器·前端·javascript
@大迁世界2 小时前
01.什么是 ReactJS?
前端·javascript·react.js·前端框架·ecmascript
猫头虎-前端技术2 小时前
这个项目需要Node 16,那个项目需要Node 18:如何解决多项目Node.js版本管理问题
前端·javascript·chrome·typescript·node.js·json·firefox
前端 贾公子3 小时前
uni-app 也能使用 App.vue?解决 uniapp 无法使用公共组件问题
开发语言·前端·javascript
SuperEugene3 小时前
Promise 从入门到实战:同步异步、回调地狱、then/catch/finally 全解
前端·javascript·面试
前端 贾公子3 小时前
uniapp 小程序获取后端的二进制 保存到手机相册
java·前端·javascript