我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于
Tiptap的富文本编辑器、NestJs后端服务、AI集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了Tiptap的深度定制、性能优化和协作功能的实现等核心难点。
如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。
知识盲区往往会让我们过度工程化,最终在性能上付出代价。
就拿 content-visibility: auto 来说,它能实现 React-Window 的效果,却不需要任何 JavaScript,也不会增加打包体积。现代视口单位(dvh、svh、lvh)也是同样的道理------它们彻底解决了我们多年来用 window.innerHeight 来修补的移动端高度问题。
这两个特性在 2024 年都达到了 90% 以上的全球支持率,完全可以投入生产使用。但现实是,我们仍然习惯性地用 JavaScript 来解决这些问题,因为在我们争论 React Server Components 的时候,CSS 已经悄悄进化了。
这篇文章就是要填补这个认知空白。我们会看一些性能对比,提供迁移方案,同时也会诚实地告诉你,什么时候 JavaScript 仍然是更好的选择。不过在开始之前,先说个显而易见的道理:如果你在用 useEffect 和 useState 来解决渲染问题,那多半是走错路了。
React 虚拟化的过度使用
React 开发者似乎把虚拟化库(比如 react-window 和 react-virtualized)当成了渲染列表的万能药。从逻辑上看,这似乎很合理:用户一次只能看到 10 个项目,为什么要渲染全部 1000 个?虚拟化会创建一个小的可见项目"窗口",滚动时卸载其他内容。
问题不在于虚拟化本身------而是我们用得太早、太频繁了。200 个产品的网格?上 react-window。50 篇文章的博客列表?上 react-virtualized。
我们在列表性能优化上形成了一种"盲目崇拜"。我们不会先检查浏览器是否已经能原生处理这些工作,而是直接开始把所有东西都包在 useMemo 和 useCallback 里,然后称之为"优化"。
下面是一个典型的 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 属性有三个值:visible、hidden 和 auto。只有 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 宽,你还是得回到 ResizeObserver 或 getBoundingClientRect()。
高度动态的布局:如果你在做一个带可拖动面板、可调整列宽、布局规则由状态和数学计算驱动的仪表板,这完全属于 JavaScript 的领域。
动画场景
需要回调:滚动驱动动画在开始或结束时不会触发事件。如果你的动画需要触发数据获取,或者需要更新应用状态,IntersectionObserver 或滚动事件监听器仍然是必要的。
总结
最后给你一个简单的决策框架:先检查 CSS 能不能直接解决问题。如果能,就用 CSS。如果不能,看看能不能用渐进增强------现代 CSS 优先,JavaScript 作为降级方案。如果这能满足需求,就用这个方案。只有当 CSS 真的搞不定时,才考虑 JavaScript 优先的方案。
重点不是要避免 JavaScript,而是不要在 CSS 已经给出答案的时候,还习惯性地用 JavaScript。大多数列表没有一千个项目。大多数动画不需要精确的回调。大多数组件用容器查询就能完美工作。
搞清楚你的 UI 真正需要什么。测量真实的性能数据。然后选择最简单的工具来解决问题。大多数时候,那个工具就是 CSS。
如果你已经用简洁的 CSS 方案替换了长期存在的 JavaScript 方案,欢迎在评论区分享你的经验。