Overreacted

在你使用 memo() 之前

2021年2月23日 • ☕️ 6 min read

有很多關於 React 效能最佳化的文章。大體而言,如果某些 state 更新得很慢,你需要:

  1. 確認你是否正在執行正式的編譯版本。(開發的編譯版本是刻意被設計成比較慢的,在極端的情況下可能會是一個數量級的慢。)
  2. 確認你沒有把 state 更新放在 tree 裡比你所需要的還高的地方。(例如,把 input 欄位的 state 放在一個中央的儲存區裡並不是個很好的想法。)
  3. 執行 React DevTools Profiler 來看哪些東西被重新 render 了,然後將最需要資源的 subtree 用 memo() 包起來。(並且在需要的地方加上 useMemo()。)

最後一個步驟尤其對在中間的 component 來說很麻煩,而且理想上編譯器會幫你做掉。或許未來可能會做到。

在這篇文章,我會分享兩種不同的技巧。它們出乎意料的基本,這就是為什麼很多人沒有意識到它們增進了 render 的效能。

這些技巧能夠補充你本來就已經知道的知識!它們並沒有取代 memouseMemo,但第一時間嘗試這些做法通常效果不錯。

一個(人為的)效能慢的 Component

以下是一個有嚴重 render 效能問題的 component:

import { useState } from 'react';

export default function App() {
  let [color, setColor] = useState('red');
  return (
    <div>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
      <ExpensiveTree />
    </div>
  );
}

function ExpensiveTree() {
  let now = performance.now();
  while (performance.now() - now < 100) {
    // 故意延遲 -- 不做任何事情而等待 100ms
  }
  return <p>我是一個非常慢的 component tree。</p>;
}

(在這裡試試)

這裡的問題是,每當 App 裡的 color 改變時,我們會重新 render <ExpensiveTree />,我們故意延遲這個 component ,讓效能變得非常差。

我可以在這使用 memo(),然後收工下班,但已經有很多文章解釋了這個用法,所以我不會再花時間在這上面。我想要展示另外兩種不同的解法。

解法 1:把 State 往下移

如果你仔細看 render 的程式碼,你會注意到只有一小部分回傳的 tree 真的使用到了當下的 color

export default function App() {
  let [color, setColor] = useState('red');  return (
    <div>
      <input value={color} onChange={(e) => setColor(e.target.value)} />      <p style={{ color }}>Hello, world!</p>      <ExpensiveTree />
    </div>
  );
}

所以,讓我們把這部分抽出,將它放到 Form component 裡,並且把 state 往 移進去:

export default function App() {
  return (
    <>
      <Form />      <ExpensiveTree />
    </>
  );
}

function Form() {
  let [color, setColor] = useState('red');  return (
    <>
      <input value={color} onChange={(e) => setColor(e.target.value)} />      <p style={{ color }}>Hello, world!</p>    </>
  );
}

(在這裡試試)

現在,如果 color 改變了,只有 Form 會被重新 render。問題解決了。

解法 2:把內容往上移

如果這個 state 已經在這個效能很差的 tree 的上面的某處被用到了,前面提到的解法就會變得不可行。例如,假設我們把 color 放到 parent <div> 裡:

export default function App() {
  let [color, setColor] = useState('red');  return (
    <div style={{ color }}>      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p>Hello, world!</p>
      <ExpensiveTree />
    </div>
  );
}

(在這裡試試)

因為 parent <div> 也使用到了 color,所以現在看起來我們沒辦法單純把 color 抽出到另一個 component,因為這樣會不可避免地會包含到 <ExpensiveTree />。這次或許無法避免使用 memo 了?

還是我們仍可以避免?

看你是否可以玩一下這個 sandbox 來找出解法。

答案出乎意料的平凡無奇:

export default function App() {
  return (
    <ColorPicker>
      <p>Hello, world!</p>      <ExpensiveTree />    </ColorPicker>
  );
}

function ColorPicker({ children }) {  let [color, setColor] = useState("red");
  return (
    <div style={{ color }}>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      {children}    </div>
  );
}

(在這裡試試)

我們把 App component 一分為二,將需要依賴 color 的部分,以及 color state 的變數本身,移進去 ColorPicker

其他不需要在乎 color 的部分則留在 App component 裡,並且把它當作 JSX 的內容傳進 ColorPicker 裡,亦即當作 children prop 來傳進去。

每當 color 改變時,ColorPicker 會重新 render。但它會跟上次從 App 裡拿到的 children prop 一樣,所以 React 不會去理那個 subtree。

因此,不會重新 render <ExpensiveTree />

這裡的寓意是什麼?

在你使用像是 memouseMemo 等最佳化的方式之前,你可以試著看看是否可以把需要改變和不需要改變的部分拆開來。

這樣的做法有趣的點在於,它們本身跟效能問題其實沒什麼關係。通常使用 children prop 來拆開 component 會讓你的應用程式的資料流比較容易被理解,而且這也減少了往 tree 下面傳的 props 的數量。利用這種方式帶來的效能增益,是附加的好處,而不是這個方式本來的目標。

令人驚訝的是,這樣的模式也帶來了 更多 未來可以增進效能的好處。

舉例來說,當 Server Components 已經穩定且可以被大家採用的時候,我們的 ColorPicker component 可以 從伺服器 接收它的 children。不論是整個 <ExpensiveTree /> component 或是它的部份都可以在伺服器上執行,甚至最高層級(top-level)的 React state 更新的那些部份,也會在客戶端被「跳過」。

上面的例子即使用了 memo 也沒辦法做到!但,這兩種做法是互補的。不要忽略把 state 往下移(和把內容往上移)的方式。

在那之後,如果效能增進的幅度還不夠的話,試著用 Profiler 並在需要的地方使用 memo。

我以前沒有讀過這個概念嗎?

是的,或許有。

這不是什麼全新的想法,這是 React 組成模型自然而然的結果。它簡單到被大家低估了,或許它值得更多的關愛。