iT邦幫忙

2022 iThome 鐵人賽

DAY 22
1
Modern Web

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

[Day 22] 保持資料流 — 不要欺騙 hooks 的 dependencies(上)

  • 分享至 

  • xImage
  •  

在上一篇關於 useEffect 的深度解析中,我們已經了解到了 dependencies 是一種效能的最佳化手段,而不是用來控制生命週期或是商業邏輯。對於 useEffect 的 dependencies 撒謊通常會導致我們的應用程式出現一些難以察覺與追蹤的 bug,因為這會導致 component 錯誤的忽略某些依賴的原始資料變化時,應該要連動的同步動作

幾乎任何有 class component 經驗的 React 開發者,可能都曾經嘗試要欺騙 dependencies(包含筆者我自己也曾這樣做過)。你可能會覺得「我只想要在 mount 時執行一次這個 effect!」

當你的 effect 重複執行時會造成一些「疊加」而非「覆蓋」的影響時,多次執行這個 effect 確實有可能對你的應用程式造成問題。然而,這個問題的解法並不是給空陣列來欺騙 dependencies 以嘗試「模擬 componentDidMount 的效果」,而是有更理想的解決手段,我們稍後就會深入這個部分。


如果欺騙 dependencies 會有什麼問題

欺騙 dependencies 會導致 component 錯誤的忽略某些依賴的原始資料變化時,應該要連動的同步動作。我們先來以一個範例來幫助我們更深入的內化這個概念,它嘗試想要讓畫面上的數字每經過一秒就會自動 +1:

function Counter() {
  const [count, setCount] = useState(0);
 
  useEffect(
    () => {
      const id = setInterval(() => {
        setCount(count + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    []
  );
 
  return <h1>{count}</h1>;
}

不過實際上這個範例是有問題的,它只會增加一次之後數字就不動了。你看出問題是出在哪裡了嗎?

在這個範例中,你可能會有一種思考脈絡:「我想要定時自動重複執行更新資料的動作,為此我們需要設置一個 setInterval,所以希望只會觸發這個 effect 一次,並且也只會在 unmount 時做一次 clearInterval。如果給 dependencies 一個空陣列 [] 的話,就能讓它只會在 mount 以及 unmount 時才執行 effect 跟 cleanup了! 」

即使這個 effect 明明就依賴了 count 這個資料,但我們卻以 [] 欺騙了 React,這樣自作聰明的行為讓我們付出了代價。

讓我們來看看問題到底出在哪裡:

// 第一次 render,count state 是 0
function Counter() {
  // ...
  useEffect(
    // 第一次 render 的 effect
    () => {
      const id = setInterval(() => {
        setCount(0 + 1); // 永遠都會是 setCount(1)
      }, 1000);
      return () => clearInterval(id);
    },
    [] // 永遠不會重新執行
  );
  // ...
}
 
// 接下來的每次 re-render,count state 都是 1
function Counter() {
  // ...
  useEffect(
    // 從第二次 render 開始,
    // 這個 effect function 雖然有產生卻會一直被跳過而沒有執行,
    // 因為我們欺騙 React 說 dependencies 是空的
    () => {
      const id = setInterval(() => {
        setCount(count + 1);
      }, 1000);
      return () => clearInterval(id);
    },
    []
  );
  // ...
}

當這個 component 第一次 render 時,count 的值是 0 ,因此第一次 render 對應的 effect 中的 setCount(count + 1) 會是 setCount(0 + 1) 。由於 dependencies 是 [] ,所以 effect 再也沒被重新執行。而每一次 render 中的 state 是永遠不變的,所以 effect 中的 setInterval 裡所依賴的 count 的值是永遠不變的。因此,這個 setInterval 每秒都會固定重複呼叫一次 setCount(0 + 1)

我們欺騙了 React 這個 effect 沒有依賴任何 component 中的值,但事實上它依賴了 count。像這樣子因為欺騙 dependencies 所連帶導致的問題有時候是很難想像並察覺的。面對這種情況時,更好的處理方式應該是永遠誠實的填寫 dependencies,然後以其它方式排除那些 effect 重複執行時會有的問題。

我們看一下如何透過誠實的填寫 dependencies 來修正上面這個範例的問題:

function Counter() {
  const [count, setCount] = useState(0);
 
  useEffect(
    () => {
      const id = setInterval(() => {
        setCount(count + 1);
      }, 1000);
 
      return () => clearInterval(id);
    },
    [count] // 正確地填寫了 deps
  );
 
  return <h1>{count}</h1>;
}

在誠實地填寫 dependencies 之後,你會發現結果的行為修好了。讓我們來模擬 render 的情況:

// 第一次 render,count state 是 0
function Counter() {
  // ...
  useEffect(
    // 第一次 render 的 effect
    () => {
      const id = setInterval(() => {
        setCount(0 + 1); // setCount(count + 1)
      }, 1000);
      return () => clearInterval(id);
    },
    [0] // [count]
  );
  // ...
}
 
// 第二次 render,count state 是 1
function Counter() {
  // ...
  useEffect(
    // 第二次 render 的 effect
    () => {
      const id = setInterval(() => {
        setCount(1 + 1);  // setCount(count + 1)
      }, 1000);
      return () => clearInterval(id);
    },
    [1] // [count]
  );
  // ...
}

由於每次 render 時的 count 值並不相同,所以每次 render 時 effect 都會正確的被重新觸發。並且,由於我們有適當的做好 cleanup 的設計,所以每次 effect 被重新執行之前,都會先執行上一次 render 的 cleanup:

// --- 第一次 render 時,count 是 0 ---

// 第一次 render 的 effect
const id = setInterval(() => {
  setCount(0 + 1);  // setCount(count + 1)
}, 1000);

// --- 第二次 render 時,count 是 1 ---

// 檢查 dependencies 中的 count 與上一次 render 時的版本是否值相同:
// 0 !== 1,所以本次 effect 不需略過,正常執行

// 先清除第一次 render 的 effect
clearInterval(id); // 第一次 render 時的 setInterval 所回傳的 id

// 第二次 render 的 effect
const id = setInterval(() => {
  setCount(1 + 1);  // setCount(count + 1)
}, 1000);

// ...後面以此類推

你會發現雖然這樣可以修正問題,並且也沒有欺騙 dependencies,但每當 count 因為 setState 而在 re-render 中更新時,我們的 interval 就會被清掉再重設,所以每個 interval 其實都只存活了一秒後就會被清除並重新產生新的。這可能不是我們想要的理想效果。接下來就讓我們來延伸探討更好的解決方法。


讓 effect 自給自足

我們應該永遠對 dependencies 保持誠實,這是一個無論在任何情況下都應該保持的原則,沒有例外。然而如果我們遇到了像上面的範例那種情況時,我們應該嘗試調整 effect 的寫法,讓它不再需要依賴一個會頻繁更新的值 — 我們仍然要永遠保持對 dependencies 誠實,但可以盡量減少依賴的項目,意思就像是「讓 effect 對於需要的依賴自給自足」。

讓我們繼續延伸上面的 counter 範例。我們想要讓這個 effect 不再依賴 count,以避免 setInterval 頻繁的重新建立與清除,這樣我們就能在保持誠實的前提之下安全地將 count 從 dependencies 中移除。

為此,我們首先得先觀察,這個 effect 為什麼會需要依賴 count

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

useEffect(
	() => {
    const id = setInterval(() => {
      setCount(count + 1); // 在這裡依賴了 count
    }, 1000);
 
    return () => clearInterval(id);
  },
  [count]
);

看起來我們只是為了呼叫 setCount 時做到「以當前值做延伸計算」的行為而已。但其實在這樣的情況下,我們並不真的需要用到 count 變數。如果你還記得的話,我們曾在前面的篇章「[Day 14] 以 functional updater 來呼叫 setState」中介紹過:當我們想要根據舊的 state 值來更新一個 state 時,我們其實可以使用一個 updater function 來呼叫 setState

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

useEffect(
	() => {
    const id = setInterval(() => {
	  // 以 updater 來更新計算新的 state,不需要依賴 count 變數
      setCount(prevCount => prevCount + 1);
    }, 1000);
 
    return () => clearInterval(id);
  },
  [] // 現在我們不再依賴 count 變數了,可以安全地將它從 deps 中移除!
);

在替換成 updater function 之後,我們的 effect 就不再依賴 count 變數了,可以安全地將它從 dependencies 中移除!你可以在這個 CodeSandbox 觀察實際效果。

在原本的寫法中, count 確實是必要的依賴,但我們其實只是為了算出 count + 1 是多少之後再丟回給 setCount 方法而已。然而,React 內部其實一直都知道這個 state 目前的值是多少,因此我們其實只需要告訴 React「我想要讓目前的值增加 1」這個動作就好。這其實就是 setCount(prevCount => prevCount + 1) 在做的事情,你可以想像它傳給了 React 一份「更新資料的操作指南」,然後請 React 內部自己按照這個指南去更新資料。

透過 updater function,我們就能安全的移除 effect 對於 state 當前值的依賴,達到「自己自足」的效果 — 而我們仍舊保持了對 dependencies 的誠實。


參考資料

  • A Complete Guide to useEffect - Overreacted
    • 本文所講的觀念蠻多的參考了這篇 Dan Abramov 的個人 blog 文章,吸收內化後再加上我自己的理解以及整理來解釋這些概念。非常推薦所有英文程度 ok 的 React 開發者去他的 blog 中閱讀原文

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 21] useEffect 其實不是 function component 的生命週期 API
下一篇
[Day 23] 保持資料流 — 不要欺騙 hooks 的 dependencies(下)
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
peter12345qwe
iT邦新手 4 級 ‧ 2024-03-23 03:13:31

關於最後一個例子,我想請教有關COUNT值的問題。

當計時器啟動,count值被加成1之後,該組件要重新渲染,因為count值被改變了。
但useEffect的依賴項是一個空數組,代表useEffect在count值變成1之後都不會執行了,所以我的理解是認為count值會永遠是1。

但實際COUNT值會一直遞增,想請問我的思路那邊出現問題了,謝謝

看更多先前的回應...收起先前的回應...
Zet iT邦新手 2 級 ‧ 2024-03-24 03:24:28 檢舉

JavaScript 的內建 API setInterval() 是用來註冊
會定時執行傳入的 callback function,註冊完就會固定每隔一段時間自動執行傳入的 callback function。

所以即使這個 effect 函式只被執行了一次,但有成功執行到 setInterval 一次,所以以下這個 callback function 每秒還是會被自動執行:

() => {
    setCount(prevCount => prevCount + 1);
}

可是當count值從0變成1的時候,應該會觸發清除函數取消掉setInterval吧?
先謝謝大大的回覆。

Zet iT邦新手 2 級 ‧ 2024-03-31 17:07:13 檢舉

cleanup 函式是在「每新一次 effect 函式被觸發前或 unmount 時」才會執行。所以這例子一般來說會執到 unmount 時才執行到 clearInterval()

感謝大大,我找到我的問題點了。
我一直以為rerender算是一種unmount,所以會直接觸發清除函式。
但其實rerender觸發清除函式的時機點是在新的useEffect被執行前,這樣的理解才是正確的嗎

我要留言

立即登入留言