[譯]React函式元件和類元件的差異

Jess發表於2019-04-17

[譯]React函式元件和類元件的差異

原文overreacted.io/how-are-fun…

React.js 開發中,函式元件(function component) 和 類元件(class component) 有什麼差異呢?

在以前,通常認為區別是,類元件提供了更多的特性(比如state)。隨著 React Hooks 的到來,這個說法也不成立了(通過hooks,函式元件也可以有state和類生命週期回撥了)。

或許你也聽說過,這兩類元件中,有一類的效能更好。哪一類呢?很多這方面的效能測試,都是 有缺陷的 ,因此要從這些測試中 得出結論 ,不得不謹慎一點。效能主要取決於你程式碼要實現的功能(以及你的具體實現邏輯),和使用函式元件還是類元件,沒什麼關係。我們觀察發現,儘管函式元件和類元件的效能優化策略 有些不同 ,但是他們效能上的差異是很微小的。

不管是上述哪個原因,我們都 不建議 你使用函式元件重寫已有的類元件,除非你有別的原因,或者你喜歡當第一個吃螃蟹的人。React Hooks 還很新(就像2014年的React一樣),目前還沒有使用hooks相關的最佳實踐。

除了上面這些,還有什麼別的差異麼?在函式元件和類元件之間,真的存在根本性的不同麼?"Of course, there are — in the mental model" (這個實在不知道咋表達,貼下作者原文吧?) 在這篇文章裡,我們將一起看下,這兩類元件最大的不同。這個不同點,在2015年函式元件函式元件 被引入React 時就存在了,但是被大多數人忽略了。

函式元件和類元件的差異

函式元件會捕獲render內部的狀態

讓我們一步步來看下,這代表什麼意思。

**注意,本文並不是對函式元件和類元件進行價值判斷。我只是展示下React生態裡,這兩種元件程式設計模型的不同點。要學習如何更好的採用函式元件,推薦官方文件 Hooks FAQ ** 。

假設我們有如下的函式元件:

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

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

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

元件會渲染一個按鈕,當點選按鈕的時候,模擬了一個非同步請求,並且在請求的回撥函式裡,顯示一個彈窗。比如,如果 props.user 的值是 Dan,點選按鈕3秒之後,我們將看到 Followed Dan 這個提示彈窗。非常簡單。

(注意,上面程式碼裡,使用箭頭函式還是普通的函式,沒有什麼區別。因為沒有 this 問題。把箭頭函式換成普通函式 function handleClick()沒有任何問題 )

我們怎樣實現同樣功能的類元件呢?很簡單的翻譯一下:

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

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

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

通常情況下,我們會認為上面兩個元件實現,是完全等價的。開發者經常像上面這樣,在函式元件和類元件之間重構程式碼,卻沒有意識到他們隱含的差異。

[譯]React函式元件和類元件的差異

但是,上面兩種實現,存在著微妙的差異 。再仔細看一看,你能看出其中的差異麼?講真,還是花了我一段時間,才看出其中的差異。

**如果你想線上看看原始碼,你可以 點選這裡 **。 本文剩餘部分,都是講解這個不同點,以及為什麼不同點會很重要。

在我們繼續往下之前,我想再次說明下,本文提到的差異性,和react hooks本身完全沒有關係!上面例子裡,我都沒用到hooks呢。

本文只是講解,react生態裡,函式元件和類元件的差異性。如果你打算在react開發中,大規模的使用函式元件,那麼你可能需要了解這個差異。

我們將使用在react日常開發中,經常遇到的一個bug,來展示這個差異

接下來,我們就來複現下這個bug。開啟 這個線上例子 ,頁面上有一個名字下拉框,下面是兩個關注元件,一個是前文的函式元件,另一個是類元件。

對每一個關注按鈕,分別進行如下操作:

  1. 點選其中1個關注按鈕
  2. 在3秒之內,重新選擇下拉框中的名字
  3. 3秒之後,注意看alert彈窗中的文字差異

你應該注意到了兩次alert彈窗的差別:

  • 在函式元件的測試情況下,下拉框中選中 Dan,點選關注按鈕,迅速將下拉框切換到Sophie,3秒之後,alert彈窗內容仍然是 Followed Dan
  • 在類元件的測試情況下,重複相同的動作,3秒之後,alert彈窗將會顯示 Followed Sophie

[譯]React函式元件和類元件的差異

在這個例子裡,使用函式元件的實現是正確的,類元件的實現明顯有bug。如果我先關注了一個人,然後切換到了另一個人的頁面,關注按鈕不應該混淆我實際關注的是哪一個

(PS,我也推薦你真的關注下 Sophie)

那麼,為什麼我們的類元件,會存在問題呢?

讓我們再仔細看看類元件的 showMessage 實現:

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

這個方法會讀取 this.props.user 。在React生態裡,props是不可變資料,永遠不會改變。但是,this卻始終是可變的

確實,this存在的意義,就是可變的。react在執行過程中,會修改this上的資料,保證你能夠在 render和其他的生命週期方法裡,讀取到最新的資料(props, state)。

因此,如果在網路請求處理過程中,我們的元件重新渲染,this.props改變了。在這之後,showMessage方法會讀取到改變之後的 this.props

這揭示了使用者介面渲染的一個有趣的事實。如果我們認為,使用者介面(UI)是對當前應用狀態的一個視覺化表達(UI=render(state)),那麼事件處理函式,同樣屬於render結果的一部分,正如使用者介面一樣 。我們的事件處理函式,是屬於事件觸發時的render,以及那次render相關聯的 propsstate

然而,我們在按鈕點選事件處理函式裡,使用定時器(setTimeout)延遲呼叫 showMessage,打破了showMessagethis.props的關聯。showMessage回撥不再和任何的render繫結,同樣丟失了本來關聯的props。從 this 上讀取資料,切斷了這種關聯。

假如函式元件不存在,那我們怎麼來解決這個問題呢?

我們需要通過某種方式,修復showMessage和它所屬的 render以及對應props的關聯。

一種方式,我們可以在按鈕點選處理函式中,讀取當前的 props,然後顯式的傳給 showMessage,就像下面這樣:

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>;
  }
}
複製程式碼

這種方式 可以解決這個問題 。然而,這個解決方式讓我們引入了冗餘的程式碼,隨著時間推移,容易引入別的問題。如果我們的 showMessage方法要讀取更多的props呢?如果showMessage還要訪問state呢?如果 showMessage 呼叫了其他的方法,而那個方法讀取了別的狀態,比如this.props.somethingthis.state.something ,我們會再次面臨同樣的問題。 我們可能需要在 showMessage 裡顯式的傳遞 this.props this.state 給其他呼叫到的方法。

這樣做,可能會破壞類元件帶給我們的好處。這也很難判斷,什麼時候需要傳遞,什麼時候不需要,進一步增加了引入bug的風險。

同樣,簡單地把所有程式碼都放在 onClick 處理函式裡,會帶給我們其他的問題。為了程式碼可讀性、可維護性等原因,我們通常會把大的函式拆分為一些獨立的小的函式。這個問題不僅僅是react才有,所有在this上維護可變資料的UI類庫,都很容易遇到這個問題

或許,我們可以在類的建構函式裡繫結一些方法?

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是 不可變的(嚴格來說,我們強烈推薦將props和state作為不可變資料)。props和state的不可變特性,完美解決了使用閉包帶來的問題。

這意味著,如果在 render方法裡,通過閉包來訪問props和state,我們就能確保,在showMessage執行時,訪問到的props和state就是render執行時的那份資料:

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

    // Note: we are *inside render*.
    // These aren't class methods.
    const showMessage = () => {
      alert('Followed ' + props.user);
    };

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

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

在render執行時,你成功的捕獲了當時的props

[譯]React函式元件和類元件的差異

通過這種方式,render方法裡的任何程式碼,都能訪問到render執行時的props,而不是後面被修改過的值。react不會再偷偷挪動我們的乳酪了。

像上面這樣,我們可以在render方法裡,根據需要新增任何幫助函式,這些函式都能夠正確的訪問到render執行時的props。閉包,這一切的救世主。

上面的程式碼,功能上沒問題,但是看起來有點怪。如果元件邏輯都作為函式定義在render內部,而不是作為類的例項方法,那為什麼還要用類呢?

確實,我們剝離掉類的外衣,剩下的就是一個函式元件:

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

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

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

這個函式元件和上面的類元件一樣,內部函式捕獲了props,react會把props作為函式引數傳進去。和this不同的是,props是不可變的,react不會修改props

如果你在函式引數裡,把props結構,程式碼看起來會更加清晰:

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

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

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

當父元件傳入不同的props來重新渲染 ProfilePage時,react會再次呼叫 ProfilePage。但是在這之前,我們點選關注按鈕的事件處理函式,已經捕獲了上一次render時的props。

這就是為什麼,在 這個例子 的函式元件中,沒有問題。

[譯]React函式元件和類元件的差異

可以看到,功能完全是正確的。(再次PS,建議你也關注下 Sunil)

現在我們理解了,在函式元件和類元件之間的這個差異:

函式元件會捕獲render內部的狀態

函式元件配合React Hooks

在有 Hooks 的情況下,函式元件同樣會捕獲render內部的 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,點選這裡)

儘管這是一個很簡陋的訊息傳送元件,它同樣展示了和前一個例子相同的問題:如果我點選了傳送按鈕,這個元件應該傳送的是,我點選按鈕那一刻輸入的資訊。

OK,我們現在知道,函式元件會預設捕獲props和state。但是,如果你想讀取最新的props、state呢,而不是某一時刻render時捕獲的資料? 甚至我們想在 將來某個時刻讀取舊的props、state 呢?

在類元件裡,我們只需要簡單的讀取 this.props this.state 就能訪問到最新的資料,因為react會修改this。在函式元件裡,我們同樣可以擁有一個可變資料,它可以在每次render裡共享同一份資料。這就是hooks裡的 useRef

function MyComponent() {
  const ref = useRef(null);
  // You can read or write `ref.current`.
  // ...
}
複製程式碼

但是,你需要自己維護 ref 對應的值。

函式元件裡的 ref 和類元件中的例項屬性 扮演了相同的角色 。你或許已經熟悉 DOM refs,但是hooks裡的 ref 更加通用。hooks裡的 ref 只是一個容器,你可以往容器裡放置任何你想放的東東。

甚至看起來,類元件裡的 this.something 也和hooks裡的 something.current 相似,他們確實代表了同一個概念。

預設情況下,react不會給函式元件裡的props、state創造refs。大多數場景下,你也不需要這樣做,這也需要額外的工作來給refs賦值。當然了,你可以手動的實現程式碼來跟蹤最新的state:

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;
  };
複製程式碼

如果我們在 showMessage裡讀取 message欄位,那麼我們會得到我們點選按鈕時,輸入框的值。但是,如果我們讀取的是 latestMessage.current,我們會得到輸入框裡最新的值——甚至我們在點選傳送按鈕後,不斷的輸入新的內容。

你可以對比 這兩個demo 來看看其中的不同。

通常來講,你應該避免在render函式中,讀取或修改 refs ,因為 refs 是可變的。我們希望能保證render的結果可預測。但是,如果我們想要獲取某個props或者state的最新值,每次都手動更新refs的值顯得很枯燥 。這種情況下,我們可以使用 useEffect 這個hook:

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

  // Keep track of the latest value.
  const latestMessage = useRef('');
  useEffect(() => {
    latestMessage.current = message;
  });

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

(demo 在這裡)

我們在 useEffect 裡去更新 ref 的值,這保證只有在DOM更新之後,ref才會被更新。這確保我們對 ref 的修改,不會破壞react中的一些新特性,比如 時間切分和中斷 ,這些特性都依賴 可被中斷的render。

像上面這樣使用 ref 不會太常見。大多數情況下,我們需要捕獲props和state 。但是,在處理命令式API的情況下,使用 ref 會非常簡便,比如設定定時器,訂閱事件等。記住,你可以使用 ref 來跟蹤任何值——一個prop,一個state,整個props,或者是某個函式。

使用 ref 在某些效能優化的場景下,同樣適用。比如在使用 useCallback 時。但是,使用useReducer 在大多數場景下是一個 更好的解決方案

總結

在這篇文章裡,我們回顧了類元件中常見的一個問題,以及怎樣使用閉包來解決這個問題。但是,你可能已經經歷過了,如果你嘗試通過指定hooks的依賴項,來優化hooks的效能,那麼你很可能會在hooks裡,訪問到舊的props或state。這是否意味著閉包會帶來問題呢?我想不是的。

正如我們上面看到的,在一些不易察覺的場景下,閉包幫助我們解決掉這些微妙的問題。不僅如此,閉包也讓我們在 並行模式 下更加容易寫出沒有bug的程式碼。因為閉包捕獲了我們render函式執行時的props和state,使得並行模式成為可能。

到目前為止,我經歷的所有情況下,訪問到舊的props、state,通常是由於我們錯誤的認為"函式不會變",或者"props始終是一樣的"。實時上不是這樣的,我希望在本文裡,能夠幫助你瞭解到這一點。

在我們使用函式來開發大部分react元件時,需要更正我們對於 程式碼優化哪些狀態會改變 的認知。

正如 Fredrik說的:

在使用react hooks過程中,我學習到的最重要規則就是,"任何變數,都可以在任何時間被改變"

函式同樣遵守這條規則。

react函式始終會捕獲props、state,最後再強調一下。

譯註: 有些地方不明白怎麼翻譯,有刪減,建議閱讀原文!

相關文章