我正在開發 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-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 屬性有三個值: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 工作的唯一例子。另一個典型場景是容器響應式設計。
響應式設計教會我們基於視口寬度寫媒體查詢。這招在大多數情況下都管用,直到你把組件放到側邊欄裡。你的卡片組件需要根據容器寬度(而不是屏幕寬度)來調整佈局。側邊欄裡的 300px 卡片應該和主內容區的 300px 卡片看起來不一樣,即使視口寬度相同。
開發者們的第一反應是用 JavaScript。我們用 ResizeObserver 監聽容器尺寸變化,然後根據容器寬度動態添加類或內聯樣式。這確實能工作,但它是命令式的、複雜的,而且需要你手動管理觀察者的生命週期。
容器查詢讓 CSS 可以直接響應容器尺寸。你的卡片組件會自動適配容器寬度。
container-type: inline-size 告訴瀏覽器這個元素是一個容器,子元素可能會查詢它的寬度。然後 @container 規則就像 @media 規則一樣工作,只不過它檢查的是容器的尺寸,而不是視口的尺寸。
瀏覽器支持率在 2025 年已經超過 90%。Chrome 105+、Safari 16+、Firefox 110+ 都支持。如果你還在寫 ResizeObserver 代碼來處理組件級響應式設計,那你其實在解決一個 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,你是在命令式地管理狀態、添加類,然後祈禱自己寫的回調代碼足夠高效。
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 方案,歡迎在評論區分享你的經驗。