使用自定义钩子重用逻辑

React 自带几个内置的 Hook,比如 useStateuseContextuseEffect。有时候,你可能希望有一个专门用途的 Hook:例如,用于获取数据、跟踪用户是否在线,或连接到聊天室。这些 Hook 在 React 中可能找不到,但你可以为你的应用需求创建自己的 Hook。

🌐 React comes with several built-in Hooks like useState, useContext, and useEffect. Sometimes, you’ll wish that there was a Hook for some more specific purpose: for example, to fetch data, to keep track of whether the user is online, or to connect to a chat room. You might not find these Hooks in React, but you can create your own Hooks for your application’s needs.

你将学习到

  • 什么是自定义钩子,以及如何编写自己的钩子
  • 如何重用组件之间的逻辑
  • 如何命名和构造你的自定义钩子
  • 何时以及为何提取自定义钩子

自定义 Hook:在组件之间共享逻辑

🌐 Custom Hooks: Sharing logic between components

想象一下你正在开发一个严重依赖网络的应用(就像大多数应用一样)。如果用户在使用你的应用时网络连接意外断开,你想要提醒他们。你会怎么做呢?看起来你的组件需要两样东西:

🌐 Imagine you’re developing an app that heavily relies on the network (as most apps do). You want to warn the user if their network connection has accidentally gone off while they were using your app. How would you go about it? It seems like you’ll need two things in your component:

  1. 跟踪网络是否在线的一种状态。
  2. 一个订阅全局 onlineoffline 事件并更新该状态的 Effect。

这将使你的组件与网络状态保持同步。你可以从如下内容开始:

🌐 This will keep your component synchronized with the network status. You might start with something like this:

import { useState, useEffect } from 'react';

export default function StatusBar() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }
    function handleOffline() {
      setIsOnline(false);
    }
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

尝试打开和关闭你的网络,并注意这个 StatusBar 如何根据你的操作更新。

🌐 Try turning your network on and off, and notice how this StatusBar updates in response to your actions.

现在想象一下,你想在不同的组件中使用相同的逻辑。你想实现一个保存按钮,当网络断开时,该按钮将被禁用,并显示“正在重新连接…”而不是“保存”。

🌐 Now imagine you also want to use the same logic in a different component. You want to implement a Save button that will become disabled and show “Reconnecting…” instead of “Save” while the network is off.

首先,你可以将 isOnline 状态和效果复制并粘贴到 SaveButton 中:

🌐 To start, you can copy and paste the isOnline state and the Effect into SaveButton:

import { useState, useEffect } from 'react';

export default function SaveButton() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }
    function handleOffline() {
      setIsOnline(false);
    }
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  function handleSaveClick() {
    console.log('✅ Progress saved');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

验证如果你关闭网络,按钮将改变其外观。

🌐 Verify that, if you turn off the network, the button will change its appearance.

这两个组件工作得很好,但它们之间逻辑的重复是不幸的。看起来尽管它们有不同的视觉外观,你仍然想在它们之间重用逻辑。

🌐 These two components work fine, but the duplication in logic between them is unfortunate. It seems like even though they have different visual appearance, you want to reuse the logic between them.

从组件中提取你自己的自定义钩子

🌐 Extracting your own custom Hook from a component

试想一下,类似于useStateuseEffect,有一个内置的useOnlineStatus Hook。那么这两个组件都可以简化,并且你可以去除它们之间的重复内容:

🌐 Imagine for a moment that, similar to useState and useEffect, there was a built-in useOnlineStatus Hook. Then both of these components could be simplified and you could remove the duplication between them:

function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function SaveButton() {
const isOnline = useOnlineStatus();

function handleSaveClick() {
console.log('✅ Progress saved');
}

return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
}

虽然没有这样的内置 Hook,但你可以自己编写。声明一个名为 useOnlineStatus 的函数,并将之前组件中所有重复的代码移到该函数中:

🌐 Although there is no such built-in Hook, you can write it yourself. Declare a function called useOnlineStatus and move all the duplicated code into it from the components you wrote earlier:

function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}

在函数的末尾,返回 isOnline。这让你的组件可以读取该值:

🌐 At the end of the function, return isOnline. This lets your components read that value:

import { useOnlineStatus } from './useOnlineStatus.js';

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();

  function handleSaveClick() {
    console.log('✅ Progress saved');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

export default function App() {
  return (
    <>
      <SaveButton />
      <StatusBar />
    </>
  );
}

确认打开和关闭网络会更新这两个组件。

🌐 Verify that switching the network on and off updates both components.

现在你的组件没有那么多重复的逻辑。更重要的是,它们内部的代码描述了它们想做什么(使用在线状态!)而不是如何去做(通过订阅浏览器事件)。

🌐 Now your components don’t have as much repetitive logic. More importantly, the code inside them describes what they want to do (use the online status!) rather than how to do it (by subscribing to the browser events).

当你将逻辑提取到自定义 Hook 中时,你可以隐藏处理某些外部系统或浏览器 API 的复杂细节。你的组件代码表达的是你的意图,而不是实现方式。

🌐 When you extract logic into custom Hooks, you can hide the gnarly details of how you deal with some external system or a browser API. The code of your components expresses your intent, not the implementation.

钩子名称总是以 use 开头

🌐 Hook names always start with use

React 应用是由组件构建的。组件是由 Hooks 构建的,无论是内置的还是自定义的。你很可能经常使用别人创建的自定义 Hooks,但偶尔你自己也可能会写一个!

🌐 React applications are built from components. Components are built from Hooks, whether built-in or custom. You’ll likely often use custom Hooks created by others, but occasionally you might write one yourself!

你必须遵循以下命名约定:

🌐 You must follow these naming conventions:

  1. **React 组件的名称必须以大写字母开头,**例如 StatusBarSaveButton。React 组件还需要返回 React 可以显示的内容,例如一段 JSX。
  2. 钩子名称必须以 use 开头,后跟大写字母,useState(内置)或 useOnlineStatus(自定义,如页面前面所示)。钩子可以返回任意值。

这个约定保证你总是可以查看一个组件并知道它的状态、Effects 以及其他 React 特性可能“隐藏”的地方。例如,如果你在组件内部看到一个 getColor() 函数调用,你可以确定它不可能包含 React 状态,因为它的名字不是以 use 开头的。然而,像 useOnlineStatus() 这样的函数调用很可能会包含对其他 Hooks 的调用!

🌐 This convention guarantees that you can always look at a component and know where its state, Effects, and other React features might “hide”. For example, if you see a getColor() function call inside your component, you can be sure that it can’t possibly contain React state inside because its name doesn’t start with use. However, a function call like useOnlineStatus() will most likely contain calls to other Hooks inside!

注意

如果你的代码检查器为 React 配置了,它将强制执行这种命名约定。向上滚动到上面的沙盒,并将 useOnlineStatus 重命名为 getOnlineStatus。注意,代码检查器将不再允许你在其中调用 useStateuseEffect。只有 Hook 和组件可以调用其他 Hook!

🌐 If your linter is configured for React, it will enforce this naming convention. Scroll up to the sandbox above and rename useOnlineStatus to getOnlineStatus. Notice that the linter won’t allow you to call useState or useEffect inside of it anymore. Only Hooks and components can call other Hooks!

深入研究

渲染期间调用的所有函数是否都应以 use 前缀开头?

🌐 Should all functions called during rendering start with the use prefix?

不。不会调用 Hook 的函数不需要成为 Hook。

🌐 No. Functions that don’t call Hooks don’t need to be Hooks.

如果你的函数没有调用任何 Hooks,请避免使用 use 前缀。相反,将其写成一个常规函数 不带 use 前缀。例如,下面的 useSorted 没有调用 Hooks,所以应该称它为 getSorted:

🌐 If your function doesn’t call any Hooks, avoid the use prefix. Instead, write it as a regular function without the use prefix. For example, useSorted below doesn’t call Hooks, so call it getSorted instead:

// 🔴 Avoid: A Hook that doesn't use Hooks
function useSorted(items) {
return items.slice().sort();
}

// ✅ Good: A regular function that doesn't use Hooks
function getSorted(items) {
return items.slice().sort();
}

这确保你的代码可以在任何地方调用此常规函数,包括以下条件:

🌐 This ensures that your code can call this regular function anywhere, including conditions:

function List({ items, shouldSort }) {
let displayedItems = items;
if (shouldSort) {
// ✅ It's ok to call getSorted() conditionally because it's not a Hook
displayedItems = getSorted(items);
}
// ...
}

如果一个函数内部至少使用了一个 Hook,你应该给它加上 use 前缀(从而使它成为一个 Hook):

🌐 You should give use prefix to a function (and thus make it a Hook) if it uses at least one Hook inside of it:

// ✅ Good: A Hook that uses other Hooks
function useAuth() {
return useContext(Auth);
}

从技术上讲,React 并不强制执行这一点。原则上,你可以创建一个不调用其他 Hook 的 Hook。这样通常会让人困惑且有一定限制,所以最好避免这种模式。然而,在某些罕见情况下,这可能是有帮助的。例如,也许你的函数现在还没有使用任何 Hook,但你计划将来向其中添加一些 Hook 调用。那么给它命名为 use 前缀是有意义的:

🌐 Technically, this isn’t enforced by React. In principle, you could make a Hook that doesn’t call other Hooks. This is often confusing and limiting so it’s best to avoid that pattern. However, there may be rare cases where it is helpful. For example, maybe your function doesn’t use any Hooks right now, but you plan to add some Hook calls to it in the future. Then it makes sense to name it with the use prefix:

// ✅ Good: A Hook that will likely use some other Hooks later
function useAuth() {
// TODO: Replace with this line when authentication is implemented:
// return useContext(Auth);
return TEST_USER;
}

那么组件将无法有条件地调用它。当你实际在内部添加 Hook 调用时,这将变得很重要。如果你不打算在其中使用 Hook(现在或以后),就不要把它做成 Hook。

🌐 Then components won’t be able to call it conditionally. This will become important when you actually add Hook calls inside. If you don’t plan to use Hooks inside it (now or later), don’t make it a Hook.

自定义钩子让你共享状态逻辑,而不是状态本身

🌐 Custom Hooks let you share stateful logic, not state itself

在前面的例子中,当你开关网络时,两个组件会一起更新。然而,认为一个单独的 isOnline 状态变量在它们之间共享是错误的。看看这段代码:

🌐 In the earlier example, when you turned the network on and off, both components updated together. However, it’s wrong to think that a single isOnline state variable is shared between them. Look at this code:

function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}

function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}

它的工作方式与提取重复之前相同:

🌐 It works the same way as before you extracted the duplication:

function StatusBar() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}

function SaveButton() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}

这是两个完全独立的状态变量和效果!它们之所以在同一时间有相同的值,是因为你用相同的外部值(无论网络是否开启)将它们同步了。

🌐 These are two completely independent state variables and Effects! They happened to have the same value at the same time because you synchronized them with the same external value (whether the network is on).

为了更好地说明这一点,我们需要一个不同的例子。考虑这个 Form 组件:

🌐 To better illustrate this, we’ll need a different example. Consider this Form component:

import { useState } from 'react';

export default function Form() {
  const [firstName, setFirstName] = useState('Mary');
  const [lastName, setLastName] = useState('Poppins');

  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
  }

  function handleLastNameChange(e) {
    setLastName(e.target.value);
  }

  return (
    <>
      <label>
        First name:
        <input value={firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name:
        <input value={lastName} onChange={handleLastNameChange} />
      </label>
      <p><b>Good morning, {firstName} {lastName}.</b></p>
    </>
  );
}

每个表单字段都有一些重复的逻辑:

🌐 There’s some repetitive logic for each form field:

  1. 有一块状态(firstNamelastName)。
  2. 有一个变化处理器(handleFirstNameChangehandleLastNameChange)。
  3. 有一段 JSX 为该输入指定了 valueonChange 属性。

你可以将重复的逻辑提取到这个 useFormInput 自定义 Hook 中:

🌐 You can extract the repetitive logic into this useFormInput custom Hook:

import { useState } from 'react';

export function useFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);

  function handleChange(e) {
    setValue(e.target.value);
  }

  const inputProps = {
    value: value,
    onChange: handleChange
  };

  return inputProps;
}

注意它只声明了一个名为 value 的状态变量。

🌐 Notice that it only declares one state variable called value.

然而,Form 组件调用 useFormInput 两次:

🌐 However, the Form component calls useFormInput two times:

function Form() {
const firstNameProps = useFormInput('Mary');
const lastNameProps = useFormInput('Poppins');
// ...

这就是为什么它像声明两个独立的状态变量一样工作!

🌐 This is why it works like declaring two separate state variables!

自定义 Hook 允许你共享有状态逻辑,但不共享状态本身 对 Hook 的每次调用都是完全独立的,与对同一 Hook 的每次其他调用无关。这就是为什么上面的两个沙盒完全等价。如果你愿意,可以向上滚动比较它们。提取自定义 Hook 前后的行为是相同的。

当你需要在多个组件之间共享状态本身时,改为提升它并向下传递即可。

🌐 When you need to share the state itself between multiple components, lift it up and pass it down instead.

在钩子之间传递反应值

🌐 Passing reactive values between Hooks

你自定义 Hook 中的代码将在组件的每次重新渲染时重新运行。这就是为什么像组件一样,自定义 Hook 需要保持纯粹 的原因。把自定义 Hook 的代码当作组件主体的一部分来考虑吧!

🌐 The code inside your custom Hooks will re-run during every re-render of your component. This is why, like components, custom Hooks need to be pure. Think of custom Hooks’ code as part of your component’s body!

因为自定义 Hook 会随着你的组件一起重新渲染,它们总是接收到最新的 props 和 state。要理解这意味着什么,请考虑这个聊天室示例。更改服务器 URL 或聊天室:

🌐 Because custom Hooks re-render together with your component, they always receive the latest props and state. To see what this means, consider this chat room example. Change the server URL or the chat room:

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

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useEffect(() => {
    const options = {
      serverUrl: serverUrl,
      roomId: roomId
    };
    const connection = createConnection(options);
    connection.on('message', (msg) => {
      showNotification('New message: ' + msg);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, serverUrl]);

  return (
    <>
      <label>
        Server URL:
        <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}

当你更改 serverUrlroomId 时,Effect 会对你的更改做出反应并重新同步。你可以从控制台消息中看出,每次更改 Effect 的依赖时,聊天都会重新连接。

🌐 When you change serverUrl or roomId, the Effect “reacts” to your changes and re-synchronizes. You can tell by the console messages that the chat re-connects every time that you change your Effect’s dependencies.

现在将副作用的代码移动到自定义钩子中:

🌐 Now move the Effect’s code into a custom Hook:

export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
showNotification('New message: ' + msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}

这使你的 ChatRoom 组件可以调用你的自定义 Hook,而不用担心它内部是如何工作的:

🌐 This lets your ChatRoom component call your custom Hook without worrying about how it works inside:

export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');

useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});

return (
<>
<label>
Server URL:
<input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
</label>
<h1>Welcome to the {roomId} room!</h1>
</>
);
}

这看起来简单多了!(但它做的事情是一样的。)

🌐 This looks much simpler! (But it does the same thing.)

注意,逻辑仍然会响应prop和state的变化。尝试编辑服务器URL或所选房间:

🌐 Notice that the logic still responds to prop and state changes. Try editing the server URL or the selected room:

import { useState } from 'react';
import { useChatRoom } from './useChatRoom.js';

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useChatRoom({
    roomId: roomId,
    serverUrl: serverUrl
  });

  return (
    <>
      <label>
        Server URL:
        <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}

注意你是如何获取一个钩子的返回值的:

🌐 Notice how you’re taking the return value of one Hook:

export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');

useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...

并将其作为输入传递给另一个 Hook:

🌐 and passing it as an input to another Hook:

export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');

useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...

每当你的 ChatRoom 组件重新渲染时,它会将最新的 roomIdserverUrl 传递给你的 Hook。这就是为什么每次重新渲染后,当它们的值不同,你的 Effect 会重新连接到聊天的原因。(如果你曾经使用过音频或视频处理软件,用这种方式串联 Hooks 可能会让你想到串联视觉或音频效果。就好像 useState 的输出“输入”到了 useChatRoom 的输入。)

🌐 Every time your ChatRoom component re-renders, it passes the latest roomId and serverUrl to your Hook. This is why your Effect re-connects to the chat whenever their values are different after a re-render. (If you ever worked with audio or video processing software, chaining Hooks like this might remind you of chaining visual or audio effects. It’s as if the output of useState “feeds into” the input of the useChatRoom.)

将事件处理程序传递给自定义钩子

🌐 Passing event handlers to custom Hooks

当你开始在更多组件中使用 useChatRoom 时,你可能希望让组件自定义其行为。例如,目前消息到达时的处理逻辑是硬编码在 Hook 内部的:

🌐 As you start using useChatRoom in more components, you might want to let components customize its behavior. For example, currently, the logic for what to do when a message arrives is hardcoded inside the Hook:

export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
showNotification('New message: ' + msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}

假设你想将此逻辑移回你的组件:

🌐 Let’s say you want to move this logic back to your component:

export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');

useChatRoom({
roomId: roomId,
serverUrl: serverUrl,
onReceiveMessage(msg) {
showNotification('New message: ' + msg);
}
});
// ...

为了使其工作,将你的自定义 Hook 修改为将 onReceiveMessage 作为其命名选项之一:

🌐 To make this work, change your custom Hook to take onReceiveMessage as one of its named options:

export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onReceiveMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl, onReceiveMessage]); // ✅ All dependencies declared
}

这会起作用,但是当你的自定义钩子接受事件处理程序时,你还可以进行另一项改进。

🌐 This will work, but there’s one more improvement you can do when your custom Hook accepts event handlers.

添加对 onReceiveMessage 的依赖并不理想,因为它会导致每次组件重新渲染时聊天重新连接。将此事件处理程序封装到 Effect Event 中以将其从依赖中移除:

🌐 Adding a dependency on onReceiveMessage is not ideal because it will cause the chat to re-connect every time the component re-renders. Wrap this event handler into an Effect Event to remove it from the dependencies:

import { useEffect, useEffectEvent } from 'react';
// ...

export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
const onMessage = useEffectEvent(onReceiveMessage);

useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ All dependencies declared
}

现在,每当 ChatRoom 组件重新渲染时,聊天不会重新连接。这里有一个完整可用的演示,展示如何将事件处理程序传递给自定义 Hook,你可以进行试用:

🌐 Now the chat won’t re-connect every time that the ChatRoom component re-renders. Here is a fully working demo of passing an event handler to a custom Hook that you can play with:

import { useState } from 'react';
import { useChatRoom } from './useChatRoom.js';
import { showNotification } from './notifications.js';

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useChatRoom({
    roomId: roomId,
    serverUrl: serverUrl,
    onReceiveMessage(msg) {
      showNotification('New message: ' + msg);
    }
  });

  return (
    <>
      <label>
        Server URL:
        <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}

注意你现在不再需要知道 useChatRoom 是如何工作的,就能使用它。你可以将它添加到任何其他组件中,传递任何其他选项,它的工作方式都不会变。这就是自定义 Hooks 的力量。

🌐 Notice how you no longer need to know how useChatRoom works in order to use it. You could add it to any other component, pass any other options, and it would work the same way. That’s the power of custom Hooks.

何时使用自定义钩子

🌐 When to use custom Hooks

你不需要为每一小段重复的代码都提取一个自定义 Hook。有些重复是可以的。例如,像之前那样提取一个 useFormInput Hook 来封装单个 useState 调用,可能没必要。

🌐 You don’t need to extract a custom Hook for every little duplicated bit of code. Some duplication is fine. For example, extracting a useFormInput Hook to wrap a single useState call like earlier is probably unnecessary.

然而,每当你编写一个 Effect 时,请考虑是否也将其封装到自定义 Hook 中会更清晰。你不应该经常需要使用 Effects, 因此,如果你正在编写一个 Effect,这意味着你需要“跳出 React”,与某些外部系统同步,或者执行 React 没有内置 API 的操作。将其封装到自定义 Hook 中可以让你准确表达你的意图以及数据如何在其中流动。

🌐 However, whenever you write an Effect, consider whether it would be clearer to also wrap it in a custom Hook. You shouldn’t need Effects very often, so if you’re writing one, it means that you need to “step outside React” to synchronize with some external system or to do something that React doesn’t have a built-in API for. Wrapping it into a custom Hook lets you precisely communicate your intent and how the data flows through it.

例如,考虑一个 ShippingForm 组件,它显示两个下拉菜单:一个显示城市列表,另一个显示所选城市的区域列表。你可能会从如下代码开始:

🌐 For example, consider a ShippingForm component that displays two dropdowns: one shows the list of cities, and another shows the list of areas in the selected city. You might start with some code that looks like this:

function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
// This Effect fetches cities for a country
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setCities(json);
}
});
return () => {
ignore = true;
};
}, [country]);

const [city, setCity] = useState(null);
const [areas, setAreas] = useState(null);
// This Effect fetches areas for the selected city
useEffect(() => {
if (city) {
let ignore = false;
fetch(`/api/areas?city=${city}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setAreas(json);
}
});
return () => {
ignore = true;
};
}
}, [city]);

// ...

虽然这段代码相当重复,但将这些 Effects 分开是正确的。 它们同步两个不同的事情,所以你不应该将它们合并成一个 Effect。相反,你可以通过将它们之间的共用逻辑提取到你自己的 useData Hook 中来简化上面的 ShippingForm 组件:

🌐 Although this code is quite repetitive, it’s correct to keep these Effects separate from each other. They synchronize two different things, so you shouldn’t merge them into one Effect. Instead, you can simplify the ShippingForm component above by extracting the common logic between them into your own useData Hook:

function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
if (url) {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}
}, [url]);
return data;
}

现在你可以在 ShippingForm 组件中将两个 Effects 都替换为对 useData 的调用:

🌐 Now you can replace both Effects in the ShippingForm components with calls to useData:

function ShippingForm({ country }) {
const cities = useData(`/api/cities?country=${country}`);
const [city, setCity] = useState(null);
const areas = useData(city ? `/api/areas?city=${city}` : null);
// ...

提取自定义 Hook 可以使数据流变得明确。你输入 url,就可以得到 data。通过将你的 Effect “隐藏”在 useData 中,你还可以阻止在 ShippingForm 组件上工作的其他人向其添加不必要的依赖。随着时间的推移,你应用的大多数 Effect 都将位于自定义 Hooks 中。

🌐 Extracting a custom Hook makes the data flow explicit. You feed the url in and you get the data out. By “hiding” your Effect inside useData, you also prevent someone working on the ShippingForm component from adding unnecessary dependencies to it. With time, most of your app’s Effects will be in custom Hooks.

深入研究

让你的自定义钩子专注于具体的高级用例

🌐 Keep your custom Hooks focused on concrete high-level use cases

首先选择你自定义 Hook 的名称。如果你很难选出一个清晰的名称,这可能意味着你的 Effect 与组件其他逻辑耦合过紧,还不适合被提取。

🌐 Start by choosing your custom Hook’s name. If you struggle to pick a clear name, it might mean that your Effect is too coupled to the rest of your component’s logic, and is not yet ready to be extracted.

理想情况下,你的自定义钩子的名称应该足够清晰,即使不经常编写代码的人也可以很好地猜测你的自定义钩子的作用、获取的内容以及返回的内容:

🌐 Ideally, your custom Hook’s name should be clear enough that even a person who doesn’t write code often could have a good guess about what your custom Hook does, what it takes, and what it returns:

  • useData(url)
  • useImpressionLog(eventName, extraData)
  • useChatRoom(options)

当你与外部系统同步时,你的自定义 Hook 名称可能更技术化,并使用该系统特有的术语。只要对熟悉该系统的人来说清楚,这样做是没问题的:

🌐 When you synchronize with an external system, your custom Hook name may be more technical and use jargon specific to that system. It’s good as long as it would be clear to a person familiar with that system:

  • useMediaQuery(query)
  • useSocket(url)
  • useIntersectionObserver(ref, options)

保持自定义 Hooks 专注于具体的高级用例。 避免创建和使用作为 useEffect API 本身的替代方案和便利封装的自定义“生命周期” Hooks:

  • 🔴 useMount(fn)
  • 🔴 useEffectOnce(fn)
  • 🔴 useUpdateEffect(fn)

例如,这个 useMount Hook 试图确保某些代码仅在“挂载”时运行:

🌐 For example, this useMount Hook tries to ensure some code only runs “on mount”:

function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');

// 🔴 Avoid: using custom "lifecycle" Hooks
useMount(() => {
const connection = createConnection({ roomId, serverUrl });
connection.connect();

post('/analytics/event', { eventName: 'visit_chat' });
});
// ...
}

// 🔴 Avoid: creating custom "lifecycle" Hooks
function useMount(fn) {
useEffect(() => {
fn();
}, []); // 🔴 React Hook useEffect has a missing dependency: 'fn'
}

useMount 这样的自定义“生命周期”钩子并不适合 React 范式。 例如,这段代码示例有一个错误(它不会对 roomIdserverUrl 的变化做出“反应”),但 linter 不会提醒你,因为 linter 只检查直接的 useEffect 调用。它不会知道你的钩子存在。

如果你正在编写副作用,请直接使用 React API 开始:

🌐 If you’re writing an Effect, start by using the React API directly:

function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');

// ✅ Good: two raw Effects separated by purpose

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

useEffect(() => {
post('/analytics/event', { eventName: 'visit_chat', roomId });
}, [roomId]);

// ...
}

然后,你可以(但不必)为不同的高级用例提取自定义钩子:

🌐 Then, you can (but don’t have to) extract custom Hooks for different high-level use cases:

function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');

// ✅ Great: custom Hooks named after their purpose
useChatRoom({ serverUrl, roomId });
useImpressionLog('visit_chat', { roomId });
// ...
}

一个好的自定义 Hook 通过限制它的行为,使调用代码更具声明性。 例如,useChatRoom(options) 只能连接到聊天室,而 useImpressionLog(eventName, extraData) 只能向分析系统发送印象日志。如果你的自定义 Hook API 不限制使用场景并且非常抽象,从长远来看,它可能带来的问题比解决的更多。

自定义钩子帮助你迁移到更好的模式

🌐 Custom Hooks help you migrate to better patterns

Effects 是一个“应急方案”:当你需要“跳出 React”并且没有更好的内置解决方案适用于你的使用场景时,你就会使用它们。随着时间的推移,React 团队的目标是通过为更具体的问题提供更具体的解决方案,将你应用中的 Effects 数量减少到最少。将你的 Effects 封装在自定义 Hooks 中,可以在这些解决方案可用时更容易升级你的代码。

🌐 Effects are an “escape hatch”: you use them when you need to “step outside React” and when there is no better built-in solution for your use case. With time, the React team’s goal is to reduce the number of the Effects in your app to the minimum by providing more specific solutions to more specific problems. Wrapping your Effects in custom Hooks makes it easier to upgrade your code when these solutions become available.

让我们回到这个例子:

🌐 Let’s return to this example:

import { useState, useEffect } from 'react';

export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }
    function handleOffline() {
      setIsOnline(false);
    }
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
  return isOnline;
}

在上面的例子中,useOnlineStatus 是通过一对 useStateuseEffect 实现的。然而,这不是最好的解决方案。它没有考虑许多边界情况。例如,它假设当组件挂载时,isOnline 已经是 true,但如果网络已经离线,这可能是错误的。你可以使用浏览器的 navigator.onLine API 来检查,但直接使用它在服务器上生成初始 HTML 时是不可行的。总之,这段代码可以改进。

🌐 In the above example, useOnlineStatus is implemented with a pair of useState and useEffect. However, this isn’t the best possible solution. There is a number of edge cases it doesn’t consider. For example, it assumes that when the component mounts, isOnline is already true, but this may be wrong if the network already went offline. You can use the browser navigator.onLine API to check for that, but using it directly would not work on the server for generating the initial HTML. In short, this code could be improved.

React 包含一个专用的 API,称为 useSyncExternalStore,它可以为你处理所有这些问题。这里是你的 useOnlineStatus Hook,重新编写以利用这个新 API:

🌐 React includes a dedicated API called useSyncExternalStore which takes care of all of these problems for you. Here is your useOnlineStatus Hook, rewritten to take advantage of this new API:

import { useSyncExternalStore } from 'react';

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine, // How to get the value on the client
    () => true // How to get the value on the server
  );
}

注意到你不需要更改任何组件就能进行这次迁移:

🌐 Notice how you didn’t need to change any of the components to make this migration:

function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}

function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}

这是为什么在自定义钩子中封装副作用通常是有益的另一个原因:

🌐 This is another reason for why wrapping Effects in custom Hooks is often beneficial:

  1. 你使流入和流出副作用的数据非常明确。
  2. 你让你的组件专注于意图而不是副作用的确切实现。
  3. 当 React 添加新功能时,你可以删除这些副作用而无需更改任何组件。

类似于设计系统, 你可能会发现从应用的组件中提取常见习惯用法到自定义 Hooks 中会很有帮助。这将使你的组件代码专注于意图,并让你避免频繁编写原始 Effects。许多优秀的自定义 Hooks 由 React 社区维护。

🌐 Similar to a design system, you might find it helpful to start extracting common idioms from your app’s components into custom Hooks. This will keep your components’ code focused on the intent, and let you avoid writing raw Effects very often. Many excellent custom Hooks are maintained by the React community.

深入研究

React 会提供任何内置的数据获取解决方案吗?

🌐 Will React provide any built-in solution for data fetching?

今天,使用 use API,可以通过将一个 Promise 传递给 use 来在渲染中读取数据:

🌐 Today, with the use API, data can be read in render by passing a Promise to use:

import { use, Suspense } from "react";

function Message({ messagePromise }) {
const messageContent = use(messagePromise);
return <p>Here is the message: {messageContent}</p>;
}

export function MessageContainer({ messagePromise }) {
return (
<Suspense fallback={<p>⌛Downloading message...</p>}>
<Message messagePromise={messagePromise} />
</Suspense>
);
}

我们仍在制定细节,但我们希望将来你可以像这样编写数据获取:

🌐 We’re still working out the details, but we expect that in the future, you’ll write data fetching like this:

import { use } from 'react';

function ShippingForm({ country }) {
const cities = use(fetch(`/api/cities?country=${country}`));
const [city, setCity] = useState(null);
const areas = city ? use(fetch(`/api/areas?city=${city}`)) : null;
// ...

如果你在应用中使用像上面 useData 这样的自定义 Hook,那么迁移到最终推荐的方法所需的更改将比你在每个组件中手动编写原始 Effect 要少。然而,旧方法仍然可以正常工作,所以如果你喜欢编写原始 Effect,你可以继续这样做。

🌐 If you use custom Hooks like useData above in your app, it will require fewer changes to migrate to the eventually recommended approach than if you write raw Effects in every component manually. However, the old approach will still work fine, so if you feel happy writing raw Effects, you can continue to do that.

有不止一种方法可以做到

🌐 There is more than one way to do it

假设你想使用浏览器 requestAnimationFrame API 从零开始 实现一个淡入动画。你可能会从一个设置动画循环的 Effect 开始。在动画的每一帧中,你可以改变你 持有在 ref 中 的 DOM 节点的不透明度,直到它达到 1。你的代码可能像这样开始:

🌐 Let’s say you want to implement a fade-in animation from scratch using the browser requestAnimationFrame API. You might start with an Effect that sets up an animation loop. During each frame of the animation, you could change the opacity of the DOM node you hold in a ref until it reaches 1. Your code might start like this:

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

function Welcome() {
  const ref = useRef(null);

  useEffect(() => {
    const duration = 1000;
    const node = ref.current;

    let startTime = performance.now();
    let frameId = null;

    function onFrame(now) {
      const timePassed = now - startTime;
      const progress = Math.min(timePassed / duration, 1);
      onProgress(progress);
      if (progress < 1) {
        // We still have more frames to paint
        frameId = requestAnimationFrame(onFrame);
      }
    }

    function onProgress(progress) {
      node.style.opacity = progress;
    }

    function start() {
      onProgress(0);
      startTime = performance.now();
      frameId = requestAnimationFrame(onFrame);
    }

    function stop() {
      cancelAnimationFrame(frameId);
      startTime = null;
      frameId = null;
    }

    start();
    return () => stop();
  }, []);

  return (
    <h1 className="welcome" ref={ref}>
      Welcome
    </h1>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Remove' : 'Show'}
      </button>
      <hr />
      {show && <Welcome />}
    </>
  );
}

为了使组件更易读,你可以将逻辑提取到一个 useFadeIn 自定义 Hook 中:

🌐 To make the component more readable, you might extract the logic into a useFadeIn custom Hook:

import { useState, useEffect, useRef } from 'react';
import { useFadeIn } from './useFadeIn.js';

function Welcome() {
  const ref = useRef(null);

  useFadeIn(ref, 1000);

  return (
    <h1 className="welcome" ref={ref}>
      Welcome
    </h1>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Remove' : 'Show'}
      </button>
      <hr />
      {show && <Welcome />}
    </>
  );
}

你可以保持 useFadeIn 代码不变,但你也可以对其进行更多重构。例如,你可以将设置动画循环的逻辑从 useFadeIn 中提取到一个自定义的 useAnimationLoop Hook 中:

🌐 You could keep the useFadeIn code as is, but you could also refactor it more. For example, you could extract the logic for setting up the animation loop out of useFadeIn into a custom useAnimationLoop Hook:

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

export function useFadeIn(ref, duration) {
  const [isRunning, setIsRunning] = useState(true);

  useAnimationLoop(isRunning, (timePassed) => {
    const progress = Math.min(timePassed / duration, 1);
    ref.current.style.opacity = progress;
    if (progress === 1) {
      setIsRunning(false);
    }
  });
}

function useAnimationLoop(isRunning, drawFrame) {
  const onFrame = useEffectEvent(drawFrame);

  useEffect(() => {
    if (!isRunning) {
      return;
    }

    const startTime = performance.now();
    let frameId = null;

    function tick(now) {
      const timePassed = now - startTime;
      onFrame(timePassed);
      frameId = requestAnimationFrame(tick);
    }

    tick();
    return () => cancelAnimationFrame(frameId);
  }, [isRunning]);
}

然而,你并不必须那样做。和常规函数一样,最终由你决定在代码的不同部分之间划分界限。你也可以采取一种完全不同的方法。你可以不把逻辑保留在 Effect 中,而是将大部分命令式逻辑移到一个 JavaScript 类: 中。

🌐 However, you didn’t have to do that. As with regular functions, ultimately you decide where to draw the boundaries between different parts of your code. You could also take a very different approach. Instead of keeping the logic in the Effect, you could move most of the imperative logic inside a JavaScript class:

import { useState, useEffect } from 'react';
import { FadeInAnimation } from './animation.js';

export function useFadeIn(ref, duration) {
  useEffect(() => {
    const animation = new FadeInAnimation(ref.current);
    animation.start(duration);
    return () => {
      animation.stop();
    };
  }, [ref, duration]);
}

Effects 让你将 React 连接到外部系统。Effects 之间需要的协调越多(例如,链式多个动画),将逻辑完全从 Effects 和 Hooks 中提取出来就越有意义,就像上面的沙盒示例一样。然后,你提取出来的代码就成为了“外部系统”。这使得你的 Effects 保持简单,因为它们只需要向你已移出 React 的系统发送消息。

🌐 Effects let you connect React to external systems. The more coordination between Effects is needed (for example, to chain multiple animations), the more it makes sense to extract that logic out of Effects and Hooks completely like in the sandbox above. Then, the code you extracted becomes the “external system”. This lets your Effects stay simple because they only need to send messages to the system you’ve moved outside React.

以上示例假设淡入逻辑需要用 JavaScript 编写。然而,这种特定的淡入动画使用普通的 CSS 动画: 实现既更简单又更高效。

🌐 The examples above assume that the fade-in logic needs to be written in JavaScript. However, this particular fade-in animation is both simpler and much more efficient to implement with a plain CSS Animation:

.welcome {
  color: white;
  padding: 50px;
  text-align: center;
  font-size: 50px;
  background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);

  animation: fadeIn 1000ms;
}

@keyframes fadeIn {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

有时,你甚至不需要钩子!

🌐 Sometimes, you don’t even need a Hook!

回顾

  • 自定义钩子让你可以在组件之间共享逻辑。
  • 自定义钩子必须以 use 开头,后跟大写字母命名。
  • Custom 钩子只共享状态逻辑,而不是状态本身。
  • 你可以将 React 值从一个钩子传递到另一个钩子,并且它们会保持最新。
  • 每次你的组件重新渲染时,所有钩子都会重新运行。
  • 你的自定义钩子的代码应该是纯粹的,就像你的组件的代码一样。
  • 将自定义钩子接收到的事件处理程序封装到副作用事件中。
  • 不要创建像 useMount 这样的自定义 Hooks。保持它们的用途具体化。
  • 如何以及在何处选择代码边界取决于你。

挑战 1 of 5:
提取 useCounter 钩子

🌐 Extract a useCounter Hook

该组件使用一个状态变量和一个 Effect 来显示每秒递增的数字。将此逻辑提取到一个名为 useCounter 的自定义 Hook 中。你的目标是使 Counter 组件的实现看起来完全像这样:

🌐 This component uses a state variable and an Effect to display a number that increments every second. Extract this logic into a custom Hook called useCounter. Your goal is to make the Counter component implementation look exactly like this:

export default function Counter() {
const count = useCounter();
return <h1>Seconds passed: {count}</h1>;
}

你需要在 useCounter.js 中编写自定义 Hook,并将其导入到 App.js 文件中。

🌐 You’ll need to write your custom Hook in useCounter.js and import it into the App.js file.

import { useState, useEffect } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <h1>Seconds passed: {count}</h1>;
}