React 基础体系 · 第 27/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 自定义 Hook:契约、组合、副作用、稳定性和测试

自定义 Hook 不是“把一段代码挪到函数里”的语法技巧。它是一种以函数形式封装 React 状态、生命周期和外部系统交互的方式。要让一个 Hook 可复用,必须同时回答五个问题:

  1. 调用者传入什么,Hook 返回什么?
  2. 返回值中的哪些部分具有稳定的身份?
  3. Hook 如何与其他 Hook 组合?
  4. 哪些逻辑属于渲染,哪些逻辑属于副作用?
  5. 如何测试行为,而不是测试某个内部实现细节?

本文以 React 19、现代 TypeScript 和主流客户端框架为背景,重点讨论这些问题之间的因果关系。

一、先建立心智模型:Hook 是组件实例中的状态协议

1.1 自定义 Hook 没有独立的生命周期

一个自定义 Hook 本质上仍然在组件函数执行期间调用:

function UserPage({ userId }: { userId: string }) {
  const user = useUser(userId);

  return <UserView user={user} />;
}

useUser 不会创建一个独立的组件实例,也没有自己的挂载、更新和卸载过程。它使用的 useStateuseEffectuseRef 等 Hook,实际都属于调用它的 UserPage 实例。

因此,下列说法是不准确的:

“这个自定义 Hook 挂载了,所以它开始请求数据。”

更准确的描述是:

“调用该 Hook 的组件实例完成提交后,其中定义的 Effect 被 React 调度执行,于是请求开始。”

如果同一个组件调用两次 Hook:

function CompareUsers() {
  const left = useUser("a");
  const right = useUser("b");

  // ...
}

那么会得到两组彼此独立的状态和 Effect。共享的是 Hook 的代码,不是 Hook 的状态。

1.2 Hook 可以抽象为一个带副作用的函数

可以把一个 Hook 粗略表示为:

H(I,R)(O,E)H(I, R) \rightarrow (O, E)

其中:

  • II 是调用者输入,例如 userId
  • RR 是 React 提供的当前组件上下文和 Hook 状态槽位;
  • OO 是返回值,例如 { status, data, error, reload }
  • EE 是对外部系统的同步行为,例如网络请求、事件订阅或定时器。

这个模型有一个重要推论:

Hook 的返回值应当描述当前渲染可以使用的数据;副作用应当在渲染提交后同步外部系统。

不能在 Hook 的函数体中直接执行会产生外部影响的操作:

function useBadUser(userId: string) {
  fetch(`/api/users/${userId}`); // 不应在渲染阶段直接请求
  return null;
}

组件函数可能被 React 重复调用、放弃某次渲染,或者在开发模式下进行额外检查。渲染阶段必须尽量保持纯粹;网络请求、订阅和 DOM 操作应放入 Effect 或其他明确的事件处理逻辑中。

二、Hook 契约:输入、输出、时序和不变量

2.1 契约不只是 TypeScript 类型

一个 Hook 的契约至少包括四部分:

  1. 输入契约:接受哪些参数,哪些参数变化会触发什么行为。
  2. 输出契约:返回值的结构、状态语义和错误表示。
  3. 时序契约:初次调用、输入变化、重新加载、卸载时分别发生什么。
  4. 稳定性契约:哪些返回值在输入不变时保持引用稳定。

例如,设计一个 useUser

type User = {
  id: string;
  name: string;
};

type UserState =
  | { status: "idle"; data: null; error: null }
  | { status: "loading"; data: User | null; error: null }
  | { status: "success"; data: User; error: null }
  | { status: "error"; data: User | null; error: Error };

可以为它规定如下契约:

  • userId 是必需的非空字符串;
  • 初始状态为 idle
  • 提交后开始请求,状态变为 loading
  • 请求成功后变为 success
  • 请求失败后变为 error
  • reload() 会重新请求当前 userId
  • reload 的函数引用在 userId 不变时保持稳定;
  • 组件卸载或 userId 变化时,旧请求不应覆盖新请求的结果。

最后一条尤其重要。只写出返回类型,并不能表达“旧请求不能覆盖新请求”这种时序保证。

2.2 用联合类型表达互斥状态

下面是一个完整的客户端 Hook:

"use client";

import { useCallback, useEffect, useState } from "react";

type User = {
  id: string;
  name: string;
};

type UserState =
  | { status: "idle"; data: null; error: null }
  | { status: "loading"; data: User | null; error: null }
  | { status: "success"; data: User; error: null }
  | { status: "error"; data: User | null; error: Error };

const initialState: UserState = {
  status: "idle",
  data: null,
  error: null,
};

export function useUser(userId: string) {
  const [state, setState] = useState<UserState>(initialState);
  const [reloadVersion, setReloadVersion] = useState(0);

  const reload = useCallback(() => {
    setReloadVersion((version) => version + 1);
  }, []);

  useEffect(() => {
    const controller = new AbortController();
    let active = true;

    setState((previous) => ({
      status: "loading",
      data: previous.data,
      error: null,
    }));

    fetch(`/api/users/${encodeURIComponent(userId)}`, {
      signal: controller.signal,
    })
      .then(async (response) => {
        if (!response.ok) {
          throw new Error(`Request failed: ${response.status}`);
        }

        // 真实应用中应对 JSON 做运行时校验,而不只依赖类型断言。
        return (await response.json()) as User;
      })
      .then((user) => {
        if (!active) return;

        setState({
          status: "success",
          data: user,
          error: null,
        });
      })
      .catch((error: unknown) => {
        if (!active) return;
        if (error instanceof DOMException && error.name === "AbortError") {
          return;
        }

        setState({
          status: "error",
          data: null,
          error: error instanceof Error ? error : new Error(String(error)),
        });
      });

    return () => {
      active = false;
      controller.abort();
    };
  }, [userId, reloadVersion]);

  return {
    ...state,
    reload,
  };
}

调用方式:

function UserPanel({ userId }: { userId: string }) {
  const user = useUser(userId);

  if (user.status === "idle" || user.status === "loading") {
    return <p>加载中……</p>;
  }

  if (user.status === "error") {
    return (
      <div>
        <p>{user.error.message}</p>
        <button onClick={user.reload}>重试</button>
      </div>
    );
  }

  return <h1>{user.data.name}</h1>;
}

这个实现中,每一步都有明确原因:

  • useState 保存当前渲染所需的状态;
  • useEffectuserId 同步到网络请求;
  • encodeURIComponent 防止路径参数中的特殊字符破坏 URL;
  • response.ok 处理 HTTP 失败,因为 fetch 对 404 或 500 通常不会自动 reject;
  • AbortController 请求取消,避免组件卸载后继续占用网络资源;
  • active 标志处理“底层实现没有完全尊重 abort”或 Promise 已经进入回调队列的情况;
  • reloadVersion 是显式的重新执行信号;
  • reload 使用函数式更新,不读取当前版本,因此可以保持空依赖数组。

这里的 reloadVersion 不是业务数据,而是一个“重新同步”的事件计数器。它变化时,Effect 重新执行;它的数值本身没有业务含义。

2.3 状态转换要能解释失败路径

该 Hook 的状态转换可以表示为:

stateDiagram-v2
    [*] --> idle
    idle --> loading: Effect 提交后执行
    loading --> success: HTTP 成功且响应有效
    loading --> error: 网络错误或 HTTP 错误
    success --> loading: userId 变化或 reload()
    error --> loading: userId 变化或 reload()
    loading --> [*]: 卸载,取消请求

关键点是:loading 不一定意味着 datanull。上面的实现保留了旧数据,因此 userId 变化时可以选择继续显示旧内容,同时展示加载状态。这是一个契约选择,而不是 React 自动决定的行为。

如果产品要求切换用户时立即清空旧数据,应改为:

setState({
  status: "loading",
  data: null,
  error: null,
});

两者都合理,但调用者必须知道是哪一种。

三、Rules of Hooks:组合成立的结构条件

3.1 Hook 调用顺序是状态关联的基础

React 通过 Hook 的调用顺序,把每次调用关联到对应的状态槽位。假设组件第一次渲染按如下顺序执行:

useState("A"); // 槽位 1
useState("B"); // 槽位 2

如果下一次渲染因为条件分支变成:

useState("B"); // 槽位 1 被错误复用

React 无法从普通 JavaScript 函数调用中推断“这是原来的第二个状态”。因此 Hook 必须:

  • 在组件或自定义 Hook 的顶层调用;
  • 不能放在 ifforwhile、普通回调或事件处理函数中;
  • 不能根据条件决定是否调用某个 Hook。

错误示例:

function useUserName(userId: string | null) {
  if (userId === null) {
    return null;
  }

  const user = useUser(userId); // 条件调用 Hook
  return user.data?.name ?? null;
}

正确方式是始终调用 Hook,把条件作为参数或数据处理逻辑:

function useUserName(userId: string | null) {
  const user = useUser(userId ?? "");

  if (userId === null) {
    return null;
  }

  return user.data?.name ?? null;
}

不过这个示例会在 userId 为空时请求 /api/users/,所以更完整的设计是让底层 Hook 支持禁用状态:

function useUser(userId: string | null) {
  // Hook 仍然始终调用内部 useState/useEffect。
  // Effect 内部根据 userId 是否为空决定是否请求。
}
useEffect(() => {
  if (userId === null) {
    setState(initialState);
    return;
  }

  // 只有有效 userId 才建立请求同步。
}, [userId]);

“始终调用 Hook,条件化 Hook 的行为”是组合成立的核心。

3.2 自定义 Hook 的命名是工具链契约

自定义 Hook 应以 use 开头,例如 useUseruseDebouncedValue。这不仅是命名习惯:

  • React 的开发工具依赖命名识别 Hook;
  • eslint-plugin-react-hooks 依赖命名规则检查 Hook 调用;
  • 读者可以通过名称知道函数可能依赖组件生命周期和状态。

一个普通工具函数如果不使用 Hook,不应为了风格强行命名为 useXxx

function formatUserName(name: string): string {
  return name.trim();
}

它是纯函数,应保持普通函数形式。把纯逻辑与 Hook 生命周期逻辑混在一起,会使测试和依赖分析更困难。

四、组合:从多个小 Hook 建立数据流

4.1 组合不是复制代码,而是嵌套协议

一个 Hook 可以调用其他 Hook:

import { useEffect, useState } from "react";

export function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = window.setTimeout(() => {
      setDebouncedValue(value);
    }, delayMs);

    return () => {
      window.clearTimeout(timer);
    };
  }, [value, delayMs]);

  return debouncedValue;
}

然后组合搜索 Hook:

function useSearchUsers(query: string) {
  const debouncedQuery = useDebouncedValue(query, 300);
  const result = useUserSearch(debouncedQuery);

  return {
    ...result,
    query,
    debouncedQuery,
  };
}

这里有两条数据流:

输入框 query
    │
    ▼
useDebouncedValue
    │ 300ms 内没有新输入
    ▼
debouncedQuery
    │
    ▼
useUserSearch
    │
    ▼
请求状态与结果

useDebouncedValue 只负责“值的时间平滑”,不负责网络请求;useUserSearch 只负责查询请求。每个 Hook 拥有自己的状态和副作用边界,组合后形成更高层的协议。

4.2 Hook 应该暴露意图,而不是暴露内部机制

调用者通常需要:

const { status, data, reload } = useUser(userId);

而不需要知道:

  • 内部使用了几个 useState
  • 是否使用了 AbortController
  • 重试是通过计数器还是随机请求 ID;
  • 请求函数是否拆成了几个内部函数。

如果调用者必须手动调用 setLoading、清理请求或比较请求序号,说明副作用边界没有封装好。

但封装不代表隐藏所有决策。比如“切换参数时是否保留旧数据”“错误是否可重试”“是否自动刷新”都属于外部可观察行为,应写进契约或通过参数明确表达。

五、副作用:Effect 是外部系统同步,不是任意异步代码

5.1 Effect 的适用条件

Effect 适合把 React 状态同步到 React 外部的系统,例如:

  • 网络请求;
  • 浏览器事件监听;
  • WebSocket;
  • 定时器;
  • DOM API;
  • 第三方非 React 小部件。

Effect 不适合仅仅把一个已有状态计算成另一个状态:

function FullName({ first, last }: { first: string; last: string }) {
  const [fullName, setFullName] = useState("");

  useEffect(() => {
    setFullName(`${first} ${last}`);
  }, [first, last]);

  return <p>{fullName}</p>;
}

这会产生额外的渲染过程:

  1. firstlast 变化;
  2. 组件先用旧的 fullName 渲染;
  3. Effect 执行并调用 setFullName
  4. 组件再次渲染出正确值。

正确方式是直接在渲染中计算:

function FullName({ first, last }: { first: string; last: string }) {
  return <p>{first} {last}</p>;
}

如果计算成本确实很高,可以考虑 useMemo,但 useMemo 是性能优化,不是正确性机制。React 可以在特定情况下丢弃缓存并重新计算,因此不能依赖它维持业务语义。

5.2 Effect 的依赖表示闭包读取的数据

Effect 回调是一个闭包。它读取了哪些响应式值,Effect 就可能需要依赖哪些值:

useEffect(() => {
  const connection = createConnection(serverUrl, roomId);
  connection.connect();

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

serverUrlroomId 改变时,React 会执行:

  1. 运行上一次 Effect 的清理函数;
  2. 使用新值执行新的 Effect;
  3. 新连接替代旧连接。

清理不是可选的装饰,而是“旧同步关系终止”的一部分。订阅、定时器和连接如果不清理,就会造成重复监听、内存泄漏或旧数据写入。

5.3 StrictMode 下的额外执行不是生产双请求保证

在开发环境的 Strict Mode 下,React 可能进行额外的 setup/cleanup 检查,典型序列类似:

setup → cleanup → setup

目的在于发现 Effect 是否能正确清理,而不是给应用提供生产语义上的“双重请求保证”。

因此,下面这种代码有问题:

useEffect(() => {
  fetch("/api/log-view");
}, []);

如果请求代表一次不可重复的业务操作,不应简单依赖 useEffect 的执行次数。应考虑:

  • 将操作放入用户明确触发的事件处理函数;
  • 让服务端接口具备幂等性;
  • 使用请求幂等键;
  • 在客户端数据层统一管理缓存和去重。

对于读取数据,重复请求通常可以通过取消、缓存或去重降低影响;对于支付、写入、发送邮件等操作,必须从接口语义上处理重复提交。

5.4 竞态条件:取消请求与拒绝旧结果是两件事

考虑如下时序:

t1: userId = "a",发出请求 A
t2: userId = "b",发出请求 B
t3: 请求 B 返回,显示用户 b
t4: 请求 A 返回,错误地覆盖成用户 a

仅仅在新 Effect 中发出 B,并不能阻止 A 的 Promise 回调晚到。需要保证旧请求结果失效。

常见方法有两类:

  1. AbortController:尝试取消底层请求;
  2. active 标志或请求序号:即使回调到达,也拒绝旧结果。

请求序号的形式如下:

const requestId = useRef(0);

useEffect(() => {
  const currentRequestId = ++requestId.current;

  fetch(url)
    .then((response) => response.json())
    .then((data) => {
      if (currentRequestId !== requestId.current) {
        return;
      }

      setData(data);
    });
}, [url]);

这里 useRef 保存跨渲染持久存在但不触发渲染的可变值。它适合保存“当前有效请求编号”这类控制信息,但不适合用来保存需要直接显示在界面上的状态。

六、稳定性:值相等不等于引用相等

6.1 React 依赖比较关注引用身份

Effect 依赖和许多组件优化通常使用 Object.is 进行比较。对象和函数即使内容相同,只要不是同一个引用,就会被视为变化:

function Parent({ userId }: { userId: string }) {
  const options = { userId };

  useEffect(() => {
    // options 每次渲染都是新对象
  }, [options]);

  return null;
}

每次渲染都会创建新的 options,所以 Effect 会重复执行。

优先把依赖拆成原始值:

useEffect(() => {
  // 使用 userId
}, [userId]);

如果确实需要传递对象,可以使用 useMemo

const options = useMemo(() => ({ userId }), [userId]);

但应明确:useMemo 通常只用于性能优化和减少引用变化,不应被当作绝对不变的永久缓存。

6.2 Hook 返回的函数是否稳定,应成为明确契约

如果 Hook 每次渲染都创建新的回调:

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

  return {
    count,
    increment: () => setCount((value) => value + 1),
  };
}

调用者把 increment 作为 Effect 依赖时,可能得到重复执行:

const { increment } = useCounter();

useEffect(() => {
  // increment 引用每次都变
}, [increment]);

如果函数不依赖会变化的值,可以用 useCallback 稳定它:

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

  const increment = useCallback(() => {
    setCount((value) => value + 1);
  }, []);

  return { count, increment };
}

setState 函数由 React 保证在组件生命周期中保持稳定,因此可以安全地在空依赖回调中使用。

如果回调读取了 count

const incrementBy = useCallback((step: number) => {
  setCount(count + step);
}, [count]);

那么函数引用会随 count 变化。这是正确的,因为闭包确实依赖 count。也可以改用函数式更新:

const incrementBy = useCallback((step: number) => {
  setCount((value) => value + step);
}, []);

只有在不需要读取当前渲染中的 count 时,这种改写才成立。

6.3 稳定性不是越多越好

为所有函数和对象都添加 useCallbackuseMemo 会增加依赖维护成本,并不能自动提升性能。稳定引用只有在存在消费者收益时才有意义,例如:

  • 作为子组件的 props,且子组件确实进行了引用比较;
  • 作为 Effect 依赖,且重复同步有实际成本;
  • 作为另一个 Hook 的输入;
  • 作为公开 Hook 契约的一部分。

如果返回对象本身需要稳定,也可以这样写:

const result = useMemo(
  () => ({ count, increment }),
  [count, increment],
);

但这里的对象在 count 变化时仍然会变化,这是合理的。稳定性应按字段说明,而不是笼统地承诺“整个返回值永远稳定”。

七、闭包与最新值:避免过时数据

7.1 事件处理函数捕获的是某次渲染的值

function SaveButton({ documentId }: { documentId: string }) {
  const [saving, setSaving] = useState(false);

  async function save() {
    setSaving(true);
    await saveDocument(documentId);
    setSaving(false);
  }

  return <button onClick={save}>保存</button>;
}

每次渲染都会创建一个新的 save,它捕获该次渲染的 documentId。这通常是正确的,因为事件发生时应使用该次 UI 所代表的值。

问题常出现在长生命周期回调中,例如只注册一次的事件监听器:

useEffect(() => {
  function onKeyDown() {
    console.log(documentId); // 可能是首次渲染的 documentId
  }

  window.addEventListener("keydown", onKeyDown);
  return () => window.removeEventListener("keydown", onKeyDown);
}, []);

如果监听器必须保持稳定,但又要读取最新值,可以用 ref 保存最新值:

const latestDocumentId = useRef(documentId);

useEffect(() => {
  latestDocumentId.current = documentId;
}, [documentId]);

useEffect(() => {
  function onKeyDown() {
    console.log(latestDocumentId.current);
  }

  window.addEventListener("keydown", onKeyDown);
  return () => window.removeEventListener("keydown", onKeyDown);
}, []);

这个模式的代价是:ref.current 的更新不会触发重新渲染,且它绕开了普通依赖分析。使用时必须确认“读取最新值”确实比“参数变化时重新建立订阅”更符合契约。

React 生态中也存在专门处理 Effect 内事件逻辑的 API,但其可用性与具体 React 小版本、框架类型声明和工具链有关。使用前应以当前 react.dev API Reference 和项目实际版本为准,不能把实验性或较新 API 当作所有 React 19 项目都默认可用。

八、客户端与服务端边界

8.1 Effect 不在服务端渲染阶段执行

useEffect 依赖浏览器提交后的生命周期,因此不会在服务端渲染阶段执行。下面的 Hook 如果用于服务端渲染,服务端不会执行其中的请求逻辑:

function useWindowWidth() {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    setWidth(window.innerWidth);
  }, []);

  return width;
}

在 React Server Components 架构中,使用 useStateuseEffect 等客户端 Hook 的模块通常必须标记为客户端模块:

"use client";

具体文件边界由框架决定。例如某些框架要求在文件顶部声明 "use client",而不是在每个调用处声明。

因此:

  • 服务端组件适合直接读取服务端数据、访问服务端资源;
  • 客户端 Hook 适合交互状态、浏览器 API、客户端订阅;
  • 不应在服务端模块中直接访问 windowdocument 或依赖浏览器生命周期;
  • 服务端先输出数据、客户端再接管交互时,要注意 hydration 前后的初始值一致性。

8.2 客户端 Hook 的首屏状态是契约的一部分

上面的 useUser 初始返回 idle,服务端和客户端首次渲染都不会立即得到用户数据。Effect 在客户端提交后才请求。

如果组件首屏根据浏览器环境计算不同内容:

function Bad() {
  const isMobile = window.innerWidth < 768;
  return <p>{isMobile ? "移动端" : "桌面端"}</p>;
}

这不仅会在服务端报错,还可能造成服务端 HTML 与客户端首次渲染不一致。应将浏览器读取放入客户端 Effect,或者由服务端和客户端采用一致的初始值。

九、测试:验证契约、时序和清理

9.1 测试 Hook 的可观察行为

测试重点应是调用者能够观察到的契约:

  • 初始状态是什么;
  • 请求成功后是什么;
  • 请求失败后是什么;
  • 参数变化是否使用新参数;
  • 旧请求是否会污染新结果;
  • reload 是否触发重新请求;
  • 卸载后是否取消或忽略结果。

不应把测试绑定到“内部恰好用了两个 useState”这种实现细节。

下面使用 Vitest 和 Testing Library。前置条件是项目已经安装:

npm install -D vitest jsdom @testing-library/react

测试环境需要配置为 jsdom,例如在 Vitest 配置中设置:

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "jsdom",
  },
});

9.2 测试成功、失败和重新加载

import { renderHook, waitFor, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useUser } from "./useUser";

describe("useUser", () => {
  beforeEach(() => {
    vi.stubGlobal(
      "fetch",
      vi.fn(() =>
        Promise.resolve(
          new Response(JSON.stringify({ id: "a", name: "Alice" }), {
            status: 200,
            headers: { "Content-Type": "application/json" },
          }),
        ),
      ),
    );
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("请求成功后返回 success", async () => {
    const { result } = renderHook(() => useUser("a"));

    expect(result.current.status).toBe("idle");

    await waitFor(() => {
      expect(result.current.status).toBe("success");
    });

    expect(result.current.data).toEqual({
      id: "a",
      name: "Alice",
    });

    expect(fetch).toHaveBeenCalledWith(
      "/api/users/a",
      expect.objectContaining({
        signal: expect.any(AbortSignal),
      }),
    );
  });

  it("HTTP 错误后返回 error", async () => {
    vi.mocked(fetch).mockResolvedValueOnce(
      new Response("Not Found", { status: 404 }),
    );

    const { result } = renderHook(() => useUser("missing"));

    await waitFor(() => {
      expect(result.current.status).toBe("error");
    });

    expect(result.current.error).toBeInstanceOf(Error);
    expect(result.current.error?.message).toContain("404");
  });

  it("reload 会发起第二次请求", async () => {
    const { result } = renderHook(() => useUser("a"));

    await waitFor(() => {
      expect(result.current.status).toBe("success");
    });

    const firstCallCount = vi.mocked(fetch).mock.calls.length;

    act(() => {
      result.current.reload();
    });

    await waitFor(() => {
      expect(vi.mocked(fetch).mock.calls.length).toBe(firstCallCount + 1);
    });
  });
});

每个测试中的关键点如下:

  • renderHook 提供真实的组件渲染上下文,因此 Hook 仍遵守 React 的状态和 Effect 规则;
  • 初始状态立即断言为 idle,验证 Effect 尚未在同一同步步骤中完成;
  • waitFor 等待 React 状态更新完成,而不是直接假设 Promise 已经结束;
  • act 包住会导致状态更新的 reload 调用;
  • Response 模拟真实的 HTTP 成功和失败;
  • expect.objectContaining 只验证请求包含 signal,不绑定不重要的实现细节。

9.3 测试请求竞态

可以用两个手动控制的 Promise 模拟旧请求晚于新请求返回:

it("旧请求返回后不能覆盖新请求", async () => {
  let resolveA!: (response: Response) => void;
  let resolveB!: (response: Response) => void;

  vi.stubGlobal(
    "fetch",
    vi.fn((url: string) => {
      if (url.endsWith("/a")) {
        return new Promise<Response>((resolve) => {
          resolveA = resolve;
        });
      }

      return new Promise<Response>((resolve) => {
        resolveB = resolve;
      });
    }),
  );

  const { result, rerender } = renderHook(
    ({ userId }: { userId: string }) => useUser(userId),
    {
      initialProps: { userId: "a" },
    },
  );

  rerender({ userId: "b" });

  await act(async () => {
    resolveB(
      new Response(JSON.stringify({ id: "b", name: "Bob" }), {
        status: 200,
      }),
    );
  });

  await waitFor(() => {
    expect(result.current.data?.id).toBe("b");
  });

  await act(async () => {
    resolveA(
      new Response(JSON.stringify({ id: "a", name: "Alice" }), {
        status: 200,
      }),
    );
  });

  expect(result.current.data?.id).toBe("b");
});

这个测试验证的不是“调用了几次 fetch”,而是更重要的时序不变量:

只有当前有效请求的结果可以更新状态\text{只有当前有效请求的结果可以更新状态}

如果删除 Hook 中的 active 检查,这个测试就可能失败,从而暴露竞态问题。

9.4 测试稳定引用

如果稳定函数是公开契约的一部分,可以直接测试:

it("reload 在 userId 不变时保持稳定", () => {
  const { result, rerender } = renderHook(() => useUser("a"));

  const firstReload = result.current.reload;

  rerender();

  expect(result.current.reload).toBe(firstReload);
});

但不要无条件测试所有引用都稳定。data、返回对象和派生值是否稳定,必须先有实际契约。否则测试会把当前实现冻结,限制后续合理重构。

9.5 测试清理和取消

it("卸载时会取消当前请求", () => {
  const abortSpy = vi.spyOn(AbortController.prototype, "abort");

  const { unmount } = renderHook(() => useUser("a"));

  unmount();

  expect(abortSpy).toHaveBeenCalled();
});

这个测试验证 Effect cleanup 被执行。需要注意,测试 AbortController 只能证明取消信号被发出,不能证明远端服务器一定已经停止处理请求;网络层是否真正取消还取决于浏览器、代理和服务端实现。

十、常见失败表现与诊断路径

10.1 Effect 无限执行

失败表现:

  • 请求不断发出;
  • 定时器不断重建;
  • React 开发控制台频繁打印日志。

常见原因是依赖中包含每次渲染都新建的函数或对象:

const options = { query };

useEffect(() => {
  search(options);
}, [options]);

诊断步骤:

  1. 暂时打印依赖的引用是否变化;
  2. 检查对象、数组、函数是否在组件体中直接创建;
  3. 将依赖拆成原始值;
  4. 只有确有必要时再使用 useMemouseCallback
  5. 不要为了消除警告而删依赖,先解释闭包读取了什么。

10.2 页面显示旧数据

失败表现:

  • 快速切换参数后,界面短暂或永久显示旧请求结果;
  • 网络面板显示请求顺序与界面结果相反。

诊断步骤:

  1. 为每次请求记录参数和递增序号;
  2. 记录请求发出和返回时间;
  3. 检查 cleanup 是否执行;
  4. 检查回调是否验证请求仍然有效;
  5. 测试“旧请求晚返回”的时序,而不是只测正常顺序。

10.3 订阅数量不断增加

失败表现:

  • 一个事件触发多次;
  • WebSocket 消息重复处理;
  • 组件卸载后仍有日志。

通常是因为 Effect 建立了外部连接,却没有返回清理函数:

useEffect(() => {
  window.addEventListener("resize", onResize);

  return () => {
    window.removeEventListener("resize", onResize);
  };
}, [onResize]);

这里还要求 onResize 的身份与监听逻辑匹配。添加监听和移除监听必须传入等价的函数引用,否则浏览器不会移除原监听器。

10.4 Hook 测试偶尔失败

常见原因不是 Hook “随机”,而是测试没有等待状态稳定:

const { result } = renderHook(() => useUser("a"));

expect(result.current.status).toBe("success"); // 过早断言

请求和 Effect 都是异步过程,应使用 waitFor。如果测试手动触发状态更新,应使用 act。如果使用假定时器测试防抖,还必须推进时钟并等待由此产生的 Promise 或 React 更新。

十一、生产取舍:何时自己写,何时使用数据层

一个简单的 useUser 适合说明 Hook 机制,但生产数据请求往往还需要:

  • 请求缓存;
  • 相同 URL 去重;
  • 窗口重新获得焦点时刷新;
  • 分页和无限滚动;
  • 重试退避;
  • 离线状态;
  • 服务端预取和 hydration;
  • 请求持久化;
  • 统一错误边界。

这些能力可以继续堆进自定义 Hook,但随着状态转换和缓存关系增加,手写实现容易出现重复请求、缓存失效和竞态错误。此时应评估成熟的数据层或框架的数据获取能力。选择库并不改变 Hook 的基本契约:仍然需要明确输入、输出、生命周期、错误和稳定性。

一个实用边界是:

  • 纯粹的状态逻辑、事件订阅、浏览器 API 封装,适合自己写 Hook;
  • 跨组件共享缓存、复杂请求生命周期和服务端数据协调,应优先考虑专门的数据方案;
  • 只为消除一两行重复代码而创建 Hook,可能增加抽象成本;
  • 当一段逻辑同时包含状态、副作用和明确的调用协议时,自定义 Hook 的收益通常更明显。

十二、检查一个自定义 Hook 是否可靠

交付前可以按以下因果关系检查:

  • 是否能准确说明输入变化会触发什么行为?
  • 返回状态是否能表达初始、进行中、成功和失败?
  • 是否把派生数据错误地放进了 Effect?
  • 每个 Effect 是否都对应一个外部系统同步关系?
  • cleanup 是否能终止或失效旧的副作用?
  • 是否处理了请求、订阅或定时器的竞态?
  • Hook 是否始终遵守固定调用顺序?
  • 返回的函数或对象哪些需要稳定,是否已经写入契约?
  • 是否区分了服务端渲染阶段与客户端 Effect 阶段?
  • 测试是否覆盖成功、失败、输入变化、重新加载、卸载和旧请求晚返回?
  • 是否把测试绑定在行为上,而不是内部 Hook 数量和实现结构上?

自定义 Hook 的质量,不由代码长度决定,而由契约是否清晰、组合是否遵守 Hook 规则、副作用是否可逆、引用身份是否可预测,以及失败路径是否被验证决定。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。