React 基础体系 · 第 27/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 自定义 Hook:契约、组合、副作用、稳定性和测试
自定义 Hook 不是“把一段代码挪到函数里”的语法技巧。它是一种以函数形式封装 React 状态、生命周期和外部系统交互的方式。要让一个 Hook 可复用,必须同时回答五个问题:
- 调用者传入什么,Hook 返回什么?
- 返回值中的哪些部分具有稳定的身份?
- Hook 如何与其他 Hook 组合?
- 哪些逻辑属于渲染,哪些逻辑属于副作用?
- 如何测试行为,而不是测试某个内部实现细节?
本文以 React 19、现代 TypeScript 和主流客户端框架为背景,重点讨论这些问题之间的因果关系。
一、先建立心智模型:Hook 是组件实例中的状态协议
1.1 自定义 Hook 没有独立的生命周期
一个自定义 Hook 本质上仍然在组件函数执行期间调用:
function UserPage({ userId }: { userId: string }) {
const user = useUser(userId);
return <UserView user={user} />;
}
useUser 不会创建一个独立的组件实例,也没有自己的挂载、更新和卸载过程。它使用的 useState、useEffect、useRef 等 Hook,实际都属于调用它的 UserPage 实例。
因此,下列说法是不准确的:
“这个自定义 Hook 挂载了,所以它开始请求数据。”
更准确的描述是:
“调用该 Hook 的组件实例完成提交后,其中定义的 Effect 被 React 调度执行,于是请求开始。”
如果同一个组件调用两次 Hook:
function CompareUsers() {
const left = useUser("a");
const right = useUser("b");
// ...
}
那么会得到两组彼此独立的状态和 Effect。共享的是 Hook 的代码,不是 Hook 的状态。
1.2 Hook 可以抽象为一个带副作用的函数
可以把一个 Hook 粗略表示为:
其中:
- 是调用者输入,例如
userId; - 是 React 提供的当前组件上下文和 Hook 状态槽位;
- 是返回值,例如
{ status, data, error, reload }; - 是对外部系统的同步行为,例如网络请求、事件订阅或定时器。
这个模型有一个重要推论:
Hook 的返回值应当描述当前渲染可以使用的数据;副作用应当在渲染提交后同步外部系统。
不能在 Hook 的函数体中直接执行会产生外部影响的操作:
function useBadUser(userId: string) {
fetch(`/api/users/${userId}`); // 不应在渲染阶段直接请求
return null;
}
组件函数可能被 React 重复调用、放弃某次渲染,或者在开发模式下进行额外检查。渲染阶段必须尽量保持纯粹;网络请求、订阅和 DOM 操作应放入 Effect 或其他明确的事件处理逻辑中。
二、Hook 契约:输入、输出、时序和不变量
2.1 契约不只是 TypeScript 类型
一个 Hook 的契约至少包括四部分:
- 输入契约:接受哪些参数,哪些参数变化会触发什么行为。
- 输出契约:返回值的结构、状态语义和错误表示。
- 时序契约:初次调用、输入变化、重新加载、卸载时分别发生什么。
- 稳定性契约:哪些返回值在输入不变时保持引用稳定。
例如,设计一个 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保存当前渲染所需的状态;useEffect把userId同步到网络请求;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 不一定意味着 data 为 null。上面的实现保留了旧数据,因此 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 的顶层调用;
- 不能放在
if、for、while、普通回调或事件处理函数中; - 不能根据条件决定是否调用某个 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 开头,例如 useUser、useDebouncedValue。这不仅是命名习惯:
- 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>;
}
这会产生额外的渲染过程:
first或last变化;- 组件先用旧的
fullName渲染; - Effect 执行并调用
setFullName; - 组件再次渲染出正确值。
正确方式是直接在渲染中计算:
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]);
当 serverUrl 或 roomId 改变时,React 会执行:
- 运行上一次 Effect 的清理函数;
- 使用新值执行新的 Effect;
- 新连接替代旧连接。
清理不是可选的装饰,而是“旧同步关系终止”的一部分。订阅、定时器和连接如果不清理,就会造成重复监听、内存泄漏或旧数据写入。
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 回调晚到。需要保证旧请求结果失效。
常见方法有两类:
AbortController:尝试取消底层请求;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 稳定性不是越多越好
为所有函数和对象都添加 useCallback、useMemo 会增加依赖维护成本,并不能自动提升性能。稳定引用只有在存在消费者收益时才有意义,例如:
- 作为子组件的 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 架构中,使用 useState、useEffect 等客户端 Hook 的模块通常必须标记为客户端模块:
"use client";
具体文件边界由框架决定。例如某些框架要求在文件顶部声明 "use client",而不是在每个调用处声明。
因此:
- 服务端组件适合直接读取服务端数据、访问服务端资源;
- 客户端 Hook 适合交互状态、浏览器 API、客户端订阅;
- 不应在服务端模块中直接访问
window、document或依赖浏览器生命周期; - 服务端先输出数据、客户端再接管交互时,要注意 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”,而是更重要的时序不变量:
如果删除 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]);
诊断步骤:
- 暂时打印依赖的引用是否变化;
- 检查对象、数组、函数是否在组件体中直接创建;
- 将依赖拆成原始值;
- 只有确有必要时再使用
useMemo或useCallback; - 不要为了消除警告而删依赖,先解释闭包读取了什么。
10.2 页面显示旧数据
失败表现:
- 快速切换参数后,界面短暂或永久显示旧请求结果;
- 网络面板显示请求顺序与界面结果相反。
诊断步骤:
- 为每次请求记录参数和递增序号;
- 记录请求发出和返回时间;
- 检查 cleanup 是否执行;
- 检查回调是否验证请求仍然有效;
- 测试“旧请求晚返回”的时序,而不是只测正常顺序。
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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Reducer 深入:Action、不可变更新、初始化和状态机
- 下一篇:React Memo、useMemo 与 useCallback:引用、成本和正确边界
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论