首页 / React 学习笔记 / 13

REACT · Vol.II · LESSON 13 · Hooks 精讲

自定义 Hook:复用的正确姿势

进阶核心#Hooks#复用

三段式样板代码,是时候抽了

第 12 篇的结尾留了个尾巴:ref 和 effect 总是成对出现,复制粘贴了一遍又一遍。今天正面解决「Hooks 怎么复用」这件事。先看一段你这几天一定写腻了的代码——「初始值从 localStorage 读,变化时写回去」:

FontSizeSetting.tsx · 手写 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 开头:

useLocalStorage.ts · 第一个自定义 Hook
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 都会为当前这个组件新开一套独立状态:

TwoPanels.tsx · 同一个 Hook 的两次调用
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 篇)的课题。

FontSizePanel ThemePanel useLocalStorage(key, init) 状态 #1 size = 14 状态 #2 theme = dark 同一个 Hook,两次调用:逻辑是同一份模板,状态是两份独立副本
图 1两个组件调用同一个自定义 Hook:逻辑复用,状态隔离
后端类比:单例 Bean vs new 实例

调用自定义 Hook,不是去单例注册表里拿共享 Bean,而是执行一个工厂方法 new 出一个新对象——逻辑(类的方法)一模一样,状态(实例字段)各异。这也解释了为什么 Java 的 static 工具方法装不下「有状态的逻辑」:工具类没有实例,而 Hook 每次调用都现场造一个。

实战再抽两个:useDebounce 与 useFetch

第二个高频样板:防抖。搜索框每敲一个字符就发一次请求,后端同学会来找你喝茶。标准解法是「值停稳 300ms 再放行」——又是「state + effect + 清理」的固定搭配,抽:

useDebounce.ts · 值停稳了才往下传
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 管善后,全套本卷零件:

useFetch.ts · 简版取数 Hook
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.ts · 三行,白拿一层防抖
// 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 稳住

usePlayer.ts · 返回包里的函数也要引用稳定
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 · 评论