使用 Effects 同步

有些元件需要與外部系統同步。例如,您可能希望根據 React 狀態控制非 React 元件、設定伺服器連線,或在元件出現在螢幕上時傳送分析記錄。 *Effects* 讓您在渲染後執行一些程式碼,以便您可以將元件與 React 之外的某些系統同步。

你將學到

  • 什麼是 Effects
  • Effects 與事件有何不同
  • 如何在元件中宣告 Effect
  • 如何在不必要時跳過重新執行 Effect
  • 為什麼 Effects 在開發中會執行兩次以及如何修復它們

什麼是 Effects 以及它們與事件有何不同?

在開始使用 Effects 之前,您需要熟悉 React 元件中的兩種邏輯類型

  • 渲染程式碼(在 描述 UI 中介紹)位於元件的最上層。在這裡,您取得 props 和狀態,對它們進行轉換,並返回您希望在螢幕上看到的 JSX。 渲染程式碼必須是純粹的。 就像數學公式一樣,它應該只 *計算* 結果,而不執行任何其他操作。

  • 事件處理程式(在 新增互動性 中介紹)是元件內部的巢狀函式,它們 *執行* 操作而不是僅僅計算它們。事件處理程式可能會更新輸入欄位、提交 HTTP POST 請求以購買產品,或將使用者導航到另一個螢幕。事件處理程式包含 “副作用”(它們會更改程式的狀態),這些副作用是由特定使用者動作(例如,按一下按鈕或輸入文字)引起的。

有時這是不夠的。考慮一個 ChatRoom 元件,它必須在螢幕上可見時連線到聊天伺服器。連線到伺服器不是純粹的計算(這是一個副作用),因此它不能在渲染期間發生。但是,沒有像點擊這樣的單一特定事件會導致 ChatRoom 顯示。

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

注意

此處及本文後續部分,大寫的「Effect」指的是上述 React 特定的定義,即由渲染引起的副作用。為了指代更廣泛的程式設計概念,我們將使用「副作用」。

你可能不需要 Effect

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

如何撰寫 Effect

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

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

讓我們詳細看看每個步驟。

步驟 1:宣告一個 Effect

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

import { useEffect } from 'react';

然後,在組件的最上層呼叫它,並在您的 Effect 中放入一些程式碼

function MyComponent() {
useEffect(() => {
// Code here will run after *every* render
});
return <div />;
}

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

讓我們看看如何使用 Effect 與外部系統同步。考慮一個 <VideoPlayer> React 組件。透過傳遞一個 isPlaying prop 來控制它是播放還是暫停會很方便

<VideoPlayer isPlaying={isPlaying} />;

您的自定義 VideoPlayer 組件會渲染內建的瀏覽器 <video> 標籤

function VideoPlayer({ src, isPlaying }) {
// TODO: do something with isPlaying
return <video src={src} />;
}

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

我們首先需要取得 <video> DOM 節點的引用。

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

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  if (isPlaying) {
    ref.current.play();  // Calling these while rendering isn't allowed.
  } else {
    ref.current.pause(); // Also, this crashes.
  }

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

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

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

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

import { useEffect, useRef } from 'react';

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);

useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});

return <video ref={ref} src={src} loop playsInline />;
}

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

當您的 VideoPlayer 組件渲染時(第一次或重新渲染時),會發生一些事情。首先,React 將更新畫面,確保 <video> 標籤在 DOM 中具有正確的 props。然後 React 將執行您的 Effect。最後,您的 Effect 將根據 isPlaying 的值呼叫 play()pause()

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

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

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

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

陷阱

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

const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});

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

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

步驟 2:指定 Effect 相依性

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

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

為了說明這個問題,以下是前一個範例,其中包含一些 console.log 呼叫和一個更新父組件狀態的文字輸入。請注意輸入如何導致 Effect 重新執行

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

您可以透過將*相依性*陣列指定為 useEffect 呼叫的第二個參數,來告訴 React 跳過不必要的 Effect 重新執行。首先在上面範例的第 14 行新增一個空的 [] 陣列

useEffect(() => {
// ...
}, []);

您應該會看到一個錯誤訊息,指出 React Hook useEffect 缺少一個相依性:'isPlaying'

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, []); // This causes an error

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

問題在於你的 Effect 內的程式碼*依賴* isPlaying prop 來決定要執行的動作,但這個依賴關係並沒有明確地宣告。要解決這個問題,請將 isPlaying 加入到依賴陣列中。

useEffect(() => {
if (isPlaying) { // It's used here...
// ...
} else {
// ...
}
}, [isPlaying]); // ...so it must be declared here!

現在所有依賴關係都已宣告,所以沒有錯誤。將 [isPlaying] 指定為依賴陣列會告訴 React,如果 isPlaying 與前一次渲染時相同,則應略過重新執行你的 Effect。透過這個更改,在輸入框中輸入文字不會導致 Effect 重新執行,但按下播放/暫停則會。

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, [isPlaying]);

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

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

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

陷阱

沒有依賴陣列和使用*空的* [] 依賴陣列的行為是不同的。

useEffect(() => {
// This runs after every render
});

useEffect(() => {
// This runs only on mount (when the component appears)
}, []);

useEffect(() => {
// This runs on mount *and also* if either a or b have changed since the last render
}, [a, b]);

我們將在下一步仔細研究「掛載」的含義。

深入探討

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

這個 Effect 同時使用了 refisPlaying,但只有 isPlaying 被宣告為依賴項。

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);

這是因為 ref 物件具有*穩定的識別性:*React 保證每次渲染中,你都會從相同的 useRef 呼叫中取得相同的物件。它永遠不會改變,所以它本身永遠不會導致 Effect 重新執行。因此,是否包含它並不重要。包含它也可以。

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying, ref]);

useState 返回的 set 函式 也具有穩定的識別性,所以你經常會看到它們也被省略在依賴項之外。如果 linter 允許您在沒有錯誤的情況下省略依賴項,那麼這樣做是安全的。

只有當 linter 可以「看到」物件是穩定的時,才能省略始終穩定的依賴項。例如,如果 ref 是從父元件傳遞的,則必須在依賴陣列中指定它。然而,這很好,因為你無法知道父元件是否總是傳遞相同的 ref,或者是有條件地傳遞多個 ref 之一。因此,你的 Effect *將*取決於傳遞的是哪個 ref。

步驟 3:如果需要,新增清除函式

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

首先撰寫 Effect 邏輯。

useEffect(() => {
const connection = createConnection();
connection.connect();
});

每次重新渲染後都連接到聊天伺服器會很慢,所以你加入了依賴陣列。

useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);

Effect 內的程式碼沒有使用任何 props 或 state,所以你的依賴陣列是 [](空的)。這告訴 React 只在元件「掛載」時執行此程式碼,也就是第一次出現在螢幕上時。

讓我們嘗試執行這段程式碼。

import { useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}

這個 Effect 只在掛載時執行,因此你可能會預期在主控台中只會印出一次 "✅ Connecting..."但是,如果你檢查主控台,"✅ Connecting..." 會被印出兩次。為什麼會發生這種情況?

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

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

看到 "✅ Connecting..." 記錄兩次可以幫助你注意到真正的問題:當元件卸載時,你的程式碼沒有關閉連線。

要解決此問題,請從你的 Effect 中返回一個*清除函式*。

useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);

每次 Effect 重新執行之前,React 都會呼叫你的清除函式,並且在元件卸載(被移除)時最後一次呼叫。讓我們看看實作清除函式後會發生什麼。

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}

現在你在開發過程中會看到三個主控台記錄。

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

這是開發中的正確行為。 透過重新掛載你的元件,React 會驗證導航離開並返回不會破壞你的程式碼。斷開連線然後再次連線正是應該發生的情況!當你妥善實作清除函式時,執行 Effect 一次與執行它、清除它,然後再次執行它之間應該沒有使用者可見的差異。由於 React 正在探測你的程式碼以查找開發中的錯誤,因此會多一對連線/斷開連線的呼叫。這是正常的,不要試圖讓它消失!

在正式環境中,您只會看到 "✅ 連線中..." 印出一次。 在開發環境中重新掛載組件只是為了幫助您找到需要清理的 Effect。您可以關閉 嚴格模式 來停用此開發行為,但我們建議您保持開啟。這讓您可以找到許多像上面那樣的錯誤。

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

React 在開發環境中會故意重新掛載您的組件,以便找出像上一個範例中的錯誤。正確的問題不是「如何只執行一次 Effect」,而是「如何修復我的 Effect,使其在重新掛載後也能正常運作」。

通常,答案是實作清理函式。清理函式應該停止或撤銷 Effect 正在執行的任何操作。經驗法則是,使用者不應該能夠區分 Effect 只執行一次(如在正式環境中)和 *設定 → 清理 → 設定* 序列(如您在開發環境中所見)。

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

陷阱

不要使用 ref 來防止 Effect 觸發

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

const connectionRef = useRef(null);
useEffect(() => {
// 🚩 This wont fix the bug!!!
if (!connectionRef.current) {
connectionRef.current = createConnection();
connectionRef.current.connect();
}
}, []);

這讓您在開發環境中只看到一次 "✅ 連線中...",但它並沒有修復錯誤。

當使用者離開頁面時,連線仍然沒有關閉,當他們返回時,會建立一個新的連線。當使用者在應用程式中導航時,連線會不斷累積,就像「修復」之前一樣。

要修復此錯誤,僅讓 Effect 執行一次是不夠的。Effect 需要在重新掛載後也能正常運作,這表示需要清理連線,如上述解決方案所示。

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

控制非 React 元件

有時您需要新增非 React 編寫的 UI 元件。例如,假設您要在地圖頁面中新增地圖組件。它有一個 setZoomLevel() 方法,並且您希望將縮放級別與 React 代碼中的 zoomLevel 狀態變數同步。您的 Effect 看起來會像這樣

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

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

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

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

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

訂閱事件

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

useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);

在開發環境中,您的 Effect 將呼叫 addEventListener(),然後立即呼叫 removeEventListener(),然後使用相同的處理程式再次呼叫 addEventListener()。因此,一次只會有一個有效的訂閱。這與在正式環境中呼叫一次 addEventListener() 具有相同的使用者可見行為。

觸發動畫

如果您的 Effect 執行動畫,則清理函式應該將動畫重置為初始值

useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // Trigger the animation
return () => {
node.style.opacity = 0; // Reset to the initial value
};
}, []);

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

擷取資料

如果您的 Effect 擷取某些內容,則清理函式應該 中止擷取 或忽略其結果

useEffect(() => {
let ignore = false;

async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}

startFetching();

return () => {
ignore = true;
};
}, [userId]);

您無法「復原」已經發生的網路請求,但您的清除函式應確保*不再相關*的提取不會持續影響您的應用程式。如果 userId'Alice' 更改為 'Bob',清除函式會確保即使 'Alice' 的回應在 'Bob' 的回應之後到達,也會被忽略。

在開發過程中,您會在「網路」分頁中看到兩個提取請求。這沒有問題。使用上述方法,第一個 Effect 會立即被清除,因此其 ignore 變數的副本將被設為 true。因此,即使有一個額外的請求,它也不會因為 if (!ignore) 檢查而影響狀態。

在正式環境中,只會有一個請求。 如果開發中的第二個請求讓您感到困擾,最好的方法是使用一個可以在組件之間重複使用請求並快取其回應的解決方案。

function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...

這不僅可以改善開發體驗,還可以讓您的應用程式感覺更快。例如,使用者按下「返回」按鈕時,不必再次等待某些資料載入,因為它會被快取。您可以自行建置這樣的快取,或使用 Effects 中手動提取的許多替代方案之一。

深入探討

Effects 中資料提取的良好替代方案是什麼?

在 Effects 內撰寫 fetch 呼叫是 一種流行的提取資料方式,尤其是在純客戶端應用程式中。然而,這是一種非常手動的方法,並且具有明顯的缺點。

  • Effects 不會在伺器端執行。 這表示初始伺服器渲染的 HTML 只會包含載入狀態,沒有資料。客戶端電腦必須下載所有 JavaScript 並渲染您的應用程式,才會發現現在需要載入資料。這不是很有效率。
  • 直接在 Effects 中提取很容易造成「網路瀑布」。 您渲染父組件,它提取一些資料,渲染子組件,然後子組件開始提取它們的資料。如果網路速度不是很慢,這明顯比並行提取所有資料慢。
  • 直接在 Effects 中提取通常表示您沒有預載或快取資料。 例如,如果組件卸載然後再次掛載,它必須再次提取資料。
  • 這不是很符合人體工學。 以避免 競爭條件 等錯誤的方式在 Effects 中撰寫 fetch 呼叫會涉及相當多的樣板程式碼。

這個缺點清單並非 React 特有。它適用於任何使用函式庫在掛載時提取資料的情況。與路由一樣,資料提取並不容易做好,因此我們建議以下方法:

  • 如果您使用 框架,請使用其內建的資料提取機制。 現代 React 框架具有整合的資料提取機制,這些機制效率高且不會遇到上述的陷阱。
  • 否則,請考慮使用或建置客戶端快取。 熱門的開源解決方案包括 React QueryuseSWRReact Router 6.4+。 您也可以建置自己的解決方案,在這種情況下,您會在底層使用 Effects,但會新增邏輯來重複使用請求、快取回應和避免網路瀑布(透過預載資料或將資料需求提升到路由)。

如果這些方法都不適合您,您可以繼續直接在 Effects 中提取資料。

發送分析

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

useEffect(() => {
logVisit(url); // Sends a POST request
}, [url]);

在開發過程中,每個網址都會呼叫 logVisit 兩次,因此您可能會想要嘗試修復它。我們建議保留這段程式碼的原樣。 與前面的範例一樣,執行一次和執行兩次之間沒有*使用者可見的*行為差異。從實用的角度來看,logVisit 在開發過程中不應該做任何事情,因為您不希望來自開發機器的記錄扭曲正式環境的指標。每次儲存檔案時,您的組件都會重新掛載,因此它無論如何在開發過程中都會記錄額外的訪問次數。

在正式環境中,不會有重複的訪問記錄。

要除錯您正在發送的分析事件,您可以將您的應用程式部署到預備環境(以正式環境模式執行)或暫時停用 嚴格模式 及其僅限開發的重新掛載檢查。您也可以從路由變更事件處理程式而不是 Effects 發送分析。為了獲得更精確的分析,Intersection Observer 可以幫助追蹤哪些組件在 viewport 中以及它們保持可見的時間長度。

非 Effect:初始化應用程式

某些邏輯應該只在應用程式啟動時執行一次。您可以將它放在組件之外。

if (typeof window !== 'undefined') { // Check if we're running in the browser.
checkAuthToken();
loadDataFromLocalStorage();
}

function App() {
// ...
}

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

非 Effect:購買產品

有時,即使您撰寫清除函式,也無法防止 Effect 執行兩次所造成的使用者可見後果。例如,也許您的 Effect 發送了 POST 請求,例如購買產品。

useEffect(() => {
// 🔴 Wrong: This Effect fires twice in development, exposing a problem in the code.
fetch('/api/buy', { method: 'POST' });
}, []);

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

購買行為不是由渲染觸發的;它是由特定的互動引起的。它只應該在使用者按下按鈕時執行。 刪除 Effect 並將您的 /api/buy 請求移至「購買」按鈕的事件處理器中:

function handleClick() {
// ✅ Buying is an event because it is caused by a particular interaction.
fetch('/api/buy', { method: 'POST' });
}

這說明了如果重新掛載破壞了應用程式的邏輯,這通常會暴露現有的錯誤。 從使用者的角度來看,造訪一個頁面應該與造訪它、點擊一個連結,然後按下「返回」再次查看該頁面沒有區別。React 在開發過程中會重新掛載您的組件一次,以驗證它們是否遵守此原則。

綜上所述

這個互動式程式碼練習區可以幫助您「感受」Effect 在實際應用中的運作方式。

此範例使用 setTimeout 來排程一個控制台日誌,其中包含在 Effect 執行三秒後顯示的輸入文字。清除函式會取消待處理的逾時。首先按下「掛載組件」

import { useState, useEffect } from 'react';

function Playground() {
  const [text, setText] = useState('a');

  useEffect(() => {
    function onTimeout() {
      console.log('⏰ ' + text);
    }

    console.log('🔵 Schedule "' + text + '" log');
    const timeoutId = setTimeout(onTimeout, 3000);

    return () => {
      console.log('🟡 Cancel "' + text + '" log');
      clearTimeout(timeoutId);
    };
  }, [text]);

  return (
    <>
      <label>
        What to log:{' '}
        <input
          value={text}
          onChange={e => setText(e.target.value)}
        />
      </label>
      <h1>{text}</h1>
    </>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Unmount' : 'Mount'} the component
      </button>
      {show && <hr />}
      {show && <Playground />}
    </>
  );
}

您一開始會看到三個日誌:排程 "a" 日誌取消 "a" 日誌,以及再次排程 "a" 日誌。三秒鐘後,還會有一個日誌顯示 a。正如您先前所學,額外的排程/取消配對是因為 React 在開發過程中會重新掛載組件一次,以驗證您是否已妥善實作清除功能。

現在將輸入編輯為 abc。如果您操作夠快,您會看到 排程 "ab" 日誌,緊接著是 取消 "ab" 日誌排程 "abc" 日誌React 總是在下一次渲染的 Effect 之前清除前一次渲染的 Effect。 這就是為什麼即使您快速輸入,一次最多也只會排程一個逾時。多次編輯輸入並觀察控制台,以感受 Effect 如何被清除。

在輸入框中輸入一些內容,然後立即按下「卸載組件」。請注意卸載如何清除上次渲染的 Effect。在這裡,它會在最後一次逾時觸發之前清除它。

最後,編輯上面的組件並註釋掉清除函式,以便逾時不會被取消。嘗試快速輸入 abcde。您預計三秒鐘後會發生什麼事?逾時內的 console.log(text) 會印出*最新*的 text 並產生五個 abcde 日誌嗎?試一試,驗證您的直覺!

三秒鐘後,您應該會看到一系列的日誌(aababcabcdabcde),而不是五個 abcde 日誌。 每個 Effect 會從其對應的渲染中「捕獲」text 值。 text 狀態是否更改無關緊要:來自 text = 'ab' 渲染的 Effect 將始終看到 'ab'。換句話說,每次渲染的 Effect 彼此隔離。如果您好奇這是如何運作的,可以閱讀關於 閉包 的資訊。

深入探討

每次渲染都有其自己的 Effects

您可以將 useEffect 視為將一段行為「附加」到渲染輸出。考慮這個 Effect

export default function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);

return <h1>Welcome to {roomId}!</h1>;
}

讓我們看看當使用者在應用程式中導航時究竟會發生什麼。

初始渲染

使用者造訪 <ChatRoom roomId="general" />。讓我們在腦海中將 roomId 替換為 'general'

// JSX for the first render (roomId = "general")
return <h1>Welcome to general!</h1>;

Effect *也是*渲染輸出的一部分。 第一次渲染的 Effect 變為

// Effect for the first render (roomId = "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Dependencies for the first render (roomId = "general")
['general']

React 執行此 Effect,它會連線到 'general' 聊天室。

使用相同的 dependencies 重新渲染

假設 <ChatRoom roomId="general" /> 重新渲染。JSX 輸出相同

// JSX for the second render (roomId = "general")
return <h1>Welcome to general!</h1>;

React 看到渲染輸出沒有改變,所以它沒有更新 DOM。

第二次渲染的 Effect 看起來像這樣

// Effect for the second render (roomId = "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Dependencies for the second render (roomId = "general")
['general']

React 會比較第二次渲染的 ['general'] 與第一次渲染的 ['general']因為所有依賴項都相同,React 會*忽略*第二次渲染的 Effect。 它永遠不會被呼叫。

使用不同的依賴項重新渲染

然後,使用者造訪 <ChatRoom roomId="travel" />。這次,組件返回不同的 JSX

// JSX for the third render (roomId = "travel")
return <h1>Welcome to travel!</h1>;

React 更新 DOM 將 "Welcome to general" 改為 "Welcome to travel"

第三次渲染的 Effect 如下所示

// Effect for the third render (roomId = "travel")
() => {
const connection = createConnection('travel');
connection.connect();
return () => connection.disconnect();
},
// Dependencies for the third render (roomId = "travel")
['travel']

React 會比較第三次渲染的 ['travel'] 與第二次渲染的 ['general']。一個依賴項不同:Object.is('travel', 'general')false。Effect 無法跳過。

在 React 可以應用第三次渲染的 Effect 之前,它需要清除上次*確實*執行的 Effect。第二次渲染的 Effect 被跳過,因此 React 需要清除第一次渲染的 Effect。如果您向上滾動到第一次渲染,您會看到它的清除在使用 createConnection('general') 建立的連線上呼叫 disconnect()。這會將應用程式與 'general' 聊天室斷開連線。

之後,React 執行第三次渲染的 Effect。它連接到 'travel' 聊天室。

卸載

最後,假設使用者導航離開,並且 ChatRoom 組件卸載。 React 會執行最後一個 Effect 的清除函式。最後一個 Effect 來自第三次渲染。第三次渲染的清除會銷毀 createConnection('travel') 連線。因此,應用程式會與 'travel' 聊天室斷開連線。

僅限開發環境的行為

當啟用嚴格模式時,React 會在掛載後重新掛載每個組件一次(狀態和 DOM 會保留)。這可以幫助您找到需要清除的 Effect,並及早發現競爭條件等錯誤。此外,每當您在開發中儲存檔案時,React 都會重新掛載 Effects。這兩種行為都僅限於開發環境。

重點回顧

  • 與事件不同,Effect 是由渲染本身而不是特定互動引起的。
  • Effect 讓您可以將組件與某些外部系統(第三方 API、網路等)同步。
  • 預設情況下,Effect 在每次渲染後執行(包括初始渲染)。
  • 如果 Effect 的所有依賴項都與上次渲染期間的值相同,React 將跳過 Effect。
  • 您無法“選擇”您的依賴項。它們由 Effect 內部的程式碼決定。
  • 空的依賴項陣列 ([]) 對應於組件“掛載”,即新增到螢幕上。
  • 在嚴格模式下,React 會將組件掛載兩次(僅限開發環境!),以對您的 Effect 進行壓力測試。
  • 如果您的 Effect 因重新掛載而中斷,您需要實作清除函式。
  • React 將在下一次 Effect 執行之前以及卸載期間呼叫您的清除函式。

挑戰 1 4:
在掛載時聚焦欄位

在此範例中,表單會渲染 <MyInput /> 組件。

使用輸入的 focus() 方法,讓 MyInput 在螢幕上出現時自動聚焦。已經有一個被註釋掉的實作,但它不太有效。找出它無效的原因,並修復它。(如果您熟悉 autoFocus 屬性,請假設它不存在:我們正在從頭開始重新實作相同的功能。)

import { useEffect, useRef } from 'react';

export default function MyInput({ value, onChange }) {
  const ref = useRef(null);

  // TODO: This doesn't quite work. Fix it.
  // ref.current.focus()    

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}

要驗證您的解決方案是否有效,請按下「顯示表單」,並驗證輸入是否收到焦點(變為反白顯示,且游標位於其中)。按下「隱藏表單」,然後再次按下「顯示表單」。驗證輸入是否再次反白顯示。

MyInput 應該只在*掛載時*聚焦,而不是在每次渲染後聚焦。要驗證行為是否正確,請按下「顯示表單」,然後重複按下「轉換為大寫」核取方塊。按一下核取方塊*不應*聚焦在其上方的輸入。