React 虚拟化的过度使用
React 开发者似乎把虚拟化库(比如
react-window 和
react-virtualized)当成了渲染列表的万能药。从逻辑上看,这似乎很合理:用户一次只能看到 10 个项目,为什么要渲染全部 1000 个?虚拟化会创建一个小的可见项目"窗口",滚动时卸载其他内容。
问题不在于虚拟化本身——而是我们用得太早、太频繁了。200 个产品的网格?上 react-window。50 篇文章的博客列表?上react-virtualized。
我们在列表性能优化上形成了一种"盲目崇拜"。我们不会先检查浏览器是否已经能原生处理这些工作,而是直接开始把所有东西都包在 useMemo和 useCallback
里,然后称之为"优化"。
下面是一个典型的react-virtualized最小配置:
import { List } from "react-virtualized"; import { memo, useCallback } from "react"; const ProductCard = memo(({ product, style }) => { return ( <div style={style} className="product-card"> <img src={product.image} alt={product.name} /> <h3>{product.name}</h3> <p>{product.price}</p> <p>{product.description}</p> </div> ); }); function ProductGrid({ products }) { // 使用 useCallback 缓存行渲染器,避免不必要的重新渲染 const rowRenderer = useCallback( ({ index, key, style }) => { const product = products[index]; return <ProductCard key={key} product={product} style={style} />; }, [products] ); return ( <List width={800} height={600} rowCount={products.length} rowHeight={300} rowRenderer={rowRenderer} /> ); }这个方案确实能工作。大约 50 行代码,给打包文件增加 15KB 左右,还需要手动设置项目高度和容器尺寸。这是目前的标准做法。
但 React 开发者很少就此打住。我们被训练得习惯性地追求重渲染优化,于是开始把所有东西都包在记忆化和回调里:
import { List } from "react-virtualized"; import { memo, useCallback, useMemo } from "react"; const ProductCard = memo(({ product, style }) => { return ( <div style={style} className="product-card"> <img src={product.image} alt={product.name} /> <h3>{product.name}</h3> <p>{product.price}</p> <p>{product.description}</p> </div> ); }); function ProductGrid({ products }) { const rowCount = products.length; // 使用 useCallback 缓存行渲染器 const rowRenderer = useCallback( ({ index, key, style }) => { const product = products[index]; return <ProductCard key={key} product={product} style={style} />; }, [products] ); // 用 useMemo 缓存行高计算(一个常量值) const rowHeight = useMemo(() => 300, []); return ( <List width={800} height={600} rowCount={rowCount} rowHeight={rowHeight} rowRenderer={rowRenderer} /> ); }看到那个useMemo(() => 300, [])了吗?我们在缓存一个常量。我们用memo()包裹组件,试图避免可能根本不存在的重渲染。我们给 react-window 已经内部优化过的函数加上了useCallback。
我们这样做,是因为觉得"应该这样做",而不是因为真的遇到了性能问题。当我们忙着消除那些假想的重渲染时,CSS 已经悄悄推出了原生解决方案。
它叫 content-visibility
。它告诉浏览器跳过渲染屏幕外的内容。思路和虚拟化一样,但浏览器会帮你处理——不需要 JavaScript,不需要滚动计算,不需要配置项目高度。
虚拟化本身没问题,它确实有效。问题在于:你的列表真的需要它吗?大多数 React 应用处理的都是几百个项目的列表,而不是几万个。对于这些场景,content-visibility能给你带来 90% 的性能提升,而复杂度却只有虚拟化的一小部分。
下面我们来看看content-visibility到底是怎么工作的
content-visibility 的工作原理
content-visibility属性有三个值:visible、hidden和auto。只有auto对性能有意义。
当你给元素设置content-visibility: auto时,浏览器会跳过该元素的布局、样式和绘制工作,直到它接近视口。注意"接近"这个词——浏览器会在元素真正进入视图之前就开始渲染,这样滚动才能保持流畅。一旦元素移出视图,浏览器就会暂停所有这些工作。
浏览器本来就知道哪些内容是可见的。它本来就有视口交集 API。它本来就在处理滚动性能。content-visibility: auto只是给了它一个"跳过渲染"的权限。
用content-visibility来实现同样的产品网格,代码会是这样:
function ProductGrid({ products }) { return ( <div className="product-grid"> {products.map((product) => ( <div key={product.id} className="product-card"> <img src={product.image} alt={product.name} /> <h3>{product.name}</h3> <p>{product.price}</p> <p>{product.description}</p> </div> ))} </div> ); }CSS 部分:
.product-card { content-visibility: auto; contain-intrinsic-size: 300px; }就两行 CSS。contain-intrinsic-size告诉浏览器为屏幕外的内容预留多少空间。没有它的话,浏览器会假设这些元素高度为零,导致滚动条计算错误。有了它,滚动体验保持一致,因为浏览器对元素大小有个大概的估算,即使它还没渲染。
这还不是 CSS 悄悄接管 JavaScript 工作的唯一例子。另一个典型场景是容器响应式设计。
容器查询:告别 ResizeObserver
响应式设计教会我们基于视口宽度写媒体查询。这招在大多数情况下都管用,直到你把组件放到侧边栏里。你的卡片组件需要根据容器宽度(而不是屏幕宽度)来调整布局。侧边栏里的 300px 卡片应该和主内容区的 300px 卡片看起来不一样,即使视口宽度相同。
开发者们的第一反应是用 JavaScript。我们用ResizeObserver监听容器尺寸变化,然后根据容器宽度动态添加类或内联样式。这确实能工作,但它是命令式的、复杂的,而且需要你手动管理观察者的生命周期。
容器查询让 CSS 可以直接响应容器尺寸。你的卡片组件会自动适配容器宽度。
container-type: inline-size告诉浏览器这个元素是一个容器,子元素可能会查询它的宽度。然后@container规则就像@media规则一样工作,只不过它检查的是容器的尺寸,而不是视口的尺寸。
浏览器支持率在 2025 年已经超过 90%。Chrome 105+、Safari 16+、Firefox 110+ 都支持。如果你还在写ResizeObserver代码来处理组件级响应式设计,那你其实在解决一个 CSS 已经解决的问题。
滚动动画:从 JavaScript 到 CSS
元素进入视口时触发的动画,一直是 JavaScript 的活儿。你想让某个元素在用户滚动时淡入,于是设置一个 IntersectionObserver
,监听可见性变化,添加类来触发 CSS 动画,然后取消观察避免内存泄漏。
const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.classList.add("fade-in"); observer.unobserve(entry.target); } }); }); document.querySelectorAll(".animate-on-scroll").forEach((el) => { observer.observe(el); });.fade-in { animation: fadeIn 0.5s ease-in forwards; } @keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }这确实能工作。这是自 2019 年IntersectionObserver发布以来的标准做法。每个视差效果、淡入卡片、滚动触发的动画都在用这个模式。
但问题是:你在用 JavaScript 告诉 CSS 什么时候基于滚动位置运行动画。浏览器本来就在跟踪滚动位置。它本来就知道元素什么时候进入视口。你在桥接两个本应该直接对话的系统。
CSS 滚动驱动动画让你直接把动画绑定到滚动进度:
@keyframes fade-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .animate-on-scroll { animation: fade-in linear both; animation-timeline: view(); animation-range: entry 0% cover 30%; }animation-timeline: view()把动画进度绑定到元素在视图中的可见程度。animation-range根据滚动位置控制动画的开始和结束时机。剩下的交给浏览器处理。
关键优势在于性能。动画在合成器线程上运行,而不是主线程。IntersectionObserver的回调在主线程上执行。如果你的 JavaScript 正在忙着渲染 React 组件或处理数据,IntersectionObserver的回调就会被延迟。滚动驱动动画能保持流畅,因为它们不会和 JavaScript 执行竞争。
浏览器支持在 2024 年达到了重要里程碑。Chrome 115+(2023 年 8 月)、Safari 18+(2024 年 9 月)都支持。Firefox 正在标志后实现。目前覆盖率已经超过 75%,这意味着你可以采用渐进增强策略,用IntersectionObserver作为旧浏览器的降级方案。
真正的优势在于性能。滚动驱动动画是声明式的。你告诉浏览器要运行什么动画,什么时候运行,浏览器会优化执行。而用IntersectionObserver,你是在命令式地管理状态、添加类,然后祈祷自己写的回调代码足够高效。
什么时候还是得用 JavaScript
CSS 不是万能的。有些特殊场景下,JavaScript 仍然是正确的选择,否认这一点是不诚实的。
虚拟化场景
真正的大列表(1000+ 项):content-visibility即使不渲染,也会把所有数据加载到 DOM 中。对于 1000 个项目,这会带来内存压力。React-virtualized 只为可见项创建 DOM 节点,内存占用更低。
动态高度列表:如果你的列表项高度可变或未知,渲染后还会变化,content-visibility就需要contain-intrinsic-size才能正常工作。当项目会根据用户交互或加载内容动态伸缩时,计算固有尺寸会变得很复杂。虚拟化库有专门的测量 API 来处理这种情况。
精确控制需求:如果你在做一个数据表格,用户需要能跳转到第 5000 行,或者需要跨页面加载恢复精确的滚动位置,虚拟化库提供了这些 API。content-visibility不提供这种级别的控制。
布局场景
需要精确测量:容器查询让 CSS 能基于尺寸自适应,但如果你需要知道容器是否正好是 247px 宽,你还是得回到ResizeObserver或getBoundingClientRect()。
高度动态的布局:如果你在做一个带可拖动面板、可调整列宽、布局规则由状态和数学计算驱动的仪表板,这完全属于 JavaScript 的领域。
动画场景
需要回调:滚动驱动动画在开始或结束时不会触发事件。如果你的动画需要触发数据获取,或者需要更新应用状态,IntersectionObserver或滚动事件监听器仍然是必要的。
总结
最后给你一个简单的决策框架:先检查 CSS 能不能直接解决问题。如果能,就用 CSS。如果不能,看看能不能用渐进增强——现代 CSS 优先,JavaScript 作为降级方案。如果这能满足需求,就用这个方案。只有当 CSS 真的搞不定时,才考虑 JavaScript 优先的方案
重点不是要避免 JavaScript,而是不要在 CSS 已经给出答案的时候,还习惯性地用 JavaScript。大多数列表没有一千个项目。大多数动画不需要精确的回调。大多数组件用容器查询就能完美工作。
搞清楚你的 UI 真正需要什么。测量真实的性能数据。然后选择最简单的工具来解决问题。大多数时候,那个工具就是 CSS。
如果你已经用简洁的 CSS 方案替换了长期存在的 JavaScript 方案,欢迎在评论区分享你的经验。