將事件與副作用分離

事件處理程式只會在您再次執行相同的互動時重新執行。與事件處理程式不同,如果副作用讀取的某些值(例如 prop 或狀態變數)與上次渲染期間的值不同,則副作用會重新同步。有時,您也希望兩種行為兼而有之:一個副作用會響應某些值而重新執行,但不會響應其他值。本頁面將教您如何做到這一點。

你將學到

  • 如何在事件處理程式和副作用之間做出選擇
  • 為什麼副作用是反應式的,而事件處理程式不是
  • 當您希望副作用程式碼的一部分不具有反應性時該怎麼做
  • 什麼是副作用事件,以及如何從副作用中提取它們
  • 如何使用副作用事件從副作用中讀取最新的 props 和狀態

在事件處理程式和副作用之間選擇

首先,讓我們回顧一下事件處理程式和副作用之間的差異。

想像您正在實作一個聊天室元件。您的需求如下所示

  1. 您的元件應自動連線到所選聊天室。
  2. 當您點擊「發送」按鈕時,它應該向聊天室發送一條訊息。

假設您已經實作了它們的程式碼,但不確定要放在哪裡。您應該使用事件處理程式還是副作用?每次您需要回答這個問題時,請考慮 為什麼 需要執行程式碼。

事件處理程式會響應特定互動而執行

從使用者的角度來看,發送訊息應該因為點擊了特定的「發送」按鈕而發生。如果您在其他任何時間或出於任何其他原因發送他們的訊息,使用者會非常不高興。這就是為什麼發送訊息應該是一個事件處理程式。事件處理程式讓您可以處理特定的互動

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>Send</button>
</>
);
}

使用事件處理程式,您可以確定 sendMessage(message) 只會在使用者按下按鈕時執行。

副作用會在需要同步時執行

回想一下,您還需要保持元件連線到聊天室。那段程式碼應該放在哪裡?

執行此程式碼的原因不是某個特定互動。使用者如何或為何導航到聊天室畫面並不重要。既然他們正在查看它並且可以與之互動,則元件需要保持與所選聊天伺服器的連線。即使聊天室元件是您應用程式的初始畫面,並且使用者根本沒有執行任何互動,您仍然需要連線。這就是為什麼它是一個副作用

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

使用此程式碼,您可以確定始終與目前選擇的聊天伺服器保持活動連線,無論使用者執行的特定互動為何。無論使用者是剛打開您的應用程式、選擇了不同的房間,還是導航到另一個畫面然後返回,您的副作用都能確保元件將保持與目前選擇的房間同步,並將在必要時重新連線。

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

const serverUrl = 'https://127.0.0.1:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

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

  function handleSendClick() {
    sendMessage(message);
  }

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [show, setShow] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <button onClick={() => setShow(!show)}>
        {show ? 'Close chat' : 'Open chat'}
      </button>
      {show && <hr />}
      {show && <ChatRoom roomId={roomId} />}
    </>
  );
}

反應值與反應邏輯

直覺上,您可以說事件處理程式始終是「手動」觸發的,例如點擊按鈕。另一方面,副作用是「自動的」:它們會根據保持同步的需要經常執行和重新執行。

有一種更精確的思考方式。

在組件主體內宣告的 Props、State 和變數稱為反應值。在此範例中,serverUrl 不是反應值,但 roomIdmessage 是。它們參與渲染資料流程。

const serverUrl = 'https://127.0.0.1:1234';

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');

// ...
}

像這樣的反應值可能會因為重新渲染而改變。例如,使用者可能會編輯 message 或在下拉式選單中選擇不同的 roomId。事件處理器和 Effects 對變化的反應不同。

  • 事件處理器內的邏輯*不是*反應式的。 除非使用者再次執行相同的互動(例如點擊),否則它不會再次運行。事件處理器可以讀取反應值,而不會對其變化做出「反應」。
  • Effects 內的邏輯*是*反應式的。 如果您的 Effect 讀取反應值,您必須將其指定為依賴項。 然後,如果重新渲染導致該值發生變化,React 將使用新值重新運行您的 Effect 邏輯。

讓我們回顧前面的例子來說明這種差異。

事件處理器內的邏輯不是反應式的

看看這行程式碼。這個邏輯應該是反應式的嗎?

// ...
sendMessage(message);
// ...

從使用者的角度來看,更改 message *並不*意味著他們想要發送訊息。 這只意味著使用者正在輸入。換句話說,發送訊息的邏輯不應該是反應式的。它不應該僅僅因為反應值已更改而再次運行。這就是為什麼它屬於事件處理器。

function handleSendClick() {
sendMessage(message);
}

事件處理器不是反應式的,因此 sendMessage(message) 只會在使用者點擊「發送」按鈕時運行。

Effects 內的邏輯是反應式的

現在讓我們回到這幾行程式碼。

// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...

從使用者的角度來看,更改 roomId *的確*意味著他們想要連接到不同的房間。 換句話說,連接到房間的邏輯應該是反應式的。您*希望*這幾行程式碼能夠「跟上」反應值,並且如果該值不同則再次運行。這就是為什麼它屬於 Effect。

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

Effects 是反應式的,因此 createConnection(serverUrl, roomId)connection.connect() 將針對 roomId 的每個不同值運行。您的 Effect 使聊天連線與當前選擇的房間同步。

從 Effects 中提取非反應式邏輯

當您想要混合反應式邏輯和非反應式邏輯時,事情會變得更加棘手。

例如,假設您想要在使用者連接到聊天時顯示通知。您從 props 讀取當前主題(深色或淺色),以便您可以以正確的顏色顯示通知。

function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
// ...

但是,theme 是一個反應值(它可以因重新渲染而改變),並且 Effect 讀取的每個反應值都必須宣告為其依賴項。 現在您必須將 theme 指定為 Effect 的依賴項。

function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme]); // ✅ All dependencies declared
// ...

試著操作這個例子,看看您是否可以發現這個使用者體驗的問題。

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

const serverUrl = 'https://127.0.0.1:1234';

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]);

  return <h1>Welcome to the {roomId} room!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Use dark theme
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

roomId 改變時,聊天會如您所料重新連線。但由於 theme 也是一個依賴項,因此每次您在深色和淺色主題之間切換時,聊天*也會*重新連線。這不太好!

換句話說,即使這行程式碼在 Effect(反應式)內,您也*不希望*它是反應式的。

// ...
showNotification('Connected!', theme);
// ...

您需要一種方法將這種非反應式邏輯與周圍的反應式 Effect 分開。

宣告 Effect Event

建構中

本節描述了在 React 穩定版本中*尚未發布*的**實驗性 API**。

使用一個名為 useEffectEvent 的特殊 Hook 將此非反應式邏輯從 Effect 中提取出來。

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...

這裡,onConnected 稱為 *Effect Event*。它是 Effect 邏輯的一部分,但它的行為更像事件處理器。它內部的邏輯不是反應式的,並且它總是「看到」您的 props 和 state 的最新值。

現在您可以從 Effect 內部調用 onConnected Effect Event。

function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ All dependencies declared
// ...

這解決了問題。請注意,您必須從 Effect 的依賴項列表中*移除* onConnectedEffect Event 不是反應式的,必須從依賴項中省略。

驗證新的行為是否如您所料。

import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';

const serverUrl = 'https://127.0.0.1:1234';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>Welcome to the {roomId} room!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Use dark theme
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

您可以將 Effect Event 視為與事件處理器非常相似。主要區別在於事件處理器響應使用者互動而運行,而 Effect Event 則是由您從 Effects 觸發。Effect Event 讓您能夠在 Effects 的反應性和不應該是反應式的程式碼之間「斷開鏈」。

使用 Effect 事件讀取最新的 props 和 state

建構中

本節描述了在 React 穩定版本中*尚未發布*的**實驗性 API**。

Effect 事件可以讓你解決許多可能 tempted 想抑制依賴性檢查器 (dependency linter) 的模式。

例如,假設你有一個 Effect 來記錄頁面訪問

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

稍後,你在你的網站上新增多個路由。現在你的 Page 元件會收到一個帶有目前路徑的 url prop。你想將 url 作為 logVisit 呼叫的一部分傳遞,但依賴性檢查器會發出警告

function Page({ url }) {
useEffect(() => {
logVisit(url);
}, []); // 🔴 React Hook useEffect has a missing dependency: 'url'
// ...
}

想想你想要程式碼做什麼。你*想要*為不同的 URL 記錄不同的訪問,因為每個 URL 代表不同的頁面。換句話說,這個 logVisit 呼叫*應該*對 url 有反應。這就是為什麼,在這種情況下,遵循依賴性檢查器,並將 url 作為依賴項新增是有意義的

function Page({ url }) {
useEffect(() => {
logVisit(url);
}, [url]); // ✅ All dependencies declared
// ...
}

現在假設你想在每次頁面訪問時包含購物車中的商品數量

function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;

useEffect(() => {
logVisit(url, numberOfItems);
}, [url]); // 🔴 React Hook useEffect has a missing dependency: 'numberOfItems'
// ...
}

你在 Effect 中使用了 numberOfItems,因此檢查器要求你將其作為依賴項新增。但是,你*不希望* logVisit 呼叫對 numberOfItems 有反應。如果使用者將商品放入購物車,並且 numberOfItems 發生變化,這*並不意味著*使用者再次訪問了該頁面。換句話說,*訪問頁面*在某種意義上是一個「事件」。它發生在一個精確的時間點。

將程式碼分成兩部分

function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;

const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});

useEffect(() => {
onVisit(url);
}, [url]); // ✅ All dependencies declared
// ...
}

這裡,onVisit 是一個 Effect 事件。其中的程式碼沒有反應性。這就是為什麼你可以使用 numberOfItems(或任何其他反應值!)而不必擔心它會導致周圍的程式碼重新執行。

另一方面,Effect 本身仍然具有反應性。Effect 內的程式碼使用 url prop,因此 Effect 將在每次使用不同 url 重新渲染後重新執行。這反過來又會呼叫 onVisit Effect 事件。

因此,你將為 url 的每次更改呼叫 logVisit,並始終讀取最新的 numberOfItems。但是,如果 numberOfItems 自身發生變化,這不會導致任何程式碼重新執行。

備註

你可能想知道是否可以不帶參數呼叫 onVisit(),並在其中讀取 url

const onVisit = useEffectEvent(() => {
logVisit(url, numberOfItems);
});

useEffect(() => {
onVisit();
}, [url]);

這可以 funktionieren ,但最好將這個 url 明確地傳遞給 Effect 事件。透過將 url 作為參數傳遞給你的 Effect 事件,你是在說訪問具有不同 url 的頁面,從使用者的角度來看,構成了一個單獨的「事件」。 visitedUrl 是發生的「事件」的*一部分*

const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});

useEffect(() => {
onVisit(url);
}, [url]);

由於你的 Effect 事件明確地「要求」 visitedUrl,現在你不能意外地從 Effect 的依賴項中移除 url。如果你移除 url 依賴項(導致不同的頁面訪問被計為一次),檢查器會向你發出警告。你希望 onVisiturl 有反應,因此不要在內部讀取 url(它在那裡不會有反應),而是*從*你的 Effect 傳遞它。

如果 Effect 內部有一些非同步邏輯,這一點尤其重要

const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});

useEffect(() => {
setTimeout(() => {
onVisit(url);
}, 5000); // Delay logging visits
}, [url]);

這裡,onVisit 內部的 url 對應於*最新*的 url(它可能已經更改),但 visitedUrl 對應於最初導致此 Effect(以及此 onVisit 呼叫)執行的 url

深入探討

抑制依賴性檢查器可以嗎?

在現有的程式碼庫中,你有時可能會看到像這樣抑制了檢查規則

function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;

useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 Avoid suppressing the linter like this:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}

useEffectEvent 成為 React 的穩定部分之後,我們建議永遠不要抑制檢查器

抑制規則的第一個缺點是,當你的 Effect 需要對你引入程式碼的新反應依賴項「做出反應」時,React 將不再警告你。在前面的例子中,你將 url 新增到依賴項中,*因為* React 提醒你這樣做。如果你停用檢查器,你將不再收到對該 Effect 的任何未來編輯的此類提醒。這會導致錯誤。

以下是一個因抑制程式碼檢查器而導致的混淆錯誤範例。在此範例中,handleMove 函式應該讀取目前的 canMove 狀態變數值,以決定點是否應該跟隨游標。然而,在 handleMove 內部,canMove 永遠是 true

你知道為什麼嗎?

import { useState, useEffect } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  function handleMove(e) {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  }

  useEffect(() => {
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)}
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}

這段程式碼的問題在於抑制了依賴項程式碼檢查器。如果您移除抑制,您會看到這個 Effect 應該依賴於 handleMove 函式。這是合理的:handleMove 是在元件主體內部宣告的,這使得它成為一個反應值。每個反應值都必須被指定為一個依賴項,否則它可能會隨著時間推移而過時!

原始程式碼的作者透過聲明 Effect 不依賴 ([]) 任何反應值來「欺騙」React。這就是為什麼在 canMove 改變(以及 handleMove)之後,React 沒有重新同步 Effect 的原因。因為 React 沒有重新同步 Effect,所以作為監聽器附加的 handleMove 是在初始渲染期間建立的 handleMove 函式。在初始渲染期間,canMovetrue,這就是為什麼初始渲染的 handleMove 將永遠看到該值的原因。

如果您從不抑制程式碼檢查器,您將永遠不會看到過時值的問題。

使用 useEffectEvent,就沒有必要「欺騙」程式碼檢查器,程式碼會按預期工作。

import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  const onMove = useEffectEvent(e => {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  });

  useEffect(() => {
    window.addEventListener('pointermove', onMove);
    return () => window.removeEventListener('pointermove', onMove);
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)}
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}

這並不意味著 useEffectEvent *永遠* 是正確的解決方案。您應該只將其應用於您不希望具有反應性的程式碼行。在上面的沙盒中,您不希望 Effect 的程式碼與 canMove 產生反應。這就是為什麼提取 Effect 事件是有意義的。

閱讀移除 Effect 依賴項以了解抑制程式碼檢查器的其他正確替代方案。

Effect 事件的限制...

建構中

本節描述了在 React 穩定版本中*尚未發布*的**實驗性 API**。

Effect 事件的使用方式非常有限。

  • 只能從 Effects 內部呼叫它們。
  • 永遠不要將它們傳遞給其他元件或 Hooks。

例如,不要像這樣宣告和傳遞 Effect 事件。

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

const onTick = useEffectEvent(() => {
setCount(count + 1);
});

useTimer(onTick, 1000); // 🔴 Avoid: Passing Effect Events

return <h1>{count}</h1>
}

function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback();
}, delay);
return () => {
clearInterval(id);
};
}, [delay, callback]); // Need to specify "callback" in dependencies
}

相反,請始終將 Effect 事件直接宣告在使用它們的 Effects 旁邊。

function Timer() {
const [count, setCount] = useState(0);
useTimer(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>
}

function useTimer(callback, delay) {
const onTick = useEffectEvent(() => {
callback();
});

useEffect(() => {
const id = setInterval(() => {
onTick(); // ✅ Good: Only called locally inside an Effect
}, delay);
return () => {
clearInterval(id);
};
}, [delay]); // No need to specify "onTick" (an Effect Event) as a dependency
}

Effect 事件是 Effect 程式碼的非反應性「片段」。它們應該在使用它們的 Effect 旁邊。

摘要...

  • 事件處理程式會因應特定的互動而執行。
  • Effects 會在需要同步時執行。
  • 事件處理程式內的邏輯不具反應性。
  • Effects 內部的邏輯具有反應性。
  • 您可以將非反應性邏輯從 Effects 移至 Effect 事件。
  • 只能從 Effects 內部呼叫 Effect 事件。
  • 不要將 Effect 事件傳遞給其他元件或 Hooks。

挑戰 1 4:
修復未更新的變數...

這個 Timer 元件維護一個 count 狀態變數,該變數每秒遞增。遞增的值儲存在 increment 狀態變數中。您可以使用加號和減號按鈕來控制 increment 變數。

但是,無論您點擊加號按鈕多少次,計數器仍然每秒遞增 1。這段程式碼有什麼問題?為什麼在 Effect 的程式碼內部,increment 總是等於 1?找出錯誤並修復它。

import { useState, useEffect } from 'react';

export default function Timer() {
  const [count, setCount] = useState(0);
  const [increment, setIncrement] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + increment);
    }, 1000);
    return () => {
      clearInterval(id);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <h1>
        Counter: {count}
        <button onClick={() => setCount(0)}>Reset</button>
      </h1>
      <hr />
      <p>
        Every second, increment by:
        <button disabled={increment === 0} onClick={() => {
          setIncrement(i => i - 1);
        }}></button>
        <b>{increment}</b>
        <button onClick={() => {
          setIncrement(i => i + 1);
        }}>+</button>
      </p>
    </>
  );
}