React 中的自定义 Hook:正确拆分可复用逻辑
1. 概述
当 React 应用中多个组件需要相同的逻辑时——调用 API、读取 localStorage、防抖输入——你很容易陷入在组件间来回复制粘贴 state 和 useEffect 的困境。本文教你如何将这类逻辑封装到自定义 Hook中:一个以 use 开头的函数,利用现有的 Hook(useState、useEffect、useRef…)来实现逻辑复用。读完本文,你将能够编写 useFetch、useLocalStorage、useDebounce,并学会用 renderHook 进行测试。
2. 问题:组件间重复的逻辑
假设你有两个组件都需要调用 API 并管理 loading/error:
function CustomerList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/customers')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...渲染
}
function OrderList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/orders')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...渲染
}
CustomerList 和 OrderList 包含几乎相同的逻辑,只是 URL 不同。如果以后想添加重试或取消请求功能,就得在到处修改——这正是需要自定义 Hook 的信号。
3. 自定义 Hook 是什么
自定义 Hook 是一个普通的 JavaScript 函数,名称以 use 开头,内部调用其他 React Hook。它不返回 JSX——它返回 state 和函数,就像一个“迷你存储”附加到调用它的组件。
注意: 每次组件调用自定义 Hook 时,React 都会创建一个独立的 state 实例。两个组件都使用
useFetch并不共享状态——它们只共享逻辑。
4. 编写第一个自定义 Hook:useFetch
// useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => {
if (!cancelled) setData(json);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
解释:
cancelled用于避免竞态条件:如果url在旧请求完成前改变,旧的结果不会被设置到 state 中。- 清理函数(
return () => { cancelled = true })在组件卸载或url改变时运行——正确遵循useEffect的生命周期。 - Hook 返回一个对象
{ data, loading, error },便于调用方解构。
现在组件更简洁了:
function CustomerList() {
const { data, loading, error } = useFetch('/api/customers');
if (loading) return <p>正在加载...</p>;
if (error) return <p>错误:{error.message}</p>;
return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}
function OrderList() {
const { data, loading, error } = useFetch('/api/orders');
// 渲染逻辑相同,不再有重复的 fetch 逻辑
}
5. 第二个例子:useLocalStorage
同步 state 和 localStorage 的逻辑也很容易被复制粘贴:
// useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = window.localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
解释:
useState(() => ...)使用惰性初始化器——函数只在挂载时运行一次,不会在每次渲染时重新读取localStorage。- Hook 返回元组
[value, setValue],就像useState一样,用户能立即理解。
在组件中使用:
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
当前主题:{theme}
</button>
);
}
5.1. 当 state 依赖前一个值时
如果你需要基于旧值进行更新(比如计数器),使用函数式更新以避免陈旧的闭包:
setValue((prev) => prev + 1); // 比 setValue(value + 1) 更安全
6. 第三个例子:useDebounce
防抖搜索输入是一个非常常见的需求:
// useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delayMs = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
解释:
- 每当
value改变时,一个新的setTimeout被创建;旧的setTimeout被清理函数中的clearTimeout取消。 - 只有当用户在
delayMs内停止输入,debounced才会更新。
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) {
fetch(`/api/search?q=${debouncedQuery}`);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
7. 命名规则和 Hook 规则
自定义 Hook 必须遵循Hook 规则,就像内置 Hook 一样:
- 名称必须以
use开头——ESLint 插件eslint-plugin-react-hooks依靠这个前缀来检查规则,如果没有use,它将无法识别这是一个 Hook。 - 只在函数顶层调用 Hook,不要在
if、for或回调中调用。 - 只在函数组件或其他自定义 Hook 中调用 Hook,不要在普通函数中调用。
npm install eslint-plugin-react-hooks --save-dev
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
启用 exhaustive-deps 帮助你不会忘记自定义 Hook 中 useEffect 的依赖项——这是导致“陈旧数据”bug 最常见的原因。
8. 测试自定义 Hook
由于自定义 Hook 不返回 JSX,你无法像测试组件那样 render 它。@testing-library/react 库提供了 renderHook 来在测试环境中调用 Hook。
<!-- 不适用于 React 项目;依赖项通过 npm 安装,见下文 -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
it('仅在延迟结束后更新值', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'a', delay: 300 } }
);
expect(result.current).toBe('a');
rerender({ value: 'ab', delay: 300 });
expect(result.current).toBe('a'); // 尚未改变
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('ab'); // 已完成防抖
vi.useRealTimers();
});
});
解释:
renderHook接受一个调用 Hook 的函数,返回result.current——Hook 在当前渲染时返回的值。rerender模拟组件使用新 props 重新渲染(就像用户输入时value改变)。vi.useFakeTimers()+act(() => vi.advanceTimersByTime(...))允许你控制时间而无需真正等待,使测试快速且稳定。
✓ useDebounce > 仅在延迟结束后更新值
9. 常见错误和何时不应该使用
常见错误:
- 在
useEffect中忘记清理:对于useFetch,如果没有cancelled标志,旧请求在返回时可能会覆盖新数据——导致显示错误的数据。 - 缺少依赖项:如果 Hook 中的
useEffect使用外部变量但没有在依赖数组中声明,Hook 将使用“过时的”值(陈旧闭包)。 - 每次渲染都返回新对象的不必要做法:如果 Hook 每次都以对象字面量返回
{ data, loading, error },下游使用React.memo的组件可能会因为引用改变而不必要地重新渲染。如果测量到真实问题,可以使用useMemo。
何时不应该编写自定义 Hook:
- 逻辑仅在一个地方使用且没有复用可能——拆分出来只会让代码更难跟踪(需要跳到另一个文件来理解)。
- 逻辑完全是纯计算(纯函数),与 state/effect 无关——应该写普通 JavaScript 函数,不需要 Hook。
- 当你需要在树中不同位置的多个组件间共享状态(不仅仅是逻辑)时——自定义 Hook 做不了这个,你需要 Context 或状态管理库(Zustand、Redux…)。
注意: 自定义 Hook 解决的是逻辑重复的问题,而不是组件间状态共享的问题。不要混淆这两个问题。
10. 结论
自定义 Hook 是一个工具,可以将有状态的逻辑从组件中提取出来,使代码更简洁、更容易测试、更易复用。你只需要记住:以 use 开头命名,遵守 Hook 规则,用 renderHook 而不是 render 来测试。通过上面的 useFetch、useLocalStorage、useDebounce,你已经掌握了框架来编写其他 Hook,如 useMediaQuery、useForm,真正践行“一次编写,到处使用”的精神。