iT邦幫忙

2022 iThome 鐵人賽

DAY 18
1
Modern Web

一次打破 React 常見的學習門檻與觀念誤解系列 第 18

[Day 18] Function component & class component 你可能不知道的關鍵區別

  • 分享至 

  • xImage
  •  

在解析過 React 的畫面更新的核心觀念以及 setState 進階的細節之後,接下來我們會往下一個大主題邁進 — 有關於 component 的 render 生命週期以及資料流。如我們最一開始前言時所述,本系列文都是以 function component 搭配 hooks 這套目前主流的寫法來進行解析,而這其中又以 useEffect 這個 hook 最容易讓人困惑與踩到地雷。我們將會在接下來的幾篇文章由淺入深逐步帶你真正理解 function component 的生命週期,以及 useEffect 的設計脈絡還有正確的用法。

我們得先從 function component 開始說起。與以前的 class component 相比,其實 function component 有一個常常被忽略的重要特性,觀察以下範例:

function BuyProductButton(props) {
  const showSuccessAlert = () => {
    alert(`購買商品「${props.productName}」成功!`); 
  };

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

  return (
    <button onClick={handleClick}>購買</button>
  );
}

這個 function component render 了一個按鈕,當按鈕點擊時會以 setTimeout 來模擬 API 請求,並且在完成時跳出一個 alert 作為成功通知。

而如果以 class component 來實作呢?一個簡單的轉換後可能長得會像這樣:

class BuyProductButton extends React.Component {
  showSuccessAlert = () => {
    alert(`購買商品「${this.props.productName}」成功!`); 
  };

  handleClick = () => {
    setTimeout(this.showSuccessAlert, 3000);
  };
  
  render() {
    return <button onClick={this.handleClick}>購買</button>
  }
}

通常我們會覺得上面這兩種 component 的寫法的行為是等價的。不過實際上,它們兩者之間有一個決定性但有時候難以察覺的區別,以下就讓我們親自來實驗看看。

我們以一個比較完整功能的小專案來進行 demo,請打開 這個 CodeSandBox,你會看到一個 select 下拉式選單用於切換商品的頁面,以及分別以 function component 還有 class component 實作的購買商品按鈕。

嘗試按照以下的步驟來分別使用這兩個按鈕:

  1. 以下拉式選單選擇一個想要買的商品的頁面,然後按下購買按鈕
  2. 在三秒內切換下拉式選單到另一個商品頁面
  3. 觀察跳出的 alert 文字

你將會發現兩種按鈕點擊之後的行為有個奇特的差別,以下舉例:

  • 以 function component 實作的購買按鈕:

    • 一開始選的商品頁面是 筆記型電腦 並點擊購買按鈕之後,在三秒內把下拉式選單切換到 智慧型手機,然後會看到跳出的 alert 文字為 購買商品「筆記型電腦」成功!

      https://i.imgur.com/rVNpj5r.gif

  • 以 class component 實作的購買按鈕:

    • 一開始選的商品頁面是 筆記型電腦 並點擊購買按鈕之後,在三秒內把下拉式選單切換到 智慧型手機,然後會看到跳出的 alert 文字為 購買商品「智慧型手機」成功!

      https://i.imgur.com/hui3iDC.gif

在以上的例子中,第一種行為才是正確的,當我選擇某個商品並送出購買後,切換到別的商品頁面查看不應該影響到我稍早的購買商品對象。因此以上的範例中 class component 的行為很明顯是有問題的。


this.props 在非同步事件中的錯置陷阱

那麼這其中的區別到底是哪裡導致的呢?讓我們先來看看 class component 中的 showSuccessAlert 方法:

class BuyProductButton extends React.Component {
  showSuccessAlert = () => {
    alert(`購買商品「${this.props.productName}」成功!`); 
  };

  // ...
}

關鍵就在這裡:這個方法中我們以 this.props.productName 的方式讀取了 props 中的商品名稱資料。然而在 React 中,props 資料本身是 immutable 的,但是 this 卻不是每當 class component re-render 時,React 會將新版的整包 props 以 mutate 的方式覆蓋進 this 當中取代舊版的 props

因此,當我們在 re-render 後再次以 this.props 這種方式取得 props 的內容的話,就會拿到最後一次 render 時的 props 資料。這種行爲會導致當這個存取動作是寫在非同步的一些事件處理時,本應使用「舊版資料」的事件卻錯誤了讀取到了「最新版資料」。

就像是上面的 class component 範例那樣:當我們點擊購買按鈕時,我們其實預期的是會跳出點擊時那瞬間的商品名稱在 alert 上,然而由於這是一個非同步的事件(setTimeout),在 alert 真正發生之前如果 component 以一個新的 props 資料 re-render 的話, this 就會被 mutate 來蓋上新的 props,然後此時非同步事件觸發,嘗試以 this.props 的方式讀取 props 資料,就導致不正確的拿到了 re-render 後的最新版資料來做輸出。

從這個問題其實可以延伸出一個重要的觀念:在前面我們有介紹過單向資料流的概念 —「UI 畫面是以原始資料延伸出來的結果」,這在 React 中的對應流程就是將 props 或 state 等原始資料經過 component render 之後產生 React element。然而我們自己定義在 component 中的 event handlers(像是上面範例中的 showSuccessAlert)也是會存取 props 或 state 的,並且你會期待事件中所讀取到的資料應該要符合它觸發那瞬間的版本。因此,其實從概念上你可以將這些 event handlers 也視為是 render 結果的一部份,它們會「屬於」某次擁有特定 props 與 state 的 render。

因此,在 class component 的非同步事件中以 this.props 的方式讀取 props 會打斷這種資料流的關聯性與可靠性,class component 中的 showSuccessAlert 方法並沒有與特定 render 的 props 資料「綁定」在一起,而是每次執行時都會從 this 當中讀取最新版本的 props,而有可能「錯失」了它本應使用的舊版 props。

那麼我們該如何在 class component 中修復這個問題?其實很簡單,就是讓 props 的讀取從 this 上脫鉤:

class BuyProductButton extends React.Component {
  showSuccessAlert = (productName) => {
    // 從參數中讀取 productName,而非 this.props
    alert(`購買商品「${productName}」成功!`); 
  };
  
  handleClick = () => {
    // 在事件一觸發的那瞬間,
    // 就先將當時的 prop 資料從 this.props 中捕捉出來並另外存放
    const { productName } = this.props;

    // 透過 closure 將 productName 帶入 setTimeout callback 中
    setTimeout(
      () => this.showSuccessAlert(productName),
      3000
    );
  };

  // ...
}

這種解法能夠成功的修好原本的 bug。我們在 handleClick 觸發的那瞬間就先將當時的 prop 資料從 this.props 中讀取出來並另外存放,然後透過 closure 的方式將 productName 帶入 setTimeout callback 中。因此即使在三秒後 setTimeout 觸發時 this.props 已經被 re-render 的新 props 資料給 mutate 修改了,也並不會影響到我們早就已經「捕捉」好的舊資料。

然而這其實並不是很直覺就能注意到的問題,我們往往都是習慣直接以 this.propsthis.state 的方式來取得資料,因此這種問題所導致的 bug 在 class component 上是如此層出不窮。其實 class component 之所以容易遇到這種問題,與物件導向在概念上本來就主要是基於 mutable 的思維有關。當資料狀態改變時,以類別中的方法來 mutate instance 上的屬性資料就是物件導向中一貫的做法,但是這種 pattern 在以 immutable 為核心概念的 React 中就顯得格格不入,甚至容易破壞資料流的關聯性。

然而,為什麼 function component 卻不會遇到這種問題?


Function components 會自動「捕捉」 render 時的資料

接下來讓我們回到 function component。在上面的範例中,function component 版本的 BuyProductButton 會接收 props 作為參數,然後在 render 中以這個 props 來讀取資料並產生 event handler

function BuyProductButton(props) {
  // 每次 render 時都產生一個新版的 showSuccessAlert
  // 會使用本次 render 版本的 props
  const showSuccessAlert = () => {
    alert(`購買商品「${props.productName}」成功!`); 
  };

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

  return (
    <button onClick={handleClick}>購買</button>
  );
}

再次回顧這段 function component 的寫法,你會發現一個關鍵:function component 的 props 是以參數的形式取得的,而不是掛在一個 this 這種 mutable 的物件身上。因此,每次 render 時傳入的 props 是只專屬於給這次 render 使用,與其它次 render 的 props 其實是完全獨立不互相影響的。當我們在 render 的過程中產生 event handlers 並在其中使用 props 或 state 時,意味著這些 event handlers 將與該次 render 所取得的 props 和 state 進行了「綁定」。

在上面的範例中,每次 render 時其實都會重新產生一個新版的 showSuccessAlert event handler,它會以 closure 的方式綁定該次 render 專屬的 props 與 state。這就是為什麼即使我們在點擊購買按鈕後立刻切換了商品, setTimeout callback 仍然會吃到原本的舊資料。而這就是 class component 與 function component 真正的巨大差別:

function component 會自動「捕捉」該次 render 時所使用的狀態資料

這種特性讓我們在撰寫 function component 完全不需要擔心非同步事件導致的 props 等資料在新舊 render 之間錯置的問題,並且讓 event handlers 也真正參與到了單項資料流當中:「原始資料是來源,使用到原始資料的 event handlers 是 render 的結果。當資料改變時,就會產生綁定新資料的新版 event handlers」。

而這個行為同樣適用於 useState 的 state 資料,我們會在接下來的章節中繼續深入這種 render 資料流的概念。


參考資料


2024/2 更新 - 實體書平裝版本預購

在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~

《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》

目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:

天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695

博客來(平裝版):
https://www.books.com.tw/products/0010982322

momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845


上一篇
[Day 17] Immutable update 的 nested reference clone 誤解
下一篇
[Day 19] 每一次 render 都有自己的 props、state 以及 event handlers
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言