跳至正文
来两杯美式
返回

React 核心(10):自定义 Hook 设计

By 来两杯美式
发布于

自定义 Hook 是厨房里的预制调料包——常用组合提前配好,用时一倒就行。

一、自定义 Hook 是什么

自定义 Hook 就是一个use 开头的函数,里面调用其他 Hook,把可复用的有状态逻辑封装起来。

// 一个最简单的自定义 Hook
function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  const toggle = () => setValue(prev => !prev);
  return [value, toggle];
}

它做的事很简单:把 useState + 一个 toggle 函数打包成一个整体。别的组件想用这个逻辑,调一下就行:

function Modal() {
  const [isOpen, toggle] = useToggle(false);
  // 直接用,不用自己写 useState + toggle 逻辑
  return (
    <>
      <button onClick={toggle}>打开/关闭</button>
      {isOpen && <div>弹窗内容</div>}
    </>
  );
}

1.1 和普通函数的区别

自定义 Hook 不是”普通函数”——它能调用其他 HookuseStateuseEffect 等),普通函数不行:

// ❌ 普通函数里调 Hook → 报错!
function getToggle() {
  const [value, setValue] = useState(false); // React 不允许
  return value;
}

// ✅ 以 use 开头的函数 → React 认可它是 Hook
function useToggle() {
  const [value, setValue] = useState(false); // 合法
  return value;
}

React 靠函数名是否以 use 开头来识别 Hook。这不是约定,是规则——违反就会出错。

1.2 它复用的是”有状态的逻辑”

注意措辞:有状态的逻辑,不是 UI,不是纯数据。

二、为什么需要自定义 Hook

2.1 问题:组件间共享”有状态的逻辑”

两个组件都需要监听窗口宽度——同样的 useState + useEffect 写两遍,复制粘贴能跑,但改一处忘改另一处就是 bug。

2.2 自定义 Hook 的解法

把重复的逻辑抽成 useWindowSize(完整实现见第五章),两个组件都只需一行:

function Sidebar() {
  const width = useWindowSize();
  return width < 768 ? <MobileSidebar /> : <DesktopSidebar />;
}

逻辑写一次,到处复用。改逻辑只改一个地方。

2.3 比 HOC / render props 更简洁

Hook 出现前,React 有两种逻辑复用方案——HOC(高阶组件包裹,注入 props)和 render props(通过 props 传函数渲染)。它们都能解决问题,但代价不小:

自定义 HookHOCrender props
嵌套层级扁平,无额外嵌套多层包裹(Wrapper Hell)回调嵌套
数据来源直观,就在函数里隐式,不知道 props 从哪来显式,但嵌套深
调试难度高(组件栈一堆匿名)
TypeScript 支持差(类型推导困难)

结论:新项目用自定义 Hook,别再写 HOC 和 render props。它们是 Hook 出现前的妥协方案。

三、命名规则

3.1 必须以 use 开头

// ✅ 正确:以 use 开头
function useToggle() { ... }
function useLocalStorage() { ... }
function useWindowSize() { ... }

// ❌ 错误:不以 use 开头 → React 不把它当 Hook
function toggle() { ... }
function getLocalStorage() { ... }

为什么?React 靠函数名前缀识别 Hook,从而应用 Hook 规则(顶层调用、不能在条件语句中调用等)。不叫 use,React 就不管你,你里面的 useState / useEffect 就会报错。

3.2 ESLint 规则

eslint-plugin-react-hooks 提供了两条规则:

// ❌ rules-of-hooks 会报错
function handleClick() {
  useState(0); // 在事件处理器里调 Hook
}

// ❌ rules-of-hooks 会报错
if (condition) {
  const [x, setX] = useState(0); // 在条件语句里调 Hook
}

建议:项目里一定要开这两条 ESLint 规则。它们不是建议,是铁律。

四、设计原则

4.1 一个 Hook 做一件事

// ❌ 做了两件事:管理表单 + 提交逻辑
function useFormAndSubmit() {
  const [form, setForm] = useState({})
  const [submitting, setSubmitting] = useState(false)
  // ... 表单逻辑
  // ... 提交逻辑
}

// ✅ 拆开:表单状态一个 Hook,提交逻辑另一个
function useForm(initialValues) { ... }
function useSubmit(submitFn) { ... }

单一职责不只适用于组件,也适用于 Hook。一个 Hook 管一件事,组合起来比一个大杂烩灵活得多。

4.2 返回值设计:数组 vs 对象

返回数组——适合返回值少(2-3 个)、调用时想自定义命名:

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue(prev => !prev);
  return [value, toggle]; // 数组
}

// 调用时自由命名
const [isOpen, toggleOpen] = useToggle();
const [isDark, toggleDark] = useToggle();

返回对象——适合返回值多(4+ 个)、需要语义明确:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // ... fetch 逻辑
  }, [url]);

  return { data, loading, error }; // 对象
}

// 调用时按需解构,顺序无关
const { data, error } = useFetch("/api/users");

判断标准:2-3 个返回值用数组,4 个以上用对象。useState 返回数组是因为永远只有 [值, setter] 两个。

4.3 参数设计

接收配置对象——参数多时用对象,方便扩展和设默认值:

function useLocalStorage(key, options = {}) {
  const { defaultValue = null, serializer = JSON } = options;
  // ...
}

// 调用
const [name, setName] = useLocalStorage("name", { defaultValue: "yi" });

接收回调函数——让 Hook 的行为可定制:

function useFetch(url, { onSuccess, onError } = {}) {
  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(data => {
        setData(data);
        onSuccess?.(data); // 成功时通知外部
      })
      .catch(err => {
        setError(err);
        onError?.(err); // 失败时通知外部
      });
  }, [url]);
}

4.4 条件判断放在 Hook 内部

// ❌ 在外部决定是否调用 Hook → 违反 Hook 规则
function ChatRoom({ roomId }) {
  if (roomId) {
    useChatConnection(roomId); // 条件调用,Hook 顺序可能变化
  }
}

// ✅ 条件判断放在 Hook 内部
function useChatConnection(roomId) {
  useEffect(() => {
    if (!roomId) return; // Hook 内部判断,Hook 本身每次都调用
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

function ChatRoom({ roomId }) {
  useChatConnection(roomId); // 无条件调用,Hook 顺序稳定
}

铁律:Hook 必须每次渲染都调用,调用顺序不能变。条件逻辑放 Hook 里面,不放外面。

五、经典实战示例

5.1 useToggle:布尔值切换

最简单的自定义 Hook,适合入门理解。使用场景:弹窗开关、折叠面板、深色模式切换。

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  const toggle = useCallback(() => setValue(prev => !prev), []);
  return [value, toggle];
}

// 使用
const [isDark, toggleDark] = useToggle(false);

5.2 useLocalStorage:持久化状态

useState 的值同步到 localStorage,刷新页面不丢失:

function useLocalStorage(key, initialValue) {
  // 惰性初始化:只在首次渲染时读 localStorage
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored !== null ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // 值变化时同步写入 localStorage
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // localStorage 满了或隐私模式,静默失败
    }
  }, [key, value]);

  return [value, setValue];
}

使用:

const [theme, setTheme] = useLocalStorage("theme", "light");
// 刷新页面,设置还在

注意:useLocalStoragesetValueuseState 的 setter 完全一样——调用方式不变,只是底层多了持久化。这就是自定义 Hook 的魅力:接口不变,能力增强

5.3 useDebounce:防抖值

搜索框输入时,等用户停顿 300ms 再触发搜索——经典的防抖场景:

function useDebounce(value, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer); // 每次 value 变化都清除旧定时器
  }, [value, delay]);

  return debouncedValue;
}

使用:

function SearchBox() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery);
  }, [debouncedQuery]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

原理:query 每次变化都启动新定时器并清除上一个,只有停顿 500ms 后 debouncedQuery 才更新。

5.4 useFetch:数据获取(简要版)

实际项目推荐用 TanStack Query,它处理了缓存、重试、去重等 useFetch 容易遗漏的细节。这里写 useFetch 是为了理解自定义 Hook 的设计思路。

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(result => {
        if (!cancelled) {
          setData(result);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

使用:

const { data, loading, error } = useFetch("/api/users");

5.5 useWindowSize:窗口尺寸

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    function handleResize() {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    }

    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return size;
}

使用:

const { width } = useWindowSize();

模式总结:所有这些 Hook 都遵循同一个套路——useState 存值 + useEffect 做事 + 返回结果。自定义 Hook 的原材料就是 useStateuseEffect

六、与普通函数 / HOC / render props 的对比

维度自定义 Hook普通工具函数HOCrender props
能否调 Hook✅ 能❌ 不能✅ 能✅ 能
返回什么数据和方法计算结果增强组件JSX
嵌套层级无额外嵌套多层包裹回调嵌套
数据流显式,从 Hook 返回显式,函数返回值隐式,注入 props显式,函数参数
适用场景有状态的逻辑复用纯计算、格式化已废弃已废弃

一句话:纯逻辑用普通函数,有状态的逻辑用自定义 Hook,UI 复用用组件。HOC 和 render props 是历史方案,新代码别用了。

七、常见错误

7.1 在普通函数里调用 Hook

// ❌ 普通函数不是 Hook,不能调 useState/useEffect
function formatDate() {
  const [locale, setLocale] = useState("zh"); // 报错!
  // ...
}

// ✅ 如果需要状态,改成自定义 Hook
function useLocaleFormatter() {
  const [locale, setLocale] = useState("zh");
  // ...
}

// ✅ 如果不需要状态,用普通函数
function formatDate(date) {
  return date.toLocaleDateString("zh");
}

7.2 返回 JSX

// ❌ Hook 返回 JSX → 它到底是 Hook 还是组件?
function useModal() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div className="modal" open={isOpen}>
      ...
    </div>
  );
}

// ✅ Hook 返回数据,组件返回 UI
function useModal() {
  const [isOpen, setIsOpen] = useState(false);
  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);
  return { isOpen, open, close };
}

function Modal({ isOpen, onClose }) {
  if (!isOpen) return null;
  return <div className="modal">...</div>;
}

原则:Hook 返回数据和方法,组件返回 UI。各司其职,不要混。

7.3 过度抽象

只有一个组件用的逻辑不需要抽 Hook——过早抽象比不抽象更糟糕,多了一个文件、多了一层间接性,但没有任何复用收益。

// ❌ 只有一个组件用,不需要抽 Hook
function UserPage() {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);
}

// ✅ 至少两个组件需要同样的逻辑,才值得抽 Hook

判断标准:同样的有状态逻辑出现在两个或以上组件中 → 抽 Hook。只出现一次 → 直接写在组件里。

八、与已有笔记的串联

自定义 Hook 不是凭空出现的,它的原材料就是 useStateuseEffect

useState  →  存值(给组件记忆)
useEffect →  做事(给组件行动力)
    ↓ 组合
自定义 Hook →  把"存值 + 做事"打包复用

回顾一下每个经典 Hook 的组成:

自定义 HookuseState 的角色useEffect 的角色
useToggle存布尔值
useLocalStorage存值 + 惰性初始化同步写入 localStorage
useDebounce存防抖后的值定时器延迟更新
useFetch存 data/loading/error发请求 + 竞态处理
useWindowSize存宽高监听 resize 事件

每个 Hook 都是 useState + useEffect 的不同组合。理解了这两个基础 Hook,自定义 Hook 就是搭积木。

useRef 也经常出现在自定义 Hook 中——凡是”需要跨渲染持久但不触发渲染”的值(定时器 ID、上一次的值、是否首次渲染),都用 useRef 存。

一句话

自定义 Hook 是 useState + useEffect 的组合包——把有状态的逻辑封装成以 use 开头的函数,让组件间共享逻辑不再复制粘贴。记住三件事:必须以 use 开头、一个 Hook 做一件事、至少两个组件需要才值得抽


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
所属专题
React
第 10 / 17 篇
查看系列全部文章
  1. 01.React 核心(1):React 组件与函数的本质区别
  2. 02.React 核心(2):声明式与 React 的实现机制
  3. 03.React 核心(3):Props 完全指南
  4. 04.React 核心(4):useState 完全指南
  5. 05.React 核心(5):useEffect 完全指南
  6. 06.React 核心(6):useRef 完全指南
  7. 07.React 核心(7):Context API 与状态共享
  8. 08.React 核心(8):useCallback 与 useMemo
  9. 09.React 核心(9):Hooks 规则与闭包陷阱
  10. 10.React 核心(10):自定义 Hook 设计
  11. 11.React 核心(11):Key 与列表渲染
  12. 12.React 核心(12):表单与受控/非受控组件
  13. 13.React 核心(13):React 渲染机制深入——Fiber 与调度
  14. 14.React 核心(14):React 18 并发特性
  15. 15.React 核心(15):Suspense 机制详解
  16. 16.React 核心(16):TanStack Query 入门指南
  17. 17.React 核心(17):TanStack Query 缓存机制详解

上一篇
React 核心(11):Key 与列表渲染
下一篇
React 核心(9):Hooks 规则与闭包陷阱