React08:自定义 Hook

第 08 课 · 自定义 Hook

🎯 本课目标

  • 会写自定义 Hook:use 开头、内部调用其他 Hook 的函数,并说清它与 composable 的一一对应关系
  • 能背出两条 Hook 规则并解释原因(按调用顺序记录状态)
  • 手写企业最常用的四个 Hook:useToggle、useLocalStorage、useDebounce、useFetch
  • 会做 Hook 组合(useTodos 组合 useLocalStorage),理解「底层管机制、上层管业务」
  • 掌握返回值设计(对象 vs 数组)与 TypeScript 标注,写出团队可以直接复用的 Hook

1. 定义:use 开头 + 内部调用其他 Hook 的函数

自定义 Hook 不是什么新 API,就是一个名字以 use 开头的普通函数,内部调用了 useState / useEffect 等内置 Hook。它是 React 里逻辑复用的唯一正统方式,对应 Vue 里的 composable。

Vue 里你这样写

ts 复制代码
// composables/useToggle.ts
import { ref } from 'vue'

export function useToggle(initial = false) {
  const on = ref(initial)
  const toggle = () => (on.value = !on.value)
  return { on, toggle }
}

React 里这样写

tsx 复制代码
// hooks/useToggle.ts
import { useCallback, useState } from 'react'

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial)          // ref() → useState
  const toggle = useCallback(() => setOn((v) => !v), [])
  return { on, toggle }                          // 没有 .value,读就是 on、写走 setOn
}

// 组件里使用:和用内置 Hook 完全一样
function DarkModeBox() {
  const { on, toggle } = useToggle(false)
  return <div style={{ background: on ? '#111' : '#fff' }} onClick={toggle}>深色:{on ? '开' : '关'}</div>
}

关键差异与注意点

  • 一一对应 :composable 里的 ref() ↔ useState,watch / onMounted ↔ useEffect,computed ↔ 渲染中直接算或 useMemo。
  • 最大的体感差 :Vue 靠响应式系统,on.value = x 改完相关视图自动更新;React 靠闭包 + 不可变更新,必须「换新值」触发重渲染,所有派生值在下一次渲染时重新算。
  • 自定义 Hook 之间可以互相调用(第 4 节的 useTodos 就是),规则与组件内一致。
  • 命名强制 use 开头:这不是风格问题,而是让 eslint-plugin-react-hooks 能检查你在这个函数里写的 Hook 是否合规。

2. Hook 规则:两条红线与原因

规则

  1. 只在顶层调用 :不能放在 if / for / 三元 / 嵌套函数里。
  2. 只在 React 函数中调用:组件或自定义 Hook 里,不能在普通 JS 函数、事件回调、类方法里调。
tsx 复制代码
// ❌ 错误:条件调用
if (isLogin) {
  const [profile, setProfile] = useState(null) // Hook 数量随条件变化 → 状态错乱
}

// ❌ 错误:事件回调里调用
const onClick = () => {
  const [count, setCount] = useState(0)
}

// ✅ 正确:无条件、按固定顺序调用
const [profile, setProfile] = useState<User | null>(null)
const [count, setCount] = useState(0)

原因(面试一句话)

React 没有依赖追踪,它靠「调用顺序」对号入座:每个 Hook 的状态按第一次渲染时的调用顺序存在组件对应的槽位里,之后每次渲染都按同样的顺序取。如果你在某次渲染里少调了一个 Hook,后面的状态就全部错位------所以顺序必须永远稳定。

关键差异与注意点

  • Vue 的 composable 也有类似建议(在 setup 顶层调用),但 Vue 不强制,因为响应式系统不依赖调用顺序;React 是硬性约束,违反直接运行时报错。
  • 「条件里要用 Hook」的正确写法:Hook 照常调用,把条件放进逻辑里(useEffect 内部 if 判断,或给 Hook 传不同参数)。
  • 组件被卸载再挂载、StrictMode 双调用都不影响规则------规则只要求「同一次组件实例的生命周期内顺序稳定」。

3. 抽取时机:和 Vue 抽 composable 的判断标准一致

  • 同一逻辑出现在两个以上组件(防抖、落盘、请求三态、分页)→ 抽。
  • 一个组件长到难以阅读,其中一段有独立状态 + 副作用(比如「轮询刷新」)→ 抽,让组件回到「只管渲染」。
  • 只用一次、且和视图强耦合的逻辑 → 别抽,抽象本身有成本。

Vue 里的判断经验 100% 可迁移:你不会为 20 行模板抽一个 composable,同样不会为一处 useState 抽 Hook。

4. 企业常用 Hook 手写

useToggle / useLocalStorage ------ Demo 1

tsx 复制代码
import { useCallback, useEffect, useState } from 'react'

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial)
  const toggle = useCallback(() => setOn((v) => !v), [])
  const setOn = useCallback((value: boolean) => setOn(value), [])
  return { on, toggle, setOn }
}

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    // 惰性初始化:只在首次渲染读一次缓存(类比 ref(读一次))
    try {
      const raw = localStorage.getItem(key)
      return raw === null ? initialValue : (JSON.parse(raw) as T)
    } catch {
      return initialValue
    }
  })

  // 值变化就同步写回:对应 Vue 的 watch(value, v => localStorage.setItem(...))
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value))
    } catch {
      // 隐私模式 / 存储已满:忽略,不影响页面
    }
  }, [key, value])

  return [value, setValue] as const
}

要点:useState(工厂函数) 做惰性初始化,避免每次渲染都 JSON.parse;副作用(写缓存)放进 useEffect 而不是 setState 回调里,保持渲染纯净。

useDebounce ------ Demo 2

tsx 复制代码
import { useEffect, useState } from 'react'

export function useDebounce<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(timer) // 值又变了 → 清掉旧定时器,只认最后一次
  }, [value, delay])
  return debounced
}

要点:返回的是「防抖后的值」而不是「防抖函数」,调用方把它当普通值用(进 useEffect 依赖、拼 URL 都行),这正是 Hook 风格与 Vue watchDebounced 风格的差异。

useFetch ------ Demo 3(封装第 5 课的请求三态)

tsx 复制代码
import { useCallback, useEffect, useState } from 'react'

export interface FetchResult<T> {
  data: T | null
  error: string | null
  loading: boolean
  refresh: () => void
}

export function useFetch<T>(url: string): FetchResult<T> {
  const [data, setData] = useState<T | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(false)
  const [tick, setTick] = useState(0) // 手动刷新开关

  useEffect(() => {
    let active = true // 过期响应标记
    setLoading(true)
    setError(null)
    apiGet(url)
      .then((res) => {
        if (!active) return // 组件已卸载 / url 已变:丢弃
        setData(res as T)
      })
      .catch((err: unknown) => {
        if (active) setError(err instanceof Error ? err.message : String(err))
      })
      .finally(() => {
        if (active) setLoading(false)
      })
    return () => {
      active = false // 清理 = 卸载或依赖再变化时作废本次请求
    }
  }, [url, tick])

  const refresh = useCallback(() => setTick((n) => n + 1), [])
  return { data, error, loading, refresh }
}

要点:三态(loading / error / data)就是你第 5 课手写过的模式;let active = true + 清理函数解决竞态 (快速切换 url 时旧响应覆盖新响应);refresh 靠改变 tick 重新触发 effect,这是 React 里「手动重跑副作用」的惯用法。

useTodos ------ Demo 4(Hook 组合)

tsx 复制代码
export function useTodos() {
  // 持久化全部委托给 useLocalStorage,这里只写业务
  const [todos, setTodos] = useLocalStorage<Todo[]>('lesson08:todos', [])

  const addTodo = useCallback(
    (text: string) => {
      const value = text.trim()
      if (!value) return
      setTodos((prev) => [...prev, { id: Date.now(), text: value, done: false }]) // 不可变更新
    },
    [setTodos],
  )
  const toggleTodo = useCallback(
    (id: number) => setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))),
    [setTodos],
  )
  const removeTodo = useCallback(
    (id: number) => setTodos((prev) => prev.filter((t) => t.id !== id)),
    [setTodos],
  )
  const leftCount = todos.filter((t) => !t.done).length

  return { todos, addTodo, toggleTodo, removeTodo, leftCount }
}

这就是 composable 互相调用的 React 版:底层 Hook 管机制(缓存、同步),业务 Hook 管语义(增删改、统计),组件只管渲染。

5. 返回值设计:对象 vs 数组

返回形式 典型代表 适用场景
数组 [value, setValue] useState、本课 useLocalStorage 值很少(1-2 个)、调用方想自己改名:const [name, setName] = useLocalStorage(...)
对象 { on, toggle, setOn } 本课 useToggle、useFetch、useTodos 三个以上返回值 / 语义需要名字才看得懂

约定俗成:只有一个主值就学 useState 用数组;多于两个或需要语义就用对象 。企业 code review 常见意见:「返回 5 个值的数组没法读,const [a, b, c, d, e] = useX() 是什么?」

类型标注规范

tsx 复制代码
// 泛型 + 显式返回类型,调用方和 IDE 都有完整提示
export function useFetch<T>(url: string): FetchResult<T>
export function useLocalStorage<T>(key: string, initialValue: T): readonly [T, Dispatch<SetStateAction<T>>]

// 对象返回值单独声明 interface,导出给使用方
export interface FetchResult<T> { data: T | null; error: string | null; loading: boolean; refresh: () => void }

注意本项目开启 verbatimModuleSyntax,类型要 import type { Dispatch, SetStateAction } from 'react' 单独导入。

6. 卸载清理:Hook 里的「onUnmounted」

composable 里用 onUnmounted 收尾(清定时器、解绑监听、取消请求);React 里没有这个钩子,统一用 useEffect 的清理函数 ------它在依赖再变化和组件卸载时都会执行。本课的 useDebounce(清定时器)和 useFetch(作废过期请求)就是两个标准样板。这也是面试题「hook 内如何做卸载清理」的标准答案。

📊 Vue ↔ React 对照速查表

你熟悉的(Vue 3) React 里叫 一句话差异
composables/useXxx.ts hooks/useXxx.ts 目录约定一致,函数名强制 use 开头
ref() useState 写法从 .value 赋值变成「换新值」
computed(() => ...) 渲染中直接算 / useMemo Vue 自动缓存,React 需手动依赖
watch(deps, cb) useEffect(cb, deps) React 无自动追踪,依赖数组手写
onMounted useEffect(cb, []) 空依赖数组 = 只在挂载后执行
onUnmounted useEffect 返回的清理函数 同一个 effect 里注册与销毁
provide / inject Context(Hook 内封装) 常包成 useTheme() 这类 Hook 再暴露
vueuse 的 useLocalStorage 手写 / ahooks 的 useLocalStorageState 思路一致:惰性读 + effect 写回
watchDebounced(vueuse) useDebounce 返回防抖值 Vue 包函数,React 直接返回值
Hook 规则(建议在 setup 顶层) Hook 规则(强制顶层) React 靠调用顺序定位状态,违反即报错

🏢 企业实战要点

  • 目录组织 :通用 Hook 放 src/hooks/(团队共享),业务 Hook 放对应模块目录(如 src/views/order/hooks/useOrderList.ts)。命名 use + 名词(useDebounce)或 use + 动宾(useFetchUser),一个文件一个 Hook,默认导出。
  • Hook 内部依赖不暴露给使用者 :useFetch 不要求调用方传「刷新开关」;useTodos 不要求调用方传 setTodos。使用者只看到 data / loading / refresh 这样的语义 API,内部实现随便重构。
  • 返回类型必须显式标注 :泛型 Hook(useFetch<T>)尤其如此,否则调用方拿到的是推断不出意义的类型。
  • 副作用一律在 useEffect 里,渲染函数保持纯净 :把 localStorage.setItem 写进 setState 回调或渲染体,StrictMode 下会执行两次,这是 review 必抓的问题。
  • 竞态处理是标配 :任何「按条件发请求」的 Hook 都要有过期丢弃(let active 或 AbortController),面试和真实事故都爱考这个。
  • 社区库知道即可 :ahooks(国内主流,useDebounceFn / useLocalStorageState / useRequest 一应俱全)。企业里通常「用 ahooks,但要能手写其中常用的几个」------本课就是在练手写。
  • 真实坑 1 :Hook 里写的 useState 初始值每次渲染都重算(useState(expensive())),要改成惰性初始化 useState(() => expensive())。
  • 真实坑 2 :把 Hook 调用放进 if 里,本地跑得好好的,加个条件后白屏报「Rendered fewer hooks than expected」。

💼 面试高频

Q1:自定义 Hook 解决什么问题?

逻辑复用。把「状态 + 操作状态的方法 + 相关副作用」打包成一个 use 开头的函数,多个组件共享同一份逻辑实现(不是共享同一份状态------每个调用处都有独立的 state)。它取代了 class 时代的 HOC 和 render props,对应 Vue 里的 composable。

Q2:Hook 规则是哪两条?为什么?

只在顶层调用(不进 if / 循环 / 嵌套函数)、只在 React 函数或自定义 Hook 里调用。原因是 React 没有依赖追踪,靠调用顺序为每个 Hook 的状态对号入座,调用顺序一旦变化,状态就会错位,所以顺序必须永远稳定一致。

Q3:自定义 Hook 和普通函数的区别?

普通函数调用时只是执行一遍;自定义 Hook 内部调用了 useState / useEffect,每次组件渲染都会重新执行,而这些 Hook 的状态由 React 按组件实例持久保存。所以它有「跨渲染的记忆」,这是普通函数闭包变量做不到的(普通函数里的变量每次调用都归零)。

Q4:和 Vue 的 composable 对比?

能力一一对应(ref ↔ useState、watch ↔ useEffect、computed ↔ useMemo),差异有三点:composable 依赖响应式系统(改 .value 自动更新),Hook 靠不可变更新触发重渲染;composable 的依赖是自动追踪的,Hook 的依赖数组要手写;Hook 规则在 React 是硬约束,Vue 只是建议。

Q5:Hook 内部如何做卸载清理?

在 useEffect 里返回清理函数,它在「依赖再变化」和「组件卸载」时执行:清除定时器、移除事件监听、AbortController.abort() 或用 let active = true 标记作废异步响应。相当于把 onUnmounted 和「依赖变化的收尾」合并在一处写。

📌 小结 + 下节预告

  • 自定义 Hook = use 开头 + 内部调用其他 Hook 的函数,是 React 版 composable,逻辑复用的正统方式。
  • 两条 Hook 规则源于「按调用顺序记录状态」,永远顶层调用、永远在 React 函数里调。
  • 手写了 useToggle / useLocalStorage / useDebounce / useFetch 四个高频 Hook,并体会了 Hook 组合(useTodos)与返回值设计。
  • 卸载清理统一走 useEffect 清理函数,请求类 Hook 必须处理竞态。

下一课(第 09 课 · React Router 路由):vue-router 的对应物------路由表、Layout + Outlet、动态路由、useNavigate 编程导航,以及 React Router 没有 beforeEach 之后如何用守卫组件做登录拦截。

相关推荐
2601_949970691 小时前
msgpackr 跨端三方库仓颉适配全流程
前端·javascript·flutter
Software攻城狮1 小时前
【uniapp和普通web 端开发的区别】
前端·uni-app·notepad++
IT_陈寒1 小时前
Vue项目实战经验分享:动态组件在Keep-Alive下的缓存陷阱
前端·人工智能·后端
染指11101 小时前
157.Agent-LangGraph-工具(实现ReAct Agent模式)
开发语言·人工智能·python·langchain·agents
程序猿乐锅1 小时前
【从0-1学习Python | 第九篇】继承
开发语言·后端·python·学习·fastapi
数智奔流1 小时前
MiniMax的低价攻势,撞上“背题”疑云
前端·javascript·算法
2601_954005621 小时前
11 种触发 × 18 种行为:ricon 交互编排系统全解
前端·后端·物联网·数学建模·信息可视化·交互
wdfk_prog1 小时前
lwip教程 38:从 `lwipopts.h` 到 Port Contract——lwIP 裁剪、构建选择、OS Port 与 Network Port
java·开发语言
开开心心就好1 小时前
证件照排版打印工具,免费可自定义尺寸
服务器·开发语言·算法·随机森林·pdf·音视频·散列表