REACT · Vol.II · LESSON 13 · Hooks 精讲
自定义 Hook:复用的正确姿势
三段式样板代码,是时候抽了
第 12 篇的结尾留了个尾巴:ref 和 effect 总是成对出现,复制粘贴了一遍又一遍。今天正面解决「Hooks 怎么复用」这件事。先看一段你这几天一定写腻了的代码——「初始值从 localStorage 读,变化时写回去」:
import { useEffect, useState } from "react";
function FontSizeSetting() {
// 初始化:惰性函数读一次 localStorage(第 4 篇的惰性初始化)
const [size, setSize] = useState(() => {
const saved = localStorage.getItem("font-size");
return saved ? Number(saved) : 14;
});
useEffect(() => {
localStorage.setItem("font-size", String(size));
}, [size]);
return (
<input type="range" value={size}
onChange={e => setSize(Number(e.target.value))} />
);
}
明天做主题色设置、后天做侧边栏折叠开关——同样的「读初始值 + 变化时写回」套路再来两遍。抽!把「state + 成对的 effect」整体搬进一个普通函数,名字以 use 开头:
import { useEffect, useState } from "react";
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const saved = localStorage.getItem(key);
return saved !== null ? (JSON.parse(saved) as T) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const; // as const:推断成元组,第 4 站细说
}
// 用起来和 useState 一个手感
const [size, setSize] = useLocalStorage("font-size", 14);
这就是自定义 Hook 的全部定义:一个名字以 use 开头、内部调用了其他 Hook 的普通函数。没有注册、没有装饰器、没有基类——它就是函数,React 靠名字认它。而且名字不是风格建议,是硬规则:eslint-plugin-react-hooks 会强制检查 use 前缀,并据此判断「这个函数里能不能调 Hook」(规则全貌第 16 篇系统讲)。
本质:复用「逻辑」,不复用「状态」
最容易误解的一点:两个组件调用同一个 useLocalStorage,它们共享状态吗?不。Hook 不是被共享的仓库,而是被执行的代码——每次调用,内部的 useState/useEffect 都会为当前这个组件新开一套独立状态:
function FontSizePanel() {
// 状态 #1:只属于 FontSizePanel
const [size, setSize] = useLocalStorage("font-size", 14);
return <input value={size} onChange={e => setSize(Number(e.target.value))} />;
}
function ThemePanel() {
// 状态 #2:只属于 ThemePanel,和上面的 size 毫无关系
const [theme, setTheme] = useLocalStorage("theme", "light");
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
{theme}
</button>
);
}
逻辑模板只有一份,状态副本一人一份——这才是「复用」的准确含义:复用的是「有状态的逻辑」,而不是状态本身。想让多个组件读写「同一份」状态,抽 Hook 解决不了,那是状态提升(第 14 篇)和 Context(第 15 篇)的课题。
调用自定义 Hook,不是去单例注册表里拿共享 Bean,而是执行一个工厂方法 new 出一个新对象——逻辑(类的方法)一模一样,状态(实例字段)各异。这也解释了为什么 Java 的 static 工具方法装不下「有状态的逻辑」:工具类没有实例,而 Hook 每次调用都现场造一个。
实战再抽两个:useDebounce 与 useFetch
第二个高频样板:防抖。搜索框每敲一个字符就发一次请求,后端同学会来找你喝茶。标准解法是「值停稳 300ms 再放行」——又是「state + effect + 清理」的固定搭配,抽:
import { useEffect, useState } from "react";
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // value 又变了:拆掉旧闹钟,重新计时
}, [value, delay]);
return debounced;
}
// 用法:输入随便抖,只有停稳的值会往下游走
const debouncedKeyword = useDebounce(keyword);
useEffect(() => {
search(debouncedKeyword);
}, [debouncedKeyword]);
注意这枚 Hook 的工作方式:keyword 本身还是调用方的 state,useDebounce 只是在旁边养了一个「慢半拍的影子」。清理函数(第 9 篇)在这里是主角——每次 value 变化先拆旧闹钟,防抖的正确性全靠它。
第三个:取数。state 管结果、effect 管同步、cleanup 管善后,全套本卷零件:
import { useEffect, useState } from "react";
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<T>;
})
.then(setData)
.catch((e: Error) => {
if (e.name !== "AbortError") setError(e); // 主动取消不算错误
});
// cleanup:url 变了 / 组件卸载了,掐断旧请求(第 9 篇)
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
声明:这是教学简版——没有缓存、没有重试、没有竞态序号守卫(AbortController 挡掉了大半)。取数的完整坑位与正规军 TanStack Query,第 32、33 篇专门展开。此处你只需要看懂:所谓取数 Hook,就是 useState 管结果 + useEffect 管同步 + cleanup 管善后,没有任何新知识。
它像 Java 里的什么?返回值怎么设计?
对照你最熟悉的几种 Java 复用手段,给自定义 Hook 定个位:
| Java 复用手段 | 像在哪 / 差在哪 |
|---|---|
| 工具类 static 方法 | 同样「抽出去复用」,但 static 方法没有状态,装不下 useState/useEffect——工具类复用不了「有状态的逻辑」,Hook 补的正是这块 |
| 模板方法模式 | 都是「骨架固定、细节填充」,但模板方法靠继承耦合,Hook 靠函数调用组合,更轻、更扁 |
| AOP 切面 | 都是「横切逻辑与业务分离」,但 AOP 把逻辑织入到你看不见的地方,Hook 的调用显式写在代码里——看得见,才好调试 |
| 策略模式 | 都靠传参定制行为,但 Hook 自带状态与生命周期,相当于「策略 + 自带状态机」的合体 |
一句话定位:自定义 Hook 是 React 里的「逻辑组合单元」——比工具类多状态,比模板方法少继承,比 AOP 更显式。
Hook 可以组合 Hook
自定义 Hook 内部调用其他 Hook(内置的或自定义的)完全合法,规则与在组件里用一模一样:最顶层调用、不塞进条件与循环(为什么,第 16 篇)。于是 Hook 之间可以像积木一样搭:
// useDebouncedFetch = useDebounce ⊕ useFetch,Hook 组合 Hook,规则不变
function useDebouncedFetch<T>(url: string, delay = 300) {
const debouncedUrl = useDebounce(url, delay);
return useFetch<T>(debouncedUrl);
}
返回值:元组还是对象?
- 两个以内、顺序稳定(尤其想保持 useState 手感时)→ 元组 +
as const:解构随意改名,const [size, setSize] = ...; - 三项以上、或日后可能扩展 → 对象:按名取用
const { data, loading, error } = ...,加字段不会破坏调用方的解构顺序(useFetch 选它就是这个原因)。
经验值:一个「值 + 设置器」就学 useState 用元组;一旦有「返回包」的味道就学 useFetch 用对象。拿不准就对象,最不容易坏。
设计提醒:返回值的「身份」也是合同的一部分
返回对象(或函数)时,第 11 篇的引用相等立刻跟进:你的返回值每次渲染是不是同一个引用?调用方一旦把整个返回对象塞进某个 effect 的依赖数组,引用天天变,effect 就天天跑。规矩和第 11 篇一致:原始值(useDebounce 返回的 debounced)和 state 本身无需操心;你亲手拼的函数和对象,要用 useCallback/useMemo 稳住:
import { useCallback, useState } from "react";
function usePlayer() {
const [playing, setPlaying] = useState(false);
// ✅ useCallback 稳住:调用方把 toggle 写进依赖数组也不会误触发
const toggle = useCallback(() => setPlaying(p => !p), []);
return { playing, toggle };
}
顺带记一条福利:useState 返回的 setter 由 React 保证终生同一引用,可以放心进任何依赖数组——这也是「返回 setter 学 useState」省心的原因。而 useFetch 的返回对象每次都是新引用,属于已知取舍:它的消费方要么逐项解构(const { data } = useFetch(url)),要么依赖数组只写原始值。
什么时候该抽?一张自检清单
最后回答「什么时候动手」。抽 Hook 成本极低,但也不是万物皆 Hook:
| 信号 | 判断 |
|---|---|
| 同一套 state + effect 组合出现在第 2 个组件里 | 立刻抽——不用等第三次(Hook 抽取成本远低于类继承重构) |
| 多个组件要读写「同一份」数据 | 抽 Hook 没用:它复用逻辑不共享状态,走状态提升 / Context(第 14、15 篇) |
| 一段纯计算,无状态无副作用 | 别抽 Hook,抽普通函数就够了 |
| 只是渲染期算得贵 | 那是 useMemo 的活(第 11 篇),不是新 Hook 的活 |
- 自定义 Hook = use 开头 + 内部调用其他 Hook 的普通函数;命名是 lint 强制的硬规则。
- 它复用的是「有状态的逻辑」,不是状态本身——每次调用状态各自隔离(new 实例,不是单例)。
- 三个模板在手:
useLocalStorage(读写同步)、useDebounce(值停稳再传)、useFetch(state 管结果 + effect 管同步 + cleanup 善后)。 - Hook 可组合 Hook,规则与内置 Hook 一致;返回值双值用元组
as const、多值用对象。 - 返回值的「身份」也是合同:亲手拼的函数/对象用
useCallback/useMemo稳住;useState的 setter 终生稳定,可直接进依赖数组。 - 自定义 Hook 是 Hook 规则约束下的最小复用单元——再往下的复用诉求,就该想想是不是「共享状态」而非「复用逻辑」了。
本章回顾
把重复的「state + effect」固定搭配抽成 use 开头的函数,你就拥有了 React 最重要的复用手段:useLocalStorage、useDebounce、useFetch 三个模板背后是同一条公式——状态是每次调用独立的一份,逻辑是所有人共享的一份模板。但这条公式也划出了边界:当两个组件需要的恰恰是「同一份状态」时,抽一百个 Hook 也无济于事,状态得挪窝——挪到它们最近的公共父组件。下一篇第 14 篇:状态提升与单向数据流,React 可预测性的基石。见。
Comments · 评论