使用自定义 Hook 重用逻辑

React 提供了一些内置的 Hook,例如 useStateuseContextuseEffect。有时,你可能希望有一个 Hook 用于更具体的用途:例如,获取数据、跟踪用户是否在线或连接到聊天室。你可能在 React 中找不到这些 Hook,但你可以根据应用程序的需要创建自己的 Hook。

你将学习

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

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

假设你正在开发一个严重依赖网络的应用程序(大多数应用程序都是如此)。当用户使用你的应用程序时,如果他们的网络连接意外中断,你想警告用户。你会怎么做?你的组件似乎需要两样东西

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

这将使你的组件与网络状态保持 同步。你可能从类似这样的代码开始

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 如何响应你的操作而更新。

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

首先,你可以将 isOnline 状态和 Effect 复制粘贴到 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>
  );
}

验证一下,如果关闭网络,按钮是否会改变外观。

这两个组件工作正常,但是它们之间的逻辑重复是不利的。似乎即使它们具有不同的视觉外观,你也想重用它们之间的逻辑。

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

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

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 的函数,并将所有重复的代码从你之前编写的组件中移动到该函数中。

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。这允许你的组件读取该值。

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 />
    </>
  );
}

验证一下,打开和关闭网络是否会更新这两个组件。

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

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

Hook 名称始终以 use 开头

React 应用程序由组件构建。组件由 Hook 构建,无论是内置的还是自定义的。你可能会经常使用其他人创建的自定义 Hook,但偶尔你可能会自己编写一个!

必须遵循以下命名约定

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

此约定确保您可以始终查看组件并了解其状态、Effects 和其他 React 功能可能“隐藏”的位置。例如,如果您在组件内看到 getColor() 函数调用,您可以确定它不可能包含 React 状态,因为它的名称不以 use 开头。但是,类似于 useOnlineStatus() 的函数调用很可能包含对其他 Hook 的调用!

注意

如果您的代码检查工具已针对 React 配置, 它将强制执行此命名约定。向上滚动到上面的沙箱,并将 useOnlineStatus 重命名为 getOnlineStatus。请注意,代码检查工具将不允许您在其中再调用 useStateuseEffect 了。只有 Hook 和组件才能调用其他 Hook!

深入探讨

渲染过程中调用的所有函数都必须以 use 前缀开头吗?

否。不调用 Hook 的函数不需要 Hook。

如果您的函数不调用任何 Hook,请避免使用 use 前缀。相反,将其编写为use 前缀的常规函数。例如,下面的 useSorted 不调用 Hook,因此将其改为 getSorted

// 🔴 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();
}

这确保您的代码可以随时调用此常规函数,包括条件语句中。

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)。

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

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

// ✅ 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。

自定义 Hook 允许您共享有状态的逻辑,而不是状态本身

在前面的示例中,当您打开和关闭网络时,两个组件会一起更新。但是,认为单个 isOnline 状态变量在它们之间共享是错误的。请查看此代码

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

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

它的工作方式与您提取重复代码之前相同。

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

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

这是两个完全独立的状态变量和 Effects!它们碰巧在同一时间具有相同的值,因为您使用相同外部值(网络是否开启)对其进行了同步。

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

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>
    </>
  );
}

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

  1. 有一部分状态(firstNamelastName)。
  2. 有一个更改处理程序(handleFirstNameChangehandleLastNameChange)。
  3. 有一段 JSX 代码指定了该输入的 valueonChange 属性。

您可以将重复的逻辑提取到这个 useFormInput 自定义 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 的状态变量。

但是,Form 组件两次调用 useFormInput:

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

这就是它像声明两个单独的状态变量一样工作的原因!

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

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

在 Hook 之间传递响应式值

自定义 Hook 内的代码会在组件的每次重新渲染期间重新运行。这就是为什么,像组件一样,自定义 Hook 需要是纯的。将自定义 Hook 的代码视为组件主体的一部分!

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

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://127.0.0.1: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 的依赖项时,聊天都会重新连接。

现在将 Effect 的代码移到自定义 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,而无需担心它内部的工作方式

export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://127.0.0.1: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>
</>
);
}

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

请注意,该逻辑仍然响应 prop 和状态更改。尝试编辑服务器 URL 或所选房间

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

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://127.0.0.1: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>
    </>
  );
}

注意你如何获取一个 Hook 的返回值

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

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

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

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

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

每次你的 ChatRoom 组件重新渲染时,它都会将最新的 roomIdserverUrl 传递给你的 Hook。这就是为什么每次它们的 value 在重新渲染后不同时,你的 Effect 都会重新连接到聊天。(如果你曾经使用过音频或视频处理软件,那么像这样链接 Hook 可能会让你想起链接视觉或音频效果。这就像 useState 的输出“馈入” useChatRoom 的输入一样。)

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

建设中

本节描述的是尚未在 React 的稳定版本中发布的实验性 API

当你开始在更多组件中使用 useChatRoom 时,你可能希望让组件自定义其行为。例如,目前,处理消息到达的逻辑是硬编码在 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]);
}

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

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

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

为了使此方法有效,请将你的自定义 Hook 更改为将 onReceiveMessage 作为其命名选项之一

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
}

这将有效,但是当你的自定义 Hook 接受事件处理程序时,你还可以进行一项改进。

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

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 的完整工作演示

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

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://127.0.0.1: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 的工作方式才能使用它。你可以将其添加到任何其他组件,传递任何其他选项,它都将以相同的方式工作。这就是自定义 Hook 的强大之处。

何时使用自定义 Hook

你不需要为每一小段重复代码都提取一个自定义 Hook。一些重复是可以的。例如,提取一个 useFormInput Hook 来包装单个 useState 调用(如前所述)可能是不必要的。

但是,每当你编写一个 Effect 时,请考虑是否也将其包装在自定义 Hook 中会更清晰。你通常不需要经常使用 Effects, 因此,如果你正在编写一个 Effect,这意味着你需要“跳出 React”与某些外部系统同步,或者做一些 React 没有内置 API 的事情。将其包装到自定义 Hook 中可以让你精确地传达你的意图以及数据如何通过它流动。

例如,考虑一个 ShippingForm 组件,它显示两个下拉列表:一个显示城市列表,另一个显示所选城市的区域列表。你可能从一些看起来像这样的代码开始

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 组件

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;
}

现在,你可以用对 useData 的调用替换 ShippingForm 组件中的两个 Effects

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 都将位于自定义 Hook 中。

深入探讨

保持你的自定义 Hook 集中于具体的、高级别的用例

首先选择自定义 Hook 的名称。如果你难以选择一个清晰的名称,这可能意味着你的 Effect 与组件的其余逻辑耦合得太紧密,尚未准备好提取。

理想情况下,你的自定义 Hook 名称应该足够清晰,即使是不常编写代码的人也能很好地猜测你的自定义 Hook 的作用、它需要什么以及它返回什么。

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

当你与外部系统同步时,你的自定义 Hook 名称可能更技术化,并使用该系统特有的术语。只要熟悉该系统的人能够理解,就可以了。

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

保持自定义 Hook 集中于具体的、高级别的用例。避免创建和使用充当 useEffect API 本身的替代方案和便捷包装器的自定义“生命周期”Hook。

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

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

function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://127.0.0.1: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 这样的自定义“生命周期”Hook 不太适合 React 范式。例如,此代码示例存在错误(它不会对 roomIdserverUrl 的更改“做出反应”),但代码检查器不会警告你,因为代码检查器只检查直接的 useEffect 调用。它不知道你的 Hook。

如果你正在编写 Effect,请先直接使用 React API。

function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://127.0.0.1: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]);

// ...
}

然后,你可以(但不必)为不同的高级别用例提取自定义 Hook。

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

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

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

自定义 Hook 可以帮助你迁移到更好的模式

Effect 是一个“逃生舱”:当你需要“跳出 React”并且没有更好的内置解决方案来满足你的用例时,你会使用它们。随着时间的推移,React 团队的目标是通过为更具体的问题提供更具体的解决方案来将你应用中的 Effect 数量减少到最小。将你的 Effect 包装在自定义 Hook 中,可以在这些解决方案可用时更轻松地升级你的代码。

让我们回到这个例子

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 时不起作用。简而言之,此代码可以改进。

幸运的是,React 18 包含一个名为 useSyncExternalStore 的专用 API,它可以为你处理所有这些问题。以下是你的 useOnlineStatus Hook,重写以利用这个新 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
  );
}

请注意,你无需更改任何组件即可进行此迁移。

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

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

这是将 Effect 包装在自定义 Hook 中通常有益的另一个原因。

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

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

深入探讨

React 是否提供任何内置的数据获取方案?

我们仍在完善细节,但我们预计将来您可以这样编写数据获取:

import { use } from 'react'; // Not available yet!

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;
// ...

如果您在应用中使用如上所示的自定义 Hook,例如useData,那么迁移到最终推荐的方法所需的更改将少于在每个组件中手动编写原始 Effects 的情况。但是,旧方法仍然可以正常工作,因此如果您觉得编写原始 Effects 很方便,您可以继续这样做。

有多种方法可以实现这一点

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

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 中。

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 中。

import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as 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 类中:

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 连接到外部系统。Effect 之间需要越多的协调(例如,要链接多个动画),就越有意义地将该逻辑完全从 Effects 和 Hook 中提取出来,就像上面的沙箱一样。然后,您提取的代码*就成为*“外部系统”。这使您的 Effects 保持简单,因为它们只需要向您已移到 React 外部的系统发送消息。

以上示例假设淡入逻辑需要用 JavaScript 编写。但是,这种特殊的淡入动画使用简单的CSS 动画:实现起来更简单且效率更高:

.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; }
}

有时,您甚至不需要 Hook!

回顾

  • 自定义 Hook 允许您在组件之间共享逻辑。
  • 自定义 Hook 的名称必须以use开头,后跟大写字母。
  • 自定义 Hook 只共享有状态逻辑,而不是状态本身。
  • 您可以将反应式值从一个 Hook 传递到另一个 Hook,并且它们保持最新。
  • 所有 Hook 都会在组件每次重新渲染时重新运行。
  • 自定义 Hook 的代码应该像组件代码一样纯净。
  • 将自定义 Hook 收到的事件处理程序包装到 Effect 事件中。
  • 不要创建像useMount这样的自定义 Hook。保持其目的的特定性。
  • 代码的边界如何以及在哪里选择取决于您。

挑战 1 5:
提取一个useCounter Hook

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

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

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

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>;
}