今天要深入探討這個機制引發的連鎖反應——Render 瀑布流(Render Waterfall),並示範如何利用「架構拆分」在不使用 React.memo 的情況下優雅化解效能坑。
在 React 中,預設的渲染行為是自頂向下(Top-Down)遞迴進行的。
當頁面頂層的父組件發生 State 更新時,React 會重新執行該父組件的函數,並順著 Component Tree 一路向下,無條件呼叫所有子組件、孫組件的函數,直到整棵樹被走訪完畢。
[Parent] ◄── State 變更,觸發 Re-render
/ \
[ChildA] [ChildB] ◄── 預設全部被迫連帶 Re-render!
/ \
[GrandChild] [GrandChild]
這不是 React 的 Bug,而是一種安全設計(Correctness by Default)。React 無法預先知道子組件內部是否依賴了父組件的隱性狀態(例如 Context 或傳遞的 Props)。為了確保 UI 與資料永遠保持 100% 一致,預設對整棵子樹進行 Re-render 是最安全的選擇。
假設我們有一個頁面,頂層維護著一個滾動位置(scrollY)或文字輸入(inputValue)等高頻變動的 State,而底下掛載了一個計算量極大的卡片列表:
// 效能坑寫法:高頻 State 與重型 UI 混在一起
function App() {
const [inputValue, setInputValue] = useState('');
return (
三、 不靠 memo 的架構優化法:State 下沉 (Moving State Down)
<div>
{/* 每次打字,inputValue 都會更新 */}
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
{/* 雖然 HeavyList 根本沒用到 inputValue,但每次打字都會被強迫 Re-render! */}
<HeavyList />
</div>
);
}
結果:使用者每打一個字,App 就 Re-render,連帶導致 重新執行,造成打字體驗嚴重 lag!
要阻止 Render 瀑布流,最直覺的做法是將 State 下沉到真正需要它的最小子組件中,阻斷對外層父組件的影響。
// 1. 將高頻 State 與 Input 獨立封裝成子組件
function SearchInput() {
const [inputValue, setInputValue] = useState('');
return (
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
);
}
// 2. 父組件保持乾淨,不再維護高頻 State
function App() {
return (
<div>
<SearchInput /> {/* 打字時,只有 SearchInput 自己會 Re-render */}
<HeavyList /> {/* 完全不受影響! */}
</div>
);
}
如果父組件必須維護 State(例如負責滾動監聽或手勢滑動),但又不想影響包含的子內容,可以使用 children 傳遞技巧:
// 將包含 State 的容器組件抽出來,並接受 children
function ScrollContainer({ children }) {
const [scrollY, setScrollY] = useState(0);
return (
<div onScroll={(e) => setScrollY(e.target.scrollTop)}>
<p>目前滾動位置:{scrollY}</p>
{children}
</div>
);
}
// 在外層組合
function App() {
return (
<ScrollContainer>
{/* HeavyList 是在 App 中建立並作為 children 傳入 */}
{/* 當 ScrollContainer 因為 scrollY 更新時,children 的引用未變,HeavyList 不會 Re-render! */}
<HeavyList />
</ScrollContainer>
);
}
原理:HeavyList 是在 App 作用域下被創建的。當 ScrollContainer Re-render 時,它收到的 children 依然是上一次渲染時的同一份 React Element 物件,React 會自動跳過該子樹的重新渲染!