保持 Component 的 Pure

有些 JavaScript 的函式為「純函式」。純函式只會執行計算,不會做別的事情。如果我們嚴格地把 component 都寫成純函式,就可以在隨著 codebase 的增長中避免一系列令人困惑且不可預期的問題出現。但是在獲得這些好處前,你必須先遵守一些規則。

You will learn

  • 什麼是 purity 以及它如何幫助你避免錯誤
  • 如何透過將變更保留在 render 階段外來保持 component 的 pure
  • 如何使用 Strict Mode 來尋找 component 中的錯誤

Purity:Component 作為公式

在計算機科學中(尤其是函數式程式設計的世界),純函式具有以下的特徵:

  • 只關心自己的事務。 這個函式不會修改任何在他被呼叫之前就已經存在的 object 或變數。
  • 一樣的輸入,一樣的輸出。 只要我們輸入相同的參數,這個函式總是回傳相同的輸出。

你可能已經熟悉純函式的其中一個例子:數學中的公式

來看這個數學公式: y = 2x

如果 x = 2 那麼 y = 4 ,永遠如此。

如果 x = 3 那麼 y = 6,永遠如此。

如果 x = 3y 不會因為一天中的時間或是股票市場的狀態而有時候是 9–12.5

如果 y = 2xx = 3y 永遠都會是 6

如果我們把它放到 JavaScript 函式中,它會長得像這樣:

function double(number) {
return 2 * number;
}

在上面的範例中,double 是一個純函式。如果你傳入 3,他永遠都會回傳 6

React 就是圍繞這個概念設計的。React 假設你編寫的每個函式都是純函式。 這表示你撰寫的所有 React component 都必須永遠在給定相同輸入的情況下,回傳相同的 JSX :

function Recipe({ drinkers }) {
  return (
    <ol>
      <li>Boil {drinkers} cups of water.</li>
      <li>Add {drinkers} spoons of tea and {0.5 * drinkers} spoons of spice.</li>
      <li>Add {0.5 * drinkers} cups of milk to boil and sugar to taste.</li>
    </ol>
  );
}

export default function App() {
  return (
    <section>
      <h1>Spiced Chai Recipe</h1>
      <h2>For two</h2>
      <Recipe drinkers={2} />
      <h2>For a gathering</h2>
      <Recipe drinkers={4} />
    </section>
  );
}

當你把 drinkers={2} 傳入 Recipe 時,會永遠回傳包含 2 cups of water 的 JSX。

當你傳入 drinkers={4},會永遠回傳包含 4 cups of water 的 JSX。

就像是數學公式一樣。

你可以把 component 想成是食譜一樣: 如果你遵循它們並且在烹飪過程中不加入新食材,那麼你每次都會得到相同的菜餚。這個「菜餚」就是 component 提供給 React render 的 JSX。

A tea recipe for x people: take x cups of water, add x spoons of tea and 0.5x spoons of spices, and 0.5x cups of milk

Illustrated by Rachel Lee Nabors

Side Effects:(非)預期的結果

React 的 rendering 過程必須永遠保持 pure。Component 應該永遠回傳它們的 JSX,而不更改任何 rendering 之前就存在的 object 或變數 - 這會使它們變得 impure!

這是一個違反規則的 component:

let guest = 0;

function Cup() {
  // Bad: changing a preexisting variable!
  guest = guest + 1;
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaSet() {
  return (
    <>
      <Cup />
      <Cup />
      <Cup />
    </>
  );
}

這個 component 正在讀取與更改在外部宣告的 guest 變數。這意味著多次呼叫這個 component 會產生不一樣的 JSX! 更重要的是,如果 其他 component 也讀取 guest,它們將依照被 render 的時間點而產生不一樣的 JSX !這是不可預測的。

回到我們的公式 y = 2x,即使現在 x = 2,我們不能保證 y = 4。我們的測試可能會失敗、我們的使用者可能會感到困惑、飛機會從天上掉下來 - 你可以看到這將會導致令人困惑的錯誤!

你可以透過guest 作為一個 prop 傳入 來修正這個 component:

function Cup({ guest }) {
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaSet() {
  return (
    <>
      <Cup guest={1} />
      <Cup guest={2} />
      <Cup guest={3} />
    </>
  );
}

現在你的 component 是 pure 的,因為它回傳的 JSX 僅依賴 guest prop。

一般來說,你不應該預期 component 以任何特定順序 render。在 y = 2x 之前或之後調用 y = 5x 並不重要:兩個公式都將各自獨立地求解。同樣的,每個 component 都應該「只考慮自己」,而不是在 rendering 過程中試圖與其他 component 協調或是依賴其他 component。Rendering 就像是一個學校考試:每個 component 都應該計算自己的 JSX!

Deep Dive

使用 Strict Mode 檢查 impure 的計算

儘管你可能還沒有全部使用過它們,但在 React 中你可以在 render 時讀取三種輸入:propsstate 以及 context。你應該永遠將這些輸入視為 read-only 。

當你想要改變某些內容來回應使用者輸入時,你應該要 set state 而非直接更改變數。你永遠都不該在 component render 過程中改變已存在的變數或 object。

React 提供了「Strict Mode」,在開發過程中它會呼叫每個 component 的函式兩次。透過呼叫兩次 component 的函式,Strict Mode 有助於找到違反這些規則的 component。

請注意在原本的範例,它顯示了「Guest #2」、「Guest #4」以及「Guest #6」,而不是「Guest #1」、「Guest #2」及「Guest #3」。原本的函式是 impure 的,所以呼叫兩次後就破壞了它。但在修正後的 pure 版本中,即使每次呼叫了兩次函式還是能夠正常運作。 純函式只進行運算,因此呼叫兩次後也不會改變任何事 — 就像是呼叫 double(2) 兩次也不會改變它的回傳值,求解 y = 2x 兩次不會改變 y 的值。相同的輸入永遠會有相同的輸出。

Strict Mode 不會影響正式環境,因此它不會拖慢用戶的應用程式速度。如需選擇 Strict Mode,你可以將你的 root component 包裝到 <React.StrictMode>。有些框架預設會這麼做。

Local mutation: 你的 component 的小秘密

在上面的範例中, 問題是 component 在 render 時改變了預先存在的變數。這通常會稱之為 「mutation」 使其聽起來有點可怕。純函式不會改變函式範圍外的變數、或是呼叫之前就已建立的 object — 這使得它們 impure!

不過,在 render 時改變剛剛才建立的變數或 object 是完全沒問題的。在這個範例中,你建立了 [] array,並賦值給 cups 變數,接著把一打杯子 push 進去:

function Cup({ guest }) {
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaGathering() {
  let cups = [];
  for (let i = 1; i <= 12; i++) {
    cups.push(<Cup key={i} guest={i} />);
  }
  return cups;
}

如果 cups 變數或者是 [] array 是在 TeaGathering 函式之外建立的,這就會是個大問題!你會在將項目放入 array 時改變一個預先存在的 object。

不過,由於你是在 TeaGathering 內的同個 render 過程中建立它們的,所以不會有問題。在 TeaGathering 範圍外的任何程式碼都不會知道發生了這個情況。這稱為 「local mutation」- 這就像是 component 自己的小秘密。

你_可能_會引起 side effects 的地方

雖然函數式程式設計在很大程度上依賴 purity,但在某些時候,_有些東西_必須改變。這就是程式設計的意義所在!這些更改例如:顯示畫面、開始一個動畫、更改資料都被稱為 side effects 。他們是_一邊發生_的事情,而不是在 render 期間發生的事情。

在 React 中,side effects 通常屬於 event handler。Event handler 是 React 在你執行某些操作(例如:點擊一個按鈕)時執行的函式。儘管 event handler 是在 component 內部定義的,但它們不會在 render 時間執行所以 event handler 不需要是 pure 的。

如果你已經用盡了所有其他選項,並且無法找到其他適合你的 side effect 的 event handler,你仍然可以選擇 component 中的 useEffect 來將其附加到回傳的 JSX。這告訴 React 在 render 後、允許 side effect 的情況下執行它。但是,這個方法應該要是你最後的手段。

可以的話,盡量嘗試透過 render 過程來表示你的邏輯。你會驚訝它能帶你走多遠!

Deep Dive

為什麼 React 在意 purity?

撰寫純函式需要一些習慣與紀律。但純函式也解鎖了一些絕佳的功能:

  • 你的 component 可以在不同環境上執行 - 例如,在伺服器上!由於它們對相同輸出會有相同結果,因此一個 component 可以滿足許多使用者請求。
  • 你可以透過 skipping rendering 那些 input 沒有更新的 component 來提升效能。這是安全的,因為純函式永遠都會回傳相同的結果,所以可以安全地 cache 它們。
  • 如果在 render 一個 deep component tree 的過程中某些資料發生變化,React 可以重新進行 render、而不浪費時間完成過時的 render。Purity 可以讓它更安全地隨時停止計算。

所有我們正在建立的 React 新功能都利用了 purity 的優點。從獲取資料到動畫再到效能,保持 component 的 purity 能夠解鎖 React 典範的力量。

Recap

  • 一個 component 是 pure 的,這意味著:
    • 只關心自己的事務。 這個函式不會修改任何在他被呼叫之前就已經存在的 object 或變數。
    • 一樣的輸入,一樣的輸出 只要我們輸入相同的參數,這個函式總是回傳一個相同的輸出。
  • Rendering 可能會在任何時間發生,因此 component 不該依賴於彼此的 rendering 順序。
  • 你不該改變任何你的 component 用來 render 的輸入。這包含 props,state,以及 context。要更新畫面的話,請 「set」 state 而不是直接修改預先存在的 object。
  • 盡量在回傳的 JSX 中表達你的 component 邏輯。當你需要「更改內容」時,你會希望在 event handler 中處理。或是作為最後的手段,你可以使用 useEffect
  • 撰寫純函式需要一些練習,不過它能解鎖 React 典範的力量。

Challenge 1 of 3:
修正一個換掉的時鐘

這個 component 希望在午夜至上午 6 點期間將 <h1> 的 CSS class 設定為 "night",並在所有其他時段都設成 "day"。但是它沒辦法運作。你能修正這個 component 嗎?

你可以透過暫時修改電腦的時區來驗證你的做法是否有效。當時間在午夜至上午 6 點時,時鐘的顏色應該要是反白的!

export default function Clock({ time }) {
  let hours = time.getHours();
  if (hours >= 0 && hours <= 6) {
    document.getElementById('time').className = 'night';
  } else {
    document.getElementById('time').className = 'day';
  }
  return (
    <h1 id="time">
      {time.toLocaleTimeString()}
    </h1>
  );
}