iT邦幫忙

2022 iThome 鐵人賽

DAY 26
1
Modern Web

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

[Day 26] Effects & cleanups 常見情境的設計技巧

  • 分享至 

  • xImage
  •  

在前面章節中我們解析了將 effect 設計成即使多次執行也能保持正確的重要性。如果你還對這個觀念不是很熟悉的話,非常建議你先閱讀系列文前面的篇幅中關於 useEffect 的深度解析。而在這個 useEffect 主題的最後一個篇章中,我們將介紹以及分享一些常見情境的 effects & cleanups 設計技巧,幫助大家在實戰中能夠更能得心應手的應對。

Effects 的理想設計方向是宣告式造成的影響為可逆的。這邊我們可以先大致介紹一些常見的 effects 設計問題:

  • 疊加性質而非覆蓋性質的操作
    • 當 effect 操作的影響是會隨著執行越多次而不斷疊加而非覆蓋時,如果沒有設計適當的 cleanup 來做相關的取消或逆轉處理,就有可能在多次執行後導致結果不如預期
  • Race condition 問題
    • 當 effect 的操作涉及到非同步的後續影響時,多次 effect 的執行順序不一定與非同步事件的回應順序相同,而導致 race condition 的問題
  • Memory leak 問題
    • 當你的 effect 會啟動持續性的監聽類工作,例如註冊某些訂閱事件,但是沒有處理對應的取消訂閱的話,就有可能在 component unmount 之後仍持續監聽訂閱,導致 memory leak 的問題

一般來說這些問題的解決方案就是實作 effect 的 cleanup function。cleanups 應該要負責停止或逆轉 effects 中造成的影響,以保證你的 effects 即使在多次執行的情況下也能正常運作,並且不會造成 memory leak 的問題。


Fetch API

呼叫 fetch 來請求一個後端的 API 或許是實戰中最常遇到的 effect:

useEffect(
  () => {
    async function startFetching() {
      const json = await fetchTodos(userId);
      setTodos(json);
    }
 
    startFetching();
  },
  [userId]
);

這個 effect 的 dependencies 是誠實的,當 userId 在 re-render 時有所改變時,可以正確的重新再執行一次 effect。不過由於 fetchTodos 的動作是非同步的,因此當這個 effect 連續被執行時,先執行的 effect 的 fetch 結果並不一定比後執行的 effect 的 fetch 要更早返回,就會造成 race condition 的問題。以下舉例可能發生的狀況流程:

// 第一次 render 時,userId 為 1,對應的 effect 被執行時發起 fetchTodos
fetchTodos(1);

// 第二次 render 時,userId 為 2,由於 userId 與上次 render 時不同
// 因此會順利觸發對應的 effect,發起另一次 fetchTodos
fetchTodos(2);

//此時過了一段時間之後,fetchTodos(2) 的非同步事件率先完成並返回結果
setTodos([ /* ...userId 為 2 的 todos 內容 */ ]);

//然後 fetchTodos(1) 的非同步事件比較晚才完成並返回結果
setTodos([ /* ...userId 為 1 的 todos 內容 */ ]);

因此,雖然我們的 effect 有正確的在 userId 改變時再次處理同步,但是最後 todos state 中留下的資料結果卻有可能反而是比較舊的請求結果。

要處理這種 fetch 的 race condition 的問題,通常能以 abort fetch 或忽略舊的 request 結果來解決。這邊我們介紹以一個簡單的 flag 就能解決的方法:

useEffect(
  () => {
    let ignore = false;
 
    async function startFetching() {
      const json = await fetchTodos(userId);
      if (!ignore) {
        setTodos(json);
      }
    }
 
    startFetching();
 
    return () => {
      ignore = true;
    };
  },
  [userId]
);

這個解法的原理非常簡單,就是讓每次 render 的 effect 本身都記得自己是否應該忽略 fetch 結果的 flag。在每次 render 的 effect 中,這個 flag 變數 ignore 的值預設會是 false,所以每次 effect 觸發的一開始時都會是正常處理的狀態。但是一旦這個 effect 在新的 render 中再次被執行前,就會先執行前一次 effect 對應的 cleanup,所以此時就會將前一次 effect 中的 ignore 改成 true。這樣處理後,即使比較前面的 fetch 更晚才返回結果,也會因為 ignore 被改成了 true 而不會進行 setTodos 的動作:

// 首次 render 時,userId 為 1,對應的 effect 被執行時發起 fetchTodos
ignore(首次 render 的 effect 裡的)= false;
fetchTodos(1);

// 第二次 render 時,userId 為 2,由於 userId 與上次 render 時不同
// 此時會先執行前一次 effect 的 cleanup 
// 然後才會執行本次 render 的 effect,發起另一次 fetchTodos
ignore(首次 render 的 effect 裡的)= true;
ignore(第二次 render 的 effect 裡的)= false;
fetchTodos(2);

// 此時過了一段時間之後,fetchTodos(2) 的非同步事件率先完成並返回結果
setTodos([ /* ...userId 為 2 的 todos 內容 */ ]);

// 然後 fetchTodos(1) 的非同步事件比較晚才完成並返回結果
// 但是由於首次 render 的 effect 中的 ignore 已經被改成 true,
// 所以不會執行 setTodos 的動作

此外,這個修改 flag 的 cleanup 也會在 unmount 時執行,而這樣就能夠很好的避免 fetch 在 component 已經 unmount 後才返回結果並嘗試 setTodos 而造成的 memory leak 問題。

第三方套件解決方案

不過關於請求 API 的情境需求,在實務上最推薦的解決方案其實還是使用主流的第三方套件。這些熱門的第三方套件通常都已經幫我們內建處理好以上的這種 race condition 問題,甚至還內建了快取機制、效能調校等實用的功能:


控制 React 外部的插件

有時候我們會在 React 專案中使用一些非 React 基礎的外部套件,例如說與第三方的 map API 做串接:

useEffect(
  () => {
    if (!mapRef.current) {
      mapRef.current = new FooMap();
    }
  },
  // 這裡是因為沒有任何依賴才填空陣列,
  // 而不是為了控制 effect 只執行一次
  [] 
);

上面這個 effect 即使多次執行也是沒問題的,因為當 mapRef.current 有東西時就不會重新執行到 new FooMap() 的處理。不過更理想的做法是把 initialize 的流程搬到 React App 的頂層 component 中,或甚至是 React 之外,以確保它在整個 App 只會執行一次,而不是每個 React component 的生命週期中都各自產生 FooMap 的 instance。因此,我們通常會建議在整個專案中盡可能的減少重複的第三方套件的初始化動作,甚至是只初始化一次就好。


而下面這個範例中的 effect 則是真的在做同步的動作,因此多次重複執行是沒有問題的:

useEffect(
  () => {
    const map = mapRef.current;
    map.setZoomLevel(zoomLevel);
  },
  [zoomLevel]
);

如果 zoomLevel 變化的太頻繁連帶導致 re-render 的效能問題的話,也可以考慮再另外加上 throttle 等調校處理。


下面這個範例中,則是控制了某些外部套件的動作,如果這個動作是不能被覆蓋的話,則你應該在 cleanup 中去執行一些可以逆轉 effect 影響的操作:

useEffect(
  () => {
    const dialog = dialogRef.current;
    dialog.showModal();
    return () => dialog.close();
  },
  []
);

監聽或訂閱事件

訂閱 DOM 或是各種自定義的事件也是常見的一種 component effect:

useEffect(
  () => {
    window.addEventListener('scroll', (e) => {
      console.log(e.clientX, e.clientY);
    });

		// ❌ 這裡應該要實作對應的 cleanup 來取消事件訂閱
  },
  []
);

如果我們沒有在 cleanups 中處理對應的取消訂閱動作,那這個訂閱就會在 component 已經 unmount 後仍持續運作,而造成 memory leak。

useEffect(
  () => {
    function handleScroll(e) {
      console.log(e.clientX, e.clientY);
    }
 
    window.addEventListener('scroll', handleScroll);
 
    // ✅ 在 cleanup 中處理事件的取消訂閱
    return () => {
      window.removeEventListener('scroll', handleScroll)
    };
  },
  []
);

setTimeoutsetInterval 也是同理,如果沒有在 cleanups 中處理取消註冊的動作的話,都有可能會在 component unmount 還嘗試執行 callback,而造成 memory leak 的問題。

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

  useEffect(
	  () => {
      const id = setInterval(() => {
        setCount(prevCount => prevCount + 1);
      }, 1000);
      
      // ✅ 在 cleanup 中處理 interval 的取消註冊
      return () => clearInterval(id);
    },
    []
  );

  // ...
}

不該是 effect:使用者的操作所觸發的事情

有時候即使你寫 cleanup 也沒有辦法清除 effect 造成的影響,例如在 effect 中去打 API 告訴後端你要結帳購買一個產品。這個 effect 影響的層面擴及到伺服器端甚至是資料庫,因此你無法透過 cleanup 去逆轉這個影響。這種情況的真正問題是我們不應該把某些對應「使用者行為意志」的動作放在 effect 令其隨著 render 而被自動多次執行:

useEffect(
  () => {
    // ❌ 這個 request 會在 React 18 的 strict mode + dev env 自動被送出兩次
    // 它應該被寫在使用者觸發的事件中,而不是隨著 render 自動執行的 effect 中
    fetch('/api/buy', { method: 'POST' });
  },
  []
);

我們應該把它放在使用者自己觸發的事件中,例如使用者點擊了一個「送出購買」的按鈕:

function handleClick() {
  // ✅ 結帳購買的動作將會由使用者進行操作後才對應觸發一次
  fetch('/api/buy', { method: 'POST' });
}

關於 useEffect 的總結整理

在過去好幾篇文章中,我們以大量的篇幅來從各種角度全面的解析了 useEffect 的核心設計概念以及正確的使用方式,到這邊也算是告一個段落了。最後我們也在此整理一下其中的重點精華觀念:

  • useEffect 的正確用途
    • Function component 沒有提供生命週期的 API,只有 useEffect 用於「從資料同步到 effect 的行為與影響」
    • useEffect 讓你根據目前的資料來同步到 React elements 以外的事物或副作用
  • useEffect 是隨著每次 render 後而自動觸發的
    • 預設情況下,每一次 render 後都應該執行屬於該 render 的 effects,來確保同步的正確性與完整性
    • useEffect 的 dependencies 是一種「忽略某些不必要的同步」的效能最佳化,而不是用來控制 effect 發生在特定的 component 生命週期,或特定的商業邏輯時機
    • 確保你有自己寫條件處理來讓 effect 中的商業邏輯在你預期的情境下才被執行,而不應該依靠 dependencies 來控制這件事情
  • useEffect 應設計成即使多次重複執行也有保持行為正確的彈性
    • 確保你的 useEffect 無論隨著 render 重新執行了幾次,你的程式結果都應該保持同步且正常運作
    • 在 React 18 的 strict mode + dev build 環境時, 每個 component 都會在生命週期中自動被 mount 兩次。這是在模擬「mount => unmount => mount 」的過程, 幫助開發者檢查 effect 的設計是否有足夠的彈性,能夠在多次被執行時仍正確運行
    • 如果 effect 多次執行會導致問題,應優先考慮實作 effect 的 cleanup function 來停止或逆轉 effect 中造成的影響。如果真的只想在特定情況下才執行 effect 中的商業邏輯的話,應自行撰寫 flag 來處理條件判斷過濾

參考資料


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 25] Reusable state — React 18 的 useEffect 在 mount 時為何會執行兩次?
下一篇
[Day 27] useCallback 與 useMemo 的正確使用時機
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
apo7752
iT邦新手 5 級 ‧ 2022-10-16 15:04:54

安安Zet大,
文章中好像race condition有拼錯的地方,再麻煩看一下是不是筆誤~

Zet iT邦新手 2 級 ‧ 2022-10-16 17:24:08 檢舉

眼睛真利XD 已修正感謝提醒~

我要留言

立即登入留言