第 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 规则:两条红线与原因
规则
- 只在顶层调用 :不能放在
if/for/ 三元 / 嵌套函数里。 - 只在 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 之后如何用守卫组件做登录拦截。