Function 與 Classes 元件的區別在哪?

boomyao發表於2019-03-07

React function 元件和 React classes 有什麼不同?

以前,一個標準答案是說 classes 提供更多的功能(例如 state)。有了 Hooks,便不是這樣了。

可能你聽過其中一個效能更好。哪一個?許多這樣的效能基準都存在缺陷,所以我會小心地從中得出結論。效能主要取決於程式碼而不是選擇一個 function 或者 一個 class。在我們觀察中,即使優化策略有所不同,但效能的差距其實微乎其微。

另一方面我們不推薦重寫你寫好的元件,除非你有其他原因且不介意成為早期試驗者。Hooks 仍然很新(就像 2014 年的 React),並且一些“最佳實踐”還未寫進教程。

React function 和 classes 是否存在本質上的區別?當然,它們 —— 在心智模型中。在這篇文章裡,我會看看它們之間的最大區別。這在2015年的 function components 中介紹過,但它經常被忽視了:

Function 元件捕獲 render 後的值

讓我們來分析下這是什麼意思。


注意:這片文章不做 classes 或者 functions 的價值衡量,我只描述兩種程式設計模型在 React 中的區別。更多關於採用 functions 的問題,請參閱 Hooks 常見問題解答


思考這個元件:

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}
複製程式碼

它展示一個帶有模擬網路請求的 setTimeout 且之後會在確認彈窗中現實出來的按鈕。例如,如果 props.user'Dan',它會在三秒後顯示'Followed Dan',非常簡單。

(請注意,上面例子中無論我是否使用箭頭還是普通函式,function handleClick() 肯定是一樣效果的。)

那我們把它寫成 class 會怎麼樣呢?直接翻譯後可能看起來像這樣:

class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  };

  handleClick = () => {
    setTimeout(this.showMessage, 3000);
  };

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}
複製程式碼

一般會認為這兩段程式碼是等效的,大家經常在這些模式中隨意的重構,而不會注意到它們的含義:

Spot the difference between two versions

但是,這兩段程式碼略微不同。 好好看看它們,有看出不同了嗎?就個人而言,我花了好一會才發現。

前面有劇透,如果你想自己找到的話,這是一個線上demo。文章接下來的部分來分析這個差異及為什麼會這樣。


在我們繼續之前,我想強調下,我所描述的差異與 React Hooks 自身無關,上面的例子甚至不需要用 Hooks!

這完全是關於 functions 和 classes 在 React 中的區別的,如果你打算在 React 應用中更常用 functions,你可能想去弄懂它。


我們將通過 React 應用中常見的一個 bug 來說明這區別

使用當前的條目選擇器和之前兩個 ProfilePage 實現來開啟這個 sandbox 例子 —— 每個渲染一個 Follow 按鈕。

按照這種操作順序使用兩個按鈕:

  1. 點選 其中一個按鈕。
  2. 在 3 秒中內改變選擇條目。
  3. 看下彈出的文字。

你會注意到一個特殊的區別:

  • 當為 functionProfilePage 時,點選 Follow Dan 的條目然後切換成 Sophie 的,仍然彈出 'Followed Dan'

  • 當為 classProfilePage 時,它會彈出 'Followed Sophie'

Demonstration of the steps


這個例子中,第一種行為是正確的。如果你關注一個人,然後切換到另外一個人的條目,我的元件不應該困惑於我要關注的是誰。class 的實現明顯是個錯誤。


所以為什麼我們的 class 例子會以這種方式執行?

讓我們仔細看看 class 中 showMessage 方法:

class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  };
複製程式碼

這個 class 方法讀取了 this.props.user,Props 在 React 中是不可變的。 但是,this ,且已經改變了

實際上,這就是在 class 中有 this 的目的,React 本身會隨著時間的推移而變異,以便你在可以渲染和生命週期中獲取到新版本。

所以如果我們元件在處於請求狀態時重渲染,this.props 會發生改變。 showMessage 方法從“太新”的 props 中獲取 user

這暴露了一個 UI 層性質上的有趣現象。如果我們說 UI 在概念上是當前應用程式狀態的函式,則事件處理程式是渲染結果的一部分 —— 就像視覺輸出一樣。我們的事件處理程式“屬於”具有特定 props 和 state 的特定 render。

但是,排程一個回掉讀取 this.props 的 timeout 會中斷該聯絡。我們的 showMessage 回撥沒有“繫結”到任何特定 render 上,因此它“丟失”了正確的 props,而讀取了 this 切斷這種聯絡。


可以說 function 元件不存在這個問題。我們要這麼解決這個問題?

我們想以某種方式 “修復” 有正確 props 的 render 與獲取它們的 showMessage 回撥之間的聯絡。沿著這種方式 props 會跟丟。

一種方法是在事件早期就讀取 this.props,然後顯示地將它們傳遞到 timeout 處理程式:

class ProfilePage extends React.Component {
  showMessage = (user) => {
    alert('Followed ' + user);
  };

  handleClick = () => {
    const {user} = this.props;
    setTimeout(() => this.showMessage(user), 3000);
  };

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}
複製程式碼

這樣可行。但是,這種方法使程式碼明顯更加冗餘,且隨著時間推移容易出錯。如果我們需要超過一個 prop 怎麼辦?如果我們也需要獲取 state 怎麼辦?如果 showMessage 呼叫其他方法,且這個方法讀取 this.props.somethingthis.state.something,我們會再次遇到同樣的問題。所以我們不得不將 this.propsthis.state 做為引數傳給每個呼叫了 showMessage 的方法。

這樣做通常會破壞通常由 class 提供的人體工程學,也難以記住或強制執行,這就是大家經常出現 bugs 的原因。

類似的,把 alert 放入 handleClick 中也無法解決這個難題。我們希望以允許拆分更多方法的方式構造程式碼,同時我們還要讀取與該呼叫相關 render 的對應 props 和 state。這個問題甚至不是 React 獨有的 —— 你可以在任何將資料放入像 this 可變物件的 UI 庫中重現它

或許,我們可以在 constructor 裡 bind 方法?

class ProfilePage extends React.Component {
  constructor(props) {
    super(props);
    this.showMessage = this.showMessage.bind(this);
    this.handleClick = this.handleClick.bind(this);
  }

  showMessage() {
    alert('Followed ' + this.props.user);
  }

  handleClick() {
    setTimeout(this.showMessage, 3000);
  }

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}
複製程式碼

不, 這樣無法修復任何東西。記住,這個問題是由於我們太遲去讀取 this.props 了 —— 不是我們這麼用語法的問題!然而,這個問題如果我們完全依靠 JavaScript 閉包就可以解決

通常會避開使用閉包,因為很知道隨著時間推移可能會發生變異的值。但在 React,props 和 state 是不可以變的!(或者至少,這是一個強烈推薦。)這去除了閉包的一個殺手鐗。

這意味著如果你封鎖一個特定 render 的 props 或 state,你總是可以獲取相同的它們:

class ProfilePage extends React.Component {
  render() {
    // 捕獲 props!
    const props = this.props;

    // 注意: 我們在 *render 裡面*
    // 這不是 class 方法。
    const showMessage = () => {
      alert('Followed ' + props.user);
    };

    const handleClick = () => {
      setTimeout(showMessage, 3000);
    };

    return <button onClick={handleClick}>Follow</button>;
  }
}
複製程式碼

你在 render 時已經“捕獲”到 props 了

Function 與 Classes 元件的區別在哪?

這樣,它內部的任何程式碼(包括 showMessage)都可以保證看到這個特定 render 的 props,React 不會再“動我們的乳酪”了。

我們在裡邊新增多少個輔助方法都可以,並且它們全都使用被捕獲的 props 和 state,救回了閉包。


上面的例子沒有錯但看起來奇怪。如果在 render 中定義函式而不是使用 class 的方法,那還要 class 做什麼?

事實上,我們可以去掉 class 這個“殼”來簡化程式碼:

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}
複製程式碼

就像上面那樣,props 仍然被捕獲了 —— React 用引數形式傳遞它們。不像 thisprops 物件本身沒有被 React 改變

如果在 function 定義時解構 props 就更明顯的:

function ProfilePage({ user }) {
  const showMessage = () => {
    alert('Followed ' + user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}
複製程式碼

當父元件用不同 props 渲染 ProfilePage 時,React 會再次呼叫 ProfilePage 方法。但我們點選了的事件程式“屬於”具有自己的 user 值的上一個 render 和讀取它的 showMessage 回撥,它們都完好無損。

這就是為什麼,在這個 demo 的 function 版本中,在 Sophie 的條目時點選 Follow 之後切換成 Sunil 會彈出 'Followed Sophie'

Function 與 Classes 元件的區別在哪?

這反應是正確的。(雖然你也可能想關注 Sunil!)


現在我們明白了 functions 與 classes 在 React 中的最大不同了:

Function 元件捕獲 渲染後的值

使用 Hooks,同樣的原則也適用於 state。思考這個例子:

function MessageThread() {
  const [message, setMessage] = useState('');

  const showMessage = () => {
    alert('You said: ' + message);
  };

  const handleSendClick = () => {
    setTimeout(showMessage, 3000);
  };

  const handleMessageChange = (e) => {
    setMessage(e.target.value);
  };

  return (
    <>
      <input value={message} onChange={handleMessageChange} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}
複製程式碼

([這是一個 線上 demo。])

雖然這不是一個好的訊息應用 UI,它實現了同樣的東西:如果我傳送一個特定的資訊,這個元件不應該困惑於傳送哪個訊息。這個 function 元件的訊息捕獲了 state 且“屬於”返回被瀏覽器點選事件呼叫的 render。所以這個訊息被設定為當我點選”傳送“時 input 裡的值。


所以我們知道 React 中的 functions 會預設捕獲 props 和 state。但如果我們希望讀取的是最新的 props 或者 state,它們不屬於特定的 render 要怎麼辦?如果我們想在未來裡讀取到它們怎麼辦?

在 classes 中,你可以讀取 this.propsthis.state,因為 this 本身是可變的,React 會改變它。在 function 元件中,你也可以有一個共享於所有元件 renders 的可變值,它叫做 “ref”:

function MyComponent() {
  const ref = useRef(null);
  // 你可以讀寫 `ref.current`。
  // ...
}
複製程式碼

但是,你需要自己管理它。

ref 和例項欄位扮演相同的角色,它是進入可變命令世界的逃脫倉。你可能熟悉 “DOM refs”,但這個原理要通俗的多,它只是一個你可以往裡面放東西的箱子。

即便在視覺上,this.someting 看起來像 something.current 的映象。它們代表了相同的概念。

預設情況下,在 function 元件中 React 不會建立最新 props 或 state 的 refs。在許多情況下是不需要它們的,且分配它們會是浪費的工作。但是,如果你願意,可以手動跟蹤值:

function MessageThread() {
  const [message, setMessage] = useState('');
  const latestMessage = useRef('');

  const showMessage = () => {
    alert('You said: ' + latestMessage.current);
  };

  const handleSendClick = () => {
    setTimeout(showMessage, 3000);
  };

  const handleMessageChange = (e) => {
    setMessage(e.target.value);
    latestMessage.current = e.target.value;
  };
複製程式碼

如果我們讀取 showMessagemessage,我們會看到我們第幾傳送按鈕時的訊息。但當我們讀取 latestMessage.current,我們獲取到的是最新的值 —— 即使我們在按下傳送按鈕後繼續輸入。

你可以比較這兩個 demos 看看區別。ref 是一種“選擇退出”渲染一致的方法,在某些情況下可以很方便。

通常你應該避免在渲染期間讀取或設定 refs,因為它們是可變的。我們想保持渲染的可預測性。但是,如果我們想獲取到特定 prop 或 state 最新的值,手動更新 ref 會很麻煩。我們可以用 effect 自動化它:

function MessageThread() {
  const [message, setMessage] = useState('');

  // 保持 track 是最新值
  const latestMessage = useRef('');
  useEffect(() => {
    latestMessage.current = message;
  });

  const showMessage = () => {
    alert('You said: ' + latestMessage.current);
  };
複製程式碼

(這是一個demo。)

我們在 effect 裡面賦值來實現 ref 值只在 DOM 被更新時才改變。這確保我們的變異不會破壞像 Time Slicing 和 Suspense 等中斷渲染的功能。

很少會像這樣去使用 ref,捕獲 props 或 state 預設下是更好的。然而,在處理像定時器和訂閱這樣的棘手地 APIs 時是很方便的。記住你可以跟蹤任何這樣的值 —— prop、state 變數、整個 props 物件,甚至是一個 function。

這種模式也可以用來做優化 —— 例如在 useCallback 標示頻繁改變時。但是,使用一個 reducer 通常是一個更好的解決方案。(這個會在以後的部落格文章中寫!)


這片文章裡,我們看到在 classes 中的普遍破壞模式,及閉包是如何幫助我們修復它的。但是,你可能注意到了當你試著通過指定依賴陣列來優化 Hooks 時,你可能會遇到過時閉包帶來的 bugs。這意味著閉包是問題?我也不這麼認為。

正如我們之前所見,閉包確實幫助我們修復了難以注意到的細微問題。同樣地,它們使編寫在併發模式下的程式碼正常工作變得更簡單。這可能是因為元件內部的邏輯在渲染後封鎖正確的 props 和 state。

在目前為止看到的所有情況中,“過時閉包”問題發生是由於 “functions 不發生變化” 或 “props 總是相同”的錯誤假設。事實並非如此,我希望這篇文章有助於澄清。

Functions 鎖住它們的 props 和 state —— 所有它們是什麼很重要。這不是一個 bug,而是一個 function 元件的特性。例如,Functions 不應該從 userEffectuseCallback 的“依賴陣列”中被排除。(上面提到常用的適當修復不管是 useReducer 或是 useRef 的解決方案 —— 我們很快會在文件中說明如何在它們之間做選擇)

在我們用 functions 寫大多數 React 程式碼時,我們需要適配我們的關於 優化程式碼什麼值會一直改變的情況。

到目前為止我用 hooks 找到的最好的心理規則是 “程式碼的任何值似乎可以在任意時間改變”。

Functions 也不例外。這需要一些時間才能在 React 學習材料裡面變成普遍的知識,從 class 心態過來的需要一些適應,但我希望這篇文章可以幫助你用新的眼光看待它。

React functions 總會捕獲它們的值 —— 且現在我們知道為什麼了。

Function 與 Classes 元件的區別在哪?

它們是一個完全不同的神奇寶貝。

翻譯原文How Are Function Components Different from Classes?(2019-03-03)

相關文章