知海

useEffectEvent()

ReactAPI 参考:React 核心

useEffectEvent()

useEffectEvent 是一个 React Hook,它可以让你将 Effect 中的事件处理独立出来。

js 复制代码
const onEvent = useEffectEvent(callback)

参考 {/reference/}

useEffectEvent(callback) {/useeffectevent/}

在组件的顶层调用 useEffectEvent 来声明一个 Effect Event。

js {4,6} 复制代码
import { useEffectEvent, useEffect } from 'react';

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

Effect Event 是 Effect 逻辑的一部分,但它们的表现更像事件处理器。它们总是“看到”渲染中的最新值(如 props 和 state),而不会重新同步你的 Effect,因此它们被排除在 Effect 依赖项之外。请参阅 将事件与 Effect 分离 了解更多信息。

在下方查看更多示例

参数 {/parameters/}

  • callback:一个包含 Effect Event 逻辑的函数。该函数可以接受任意数量的参数,并返回任意值。当你调用返回的 Effect Event 函数时,callback 始终访问调用时渲染提交的最新值。

返回值 {/returns/}

useEffectEvent 返回一个 Effect Event 函数,其类型签名与你的 callback 相同。

你可以在 useEffectuseLayoutEffectuseInsertionEffect 或同一组件中的其他 Effect Event 内部调用此函数。

为了防止在错误的上下文中调用 Effect Event,强制执行以下限制:

  • useEffectEvent 是一个 Hook,因此你只能在组件顶层或你自己的 Hook 中调用它。你不能在循环或条件语句中调用它。如果需要,请提取一个新组件并将 Effect Event 移入其中。
  • Effect Event 只能从 Effect 或其他 Effect Event 内部调用。不要在渲染期间调用它们,也不要将它们传递给其他组件或 Hook。eslint-plugin-react-hooks 的 linter 会强制执行此限制。
  • 不要使用 useEffectEvent 来避免在 Effect 的依赖数组中指定依赖项。这会隐藏 bug,并使你的代码更难理解。只将它用于真正由 Effect 触发的事件逻辑。
  • Effect Event 函数没有稳定的标识。它们的标识会在每次渲染时刻意改变。

为什么 Effect Event 不稳定? {/why-are-effect-events-not-stable/}

useState 或 ref 中的 set 函数不同,Effect Event 函数没有稳定的标识。它们的标识会在每次渲染时刻意改变:

js 复制代码
// 🔴 错误:将 Effect Event 包含在依赖项中
useEffect(() => {
  onSomething();
}, [onSomething]); // ESLint 会对此发出警告

这是一个深思熟虑的设计选择。Effect Event 只会在同一组件的 Effect 内部被调用。由于你只能在本地调用它们,不能将它们传递给其他组件或包含在依赖数组中,因此稳定的标识没有意义,反而会掩盖 bug。

不稳定的标识充当运行时断言:如果你的代码错误地依赖于函数标识,你会看到 Effect 在每次渲染时重新运行,从而使 bug 变得明显。

这种设计强化了 Effect Event 在概念上属于特定 Effect 的理念,它们不是用于选择退出响应式行为的通用 API。


用法 {/usage/}

在 Effect 中使用事件 {/using-an-event-in-an-effect/}

在组件顶层调用 useEffectEvent 来创建一个 Effect Event

js [[1, 1, "onConnected"]] 复制代码
const onConnected = useEffectEvent(() => {
  if (!muted) {
    showNotification('已连接!');
  }
});

useEffectEvent 接收一个 事件回调,并返回一个 Effect Event。Effect Event 是一个可以在 Effect 内部调用而无需重新连接 Effect 的函数:

js [[1, 3, "onConnected"]] 复制代码
useEffect(() => {
  const connection = createConnection(roomId);
  connection.on('connected', onConnected);
  connection.connect();
  return () => {
    connection.disconnect();
  }
}, [roomId]);

由于 onConnected 是一个 Effect Eventmutedtheme 不在 Effect 依赖项中。

不要使用 Effect Event 来跳过依赖项 {/pitfall-skip-dependencies/}

你可能会想用 useEffectEvent 来避免列出你认为“不必要”的依赖项。然而,这会隐藏 bug,并使你的代码更难理解:

js 复制代码
// 🔴 错误:使用 Effect Event 隐藏依赖项
const logVisit = useEffectEvent(() => {
  log(pageUrl);
});

useEffect(() => {
  logVisit()
}, []); // 缺少 pageUrl 意味着你会错失日志

如果某个值应该导致你的 Effect 重新运行,请将其保留为依赖项。只对真正不应该重新触发 Effect 的逻辑使用 Effect Event。

参见 将事件与 Effect 分离 了解更多信息。


在定时器中使用最新值 {/using-a-timer-with-latest-values/}

当你在 Effect 中使用 setIntervalsetTimeout 时,你通常希望读取渲染中的最新值,而不希望在这些值变化时重启定时器。

这个计数器每秒按当前的 increment 值递增 countonTick Effect Event 会读取最新的 countincrement,而不会导致 interval 重新启动:

js 复制代码
import { useState, useEffect, useEffectEvent } from 'react';

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

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

  useEffect(() => {
    const id = setInterval(() => {
      onTick();
    }, 1000);
    return () => {
      clearInterval(id);
    };
  }, []);

  return (
    <>
      <h1>
        计数器:{count}
        <button onClick={() => setCount(0)}>重置</button>
      </h1>
      <hr />
      <p>
        每秒递增:
        <button disabled={increment === 0} onClick={() => {
          setIncrement(i => i - 1);
        }}>–</button>
        <b>{increment}</b>
        <button onClick={() => {
          setIncrement(i => i + 1);
        }}>+</button>
      </p>
    </>
  );
}
css 复制代码
button { margin: 10px; }

尝试在定时器运行时更改递增值。计数器会立即使用新的递增值,但定时器会保持平稳运行,不会重新启动。


在事件监听器中使用最新值 {/using-an-event-listener-with-latest-values/}

当你在 Effect 中设置事件监听器时,通常需要在回调中读取渲染中的最新值。如果没有 useEffectEvent,你需要在依赖项中包含这些值,这会导致监听器在每次变化时被移除并重新添加。

这个示例展示了一个跟随光标的圆点,但仅在选中“允许移动”时。onMove Effect Event 始终读取最新的 canMove 值,而不会重新运行 Effect:

js 复制代码
import { useState, useEffect, 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)}
        />
        允许圆点移动
      </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,
      }} />
    </>
  );
}
css 复制代码
body {
  height: 200px;
}

切换复选框并移动光标。圆点会立即响应复选框状态,但事件监听器只在组件挂载时设置一次。


避免重新连接外部系统 {/showing-a-notification-without-reconnecting/}

useEffectEvent 的一个常见用例是:你想响应 Effect 做某件事,但该“某件事”依赖于你不希望响应其变化的值。

在这个示例中,聊天组件连接到房间并在连接时显示通知。用户可以通过复选框静音通知。但是,你不希望每次用户更改设置时都重新连接到聊天室:

json package.json hidden 复制代码
{
  "dependencies": {
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "toastify-js": "1.12.0"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}
js 复制代码
import { useState, useEffect, useEffectEvent } from 'react';
import { createConnection } from './chat.js';
import { showNotification } from './notifications.js';

function ChatRoom({ roomId, muted }) {
  const onConnected = useEffectEvent((roomId) => {
    console.log('✅ Connected to ' + roomId + ' (muted: ' + muted + ')');
    if (!muted) {
      showNotification('已连接到 ' + roomId);
    }
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    console.log('⏳ Connecting to ' + roomId + '...');
    connection.on('connected', () => {
      onConnected(roomId);
    });
    connection.connect();
    return () => {
      console.log('❌ Disconnected from ' + roomId);
      connection.disconnect();
    }
  }, [roomId]);

  return <h1>欢迎来到 {roomId} 房间!</h1>;
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [muted, setMuted] = useState(false);
  return (
    <>
      <label>
        选择聊天室:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">综合</option>
          <option value="travel">旅行</option>
          <option value="music">音乐</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={muted}
          onChange={e => setMuted(e.target.checked)}
        />
        静音通知
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        muted={muted}
      />
    </>
  );
}
js src/chat.js 复制代码
const serverUrl = 'https://localhost:1234';

export function createConnection(roomId) {
  // 真实的实现会连接到服务器
  let connectedCallback;
  let timeout;
  return {
    connect() {
      timeout = setTimeout(() => {
        if (connectedCallback) {
          connectedCallback();
        }
      }, 100);
    },
    on(event, callback) {
      if (connectedCallback) {
        throw Error('不能添加两次处理器。');
      }
      if (event !== 'connected') {
        throw Error('仅支持 "connected" 事件。');
      }
      connectedCallback = callback;
    },
    disconnect() {
      clearTimeout(timeout);
    }
  };
}
js src/notifications.js 复制代码
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';

export function showNotification(message, theme) {
  Toastify({
    text: message,
    duration: 2000,
    gravity: 'top',
    position: 'right',
    style: {
      background: theme === 'dark' ? 'black' : 'white',
      color: theme === 'dark' ? 'white' : 'black',
    },
  }).showToast();
}
css 复制代码
label { display: block; margin-top: 10px; }

尝试切换房间。聊天会重新连接并显示通知。现在静音通知。由于 muted 是在 Effect Event 内部而不是在 Effect 中读取的,因此聊天保持连接状态。


在自定义 Hook 中使用 Effect Event {/using-effect-events-in-custom-hooks/}

你可以在自己的自定义 Hook 中使用 useEffectEvent。这让你可以创建可复用的 Hook,封装 Effects,同时保持某些值不具有响应性:

js 复制代码
import { useState, useEffect, useEffectEvent } from 'react';

function useInterval(callback, delay) {
  const onTick = useEffectEvent(callback);

  useEffect(() => {
    if (delay === null) {
      return;
    }
    const id = setInterval(() => {
      onTick();
    }, delay);
    return () => clearInterval(id);
  }, [delay]);
}

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

  useInterval(() => {
    setCount(c => c + incrementBy);
  }, 1000);

  return (
    <div>
      <h2>计数:{count}</h2>
      <p>每秒递增 {incrementBy}</p>
    </div>
  );
}

export default function App() {
  const [incrementBy, setIncrementBy] = useState(1);

  return (
    <>
      <label>
        递增:{' '}
        <select
          value={incrementBy}
          onChange={(e) => setIncrementBy(Number(e.target.value))}
        >
          <option value={1}>1</option>
          <option value={5}>5</option>
          <option value={10}>10</option>
        </select>
      </label>
      <hr />
      <Counter incrementBy={incrementBy} />
    </>
  );
}
css 复制代码
label { display: block; margin-bottom: 8px; }

在这个示例中,useInterval 是一个设置 interval 的自定义 Hook。传递给它的 callback 被包装在 Effect Event 中,因此即使每次渲染都传入新的 callback,interval 也不会重置。


疑难解答 {/troubleshooting/}

我收到了一个错误:“包装在 useEffectEvent 中的函数不能在渲染期间调用” {/cant-call-during-rendering/}

此错误意味着你在组件的渲染阶段调用了 Effect Event 函数。Effect Event 只能在 Effect 或其他 Effect Event 内部调用。

js 复制代码
function MyComponent({ data }) {
  const onLog = useEffectEvent(() => {
    console.log(data);
  });

  // 🔴 错误:在渲染期间调用
  onLog();

  // ✅ 正确:在 Effect 中调用
  useEffect(() => {
    onLog();
  }, []);

  return <div>{data}</div>;
}

如果你需要在渲染期间运行逻辑,请不要将其包装在 useEffectEvent 中。直接调用逻辑,或将其移入 Effect。


我收到了一个 lint 错误:“从 useEffectEvent 返回的函数不得包含在依赖数组中” {/effect-event-in-deps/}

如果你看到类似“从 useEffectEvent 返回的函数不得包含在依赖数组中”的警告,请从依赖项中移除 Effect Event:

js 复制代码
const onSomething = useEffectEvent(() => {
  // ...
});

// 🔴 错误:Effect Event 在依赖项中
useEffect(() => {
  onSomething();
}, [onSomething]);

// ✅ 正确:依赖项中没有 Effect Event
useEffect(() => {
  onSomething();
}, []);

Effect Event 被设计为从 Effect 中调用,而无需列为依赖项。linter 强制执行此规则,因为函数标识是刻意不稳定的。将其包含在依赖项中会导致你的 Effect 在每次渲染时重新运行。


我收到了一个 lint 错误:“... 是使用 useEffectEvent 创建的函数,只能从 Effect 中调用” {/effect-event-called-outside-effect/}

如果你看到类似“... 是使用 React Hook useEffectEvent 创建的函数,只能从 Effect 和 Effect Event 中调用”的警告,说明你在错误的位置调用了该函数:

js 复制代码
const onSomething = useEffectEvent(() => {
  console.log(value);
});

// 🔴 错误:从事件处理器中调用
function handleClick() {
  onSomething();
}

// 🔴 错误:传递给子组件
return <Child onSomething={onSomething} />;

// ✅ 正确:从 Effect 中调用
useEffect(() => {
  onSomething();
}, []);

Effect Event 被特别设计用于定义它们的组件本地的 Effect。如果你需要用于事件处理器或传递给子组件的回调,请改用普通函数或 useCallback

帮助我们改进文档

发现翻译问题或内容错误?请告诉我们。