[React] React 中的副作用處理、初探 useEffect

Monica
27 min readMay 26, 2024

--

《React 思維進化》 筆記系列
1. [React] DOM, Virtual DOM 與 React element
2. [React] 了解 JSX 與其語法、畫面渲染技巧
3. [React] 單向資料流介紹以及 component 初探
4. [React] 認識狀態管理機制 state 與畫面更新機制 reconciliation
5. [React] 在子 component 觸發父 component 資料更新、深入理解 batch update 與 updater function
6. [React] 了解 immutable state 與 immutable update 方法
7. [React] 認識 component 的生命週期、了解每次 render 都有自己版本的資料
8. [React] React 中的副作用處理、初探 useEffect
9. [React] 對 hooks 的 dependencies 誠實,以維護資料流的連動
10. [React] React 18 effect 函式執行兩次的原因及 useEffect 常見情境
11. [React] 認識 useCallback、useMemo,了解 hooks 運作原理

前言

承接上篇 [React] 認識 component 的生命週期、了解每次 render 都有自己版本的資料,此篇主要敘述的是《React 思維進化》 5–1 ~ 5–2 章節的筆記,若有錯誤歡迎大家回覆告訴我~

什麼是 effect?

之前文章的補充有稍微提過 effect,effect 全名為 side effect,中文為「副作用」。當一個函式在執行過程中,除了最後回傳值外,還對函式外部環境有其他互動或修改,就稱這函式帶有副作用。在 JavaScript 常見副作用行為如:修改瀏覽器的 DOM 結構、發起 API 請求等。

以以下程式碼來看,單看 function 名稱可能以為只是一個回傳數字兩倍值的簡單函式,但其實函式中間做了其他與外部環境的互動,因此這是一個有副作用的函式。

let globalVariable = 0;

function calculateDouble(number){
globalVariable += 1; // 修改函式外部環境的變數

localStorage.setItem('globalVariable', globalVariable); // 與函式外部環境互動:修改 localStorage

console.log('Hello React'); // 與函式外部環境互動:在控制台輸出

fetch(/*…*/).then((res)=>{ //與函式外部環境互動: 網路請求
//…
});

document.querySelector('.app').style.color = 'red'; // 與函式外部環境互動:修改 DOM element

return number *2
}

副作用的負面影響

副作用不是絕對的壞事,有時副作用帶來的效果才是我們期待的函式目的,但帶有副作用的函式仍可能帶來負面影響,了解負面影響也會讓我們在撰寫時更加謹慎~

副作用的負面影響如:

  • 可預測性降低,程式的行為與影響難以控管:
    - 副作用函式可能受外部狀態或變數影響,而外部資料可能在其他地方被修改
    - 副作用函式可能影響函式外部環境,例如某函式存取外部全域變數 obj,在函式內為這個 obj 加上 foo 屬性,就影響了外部變數
let obj = {a: 1};
function fn(){
obj.foo = 'new value';//修改外部變數
}
  • 測試困難:副作用涉及外部資源或狀態,要模擬或隔離這些變因的測試會更困難
  • 高耦合度:當函式依賴或影響外部資源或狀態時,就會和那些資源、狀態緊密關聯,導致更改或重構困難,改 A 就要擔心會影響 B
  • 難以維護和理解: 在理解有副作用的函式時,要考慮更多的上下文和其他外部資源的影響
  • 優化限制:沒有副作用的函式更容易被優化,而帶有副作用的函式則限制了優化可能性

即使副作用有許多負面影響,不代表副作用是不好、不該使用的。實際應用時副作用其實難以避免,有時反而是必要的,也因此許多程式語言會提供特殊工具來管理或隔離副作用。

React component function 中的副作用

接下來我們稍微探討副作用的其中兩個負面影響,因為它們跟 React 的 component function 有關。

函式多次執行所疊加造成的副作用影響難以預測

當一段帶有副作用的函式被多次執行時,其造成的影響可能會被疊加:

let globalVariable = 0;

function calculateDouble(number){
globalVariable += 1; // 每次函式執行時,就會讓外部變數 globalVariable 被+1

localStorage.setItem('globalVariable', globalVariable); // 每次函式執行時,就會修改一次 localStorage

console.log('Hello React'); // 每次函式執行時,就會在控制台輸出一次

fetch(/*…*/).then((res)=>{ // 每次函式執行時,就會發起一次網路請求
//…
});

document.querySelector('.app').style.color = 'red'; // 每次函式執行時,就會修改一次 DOM 元素

return number *2
}

隨函式執行次數增加,副作用造成的效果不斷累積,以上述範例來說,重複執行函式會導致外部變數不斷被 +1、不斷在控制台輸出、不斷發起新網路請求…等。

這代表函式執行不同次數所造成的影響與結果會不同,每次執行的結果都不同。

這和 React 的 component function 有什麼關係?

React 的 component function 會因 re-render 而被多次執行,而我們需避免「多次執行後所疊加的難以預測的影響」,因為當 component function 的 render 過程包含副作用,就可能會隨著後續多次 re-render 而讓副作用影響不斷疊加,導致非預期結果。

副作用可能會拖慢甚至阻塞函式本身的計算流程

拖慢如:若在函式內包含 DOM 操作,因 DOM 操作須連動瀏覽器渲染引擎,操作較耗時,後續程式碼要等 DOM 操作完才能繼續執行。

阻塞如:函式的副作用操作有非預期錯誤時,會導致函式後續的程式無法繼續執行並回傳結果。

這和 React 的 component function 有什麼關係?

React 會 render component function 以產生對應畫面的 React element,而我們需避免 component function 「拖慢或阻塞函式計算流程」,因為如果在執行(render) component function 時包含副作用操作,副作用處理可能會拖慢、阻塞 React element 的產生,導致畫面更新卡頓,也影響使用者體驗。

useEffect 來處理 component 內的副作用

根據上述兩點,我們不應在 component function 的 render 過程中包含副作用的處理,但前面也提過,實際程式開發時,副作用是難以避免的,我們需要在某些 component 與外部環境互動,如:發起對後端的請求、監聽事件…等。

如果不應在 component render 時包含副作用處理,有時又需要副作用的操作,該怎麼辦?我們就要使用 React 提供的 API useEffect 來管理 component 內的副作用。

useEffect 可以解決上述問題:

  • 問題一:若 component function 多次 re-render,副作用會不斷疊加
    - useEffect 解法:透過 cleanup 函式來指定如何清除副作用的影響,以 cleanup 函式來清除或逆轉副作用造成的影響
  • 問題二:在 render component function 時執行副作用可能會阻塞產生 React element 的過程
    - useEffect 解法:在每次 render 流程完成後才執行副作用處理,避免副作用阻塞畫面的產生或更新

於是我們就接著來了解 useEffect 吧~

useEffect 初探

useEffect 是專門在 component function 內管理副作用的 hooks API,前面介紹 useState 時提過,hooks 只能在 component function 內呼叫,所以 useEffect 也只能在 component function 內呼叫。

useEffect 呼叫方式:


import { useEffect } from 'react';
export default function App(props){
//...
useEffect(effectFunction, dependencies?)
//...
}

effectFunction 參數

  • 傳入一個函式,放副作用處理邏輯
  • 如果副作用造成的影響需要被清理,可讓 effectFunction 函式本身回傳一個清理副作用的 cleanup 函式
  • 此函式的執行時機:
    - component 每次 render 完成、且實際 DOM 被更新後,才執行
    - componen unmount 時,會執行最後一次 render 的 cleanup 函式
  • 本文接下來都會以「effect 函式」稱呼此參數

dependencies 參數

  • 可選填的陣列參數
    - 陣列內應包含 effect 函式中所有依賴的 component 資料項目,如: props、state 或任何受資料流影響、可能會變動的延伸資料
  • dependencies 參數提供與否,會影響 effect 函式的執行
    - 若不提供 dependencies:effect 函式預設會在每次 render 後都執行一次
    - 若有提供 dependencies:React 會在 re-render 時用 Object.is 一一比較陣列中所有依賴項目的值與前一次 render 版本的是否相同,若相同,則跳過執行此次 render 的 effect 函式

useEffect 的使用步驟

  1. 定義一個 effect 函式(effectFunction 參數)來處理副作用

預設此 effect 函式會在每次 render 後都被執行一次。

useEffect(
() => {
articleAPI.subscribeUpdates(props.id, handleArticleUpdate);//根據 props.id 的值來訂閱文章狀態更新
}
);

以上述為例,effect 函式會依賴 props.id 資料來呼叫 articleAPI.subscribeUpdates 方法,當 id 為 props.id 的文章更新時,就自動呼叫 handleArticleUpdate 處理後續邏輯(例如更新文章資訊、或通知使用者)。

這個訂閱更新的副作用處理何時執行?component 首次執行後,effect 函式就會被執行,就可訂閱文章狀態,並預設在每次 render 後都會被執行一次。

2. 加上 cleanup 函式來清理副作用(如果需要的話)

某些副作用會因 component 多次 render 後而疊加其影響,造成預期外問題,因此我們需透過 cleanup 函式清除或逆轉副作用造成的影響。

接續訂閱文章更新的範例,component 每次 render 後都會觸發此副作用的執行,再訂閱一次文章狀態,而這會造成如下問題:

  • 訂閱多次疊加,callback 函式被重複執行
    - 若文章有變化,handleArticleUpdate 會被執行多次
  • 訂閱對象改變,但舊的訂閱仍被觸發
    - 若改訂閱新文章時,舊 id 的文章訂閱事件仍會被觸發
  • component unmount 後,仍持續觸發訂閱事件
    - 若 component unmount 後仍持續觸發訂閱事件,會導致 memory leak、效能浪費問題

因此我們要透過 cleanup 函式,在新訂閱發生前,先取消舊的訂閱:

useEffect(
() => {
articleAPI.subscribeUpdates(props.id, handleArticleUpdate);
return () => {//在 effect 函式回傳另一個函式作為 cleanup 函式,在其中處理副作用的清理
articleAPI.unsubscribeUpdates(props.id, handleArticleUpdate);
};
}
);

在新 effect 函式執行前,會先執行前一次 render 的 cleanup 函式以清除之前的副作用影響,之後再執行本次 render 的 effect 函式。

cleanup 函式是選填,在某些情況下不一定要 cleanup 函式,如:某些副作用不會造成疊加影響,而是覆蓋前一次的影響,那就不需要 cleanup 函式。

補充:memeory leak(記憶體洩漏)
當一段程式碼不再使用某些記憶體空間,但該記憶體卻沒被釋放,導致記憶體無法被其他程式使用、或重新分配,就會造成 memeory leak。
反覆執行具有 memory leak 的程式可能導致系統可用的記憶體空間減少,影響效能。

3. 指定 effect 函式的 dependencies 陣列,以跳過某些不必要的副作用處理

根據 effect 函式中會依賴的資料項目定義 dependencies 陣列,dependencies 陣列可幫助 React 在某些時候可安全的跳過 effect 函式的執行,節省效能。

延續文章訂閱的範例,其實上述步驟已達到期望效果:component 一次 render 後,會先執行該副作用前一次 render 的 cleanup 函式以清除舊的訂閱,再執行這次 render 的 effect 函式來進行新訂閱。

但如果 props.id 與前一次 render 相同,就會先取消訂閱,之後又馬上訂閱同一篇文章,這會導致效能浪費。

因此我們透過 dependencies 參數指定 effect 函式依賴哪些資料項目,來達到效能優化:

useEffect(
() => {
articleAPI.subscribeUpdates(props.id, handleArticleUpdate);
return () => {
articleAPI.unsubscribeUpdates(props.id, handleArticleUpdate);
};
},
[props.id]
);

component re-render 後,React 會用 Object.is 檢查「前一次 render 時 dependencies 陣列內所有項目」和「本次 render 時 dependencies 陣列內所有項目」是否完全相同(每個項目一一檢查):

  • 若有其中一個不同:照常執行前一次 render 的 cleanup 函式和本次 render 的 effect 函式
  • 若所有項目相同:可安全跳過本次 render 的副作用處理

補充:不提供 dependencies 參數和以 [] 作為 dependencies 參數的意義不同
- 不提供 dependencies 參數:維持預設行為,每次 render 後都執行一次 effect 函式
- 提供一個
[] 作為 dependencies 參數:effect 函式沒有依賴任何資料,可在每次 re-render 時都安全跳過 effect 函式的執行

每次 render 都有其自己版本的 effect 函式

每次 render 都有自己版本的 props、state 以及 event handler 函式,那 effect 函式呢?

effect 函式若要存取 props 或 state 值,其概念與 event handler 相同,因為每次 render 都有該次 render 版本的 props 和 state 值,若 effect 函式要存取 props 或 state,就會因 JavaScript closure 特性而捕捉該次 render 版本的值。延伸來說,就代表每次 render 都會產出屬於該次 render 版本的 effect 函式,每次 render 都會有不同的 effect 函式。

以下面範例來說,JavaScript closure 特性讓 effect 函式記得的 count 變數永遠是該次 render 版本的值:

import { useEffect, useState } from "react";

export default function Counter(){
const [count, setCount] = useState(0);

useEffect(()=>{//傳入 inline 函式作為 effect 函式,每次 re-render 執行到這時都會重新產生新 effect 函式,同一個 useEffect 內的 effect 函式在不同次 render 間是不同的函式
document.title = `You clicked ${count} times`;
})

return (
<div>
<p>Yot clicked {count} times</p>
<button onClick={()=>setCount(count+1)}>Click me</button>
</div>
)
}

因此我們可想像 effect 函式也是 render 輸出結果的一部分,是 render 結果的副產物(主產物是 React element),延伸我上篇文章的小結與示意圖,可以改成這樣:

每次 render component function 時,都會

  • 產生該次 render 版本的 props 與 state
  • 產生該次 render 版本的 event handler,此 event handler 存取到的 props 與 state 是該次 render 版本的資料,這些資料固定不變
  • 產生該次 render 版本的 effect 函式,此 effect 函式存取到的 props 與 state 是該次 render 版本的資料,這些資料固定不變

每次 render 都有其自己版本的 cleanup 函式

cleanup 函式同理,cleanup 函式每次 render 都會重新產生,並依賴該次 render 版本的 state 與 props,而因為該次 render 的 state 與 props 永不變,cleanup 函式透過 JavaScript closure 存取到的值也固定不變。

在模擬 cleanup 函式在不同次 render 的流程前,先整理一下 effect 函式與 cleanup 函式的執行時機:

  1. React 以本次 render 版本的 props 與 state 產出 React element
  2. 若非首次 render,會比較新舊 React element 差異處
  3. 瀏覽器完成畫面 DOM 的繪製或操作
  4. 執行前一次 render 版本的 cleanup 函式(如果有的話);若是首次 render 則跳過這步
  5. 執行本次 render 的 effect 函式

接續之前講 reconciliation 文章所繪的流程圖,整個流程的示意圖如下:

可從流程圖看出,effect 函式會在瀏覽器更新完畫面後才會執行

接著模擬一下不同次 render 流程:

//不同次 render
//假設第一次 render 的 props 是{id: 1}
useEffect(
() => {
articleAPI.subscribeUpdates(1, handleArticleUpdate);
//每次 render 都重新產生 cleanup 函式,以 closure 記住這次 render 版本的 props.id 為 1
return () => {
articleAPI.unsubscribeUpdates(1, handleArticleUpdate);
};
}
);
//第二次 render 的 props 是{id: 2}
useEffect(
() => {
articleAPI.subscribeUpdates(2, handleArticleUpdate)
//每次 render 都重新產生 cleanup 函式,以 closure 記住這次 render 版本的 props.id 為 2
return () => {
articleAPI.unsubscribeUpdates(2, handleArticleUpdate);
};
}
);
  • 首次 render 時,props 是 {id: 1}
    1. 以 props {id: 1} 產生對應畫面的 React element
    2. 瀏覽器完成實際畫面繪製,可看到對應 props {id: 1} 版本的畫面結果
    3. 執行本次 render 版本(props 為 {id: 1} 版本)的 effect 函式
  • 第二次 render,props 是 {id: 2}
    1. 以 props {id: 2} 產生對應畫面的 React element
    2. 瀏覽器完成實際畫面繪製,可看到對應 props {id: 2} 版本的畫面結果
    3. 清理前一次 render 的 effect 函式副作用,也就是執行前一次 render (props 為 {id: 1} 版本)的 cleanup 函式
    4. 執行本次 render 版本(props 為 {id: 2} 版本)的 effect 函式

component render 資料流小結

延伸上面 component 每次 render 的示意圖,可以總結 component 每次 render…

  • 都有自己版本的 state 與 props 快照,值永遠不變
  • 定義的各種函式都會透過 closure 捕捉該次 render 的 state 與 props
    - 各種函式包含: event handler 函式、effect 函式、cleanup 函式
    - 無論函式在多久後被執行,讀取到的 state 與 props 固定不變

useEffect 其實不是 function component 的生命週期 API

useEffect 的用途是讓原始資料可以同步化到畫面以外的副作用處理上,官方文件的說明是:「useEffect is a React Hook that lets you synchronize a component with an external system.」。例如透過 effect 函式讓 document 的 title 可以和 state 資料同步,我們是在告訴 React「我要 count 的 state 值可以跟瀏覽器標題同步」,這是一種宣告式程式設計。

宣告式(declarative)程式設計只關注預期結果的樣貌,不在乎過程如何達到。就像前面文章提到的 Virtual DOM 與 React element 概念,我們透過 React element 描述期待的畫面,而不關心細部如何操作來達到期待畫面。

與宣告式程式設計相對的是指令式(imperative)程式設計,指令式程式設計關心達到目標的細節過程,而較難知道這些細部操作最後會有什麼結果。若我們手動操作實際 DOM,需要逐步驟疊加 DOM 操作(例如:先改 color、再改 height、再 append 到某個 DOM…等逐步操作)才能讓結果如我們的期待,但從這些步驟很難看出疊加執行後的畫面結果,只能實際執行來看最後畫面的樣貌。

前端應用對同步化的複雜度與精確度要求高,宣告式同步更易維護這類應用,因此前端框架大多傾向宣告式風格。

回到 useEffect,它以宣告式的方式告訴 React,根據 props 或 state 資料來同步副作用。我們不關心副作用在第幾次 render 或在哪個生命週期階段執行,只要結果是原始資料同步到外部系統。由於 useEffect 不在乎副作用在哪個生命週期執行,它不是 function component 生命週期的 API,不是用來在 component 特定生命週期執行某 callback而是將資料同步到 React element 外的系統。無論副作用執行多少次,資料流及程式邏輯都應正常同步。

試圖控制 effect 函式僅在第一次 render 時執行,違反了 useEffect 的設計思維。effect 函式應依賴「同步的目的地」,而非「執行時機」。官方文件 Lifecycle of Reactive Effects 指出,effect 函式只在乎「開始同步」和「結束同步」兩件事。當 dependencies 參數為空陣列時,從 component 角度看,effect 函式在 component mount 時執行,並只在 component unmount 時才結束;但從 effect 的角度看,我們只是指定它如何開始和結束同步

為什麼要以 useEffect 的資料流同步化取代生命週期 API

先看一段 class component 處理副作用的程式碼:

componentDidMount(){
//mount 後進行副作用處理
articleAPI.subscribeUpdates(
this.props.id,
this.handleArticleUpdate
)
}

componentDidUpdate(){
//取消訂閱前一次 render 版本的 props.id 的訂單
articleAPI.unsubscribeUpdates(
prevProps.id,
this.handleArticleUpdate
);

//訂閱這次 render 的 props.id 的訂單
articleAPI.subscribeUpdates(
this.props.id,
this.handleArticleUpdate
);
}

componentWillUnmount(){
//清除副作用的影響
articleAPI.unsubscribeUpdates(
this.props.id,
this.handleArticleUpdate
)
}

可看出 class component 需要透過生命週期 API 來處理副作用,可能導致以下問題:

  • 開發者要在 componentDidMountcomponentDidUpdatecomponentWillUnmount 中分別考慮如何將資料同步到外部系統,並判斷各生命週期中應該做什麼來達到同步效果。當應用變複雜時就容易出錯。
  • 生命週期 API 內的副作用處理難以重用。
  • component 內可能有多個副作用處理,不同副作用處理同時寫在 componentDidMountcomponentDidUpdatecomponentWillUnmount 中,容易產生衝突、難以維護。

因應上述問題,function component 如何處理副作用?

  • useEffect 處理副作用同步化,並定義「清除副作用影響」的 cleanup 函式;不用在乎 mount、update 或 unmount 時要做什麼。
  • 副作用的同步只是一段函式,易於重用與維護。
  • 重視同步化的目標,而非執行時機。可更專注商業邏輯,而非component 生命週期。

Dependencies 是一種效能優化,而非執行時機的控制

dependencies 陣列參數是用來告訴 React,此 effect 函式依賴哪些資料,若陣列內所有資料都和上一次 render 相同,就可安全跳過此次 render 的副作用處理,節省效能。

dependencies 在執行 effect 函式扮演的角色如下:

component 首次 render

  • 執行 effect 函式,進行首次副作用處理
  • 記住這次 render 時 dependencies 陣列內所有項目的值

component re-render

  • 要執行 effect 函式前,先檢查 dependencies 陣列內所有項目的值在本次 render 和上一次 render 有沒有不同
    - 若所有項目都相同,effect 函式可被安全跳過,此時不會再記下 dependencies 陣列內項目的值,因為值也沒變
    - 若其中有一個項目不同,繼續執行本次 render 的 effect 函式,並記住這次 render 時 dependencies 陣列內所有項目的值

補充,官方文件 Lifecycle of Reactive Effects 有提到,dependencies 陣列須包含所有 effect 函式讀取的 reactive value。reactive value 指的是 props、state、根據 props 和 state 計算的值,以及其他在 component 內宣告的變數,任何 reactive value 都可能在 re-render 時改變,所以要將 effect 函式有用到的 reactive value 都納入 dependencies。而 mutable values 和全域變數(global variables)不是 reactive,不能納入 dependencies 陣列。更多說明可參考 Lifecycle of Reactive Effects,書中後面章節也會針對 dependencies 有更多敘述。

副作用沒有任何依賴資料時的 dependencies

如果副作用剛好沒用到任何依賴資料(reactive value),可將空陣列作為dependencies 參數:

useEffect(()=>{
document.title = `Hello React`;
},[]); //副作用沒有任何依賴資料,dependencies 參數填寫空陣列

若將空陣列作為 dependencies 參數,首次 render 時,副作用會正常執行;re-render 時,因為沒有依賴資料,可跳過副作用處理。

需注意的是,只有在 effect 函式沒有任何依賴時,才可將空陣列作為 dependencies 參數。不應該因為希望 effect 函式只執行一次,而故意將空陣列作為 dependencies 參數,這會導致非預期錯誤。欺騙 dependencies 參數的做法會破壞 useEffect 的設計初衷,進而造成同步問題和潛在錯誤。

Dependencies 是用來判斷「何時可以安全的跳過」,而不是指定「只有何時才會執行」

dependencies 是一種效能優化手段,而非邏輯控制。其用途在於告訴 React 何時可安全跳過該次 render 的副作用處理,而不是用來指定 effect 函式在特定生命週期或商業邏輯下才執行。開發者應對 dependencies 誠實,不應操縱 dependencies 參數來達到期望效果,例如故意填寫空陣列以模擬 componentDidMount 生命週期 API。對 dependencies 說謊會導致難以察覺的錯誤。

dependencies 的「依賴沒更新可安全跳過」機制不代表「依賴沒更新時一定會跳過」,什麼是「安全跳過」? 就代表跳過不會有問題、但即使沒跳過而照常執行了,也同樣不會有問題👌。所以我們不該以 dependencies 來控制 effect 函式只在特定條件下執行,因為這等於相信「dependencies 資料沒更新時一定會跳過執行」,但是! 我們無法透過 dependencies 來保證一定會跳過。

既然 dependencies 是效能優化手段,那它就是一種「有也很好,但沒有也不會怎麼樣」的選填,即使沒有 dependencies 優化,應用也要正常運作。因此要如何證實自己寫的副作用安全可靠? 我們要確認「即使沒有 dependencies 參數,每次 render 後都執行副作用,也能保持執行結果正確」。

最後再次重申,dependencies 的效能優化行為,多數情況可如期跳過,但不能保證,因此:

  • ⛔️ 請不要將 dependencies 用在效能優化外的用途
  • ⛔️ 請不要以 dependencies 模擬生命週期 API
  • ⛔️ 請不要以 dependencies 來判斷「這段函式會因為依賴沒更新而被跳過」

最後也推薦大家讀這篇 React 官方文章,詳細介紹了 useEffectSynchronizing with Effects,以及 Dan Abramov 的文章:A Complete Guide to useEffect

--

--