news 2026/5/1 8:33:41

别再让 JavaScript 抢 CSS 的活儿了,css原生虚拟化来了

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
别再让 JavaScript 抢 CSS 的活儿了,css原生虚拟化来了

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属性有三个值:visiblehiddenauto。只有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 宽,你还是得回到ResizeObservergetBoundingClientRect()

高度动态的布局:如果你在做一个带可拖动面板、可调整列宽、布局规则由状态和数学计算驱动的仪表板,这完全属于 JavaScript 的领域。

动画场景

需要回调:滚动驱动动画在开始或结束时不会触发事件。如果你的动画需要触发数据获取,或者需要更新应用状态,IntersectionObserver或滚动事件监听器仍然是必要的。

总结

最后给你一个简单的决策框架:先检查 CSS 能不能直接解决问题。如果能,就用 CSS。如果不能,看看能不能用渐进增强——现代 CSS 优先,JavaScript 作为降级方案。如果这能满足需求,就用这个方案。只有当 CSS 真的搞不定时,才考虑 JavaScript 优先的方案

重点不是要避免 JavaScript,而是不要在 CSS 已经给出答案的时候,还习惯性地用 JavaScript。大多数列表没有一千个项目。大多数动画不需要精确的回调。大多数组件用容器查询就能完美工作。

搞清楚你的 UI 真正需要什么。测量真实的性能数据。然后选择最简单的工具来解决问题。大多数时候,那个工具就是 CSS。

如果你已经用简洁的 CSS 方案替换了长期存在的 JavaScript 方案,欢迎在评论区分享你的经验。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/1 5:45:03

Java 大视界 -- Java 大数据机器学习模型在电商用户生命周期价值评估与客户关系精细化管理中的应用

Java 大视界 -- Java 大数据机器学习模型在电商用户生命周期价值评估与客户关系精细化管理中的应用 引言&#xff1a;正文&#xff1a;一、电商用户运营的 “糊涂账”&#xff1a;不是所有客户都该被讨好1.1 运营者的 “三大错觉”1.1.1 错把 “过客” 当 “贵客”1.1.2 不知道…

作者头像 李华
网站建设 2026/5/1 6:50:18

Seed-Coder-8B-Base能否生成可靠的分布式锁?

Seed-Coder-8B-Base能否生成可靠的分布式锁&#xff1f; 在电商大促的零点时刻&#xff0c;成千上万的请求涌向同一个商品库存接口。如果此时没有正确的并发控制机制&#xff0c;轻则超卖、订单混乱&#xff0c;重则资金错账、用户投诉。这种高并发场景下&#xff0c;开发者的第…

作者头像 李华
网站建设 2026/5/1 7:57:07

FeHelper:重塑前端开发体验的全能工具箱

FeHelper&#xff1a;重塑前端开发体验的全能工具箱 【免费下载链接】FeHelper &#x1f60d;FeHelper--Web前端助手&#xff08;Awesome&#xff01;Chrome & Firefox & MS-Edge Extension, All in one Toolbox!&#xff09; 项目地址: https://gitcode.com/gh_mirr…

作者头像 李华
网站建设 2026/5/1 5:44:33

绝区零一条龙:5分钟快速上手,解放双手的智能助手

绝区零一条龙&#xff1a;5分钟快速上手&#xff0c;解放双手的智能助手 【免费下载链接】ZenlessZoneZero-OneDragon 绝区零 一条龙 | 全自动 | 自动闪避 | 自动每日 | 自动空洞 | 支持手柄 项目地址: https://gitcode.com/gh_mirrors/ze/ZenlessZoneZero-OneDragon 还…

作者头像 李华
网站建设 2026/4/26 2:39:23

【万字长文】LangChain MiddleWare深度实战:手把手打造可控智能体!

简介 本文详解LangChain MiddleWare机制&#xff0c;包括六种插入点和两种风格(Node/Wrap)。重点介绍SummarizationMiddleware(对话历史摘要压缩)和HumanInTheLoopMiddleware(工具调用前人工审核)两种内置中间件&#xff0c;通过代码示例展示approve、reject、edit三种决策路径…

作者头像 李华
网站建设 2026/5/1 8:01:29

微信公众号运营:如何持续输出LobeChat相关内容?

微信公众号运营&#xff1a;如何持续输出LobeChat相关内容&#xff1f; 在AI技术席卷各行各业的今天&#xff0c;越来越多开发者和内容创作者开始关注一个现实问题&#xff1a;如何让强大的大语言模型真正“落地”到具体场景中&#xff1f; 我们不缺模型——从GPT、Claude到本…

作者头像 李华