第四三章 逃生舱口-通过自定义 Hooks 重用逻辑

2023-02-28  本文已影响0人  深圳都这么冷

通过自定义 Hooks 重用逻辑

React 带有几个内置的 Hook,例如 useState、useContext 和 useEffect。 有时,您会希望有一个 Hook 用于某些更具体的目的:例如,获取数据、跟踪用户是否在线或连接到聊天室。 您可能在 React 中找不到这些 Hooks,但您可以根据应用程序的需要创建自己的 Hooks。

你将学习

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

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

想象一下,您正在开发一个严重依赖网络的应用程序(就像大多数应用程序一样)。 如果用户在使用您的应用程序时网络连接意外断开,您想警告他们。 你会怎么做?

看起来你的组件中需要两件事:

跟踪网络是否在线的一种状态。
订阅全局在线和离线事件并更新该状态的 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

想象一下,类似于 useState 和 useEffect,有一个内置的 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。 这让您的组件读取该值:
useOnlineStatus.js

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

App.js

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 的粗糙细节。 您的组件的代码表达了您的意图,而不是实现。

钩子名称总是以 use 开头

React 应用程序是从组件构建的。 组件是从 Hooks 构建的,无论是内置的还是自定义的。 您可能会经常使用其他人创建的自定义 Hooks,但有时您可能会自己编写一个!

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

React 组件名称必须以大写字母开头,例如 StatusBar 和 SaveButton。 React 组件还需要返回一些 React 知道如何显示的东西,比如一段 JSX。
挂钩名称必须以 use 开头,后跟大写字母,例如 useState(内置)或 useOnlineStatus(自定义,如页面前面所示)。 钩子可以返回任意值。
此约定保证您始终可以查看组件并了解其状态、效果和其他 React 功能可能“隐藏”的位置。 例如,如果您在组件中看到一个 getColor() 函数调用,您可以确定它不可能在内部包含 React 状态,因为它的名称不是以 use 开头的。 但是,像 useOnlineStatus() 这样的函数调用很可能包含对内部其他 Hook 的调用!

注意

如果您的 linter 是为 React 配置的,它将强制执行此命名约定。 向上滚动到上面的沙盒并将 useOnlineStatus 重命名为 getOnlineStatus。 请注意,linter 将不再允许您在其中调用 useState 或 useEffect。 只有 Hooks 和组件才能调用其他 Hooks!

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

不,不调用 Hooks 的函数不需要是 Hooks。

如果您的函数不调用任何 Hook,请避免使用前缀。 相反,将其编写为不带 use 前缀的常规函数。 例如,下面的 useSorted 不调用 Hooks,所以调用它 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。 这通常会造成混淆和限制,因此最好避免这种模式。 但是,在极少数情况下它可能会有帮助。 例如,也许你的函数现在没有使用任何 Hooks,但你计划在将来向它添加一些 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 调用时,这将变得很重要。 如果您不打算在其中使用 Hooks(现在或以后),请不要将其设为 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. 有一个状态(firstName 和 lastName)。
  2. 有一个更改处理程序(handleFirstNameChange 和 handleLastNameChange)。
  3. 有一段 JSX 指定该输入的 value 和 onChange 属性。

您可以将重复逻辑提取到这个 useFormInput 自定义 Hook 中:
useFormInput.js

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

App.js

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

export default function Form() {
  const firstNameProps = useFormInput('Mary');
  const lastNameProps = useFormInput('Poppins');

  return (
    <>
      <label>
        First name:
        <input {...firstNameProps} />
      </label>
      <label>
        Last name:
        <input {...lastNameProps} />
      </label>
      <p><b>Good morning, {firstNameProps.value} {lastNameProps.value}.</b></p>
    </>
  );
}

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

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

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

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

自定义挂钩让您共享有状态逻辑,但不能共享状态本身。 对 Hook 的每次调用都完全独立于对同一 Hook 的所有其他调用。 这就是为什么上面的两个沙箱是完全等价的。 如果您愿意,请向上滚动并比较它们。 提取自定义 Hook 前后的行为是相同的。

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

在 Hook 之间传递反应值

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

因为自定义 Hooks 与您的组件一起重新渲染,所以它们总是收到最新的道具和状态。 要了解这意味着什么,请考虑这个聊天室示例。 更改服务器 URL 或选定的聊天室:
App.js

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

export default function App() {
  const [roomId, setRoomId] = useState('general');
  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>
      <hr />
      <ChatRoom
        roomId={roomId}
      />
    </>
  );
}

ChatRoom.js

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

chat.js

export function createConnection({ serverUrl, roomId }) {
  // A real implementation would actually connect to the server
  if (typeof serverUrl !== 'string') {
    throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
  }
  if (typeof roomId !== 'string') {
    throw Error('Expected roomId to be a string. Received: ' + roomId);
  }
  let intervalId;
  let messageCallback;
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
      clearInterval(intervalId);
      intervalId = setInterval(() => {
        if (messageCallback) {
          if (Math.random() > 0.5) {
            messageCallback('hey')
          } else {
            messageCallback('lol');
          }
        }
      }, 3000);
    },
    disconnect() {
      clearInterval(intervalId);
      messageCallback = null;
      console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl + '');
    },
    on(event, callback) {
      if (messageCallback) {
        throw Error('Cannot add the handler twice.');
      }
      if (event !== 'message') {
        throw Error('Only "message" event is supported.');
      }
      messageCallback = callback;
    },
  };
}

notifications.js

import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';

export function showNotification(message, theme = 'dark') {
  Toastify({
    text: message,
    duration: 2000,
    gravity: 'top',
    position: 'right',
    style: {
      background: theme === 'dark' ? 'black' : 'white',
      color: theme === 'dark' ? 'white' : 'black',
    },
  }).showToast();
}

当您更改 serverUrl 或 roomId 时,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://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>
    </>
  );
}

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

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

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

export default function App() {
  const [roomId, setRoomId] = useState('general');
  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>
      <hr />
      <ChatRoom
        roomId={roomId}
      />
    </>
  );
}

ChatRoom.js

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

useChatRoom.js

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

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

chat.js

export function createConnection({ serverUrl, roomId }) {
  // A real implementation would actually connect to the server
  if (typeof serverUrl !== 'string') {
    throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
  }
  if (typeof roomId !== 'string') {
    throw Error('Expected roomId to be a string. Received: ' + roomId);
  }
  let intervalId;
  let messageCallback;
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
      clearInterval(intervalId);
      intervalId = setInterval(() => {
        if (messageCallback) {
          if (Math.random() > 0.5) {
            messageCallback('hey')
          } else {
            messageCallback('lol');
          }
        }
      }, 3000);
    },
    disconnect() {
      clearInterval(intervalId);
      messageCallback = null;
      console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl + '');
    },
    on(event, callback) {
      if (messageCallback) {
        throw Error('Cannot add the handler twice.');
      }
      if (event !== 'message') {
        throw Error('Only "message" event is supported.');
      }
      messageCallback = callback;
    },
  };
}

notifications.js

import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';

export function showNotification(message, theme = 'dark') {
  Toastify({
    text: message,
    duration: 2000,
    gravity: 'top',
    position: 'right',
    style: {
      background: theme === 'dark' ? 'black' : 'white',
      color: theme === 'dark' ? 'white' : 'black',
    },
  }).showToast();
}

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

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

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

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

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

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

每次您的 ChatRoom 组件重新呈现时,它都会将最新的 roomId 和 serverUrl 传递给您的 Hook。 这就是为什么只要重新渲染后它们的值不同,您的 Effect 就会重新连接到聊天。 (如果你曾经使用过音乐处理软件,像这样链接 Hooks 可能会让你想起链接多个音频效果,比如添加混响或合唱。就好像 useState 的输出“馈入”useChatRoom 的输入。)

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

构建中
本节描述了一个尚未添加到 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://localhost: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 Event 中以将其从依赖项中删除:

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 的完整工作演示:
App.js

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

export default function App() {
  const [roomId, setRoomId] = useState('general');
  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>
      <hr />
      <ChatRoom
        roomId={roomId}
      />
    </>
  );
}

ChatRoom.js

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.js

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

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

chat.js

export function createConnection({ serverUrl, roomId }) {
  // A real implementation would actually connect to the server
  if (typeof serverUrl !== 'string') {
    throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
  }
  if (typeof roomId !== 'string') {
    throw Error('Expected roomId to be a string. Received: ' + roomId);
  }
  let intervalId;
  let messageCallback;
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
      clearInterval(intervalId);
      intervalId = setInterval(() => {
        if (messageCallback) {
          if (Math.random() > 0.5) {
            messageCallback('hey')
          } else {
            messageCallback('lol');
          }
        }
      }, 3000);
    },
    disconnect() {
      clearInterval(intervalId);
      messageCallback = null;
      console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl + '');
    },
    on(event, callback) {
      if (messageCallback) {
        throw Error('Cannot add the handler twice.');
      }
      if (event !== 'message') {
        throw Error('Only "message" event is supported.');
      }
      messageCallback = callback;
    },
  };
}

notifications.js

import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';

export function showNotification(message, theme = 'dark') {
  Toastify({
    text: message,
    duration: 2000,
    gravity: 'top',
    position: 'right',
    style: {
      background: theme === 'dark' ? 'black' : 'white',
      color: theme === 'dark' ? 'white' : 'black',
    },
  }).showToast();
}

请注意,您不再需要了解 useChatRoom 的工作原理即可使用它。 您可以将它添加到任何其他组件,传递任何其他选项,并且它会以相同的方式工作。 这就是自定义 Hooks 的力量。

何时使用自定义 Hooks

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

但是,每当您编写 Effect 时,请考虑将其包装在自定义 Hook 中是否会更清晰。 你不应该经常需要 Effects,所以如果你正在写一个,这意味着你需要“走出 React”以与一些外部系统同步或做一些 React 没有内置 API 的事情 . 将 Effect 包装到自定义 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]);

  // ...

虽然这段代码相当重复,但将这些 Effect 彼此分开是正确的。 它们同步两个不同的东西,所以你不应该把它们合并成一个 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;
}

现在,您可以将 ShippingForm 组件中的两个 Effects 替换为对 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,然后获取数据。 通过将您的效果“隐藏”在 useData 中,您还可以防止处理 ShippingForm 组件的人员向其添加不必要的依赖项。 理想情况下,随着时间的推移,您应用程序的大部分效果都将在自定义 Hooks 中。

深度阅读:让您的自定义 Hooks 专注于具体的高级用例

首先选择您的自定义 Hook 的名称。 如果您难以选择一个清晰的名称,则可能意味着您的 Effect 与组件逻辑的其余部分过于耦合,并且尚未准备好提取。

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

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

当您与外部系统同步时,您的自定义 Hook 名称可能更具技术性并使用特定于该系统的行话。 只要熟悉该系统的人清楚就可以了:

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

让自定义 Hooks 专注于具体的高级用例。 避免创建和使用自定义“生命周期”挂钩,这些挂钩充当 useEffect API 本身的替代和便利包装器:

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

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

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 这样的自定义“生命周期”Hooks 不太适合 React 范例。 例如,此代码示例有一个错误(它不会对 roomId 或 serverUrl 更改做出“反应”),但 linter 不会就此向您发出警告,因为 linter 仅检查直接 useEffect 调用。 它不会知道你的 Hook。

如果您正在编写 Effect,请直接使用 React API 开始:

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]);

  // ...
}

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

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 不限制用例并且非常抽象,那么从长远来看,它可能会引入比解决的问题更多的问题。

自定义 Hooks 帮助您迁移到更好的模式

Effects 是一个“逃生舱口”:当你需要“走出 React”并且没有更好的内置解决方案适合你的用例时,你可以使用它们。 随着时间的推移,React 团队的目标是通过为更具体的问题提供更具体的解决方案,将应用程序中的效果数量减少到最少。 在这些解决方案可用时,在自定义 Hooks 中包装 Effects 可以更轻松地升级您的代码。 让我们回到这个例子:
App.js

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

useOnlineStatus.js

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 是通过一对 useState 和 useEffect 来实现的。 然而,这并不是最好的解决方案。 它没有考虑许多边缘情况。 例如,它假定当组件挂载时,isOnline 已经为真,但如果网络已经离线,这可能是错误的。 您可以使用浏览器 navigator.onLine API 来检查它,但如果您在服务器上运行 React 应用程序以生成初始 HTML,直接使用它会中断。 简而言之,这段代码可以改进。

幸运的是,React 18 包含一个名为 useSyncExternalStore 的专用 API,它可以为您解决所有这些问题。 以下是您的 useOnlineStatus Hook 重写以利用这个新 API 的方式:
App.js

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

useOnlineStatus.js

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

这是为什么在自定义 Hooks 中包装 Effects 通常是有益的另一个原因:

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

与设计系统类似,您可能会发现开始将应用程序组件中的常用习语提取到自定义 Hook 中会很有帮助。 这将使您的组件代码专注于意图,并让您避免经常编写原始效果。 React 社区也维护了很多优秀的自定义 Hooks。

深度阅读: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;
  // ...

如果您在您的应用程序中使用像上面的 useData 这样的自定义 Hooks,那么与您在每个组件中手动编写原始 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 中:
App.js

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.js

import { useEffect } from 'react';

export function useFadeIn(ref, duration) {
  useEffect(() => {
    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();
  }, [ref, duration]);
}

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

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.js

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

但是,您不必那样做。 与常规函数一样,最终您决定在何处划定代码不同部分之间的界限。 例如,您也可以采用非常不同的方法。 您可以将大部分命令式逻辑移动到 JavaScript 类中,而不是将逻辑保留在 Effect 中:
App.js

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.js

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

animation.js

export class FadeInAnimation {
  constructor(node) {
    this.node = node;
  }
  start(duration) {
    this.duration = duration;
    this.onProgress(0);
    this.startTime = performance.now();
    this.frameId = requestAnimationFrame(() => this.onFrame());
  }
  onFrame() {
    const timePassed = performance.now() - this.startTime;
    const progress = Math.min(timePassed / this.duration, 1);
    this.onProgress(progress);
    if (progress === 1) {
      this.stop();
    } else {
      // We still have more frames to paint
      this.frameId = requestAnimationFrame(() => this.onFrame());
    }
  }
  onProgress(progress) {
    this.node.style.opacity = progress;
  }
  stop() {
    cancelAnimationFrame(this.frameId);
    this.startTime = null;
    this.frameId = null;
    this.duration = 0;
  }
}

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

上面的示例假设淡入逻辑需要用 JavaScript 编写。 然而,这个特殊的淡入动画使用纯 CSS 动画来实现既简单又高效:
App.js

import { useState, useEffect, useRef } from 'react';
import './welcome.css';

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

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

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

回顾

上一篇 下一篇

猜你喜欢

热点阅读