React 為什麼重新渲染

前端小智發表於2022-11-28
本文首發於微信公眾號:大遷世界, 我的微信:qq449245884,我會第一時間和你分享前端行業趨勢,學習途徑等等。
更多開源作品請看 GitHub https://github.com/qq449245884/xiaozhi ,包含一線大廠面試完整考點、資料以及我的系列文章。

更新(重新渲染)是 React 的重要特性 —— 當使用者與應用互動的時候,React 需要重新渲染、更新 UI,以響應使用者的輸入。但是,React 為什麼會重新渲染呢?如果不知道 React 為什麼會重新渲染,我們如何才能避免額外的重新渲染呢?

TL; DR

狀態改變是 React 樹內部發生更新的唯二原因之一。

這句話是 React 更新的公理,不存在任何例外。本文也將會圍繞解釋這句話展開。為了避免有人抬槓,這句話引入了一些限制定語和關鍵詞:

名詞解釋

「更新」和「重新渲染」

在 React 中,「更新」和「重新渲染」是關係緊密,但是含義完全不同的兩個詞。下面這句話才能正確表達這兩個詞的正確含義:

React 的「更新」包含三個階段:渲染(Render),使用 createElementjsx-runtime 產生全新的 React Element 物件、組裝出一顆 React 樹;Reconcilation,React Reconciler 比較 新生成的 React 樹 和 當前的 React 樹,判斷如何用最高效的方法實現「更新」;Commit,操作 Host(如 DOM、Native 等),使新的 UI 呈現在使用者面前。

大部分開發者會把「更新」和「重新渲染」混為一談,因為在上述三個階段中,只有「渲染」這一階段是開發者可以控制的(「Reconcilation」和「Commit」分別由 react-reconcilerReact Host 控制)。本文接下來的部分中,「重新渲染」一律指代 React 元件在「更新」時的「渲染」階段,而「更新」則一律指代(重新)渲染、Reconcilation 和 Commit 整個過程。

「React 樹」和「React 樹內部」

React Tree 本身可以在任意時候更新。實際上,如果你曾經透過 React 文件學習 React,你在「Hello World」一章就已經見過這個 Pattern 了:

const root = ReactDOM.createRoot(document.getElementById('root'));

function tick() {
  const element = (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {new Date().toLocaleTimeString()}.</h2>
    </div>
  );
  root.render(element);
  // 如果你是在 React 18 釋出以前學習的 React,你可能會用 ReactDOM.render():
  // ReactDOM.render(element, document.getElementById('root'));
}

setInterval(tick, 1000);

每秒鐘呼叫一次 ReactDOM 提供的 render 使一整顆 React 樹進行了完整的更新。但是絕大部分時候,你不會更新一整顆 React 樹,而是 React 樹內的一部分元件(在 React 應用中,你只會呼叫一次 createRoot().render 或者 hydrateRoot())。

「唯二原因」

如果你在使用 React class 元件,那麼你可以使用繼承自 React.ComponentforceUpdate 方法更新一個元件:

class MyComponent extends React.Component {
  handleInput() {
    this.forceUpdate();
  }
}

因此,我們也可以把這句話改寫成:如果一顆 React 樹中所有的 class 元件都沒有使用 forceUpdate 方法,那麼狀態改變是這顆 React Tree 內部發生更新的唯一原因。

在正文開始之前,先放出一句非常具有迷惑性的話:

誤區 0:React 元件更新有三個原因:狀態改變,prop 改變,Context 改變。

如果你去問一些使用 React 的開發者「為什麼 React 會更新/重新渲染」,大概會得到這個答案。這句話不無道理,但是並不能反應真實的 React 更新機制。

本文只會介紹 React 為什麼會發生更新,不會介紹如何避免「不必要」的更新(也許我會以這個為話題另外寫一篇文章?)。

狀態更新和單向資料流

讓我們以計數器為例:

const BigNumber = ({ number }) => (
  <div style={{ fontWeight: 700, fontSize: 36 }}>{number}</div>
);

const Counter = () => {
  const [count, setCount] = useState(0);
  const handleButtonClick = useCallback(() => setCount(count => count + 1), []);

  return (
    <div>
      <BigNumber number={count} />
      <button onClick={handleButtonClick}>Increment</button>
    </div>
  );
};

const App = () => (
  <>
    <Counter />
    <footer>
      <a href="https://skk.moe/">Sukka</a>
    </footer>
  </>
);

在這個例子中,我們宣告瞭三個元件,根元件 <App /> 渲染了 <Counter />;而 <Counter /> 渲染了 <BigNumber />。在 <Counter /> 元件中,我們宣告瞭一個元件內的狀態 count,當點選按鈕時會改變狀態 count、使其遞增。

當我們點選按鈕的時候,setCount 被呼叫、count 狀態發生改變,React 更新了 <Counter /> 元件。而當 React 更新一個元件時,也會更新這個元件下的所有子元件(至於為什麼,很快就會講的)。因此 <Counter /> 元件更新時,子元件 <BigNumber /> 也會更新。

現在讓我們先釐清一個最簡單的誤區:

誤區 1:當一個狀態發生改變時,整顆 React 樹都會更新。

有少數使用 React 的開發者會相信這一點(還好不是大多數!)。實際上,當狀態發生改變的時候,React 只會更新「擁有這個狀態」的元件,和這個元件的所有子元件。

為什麼父元件(在這個例子中,<App /><Counter /> 的父元件)沒有發生更新呢?因為 React 的主要任務就是保持 React 內的狀態和 React 渲染的 UI 的同步。React 更新,就是找出如何改變 UI,使其和新的狀態同步。而在 React 中,資料是自上而下單向傳遞的(單向資料流,The Data Flows Down)。在這個例子中,<Counter /> 元件的狀態 count 向下流向了 <BigNumber /> 元件的 prop number,但是不可能向上流向了 <App /> 元件。因此,count 狀態改變,<App /> 元件並不需要更新。

count 狀態改變時,<Counter /> 元件及其子元件 <BigNumber /> 都發生了更新。而 <BigNumber /> 元件更新時,使用了 prop number 的新的值進行渲染。那麼 <BigNumber /> 元件更新的原因是因為 prop number 的改變嗎?

不,和 props 完全沒有關係

誤區 2:React 元件更新的其中一個原因是它的 prop 發生了改變。

現在讓我們修改一下上面那個例子:

import BigNumber from './big-number';

const SomeDecoration = () => <div>Hooray!</div>

const Counter = () => {
  const [count, setCount] = useState(0);
  const handleButtonClick = useCallback(() => setCount(count => count + 1), []);

  return (
    <div>
      <BigNumber number={count} />
      <button onClick={handleButtonClick}>Increment</button>
      <SomeDecoration />
    </div>
  );
};

const App = () => (
  <>
    <Counter />
    <footer>
      <a href="https://skk.moe/">Sukka</a>
    </footer>
  </>
);

<SomeDecoration /> 元件不接受任何 prop、不使用其父元件 <Counter />count 狀態,但是當 count 狀態發生改變時,<SomeDecoration /> 元件仍然發生了更新。當一個元件更新時,React 會更新 所有的子元件,不管這個子元件是否接受一個 prop:React 並不能百分之百肯定 <SomeDecoration /> 元件是否直接/間接地依賴了 count 狀態。

理想中,每一個 React 元件都應該是一個 純函式 —— 一個「純」的 React 元件,當輸入相同的 props 時,總是會渲染相同的 UI。但是現實是骨感的,我們非常容易寫出一個「不純」的 React 元件:

const CurrentTime = () => <p>Last rendered at {new Date().toString()}</p>

包含了狀態(使用了 useState)的元件也不是純元件:即使 prop 不改變,元件也會因為狀態不同而渲染出不同的 UI。

有的時候,你很難判斷一個元件是否是純元件。你可能會將一個 Ref 作為 prop 傳遞給一個元件(forwardRefuseImperativeHandle,諸如此類的 case)。Ref 本身是 Reference Stable 的、React 並不能知道 Ref 中的值是否改變。

React 的目標是展示最新、維持一致的 UI。為了避免向使用者展示過時的 UI,當父元件更新時,React 會更新所有子元件,即使子元件不接受任何 prop。props 和元件更新沒有任何關係

純元件和 memo

你大概很熟悉(或者至少聽說過)React.memoshouldComponentUpdate 或者 React.PureComponent,這些工具允許我們「忽略更新」:

const SomeDecoration = memo(() => <div>Hooray!</div>);

當我們將 <SomeDecoration /> 元件的宣告包裹在 memo 中時,我們實際上做的是告訴 React「嘿!我覺得這是個純元件,只要它的 prop 不改變,我們就別更新它」。

現在,讓我們把 <SomeDecoration /><BigNumber /> 都包裹在 memo 中,看看會發生什麼:

const BigNumber = memo(({ number }) => (
  <div style={{ fontWeight: 700, fontSize: 36 }}>{number}</div>
));

const SomeDecoration = memo(() => <div>Hooray!</div>);

const Counter = () => {
  const [count, setCount] = useState(0);
  const handleButtonClick = useCallback(() => setCount(count => count + 1), []);

  return (
    <div>
      <BigNumber number={count} />
      <button onClick={handleButtonClick}>Increment</button>
      <SomeDecoration />
    </div>
  );
};

const App = () => (
  <>
    <Counter />
    <footer>
      <a href="https://skk.moe/">Sukka</a>
    </footer>
  </>
);

現在,當 count 狀態更新後,React 會更新 <Counter /> 元件及其所有子元件,<BigNumber /><SomeDecoration />。由於 <BigNumber /> 接受一個 prop number,而 number 的值發生了改變,因此 <BigNumber /> 會更新。但是 <SomeDecoration />prop 沒有發生改變(因為不接受任何 prop),所以 React 跳過了 <SomeDecoration /> 的更新。

於是你想,為什麼 React 不預設所有元件都是純元件呢?為什麼 React 不 memo 所有元件呢?事實上,React 元件更新的開銷沒有想象中的那麼大。以 <SomeDecoration /> 元件為例,它只需要渲染一個 <div />

如果一個元件接受很多複雜的 prop,有可能渲染這個元件並對比 Virtual DOM 的效能開銷甚至小於等於淺比較所有 prop 的開銷。絕大部分時候,React 是足夠快的。因此,只有當一個 純元件 有大量純的子元件、或者這個 純元件 內部有很多複雜計算時,我們才需要將其包裹在 memo 中。

當一個包裹在 memo 中的元件使用了 useStateuseReducer 或者 useContext,當這個元件內的狀態發生改變時,這個元件仍然會更新。

另外一個 React 預設不 memo 所有元件的原因是:讓 React 在 Runtime 中判斷子元件的全部依賴、以跳過子元件的不必要更新,是非常困難、非常不現實的。計運算元元件依賴的最好時機是編譯期間。關於這個 idea 的更多細節,可以看看黃玄在 React Conf 2021 上的演講 React without memo。

讓我們談談 Context

誤區 3:React 元件更新的其中一個原因是 Context.Provider 的 value 發生了更新。

如果說,當一個元件由於狀態改變而更新時,其所有子元件都要隨之更新。那麼當我們透過 Context 傳遞的狀態發生改變時,訂閱了這個 Context 的所有子元件都要更新也是毫不意外的了。

對於純元件來說,Context 可以視為一個「隱藏的」、或者「內部的」prop:

const User = memo(() => {
  const user = useContext(UserContext);

  if (!user) {
    return 'Hello, new comer!';
  }

  return `Hello, ${user.name}!`;
})

在上面的例子中,<User /> 元件是一個不接受任何 prop、不使用 useState、也沒有任何副作用的純元件。但是,<User /> 元件依賴 UserContext。當 UserContext 儲存的狀態發生改變時,<User /> 元件也會更新。

眾所周知,當 Contextvalue 發生改變的時候,所有 <Context.Provider /> 的子元件都會更新。那麼為什麼即使不依賴 Context 的子元件也會更新呢?Context 本身並不是一個狀態管理工具,只是一種狀態傳遞工具。Context 的 value 發生改變的根本原因還是狀態的改變:

const CountContext = createContext(0);

const BigNumber = memo(() => {
  const number = useContext(CounterContext);
  return (
    <div style={{ fontWeight: 700, fontSize: 36 }}>{number}</div>
  )
});

const Counter = () => {
  const [count, setCount] = useState(0);
  const handleButtonClick = useCallback(() => setCount(count => count + 1), []);

  return (
    <div>
      <CountContext.Provider value={count}>
        <BigNumber number={count} />
      </CountContext.Provider>
      <SomeDecoration />
      <button onClick={handleButtonClick}>Increment</button>
    </div>
  );
};

正如上面的例子,CountContext 發生改變的原因,是 <Counter /> 元件的 count 狀態發生了改變;發生更新的,也不僅僅是 CountContext 的消費元件(及其子元件),還包括 <Counter /> 所有的子元件。

程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug

來源:https://blog.skk.moe/post/react-re-renders-101/

交流

有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

相關文章