iT邦幫忙

2022 iThome 鐵人賽

DAY 21
3
Modern Web

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

[Day 21] useEffect 其實不是 function component 的生命週期 API

  • 分享至 

  • xImage
  •  

經過了前兩篇章的洗禮,相信你現在對於 function component 的 render 概念已經有一定程度的掌握。接著就讓我們進入這個階段的重頭戲:useEffect

useEffect 是一個可能所有學習過 React hooks 的人都曾感到困惑的 API,尤其是對於那些以前熟悉 class component 的開發者們。包括筆者我自己在內,應該有非常多 React 的開發者都曾經嘗試在 function component 用 useEffect 去模擬 class component 的 componentDidMountcomponentDidUpdate 等生命週期 API。不過事實上這並不是一個正確的 useEffect 思考模型以及使用方式,即使在 hooks 已經推出了好幾年的今天,也仍有非常多 React 開發者對於 useEffect 真正的用途有所誤解。

接下來就讓我們延續著前兩個篇章的 render 資料流的概念,帶大家一起深入的正確理解 useEffect


宣告式的同步化,而非生命週期 API

每當我們呼叫 setState 更新資料時,React 就會以最新的資料重新執行 render,並產生對應的 React elements 畫面結果然後自動同步到 DOM。對於 render 本身來說,這個過程在「mount」或是「update」之間並沒有差異。

這種「從原始資料同步到畫面結果」的動作其實也就是在維持單向資料流的運作。而在上一章節中我們解析過的觀念「每次 render 都有它自己的 effects」其實也是這個概念的延伸。Effect function 會在每次 render 時都被重新產生,並以 closure 的方式依賴屬於該次 render 版本的原始資料,因此我們其實可以延伸理解成:useEffect 的用途是「將原始資料同步到畫面以外的副作用」

function Example() {
  const [count, setCount] = useState(0);
  
  // 這個 effect 是在把 count 資料同步到瀏覽器的頁面標題上
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <button onClick={() => setCount(count + 1)}>
      Click me
    </button>
  );
}

宣告式與指令式程式設計

在這個同步的動作中,React 並不在乎現在是第一次 render(也就是常聽到的 mount),還是第二次以後的 render (相較於 mount 的 update)。這個 effect 在這些情況要做的事情是一樣的,都是將 count 原始資料同步到瀏覽器的頁面標題上。

這種概念在程式設計的範式中被稱之為「宣告式(Declarative)」,意思是說我們只在乎結果是否正確,而不在乎過程的細節是怎麼處理的。其實前面章節中介紹過 Virtual DOM 與 React elements 的設計就是 React 針對 UI 管理的一種宣告式的設計:我們不在乎這次新畫面與舊畫面細節差異在哪裡,我們只是告訴 React 新畫面(也就是結果)要長什麼樣子,至於最後 DOM 到底要操作哪邊才能滿足畫面更新的需求(操作過程),那是 React 自動會處理的事情,我們無需經手與操心。

相對於宣告式的風格,「指令式(Imperative)」的概念則正好相反,著重於關心「如何達到目標的細節流程」,但是相對的你就會很難直覺的了解這些操作執行後的結果是否如預期。例如像是當你自己手動操作 DOM 來更新畫面時,你必須非常清楚具體要修改 DOM tree 的哪些地方,且不能有任何遺漏,才能讓結果是正確的。而只看程式碼時,其實你也很難直覺的想像執行後的結果預期上會是如何,因為這些結果是透過各種流程操作的「疊加」累積而成的,必須實際跑跑看才知道效果。

因此對於「同步狀態」的動作要求更高的情境下,宣告式的風格其實會是更容易維護以及預期執行結果的方式,這也是為什麼目前主流的前端解決方案在 UI 的開發體驗上都會以傾向宣告式的風格來設計,而 React 更是其中的佼佼者。

useEffect 其實也是在相同的概念下去設計的。它並不關心這個 effect 流程是在 mount 時還是 update 時執行,也不關心被重複執行了幾次,只要最後從原始資料同步到 effect 的結果是正確的就可以了。

因此嚴格來說, useEffect 其實並不是一種 function component 生命週期的 API。雖然說它的執行時機確實與 componentDidMount 以及 componentDidUpdate 類似(不過其實有些微區別),但它被設計的用途並不是讓你在 React 運作的特定時機來執行一個自定義的 callback ,而是有一種明確的指定用途 — 用來將原始資料同步到 React elements 以外的東西上。並且在理想上這個 effect 無論隨著 render 重複執行了多少次,你的程式都應該保持同步且正常運作。

這確實是有點不同於大多數有 class component 經驗開發者所熟悉的 mount / update / unmount 思考模型。因此如果你嘗試把 effect 寫成是否在 component 第一次的 render 而有所行為不同的話,其實是違反了 useEffect 本身預期的設計思維。如果我們的執行結果是依賴於「過程」而不是「目的地」的話,我們很容易寫出同步動作有問題的程式碼。


為什麼 effects & cleanups 要在每次 render 後都執行

在預設的情況下,effects 其實會在每次 render 後都被執行。在經過了上面「同步化」概念的洗禮後,相信你也很容易就能理解:因為 effects 也是單向資料流的一部份,因此 effects 可以被視為是 render 結果的副產物。

然而有時候 effects 中的行為如果重複執行的話可能會有一些非預期的問題,此時就需要 cleanup function 來清除或還原這些 effects 造成的影響。而 cleanups 與 effects 一樣是會在每次 render 後都執行,而不是只有在 unmount 時才會執行,來保證每次 effect 重複執行不會造成一些疊加的問題。

我們來透過觀察以下的 class component 範例來解釋:

componentDidMount() {
  OrderAPI.subscribeStatus(
    this.props.id,
    this.handleStatusChange
  );
}
 
componentWillUnmount() {
  OrderAPI.unsubscribeStatus(
    this.props.id,
    this.handleStatusChange
  );
}

這個範例中的 class component 會以 props.id 來在 componentDidMount 時訂閱指定訂單的狀態,並在 componentWillUnmount 去取消這個訂單狀態的訂閱。看起來似乎沒什麼問題?

但是如果這個訂單狀態會顯示在畫面上,而此時我們切換查看的訂單而導致 component 以新的 props.id 進行了 re-render 呢?此時這個 component 將錯誤的仍舊顯示原本的那個訂單的狀態。而當 unmount 時,也不會正確的取消原本那個訂單的狀態訂閱,進而導致 memory leak 等問題。

在 class component 中,我們需要加上 componentDidUpdate 來處理這種情況,而忘記正確的處理 componentDidUpdate 是 React 的開發中常見的 bug 來源。

componentDidMount() {
  OrderAPI.subscribeStatus(
    this.props.id,
    this.handleStatusChange
  );
}

// 加上 componentDidUpdate 的處理以解決 bug
componentDidUpdate(prevProps) {
  // 從先前的訂單 id 取消訂閱
  OrderAPI.unsubscribeStatus(
    prevProps.id,
    this.handleStatusChange
  );

  // 訂閱下一個訂單 id
  OrderAPI.subscribeStatus(
    this.props.id,
    this.handleStatusChange
  );
}
 
componentWillUnmount() {
  OrderAPI.unsubscribeStatus(
    this.props.id,
    this.handleStatusChange
  );
}

而在 function component 中,由於 useEffect 預設會在每一次 render 後都處理同步的動作,不需要專門為了 mount 還是 update 的情況分別處理兩套流程。在執行下一個 effect 之前,它將以執行 cleanup 來清除上一次 render 的 effect:

useEffect(() => {
  OrderAPI.subscribeStatus(props.id, handleChange);
  return () => {
    OrderAPI.unsubscribeStatus(props.id, handleChange);
  };
});

以實際的多次 render 來模擬一下執行的流程:

// -- Mount with { id: 1 } props ---
// 執行第一個 effect
OrderAPI.subscribeStatus(1, handleChange);     

// --- Update with { id: 2 } props ---
// 清除前一個 effect
OrderAPI.unsubscribeStatus(1, handleChange);
// 執行下一個 effect
OrderAPI.subscribeStatus(2, handleChange);
 
// --- Update with { id: 3} props ---
// 清除前一個 effect
OrderAPI.unsubscribeStatus(2, handleChange);
// 執行下一個 effect
OrderAPI.subscribeStatus(3, handleChange);
 
// --- Unmount ---
// 清除前一個 effect
OrderAPI.unsubscribeStatus(3, handleChange);

dependencies 是一種效能最佳化,而非邏輯控制

預設情況下,每一次 render 後都應該執行屬於該 render 的 effect,來確保同步的正確性與完整性。然而,我們的 component 有可能會遇到因為與 effect 無關的資料更新而觸發 re-render,此時 effect 仍會被執行。讓我們來看看以下的範例:

function Example({ name }) {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    document.title = 'Hello, ' + name;
  });
 
  return (
    <>
      <h1>Hello, {name}</h1>
      <button onClick={() => setCount(count + 1)}>
        +1
      </button>
    </>
  );
} 

在以上的範例中,我們會以 props 中的 name 來在 effect 中進行同步到瀏覽器頁面標題的動作。然而如果我們點擊 component 中的按鈕的話,也會因為觸發 count 的 setState 而 re-render,導致與 count state 完全無關的 effect 再次被執行。雖然這個 effect 的設計可以很好的保證即使重複被執行仍會結果正確,但是在依賴的原始資料沒有變動的情況下所做的同步動作顯然是多餘的,應該是可以被省略以節省效能。

因此,我們可以透過提供 effect 的 dependencies 來告訴 React,這個 effect 的同步動作是依賴於哪些資料,如果這個清單中記載的所有依賴資料都與上一次 render 時沒有差異,就代表沒有再次進行同步的需要,因此 React 就可以安全的略過該次 render 的 effect 執行,來達到節省效能的目的。

function Example({ name }) {
  const [count, setCount] = useState(0);
 
  useEffect(
    () => {
      document.title = 'Hello, ' + name;
    },
    [name] // 在 deps 陣列中填上 effect 同步動作中依賴的 name
  );
 
  return (
    <>
      <h1>Hello, {name}</h1>
      <button onClick={() => setCount(count + 1)}>
        +1
      </button>
    </>
  );
}

如此一來,這個範例中的 effect 就會在本次 render 中的 name 與上一次 render 時的 name 相同時,自動略過執行。

因此,理解「dependencies 是一種效能最佳化手段,而非邏輯控制」是掌握 useEffect 相當重要的一環。它用來告訴 React 何時可以因為依賴的原始資料沒有變化而安全地略過本次 effect 的同步,而並不是用來「指定」effect 在什麼特定的「生命週期」或「商業邏輯條件」下才要執行。你應該要永遠誠實地根據真實的資料依賴情況來填寫 dependencies 陣列,而不是自作聰明的想透過欺騙 dependencies 來達到某些效果(像是明明有依賴卻填寫空陣列來模擬 componentDidMount 的效果)。

useEffect 的 dependencies 撒謊通常會導致我們的應用程式出現一些難以察覺與追蹤的 bug。如果你的 effect 多次執行會有問題的話,那你該做的事情應該是嘗試為這個 effect 撰寫有效的 cleanup。而如果你的 effect 在某些商業邏輯滿足的情況下才想要執行的話,你也應該自行在 effect 中去寫這些條件判斷,而不是直接依賴 dependencies 的略過機制。

由於 dependencies 是一種效能最佳化手段,因此你應該以效能最佳化的原則去思考這個問題:「即使沒有做這個效能最佳化的時候,應用程式也應該保持執行效果正常」,因此想要確保你的 effect 是安全且可靠的最有效辦法,就是讓你的 effect 即使不填寫 dependencies 而每次 render 後都會執行,也能保持邏輯的正確性。

所以當你在設計 effect 內的邏輯時,不應該考慮「這個 effect 會在哪幾次 render 時被執行」,而是即使每一次 render 時都執行這個 effect,其邏輯依然能正常運作。因為重點不是哪些 render 會執行這個 effect,或是要經過幾次 render 才能完成這個 effect 想做的事情,而是這個 effect 最後的執行結果能夠「完整的同步」資料的變化就好。

並且事實上,React 也有在官方文件中提到過:hooks 的 dependencies 只是一種效能最佳化而非語意保證,在未來 React 有可能會在某些時刻「忘記」dependencies 的舊值來釋放記憶體。 因此如果你的 effect 嘗試把 dependencies 作為效能最佳化以外的用途,就有可能連帶導致你的 effect 中的邏輯在你非預期的情況下被再次執行。


useEffect 的核心思考模型整理

我們來將目前為止提及的各種關於 useEffect 的觀念做一個整理:

  • Function component 並沒有提供生命週期的 API,只有 useEffect 提供「同步資料到 effect」的用途
  • useEffect 讓你根據目前的資料來同步 React elements(畫面)以外的任何事物與副作用
  • 在一般情況下, useEffect 會在每次 component render 且瀏覽器完成 DOM 的更新 & 繪製畫面之後才執行,以避免阻塞 component render 的過程 & 瀏覽器繪製畫面的過程
    • 例外情況:在 React 18 中,如果某次 render 是被 flushSync 包著的 setState 所觸發,則該次 render 的 effect 將提早在瀏覽器畫面排版與繪製「之前」就進行觸發
  • useEffect 在概念上並不區分 mount 與 update 的情況,它們被視為是同一種情境
  • 預設情況下,每一次 render 後都應該執行屬於該 render 的 useEffect,來確保同步的正確性與完整性
  • 理想上同一個 effect 無論隨著 render 重複被執行了多少次,你的程式都應該保持同步且正常運作
  • useEffect 的 dependencies 是一種「忽略某些不必要的執行」的效能最佳化手段,而不是用來控制 effect 發生在特定的 component 生命週期,或特定的商業邏輯時機

參考資料


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 20] 每一次 render 都有自己的 effects
下一篇
[Day 22] 保持資料流 — 不要欺騙 hooks 的 dependencies(上)
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言