v19.2Latest

與 Effect 同步

有些元件需要與外部系統同步。例如,您可能想根據 React 狀態來控制一個非 React 元件、建立伺服器連線,或是在元件出現在畫面上時傳送分析日誌。Effect讓您可以在渲染後執行一些程式碼,以便將您的元件與 React 外部的某些系統同步。

您將學習
  • 什麼是 Effect
  • Effect 與事件有何不同
  • 如何在您的元件中宣告 Effect
  • 如何避免不必要地重新執行 Effect
  • 為什麼 Effect 在開發環境中會執行兩次,以及如何修正

什麼是 Effect?它們與事件有何不同?

在深入瞭解 Effect 之前,您需要熟悉 React 元件內的兩種邏輯:

  • 渲染程式碼(在描述 UI中介紹)位於您元件的頂層。這是您接收 props 和 state、轉換它們,並返回您想在畫面上看到的 JSX 的地方。渲染程式碼必須是純粹的。就像數學公式一樣,它應該只計算結果,而不做其他事情。
  • 事件處理器(在新增互動性中介紹)是您元件內的巢狀函式,它們會執行操作而不僅僅是計算。事件處理器可能會更新輸入欄位、提交 HTTP POST 請求以購買產品,或將使用者導航到另一個畫面。事件處理器包含由特定使用者操作(例如,按鈕點擊或輸入)引起的「副作用」(它們會改變程式的狀態)。

有時這還不夠。考慮一個ChatRoom元件,它必須在每次出現在畫面上時連線到聊天伺服器。連線到伺服器不是純粹的計算(它是副作用),因此不能在渲染期間發生。然而,並沒有像點擊這樣的單一特定事件會導致ChatRoom被顯示。

Effect讓您可以指定由渲染本身引起的副作用,而不是由特定事件引起的副作用。在聊天中傳送訊息是一個事件,因為它是由使用者點擊特定按鈕直接引起的。然而,建立伺服器連線是一個Effect,因為無論是哪種互動導致元件出現,它都應該發生。Effect 在提交結束後、畫面更新後執行。這是將 React 元件與某些外部系統(如網路或第三方函式庫)同步的好時機。

注意

在此處及後文中,大寫的「Effect」指的是上述 React 特定的定義,即由渲染引起的副作用。若要指更廣泛的程式設計概念,我們會說「副作用」。

您可能不需要 Effect

不要急於在您的元件中新增 Effect。請記住,Effect 通常用於「跳出」您的 React 程式碼並與某些外部系統同步。這包括瀏覽器 API、第三方小工具、網路等等。如果您的 Effect 僅根據其他狀態調整某些狀態,您可能不需要 Effect。

如何撰寫 Effect

要撰寫 Effect,請遵循以下三個步驟:

  1. 宣告一個 Effect。預設情況下,您的 Effect 會在每次提交後執行。
  2. 指定 Effect 的依賴項。大多數 Effect 應該只在需要時重新執行,而不是在每次渲染後。例如,淡入動畫應該只在元件出現時觸發。連線和斷開聊天室的連線應該只在元件出現和消失時,或聊天室變更時發生。您將學習如何透過指定依賴項來控制這一點。
  3. 如果需要,新增清理函式。有些 Effect 需要指定如何停止、撤銷或清理它們所做的任何事情。例如,「連線」需要「斷線」、「訂閱」需要「取消訂閱」,而「擷取」需要「取消」或「忽略」。您將學習如何透過返回一個清理函式來做到這一點。

讓我們詳細看看這些步驟中的每一步。

步驟 1:宣告一個 Effect

要在您的元件中宣告一個 Effect,請從 React 匯入useEffect Hook

然後,在您的元件頂層呼叫它,並將一些程式碼放入您的 Effect 中:

每次您的元件渲染時,React 會更新畫面然後執行useEffect內部的程式碼。換句話說,useEffect會將一段程式碼的執行「延遲」到該次渲染反映到畫面上之後。

讓我們看看如何使用 Effect 來與外部系統同步。考慮一個<VideoPlayer> React 元件。如果能透過傳遞一個isPlaying屬性來控制它是播放還是暫停,那就太好了:

您自訂的VideoPlayer元件會渲染瀏覽器內建的<video>標籤:

然而,瀏覽器的<video>標籤並沒有isPlaying屬性。控制它的唯一方法是手動呼叫 DOM 元素上的play()pause()方法。您需要同步isPlaying屬性的值(該值指示影片目前應該播放)與像play()pause()這樣的呼叫。

我們首先需要取得一個指向<video>DOM 節點的 ref。

您可能會想在渲染期間嘗試呼叫play()pause(),但這是不正確的:

這段程式碼不正確的原因是它試圖在渲染期間對 DOM 節點進行操作。在 React 中,渲染應該是 JSX 的純計算,不應包含像修改 DOM 這樣的副作用。

此外,當第一次呼叫VideoPlayer時,它的 DOM 還不存在!還沒有 DOM 節點可以呼叫play()pause(),因為在您返回 JSX 之前,React 不知道要建立什麼 DOM。

這裡的解決方案是useEffect包裹副作用,將其移出渲染計算:

透過將 DOM 更新包裹在 Effect 中,您讓 React 先更新畫面。然後您的 Effect 才會執行。

當您的VideoPlayer元件渲染時(無論是第一次還是重新渲染),會發生幾件事。首先,React 會更新畫面,確保<video>標籤以正確的屬性存在於 DOM 中。然後 React 會執行您的 Effect。最後,您的 Effect 會根據isPlaying的值呼叫play()pause()

多次按下播放/暫停,觀察影片播放器如何與isPlaying值保持同步:

在這個範例中,您同步到 React 狀態的「外部系統」是瀏覽器的媒體 API。您可以使用類似的方法將舊的非 React 程式碼(例如 jQuery 外掛)包裝成宣告式的 React 元件。

請注意,在實際應用中控制影片播放器要複雜得多。呼叫play()可能會失敗,使用者可能會使用瀏覽器內建的控制項來播放或暫停,等等。這個範例非常簡化且不完整。

陷阱

預設情況下,Effect 會在每次渲染後執行。這就是為什麼像這樣的程式碼會產生無限迴圈:

Effect 是作為渲染的結果而執行的。設定狀態會觸發渲染。在 Effect 中立即設定狀態,就像將電源插座插到它自己身上一樣。Effect 執行,它設定狀態,這導致重新渲染,這導致 Effect 執行,它再次設定狀態,這導致另一次重新渲染,依此類推。

Effect 通常應該將您的元件與一個外部系統同步。如果沒有外部系統,而您只想根據其他狀態來調整某些狀態,您可能不需要 Effect。

步驟 2:指定 Effect 的依賴項

預設情況下,Effect 會在每次渲染後執行。通常,這不是您想要的:

  • 有時,這很慢。與外部系統同步並非總是即時的,因此您可能希望除非必要,否則跳過它。例如,您不會想在每次按鍵時都重新連線到聊天伺服器。
  • 有時,這是錯誤的。例如,您不會想在每次按鍵時都觸發元件淡入動畫。動畫應該只在元件首次出現時播放一次。

為了演示這個問題,這裡是之前的範例,加上幾個console.log呼叫和一個更新父元件狀態的文字輸入框。請注意輸入文字如何導致 Effect 重新執行:

您可以透過指定一個依賴項陣列作為 跳過不必要的 Effect 重新執行呼叫的第二個參數,來告訴 ReactuseEffect。首先在上面的範例第 14 行添加一個空的[] 陣列:

您應該會看到一個錯誤,顯示React Hook useEffect has a missing dependency: 'isPlaying'

問題在於您的 Effect 內部的程式碼依賴於 isPlayingprop 來決定要做什麼,但這個依賴項沒有被明確宣告。要解決這個問題,請將isPlaying添加到依賴項陣列中:

現在所有依賴項都已宣告,因此沒有錯誤。將 [isPlaying] 指定為依賴項陣列告訴 React,如果 isPlaying與上一次渲染時相同,則應該跳過重新執行您的 Effect。透過這個更改,在輸入框中打字不會導致 Effect 重新執行,但按下播放/暫停按鈕會:

依賴陣列可以包含多個依賴項。只有當你指定的所有依賴項的值與上一次渲染時的值完全相同時,React 才會跳過重新執行 Effect。React 使用Object.is比較來比較依賴項的值。詳情請參閱useEffect 參考

請注意,你無法「選擇」你的依賴項。如果你指定的依賴項與 React 根據你 Effect 內的程式碼所預期的依賴項不符,你將會收到一個 lint 錯誤。這有助於發現程式碼中的許多錯誤。如果你不希望某些程式碼重新執行,請編輯 Effect 程式碼本身,使其「不需要」該依賴項。

陷阱

沒有依賴陣列和帶有[]依賴陣列的行為是不同的:

我們將在下一步中仔細探討「掛載」的含義。

Deep Dive
為什麼 ref 被省略在依賴陣列之外?

步驟 3:如果需要,添加清理函數

考慮一個不同的例子。你正在編寫一個ChatRoom 元件,當它出現時需要連接到聊天伺服器。你獲得了一個 createConnection()API,它返回一個具有connect()disconnect()方法的物件。你如何在元件顯示給使用者時保持其連接狀態?

首先寫出 Effect 邏輯:

在每次重新渲染後都連線到聊天室會很慢,因此你加入了依賴陣列:

Effect 內的程式碼沒有使用任何 props 或 state,因此你的依賴陣列是[](空的)。這告訴 React 只在元件「掛載」時(即首次出現在畫面上時)執行這段程式碼。

讓我們試著執行這段程式碼:

這個 Effect 只在掛載時執行,因此你可能預期控制台只會印出一次"✅ Connecting..."然而,如果你檢查控制台,"✅ Connecting..."會被印出兩次。為什麼會這樣?

想像 ChatRoom 元件是一個包含許多不同畫面的大型應用程式的一部分。使用者從 ChatRoom頁面開始他們的旅程。元件掛載並呼叫connection.connect()。接著想像使用者導航到另一個畫面——例如,設定頁面。ChatRoom元件會卸載。最後,使用者點擊返回,ChatRoom 再次掛載。這會建立第二個連線——但第一個連線從未被銷毀!隨著使用者在應用程式中導航,連線會不斷累積。

如果沒有進行廣泛的手動測試,這類錯誤很容易被忽略。為了幫助你快速發現它們,在開發環境中,React 會在元件初始掛載後立即重新掛載每個元件一次。

看到 "✅ Connecting..."日誌出現兩次,有助於你注意到真正的問題:你的程式碼在元件卸載時沒有關閉連線。

要修復這個問題,請從你的 Effect 中回傳一個清理函數

每次在 Effect 再次執行之前,以及元件卸載(被移除)時最後一次,React 都會呼叫你的清理函數。讓我們看看實作清理函數後會發生什麼:

現在你在開發環境中會得到三條控制台日誌:

  1. "✅ Connecting..."
  2. "❌ Disconnected."
  3. "✅ Connecting..."

這是開發環境中的正確行為。透過重新掛載你的元件,React 驗證了導航離開再返回不會破壞你的程式碼。斷開連線然後重新連線正是應該發生的情況!當你妥善實作清理時,Effect 執行一次與執行、清理、再執行之間,使用者應該看不出任何差異。之所以會多出一組連接/斷開的呼叫,是因為 React 正在開發環境中探查你的程式碼是否存在錯誤。這是正常的——不要試圖讓它消失!

在生產環境中,你只會看到"✅ Connecting..."被印出一次。重新掛載元件只發生在開發環境中,以幫助你找到需要清理的 Effect。你可以關閉嚴格模式來選擇退出開發行為,但我們建議保持開啟。這可以讓你發現許多像上面這樣的錯誤。

如何在開發環境中處理 Effect 觸發兩次?

React 在開發環境中故意重新掛載你的元件,以發現像上一個例子中的錯誤。正確的問題不是「如何讓 Effect 只執行一次」,而是「如何修正我的 Effect,使其在重新掛載後仍能正常運作」。

通常,答案是實作清理函數。清理函數應該停止或復原 Effect 所做的任何事情。經驗法則是,使用者不應該能夠區分 Effect 執行一次(如在生產環境中)與設定 → 清理 → 設定序列(如你在開發環境中會看到的)之間的差異。

你編寫的大多數 Effect 都將符合以下常見模式之一。

陷阱

不要使用 ref 來防止 Effect 觸發

在開發環境中防止 Effect 觸發兩次的一個常見陷阱是使用ref 來防止 Effect 執行超過一次。例如,你可以用 useRef來「修正」上述錯誤:

這使得你在開發環境中只看到一次"✅ Connecting...",但它並沒有修復錯誤。

當使用者導航離開時,連線仍然沒有關閉,當他們導航回來時,會建立一個新的連線。隨著使用者在應用程式中導航,連線會不斷累積,與「修正」前的情況相同。

要修復錯誤,僅僅讓 Effect 執行一次是不夠的。Effect 需要在重新掛載後正常工作,這意味著連線需要像上面的解決方案一樣被清理。

請參閱以下範例,了解如何處理常見模式。

控制非 React 小工具

有時你需要添加不是用 React 編寫的 UI 小工具。例如,假設你正在向頁面添加一個地圖元件。它有一個setZoomLevel()方法,並且你希望縮放等級與 React 程式碼中的zoomLevel狀態變數保持同步。你的 Effect 看起來會類似這樣:

請注意,在這種情況下不需要清理。在開發環境中,React 會呼叫 Effect 兩次,但這不是問題,因為使用相同的值呼叫setZoomLevel兩次不會執行任何操作。它可能會稍微慢一些,但這無關緊要,因為在生產環境中它不會不必要地重新掛載。

有些 API 可能不允許您連續呼叫它們兩次。例如,內建showModal 方法的 <dialog>元素,如果您呼叫它兩次就會拋出錯誤。請實作清理函數並讓它關閉對話框:

在開發環境中,您的 Effect 會呼叫showModal(),然後立即 close(),接著再次showModal()。這與呼叫一次 showModal()具有相同的使用者可見行為,就像您在生產環境中看到的那樣。

訂閱事件

如果您的 Effect 訂閱了某些內容,清理函數應該取消訂閱:

在開發環境中,您的 Effect 會呼叫addEventListener(),然後立即 removeEventListener(),接著再次使用相同的處理函數呼叫addEventListener()。因此,一次只會有一個有效的訂閱。這與呼叫一次 addEventListener()具有相同的使用者可見行為,就像在生產環境中一樣。

觸發動畫

如果您的 Effect 將某個元素以動畫形式呈現,清理函數應該將動畫重置為初始值:

在開發環境中,不透明度將被設定為1,然後設定為0,接著再次設定為1。這應該與直接將其設定為 1具有相同的使用者可見行為,這也是在生產環境中會發生的情況。如果您使用支援補間動畫的第三方動畫庫,您的清理函數應該將時間軸重置為其初始狀態。

擷取資料

如果你的 Effect 獲取資料,清理函式應該要麼中止擷取,要麼忽略其結果:

你無法「撤銷」已經發生的網路請求,但你的清理函式應確保那些不再相關的擷取不會繼續影響你的應用程式。如果userId'Alice'變為'Bob',清理機制會確保'Alice'的回應即使是在'Bob'之後抵達也會被忽略。

在開發環境中,你會在網路標籤頁看到兩次擷取。這沒有問題。使用上述方法,第一個 Effect 會立即被清理,因此其ignore變數副本會被設為true。所以即使有額外的請求,由於if (!ignore)檢查,它也不會影響狀態。

在生產環境中,只會有一次請求。如果開發環境中的第二次請求困擾你,最好的方法是使用一種能去重複請求並在元件間快取其回應的解決方案:

這不僅能改善開發體驗,還能讓你的應用程式感覺更快。例如,使用者按下返回按鈕時,不必等待某些資料再次載入,因為它已被快取。你可以自己建立這樣的快取,或者使用許多替代手動在 Effect 中擷取的方法之一。

Deep Dive
在 Effect 中進行資料擷取有哪些好的替代方案?

傳送分析資料

考慮這段在頁面訪問時傳送分析事件的程式碼:

在開發環境中,logVisit會對每個 URL 呼叫兩次,因此你可能會想嘗試修復這個問題。我們建議保持這段程式碼不變。與前面的範例一樣,執行一次和執行兩次之間沒有使用者可見的行為差異。從實際角度來看,logVisit在開發環境中不應該做任何事情,因為你不希望來自開發機器的日誌影響生產環境的指標。每次儲存檔案時,你的元件都會重新掛載,因此無論如何,在開發環境中它都會記錄額外的訪問。

在生產環境中,不會有重複的訪問日誌。

要除錯你傳送的分析事件,你可以將應用程式部署到預備環境(以生產模式執行),或暫時選擇退出 嚴格模式及其僅在開發環境中進行的重新掛載檢查。你也可以從路由變更事件處理常式傳送分析資料,而不是從 Effect。對於更精確的分析,交集觀察器可以幫助追蹤哪些元件在視口中以及它們保持可見的時間。

不是 Effect:初始化應用程式

有些邏輯應該只在應用程式啟動時執行一次。你可以將其放在元件外部:

這確保了此類邏輯只在瀏覽器載入頁面後執行一次。

不是 Effect:購買產品

有時,即使你撰寫了清理函式,也無法防止執行 Effect 兩次所導致的使用者可見後果。例如,也許你的 Effect 傳送了一個 POST 請求,像是購買產品:

你不會想購買產品兩次。然而,這也是為什麼你不應該將此邏輯放在 Effect 中的原因。如果使用者前往另一個頁面然後按返回呢?你的 Effect 會再次執行。你不希望在使用者訪問頁面時購買產品;你希望在使用者點擊購買按鈕時購買。

購買不是由渲染引起的;它是由特定的互動引起的。它應該只在使用者按下按鈕時執行。刪除 Effect 並將你的/api/buy請求移到購買按鈕的事件處理常式中:

這說明如果重新掛載會破壞你的應用程式邏輯,這通常會暴露出既有的錯誤。從使用者的角度來看,瀏覽一個頁面不應該與瀏覽它、點擊一個連結、然後按返回鍵再次查看該頁面有所不同。React 透過在開發環境中重新掛載元件一次來驗證你的元件是否遵守這個原則。

總結

這個遊樂場可以幫助你「感受」Effect 在實際中是如何運作的。

這個範例使用setTimeout來安排一個 console.log,在 Effect 執行三秒後顯示輸入的文字。清理函式會取消待處理的計時器。首先點擊「掛載元件」:

一開始你會看到三條日誌:Schedule "a" logCancel "a" logSchedule "a" log 再次出現。三秒後還會有一條顯示 a的日誌。正如你之前學到的,額外的排程/取消配對是因為 React 在開發環境中會重新掛載元件一次,以驗證你是否正確實作了清理。

現在將輸入框內容編輯為abc。如果你操作得夠快,你會立即看到Schedule "ab" log,緊接著是 Cancel "ab" logSchedule "abc" logReact 總是在下一次渲染的 Effect 之前清理上一次渲染的 Effect。這就是為什麼即使你快速輸入,同一時間最多也只會有一個計時器被排程。編輯輸入框幾次,觀察控制台,感受一下 Effect 是如何被清理的。

在輸入框中輸入一些內容,然後立即點擊「卸載元件」。注意卸載是如何清理最後一次渲染的 Effect 的。在這裡,它會在最後一個計時器有機會觸發之前將其清除。

最後,編輯上面的元件並註解掉清理函式,這樣計時器就不會被取消。嘗試快速輸入 abcde。你預期三秒後會發生什麼?計時器內的console.log(text) 會印出 最新的text 並產生五條 abcde日誌嗎?試試看,驗證你的直覺!

三秒後,你應該會看到一系列日誌(aababcabcdabcde),而不是五條abcde日誌。每個 Effect 都會「捕獲」其對應渲染時的text 值。 無論 text 狀態是否改變:來自 text = 'ab'那次渲染的 Effect 總是會看到'ab'。換句話說,每次渲染的 Effect 都是彼此隔離的。如果你好奇這是如何運作的,可以閱讀關於

Deep Dive
每次渲染都有其專屬的 Effect

回顧

  • 與事件不同,Effect 是由渲染本身觸發,而非特定的互動。
  • Effect 讓你能將元件與外部系統(第三方 API、網路等)同步。
  • 預設情況下,Effect 會在每次渲染(包括初始渲染)後執行。
  • 如果 Effect 的所有依賴項值與上次渲染時相同,React 將跳過該 Effect。
  • 你無法「選擇」依賴項。它們是由 Effect 內部的程式碼決定的。
  • 空的依賴陣列([])對應於元件「掛載」,即被添加到畫面上。
  • 在嚴格模式中,React 會掛載元件兩次(僅在開發環境中!)以壓力測試你的 Effect。
  • 如果你的 Effect 因重新掛載而中斷,你需要實作清理函式。
  • React 會在 Effect 下次執行前以及元件卸載時呼叫你的清理函式。

Try out some challenges

Challenge 1 of 4:Focus a field on mount #

In this example, the form renders a <MyInput /> component.

Use the input’s focus() method to make MyInput automatically focus when it appears on the screen. There is already a commented out implementation, but it doesn’t quite work. Figure out why it doesn’t work, and fix it. (If you’re familiar with the autoFocus attribute, pretend that it does not exist: we are reimplementing the same functionality from scratch.)

To verify that your solution works, press “Show form” and verify that the input receives focus (becomes highlighted and the cursor is placed inside). Press “Hide form” and “Show form” again. Verify the input is highlighted again.

MyInput should only focus on mount rather than after every render. To verify that the behavior is right, press “Show form” and then repeatedly press the “Make it uppercase” checkbox. Clicking the checkbox should not focus the input above it.