在開發過程中,元件的渲染效能是一個前端開發者必須面對的挑戰。或許開始接觸程式開發時,我們不太重視這一點,認為只要功能能正常運作就足夠。然而,隨著我們的工作時間增加、經驗累積,專案變得愈加複雜,頁面的載入速度和渲染效率便變得尤為重要。

因此,了解並優化 React 的渲染行為,並提升整體應用程式的效能,是一個需要學習的課題,今天也想以 React 元件渲染為例,與大家分享幾個我踩過的坑。

React 元件重新渲染的情境

開發 React 專案時,是否發生過「元件明明沒必要更新,卻還是重新渲染了一遍」這個情況?

▼ 讓我們用以下程式碼為例,這邊有一個父組件 Parent 和子組件 Child

import { useState } from "react";

const Parent = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    console.log("Button clicked!");
  };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <Child onClick={handleClick} />
      <div>count: {count}</div>
    </div>
  );
};

const Child = ({ onClick }) => {
  console.log("Child rendered");
  return <button onClick={onClick}>Click me</button>;
};

export default Parent;

▼ 可以看到每次點擊 IncrementChild 也會跟著重新渲染

不知道大家有沒有注意到這個狀況, onClick 明明沒變,為什麼跟著渲染了呢?

先講結論:因為 handleClickParent 每次渲染時都是一個新函數,React 認為 props 變了,就觸發了子元件 (Child) 的更新。

AD

React 渲染機制的核心概念

前面提到的「React 認為 props 變了,就觸發了子元件 (Child) 的更新」是什麼意思?

在 React 中,元件的渲染是由「狀態」或「屬性」(props) 的變化驅動的。因此 React 會在父元件渲染時,檢查傳給子元件的 props 是否跟上次不同,如果 props參考改變了,就假設這個改變可能會影響子元件的顯示,於是觸發子元件的重新渲染。

回到上述的程式碼範例,我們點擊了「Increment」按鈕,會更新 count,也意味者 Parent 父元件會重新渲染,而 Parent 每次渲染時,裡面的 handleClick 都是一個全新的函式,即使內容沒變,但他會有新的記憶體位置(也就是參考改變了)。

因為 handleClickChild 子元件的 onClick prop,當 React 發現這個 props 從舊的 handleClick 變成新的 handleClick,就認為 props 變了,於是讓 Child 子元件也重新渲染。所以即使 Child 的按鈕功能沒變,也會一直執行並印出 console: Child rendered.

▼ 文字敘述可能不是很好懂,我做了一個圖表版的給大家參考這其中的影響與步驟 (會不會圖表版的更難懂 😂 )

重複渲染造成的影響

重複渲染這個問題重要嗎?試想如果今天有一個表單頁面,裡面有非常多的文字方塊,每一個文字方塊都是一個子組件,每個子元件都有一個 callback。如果不處理這個狀況,表單輸入更新後,父元件一渲染,所有子元件都會跟著重新跑一遍,很容易造成頁面卡頓的現象。

如何優化 React 的渲染問題

首先,再簡單複習一次造成這問題的根本原因,因為父元件每次渲染都會產生新的函式參考,導致子元件無端重新渲染。

這個問題的解法,有的人可能聽過,一個是 useCallback,一個是 React.memo,接下來會跟大家介紹這兩個功能以及該怎麼使用。

什麼是 useCallback

useCallback 是 React 提供的一個 Hook,用來記住一個函式,並確保該函式在多次渲染後,還能保持相同的參考(記憶體位置),除非指定的依賴項改變了。

▼ 這是 React 對 useCallback 的定義

const anyFunction = useCallback(() => {
  // ...
}, [dependencies])

那麼回到我們前面的例子,剛一直有重複強調,因為父元件的渲染造成 handleClick() 的參考改變,所以 React 以為他是新的 handleClick() 故重新渲染。而 useCallback 就是用來解決這個情況,幫助我們穩定函式,不要讓他的參考改變。

▼ 我們可以將 handleClick()改寫如下

const handleClick = useCallback(() => {
  console.log("Button clicked!");
}, []); // 空陣列,因為不依賴任何變數

什麼是 React.memo

React.memo 用來記憶一個函數元件,防止它在 props 沒有改變的情況下重新渲染

▼ 這是 React 對 React.memo 的定義

const component = React.memo((props) => {
  // ...
})

在 React 中,預設行為是「只要父元件重新渲染,所有的子元件都會跟著重新渲染,即使子元件的 props 沒變」。乍看沒什麼問題,但如果子元件很複雜 (比如有很多計算或很多 DOM 操作),這種不必要的渲染就會拖慢效能。

React.memo 的作用就是避免這種情況,讓子元件在真正需要時才更新。

同樣回到前面的例子,我們要將 React.memo 放在 Child 子元件中,避免不要的渲染

const Child = React.memo(({ onClick }) => {
  console.log("Child rendered");
  return <button onClick={onClick}>Click me</button>;
});

最後加上 useCallback 以及 React.memo 的完整程式碼如下

import React, { useState, useCallback } from "react";

const Parent = () => {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log("Button clicked!");
  }, []);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <Child onClick={handleClick} />
      <div>count: {count}</div>
    </div>
  );
};

const Child = React.memo(({ onClick }) => {
  console.log("Child rendered");
  return <button onClick={onClick}>Click me</button>;
});

export default Parent;

▼ 此時再點擊 Increment 時,Child 子元件就不會重新渲染了

重構時的注意事項

既然 useCallbackReact.memo 那麼好用,是不是可以在每個函式和子元件都加上去呢?

根據大家求學那麼多年的經驗來看,當有人這樣問時,表示答案一定是錯的 XXD。

useCallback 依賴項的重要性

首先,我們要確認 useCallback 的依賴項該怎麼填寫,如果 handleClick() 需要用到 count,就必須要加入依賴項:

const handleClick = useCallback(() => {
  console.log("Count is:", count);
}, [count]);

當然,這也意味著 count 改變時 handleClick() 會更新,此時就會回到我們一開始的問題,點擊「Increment」按鈕時,會重新渲染 Child 子元件。

因此答案沒有絕對,大家要根據自己的專案需求與程式結構,寫出正確的依賴項,才能避免後續的各種問題

React.memoprops 的穩定性

React.memo 的效果取決於 props 是否穩定。如果 props 本身不穩定 (比如每次渲染都會產生新的值或參考),那 React.memo 就不會生效,React.memo 不是萬靈丹,他需要 props 的搭配才能發揮作用。

那要如何讓 props 穩定?就是跟前面提到的 useCallback 有關了,我們使用 useCallbackhandleClick() 的參考不變,React.memo 比較 props 時發現 onClick 沒變,就不會渲染

也就是說,如果要用 React.memo,就要先用 useCallback 穩定 props

適度優化

老實說,不是所有場景都一定要用 React.memouseCallback,過度使用反而會讓程式碼變得複雜。如果元件很單純,渲染成本也低,加了 React.memo 反而會造成記憶體負擔。

大家可以試著用 React DevTools Profiler 先確認問題,再針對性處理,可以到 Chrome Extension 下載這個 Tool。

小結

最後幫大家總結 useCallbackReact.memo 的特性:

useCallback 是幫助我們穩定函式的 Hook,特別適合解決「props 變了導致子元件重新渲染」的問題。它的核心是透過快取讓函式的參考不變,搭配 React.memo 能大幅提升效能。

React.memo 是個幫助我們優化效能的工具,讓子元件只有在 props 改變時才渲染。它透過淺比較 props 以跳過不必要的更新,跟 useCallback 是最佳拍檔。

希望今天這篇介紹,可以讓大家更了解 React 的渲染機制,有任何問題歡迎留言討論唷。