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

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

知识盲区往往会让我们过度工程化,最终在性能上付出代价。

就拿 content-visibility: auto 来说,它能实现 React-Window 的效果,却不需要任何 JavaScript,也不会增加打包体积。现代视口单位(dvhsvhlvh)也是同样的道理------它们彻底解决了我们多年来用 window.innerHeight 来修补的移动端高度问题。

这两个特性在 2024 年都达到了 90% 以上的全球支持率,完全可以投入生产使用。但现实是,我们仍然习惯性地用 JavaScript 来解决这些问题,因为在我们争论 React Server Components 的时候,CSS 已经悄悄进化了。

这篇文章就是要填补这个认知空白。我们会看一些性能对比,提供迁移方案,同时也会诚实地告诉你,什么时候 JavaScript 仍然是更好的选择。不过在开始之前,先说个显而易见的道理:如果你在用 useEffectuseState 来解决渲染问题,那多半是走错路了。

React 虚拟化的过度使用

React 开发者似乎把虚拟化库(比如 react-window 和 react-virtualized)当成了渲染列表的万能药。从逻辑上看,这似乎很合理:用户一次只能看到 10 个项目,为什么要渲染全部 1000 个?虚拟化会创建一个小的可见项目"窗口",滚动时卸载其他内容。

问题不在于虚拟化本身------而是我们用得太早、太频繁了。200 个产品的网格?上 react-window。50 篇文章的博客列表?上 react-virtualized

我们在列表性能优化上形成了一种"盲目崇拜"。我们不会先检查浏览器是否已经能原生处理这些工作,而是直接开始把所有东西都包在 useMemouseCallback 里,然后称之为"优化"。

下面是一个典型的 react-virtualized 最小配置:

javascript 复制代码
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 开发者很少就此打住。我们被训练得习惯性地追求重渲染优化,于是开始把所有东西都包在记忆化和回调里:

javascript 复制代码
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 来实现同样的产品网格,代码会是这样:

javascript 复制代码
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 部分:

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 动画,然后取消观察避免内存泄漏。

javascript 复制代码
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);
});
css 复制代码
.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 滚动驱动动画让你直接把动画绑定到滚动进度:

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 方案,欢迎在评论区分享你的经验。

相关推荐
CQ_YM2 小时前
Linux进程终止
linux·服务器·前端·进程
晓得迷路了2 小时前
栗子前端技术周刊第 110 期 - shadcn/create、Github 更新 npm 令牌政策、Deno 2.6...
前端·javascript·css
nvd112 小时前
GKE web 应用实现 Auth0 + GitHub OAuth 2.0登录实施指南
前端·github
前端小端长2 小时前
项目里满是if-else?用这5招优化if-else让你的代码清爽到飞起
开发语言·前端·javascript
笨小孩7872 小时前
Flutter跨平台开发全解析:从原理到实战的深度指南
javascript·react native·react.js
胡萝卜3.02 小时前
现代C++特性深度探索:模板扩展、类增强、STL更新与Lambda表达式
服务器·开发语言·前端·c++·人工智能·lambda·移动构造和移动赋值
AI_56782 小时前
Vue3组件通信的实战指南
前端·javascript·vue.js
烤麻辣烫2 小时前
黑马大事件学习-16 (前端主页面)
前端·css·vue.js·学习
Dragon Wu2 小时前
TanStack Query(React Query) 使用总结
前端·react.js·前端框架·react