文章

自定义 Hooks 设计模式与闭包陷阱

面试向讲清自定义 Hook:本质与三条硬规则、返回值与参数的接口设计、SSR 安全与清理、 useDebounce / useLatest / useInterval / useRequest 四个高频手写实现, 以及过期闭包的四种解法(函数式更新 / 依赖数组 / useRef / useEffectEvent)与各自代价。

自定义 Hooks 设计模式与闭包陷阱

一句话概括

自定义 Hook 不是什么新语法,它就是一个以 use 开头、内部调用了其他 Hook 的普通函数。React 加这个前缀约定的唯一目的,是让 ESLint 能识别出”这是个 Hook,得按 Hook 的规则检查”。

面试考它有两点:一是能不能手写一个(useDebounce、useInterval、useRequest 是出现频率最高的三个),二是能不能讲清闭包陷阱——为什么 Hook 里读到的 state 是旧值、怎么修、每种修法的代价是什么。

一句话记住本质:自定义 Hook 复用的是”状态逻辑”,不是”状态本身”。 两个组件调用同一个 useCounter(),得到的是两份互不相干的状态。

核心知识点

1. 三条硬规则 + 一个真相

1
2
3
4
5
6
7
// 规则一:名字必须以 use 开头,否则 React 无法对它做 Hook 规则检查
function useOnlineStatus() { /* ✅ */ }
function getOnlineStatus()  { /* ❌ 里面调 useState 会被引擎和 lint 当普通函数处理 */ }

// 规则二:只能顶层调用,不能写在 if / for / 嵌套函数里(Hook 顺序必须稳定)

// 规则三:自定义 Hook 里可以调内置 Hook,也可以调别的自定义 Hook——这是组合的基础

真相:每次调用状态独立。

1
2
3
4
5
function A() { const { count } = useCounter(); return <span>{count}</span> }
function B() { const { count } = useCounter(); return <span>{count}</span> }
// A 和 B 的 count 各玩各的,互不影响

想让多个组件共享同一份状态,自定义 Hook 做不到——那是 Context 或状态库的活儿。这是面试里区分”背过八股”和”真写过”的分水岭。

2. 接口设计:返回值和参数怎么定

返回值:看数量选形态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ 一到两个值、调用方想自己起名 → 返回 tuple(镜像 useState 的习惯)
function useToggle(init = false) {
  const [value, setValue] = useState(init)
  const toggle = useCallback(() => setValue(v => !v), [])
  return [value, toggle] as const          // as const 保证解构出来的类型不丢
}
const [isOpen, toggleOpen] = useToggle()

// ✅ 三个以上、语义需要名字 → 返回对象
function useRequest(url) {
  return { data, loading, error, refetch }
}
const { data, loading } = useRequest('/api/user')

参数:能传配置对象就别堆位置参数,以后加字段不用改调用方。

1
2
3
4
// ❌ 以后要加个 immediate 参数,所有调用点都得改
useRequest(url, true, 3)
// ✅
useRequest(url, { immediate: true, retry: 3 })

两个必守的工程细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ✅ SSR 安全:惰性初始化 + window 守卫,避免在服务端渲染时访问浏览器 API
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    if (typeof window === 'undefined') return initialValue   // 服务端直接返回默认值
    try {
      const raw = window.localStorage.getItem(key)
      return raw ? JSON.parse(raw) : initialValue
    } catch {
      return initialValue                                    // JSON.parse 失败要兜住
    }
  })
  // ...
}

// ✅ 有副作用就必须清理:定时器、监听、请求,一个都别漏
useEffect(() => {
  const id = setInterval(tick, 1000)
  return () => clearInterval(id)      // 不清理 = 组件卸载后还在跑 = 内存泄漏 + 对已卸载组件 setState
}, [])

3. 手写四个高频 Hook

① useDebounce —— 搜索框必备

1
2
3
4
5
6
7
8
9
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 kw = useDebounce(query, 300); useEffect(() => search(kw), [kw])

② useLatest —— 专治过期闭包

1
2
3
4
5
function useLatest<T>(value: T) {
  const ref = useRef(value)
  useEffect(() => { ref.current = value }, [value])  // 在 effect 里同步
  return ref
}

注意这里不要在渲染期直接写 ref.current = value。很多库(包括 ahooks 老版本)图省事这么写,但 React 官方明确要求”不要在渲染过程中读写 ref.current”:并发渲染下这次渲染可能被丢弃,写进去的值却留下了。

③ useInterval —— 经典面试题

1
2
3
4
5
6
7
8
function useInterval(callback: () => void, delay: number | null) {
  const cbRef = useLatest(callback)          // 用 ref 存最新回调
  useEffect(() => {
    if (delay === null) return               // delay 为 null 表示暂停
    const id = setInterval(() => cbRef.current(), delay)
    return () => clearInterval(id)
  }, [delay, cbRef])                          // 只依赖 delay,回调变了不会重建定时器
}

这题的考点就在那个依赖数组上:如果写 [callback, delay],回调每次渲染都是新函数,定时器会被反复清掉重建,等于没生效。

④ useRequest —— 带竞态处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function useRequest<T>(url: string) {
  const [state, setState] = useState<{ data: T | null; loading: boolean; error: Error | null }>({
    data: null, loading: true, error: null,
  })
  useEffect(() => {
    const controller = new AbortController()   // 每次请求一个 controller
    setState({ data: null, loading: true, error: null })
    fetch(url, { signal: controller.signal })
      .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json() as Promise<T> })
      .then(data => setState({ data, loading: false, error: null }))
      .catch((err: unknown) => {
        if (err instanceof Error && err.name === 'AbortError') return  // 主动取消,不报错
        setState({ data: null, loading: false, error: err as Error })
      })
    return () => controller.abort()   // url 变了/组件卸载 → 取消上一个请求
  }, [url])
  return state
}

AbortController 这行是加分项:它同时解决了内存泄漏(卸载后还在 setState)和竞态(先发的请求后到,把新结果覆盖成旧数据)。

4. 闭包陷阱:为什么 Hook 里读到的是旧值

结论:每次渲染都是一张快照。 那次渲染创建的回调函数,眼里的 state 就永远是那次渲染的值——它活在过去了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function useBadCounter() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1)      // ❌ count 永远是 0,计数器卡在 1
    }, 1000)
    return () => clearInterval(id)
  }, [])                       // 空依赖 = effect 只跑一次 = 闭包永远锁在首次渲染
  return count
}

// ✅ 函数式更新:值由 React 传进来,不经过闭包
setCount(c => c + 1)

自定义 Hook 里的坑更隐蔽,因为调用方看不见内部实现:

1
2
3
4
5
6
7
8
function Timer() {
  const [count, setCount] = useState(0)
  useInterval(() => {
    console.log(count)     // ⚠️ 调的人以为能打印最新值,实际取决于 useInterval 内部怎么写
  }, 1000)
}

所以第 3 节里 useInterval 必须用 useLatest 包一层回调——这是自定义 Hook 作者的责任,不是使用者的。

5. 四种解法与各自的代价

解法适用场景代价 / 注意
函数式更新 setX(prev => ...)新值依赖旧值最干净,但只能用在 setState 上,读值场景救不了
补全依赖数组effect 本来就该跟着值重新同步会重建定时器 / 重连 WebSocket,不是所有场景都接受
useRef / useLatest回调要读最新值,但 effect 不能重建手动维护引用,等于主动放弃一部分响应式,别滥用
useEffectEvent同上,官方给的正解目前仍是实验性 API(只在 canary 有),且返回的函数身份每次渲染都变,不能当 useCallback 用,也不能传给别的组件
1
2
3
4
5
6
7
8
9
10
11
12
13
// 实验性 API,面试里"知道有这个东西 + 知道它还没稳定"就够了
function ChatRoom({ roomId, theme }) {
  const onMessage = useEffectEvent(msg => {
    showToast(theme, msg)     // 读到最新 theme
  })
  useEffect(() => {
    const conn = connect(roomId)
    conn.on('message', onMessage)
    return () => conn.disconnect()
  }, [roomId])                // theme 不进依赖,不会因为换个主题就断线重连
}

别把 useEffectEvent 当成”藏依赖”的工具——官方明确反对用它来跳过依赖声明,那只会把 bug 藏起来。它只适用于”确实是从 effect 里触发的事件式逻辑”。

6. 怎么测自定义 Hook

1
2
3
4
5
6
7
8
9
10
import { renderHook, act } from '@testing-library/react'   // v13.1+ 起内置,不用再装 react-hooks 那个包

test('useCounter 能加能减', () => {
  const { result } = renderHook(() => useCounter(0))
  expect(result.current.count).toBe(0)
  act(() => { result.current.increment() })   // 触发状态更新必须包 act
  expect(result.current.count).toBe(1)
})

Hook 不能脱离组件直接调用,所以必须借 renderHook 把它挂到一个测试用的空组件上。这也能解释一个常见报错:“Hooks can only be called inside the body of a function component”——你在普通函数里调了 Hook,React 找不到当前正在渲染的组件实例。

其实你每天都在用

  1. 搜索框防抖:useDebounce(query, 300),输入停了 300ms 才发请求。
  2. 轮询接口:useInterval(fetchData, 5000),比手写 setInterval 少一堆清理逻辑。
  3. 监听网络状态:useOnlineStatus(),断网时按钮变灰。
  4. 响应式布局:useMediaQuery('(max-width: 768px)'),比在 JS 里读 window.innerWidth 靠谱。
  5. 表单草稿:useLocalStorage('draft', ''),刷新页面草稿还在。
  6. 接口请求:useRequest(url) 一把梭返回 data / loading / error。
  7. WebSocket 订阅:useWebSocket(url),在 cleanup 里 close(),否则切页面就泄漏一条连接。
  8. 点击外部关闭下拉:useClickOutside(ref, close),本质是 useEventListener 的一个特例。

常见误解(FAQ)

❌ 误区1:”两个组件调同一个自定义 Hook,状态是共享的”

不是。Hook 里的 useState 挂在调用它的那个组件的 Fiber 上,两个组件各有一份。要共享就得上 Context 或 Zustand/Redux——这也是为什么”自定义 Hook + Context”经常成对出现。

❌ 误区2:”在 useEffect 里加个 eslint-disable 跳过依赖检查就完事了”

依赖数组不是给 lint 看的,它决定 effect 什么时候重新同步。空数组 + 用到了 state,就是过期闭包;写全了又导致反复重连,说明该把那部分逻辑抽成 useEffectEvent 或者拆成两个 effect,而不是关掉警告。

❌ 误区3:”useCallback 能让函数里的变量保持最新”

正好相反。useCallback(fn, []) 会把 fn 冻结在第一次渲染,里面读到的 state 永远是初始值。它的作用是稳定函数引用(配合 memo 子组件),不是保证数据新鲜。

❌ 误区4:”useLatest 直接 ref.current = value 写在函数体里就行”

能跑,但官方不推荐:React 要求不在渲染期读写 ref.current。并发渲染下这次渲染可能被丢弃重来,你写进去的值却留下了,容易引发难以复现的 bug。放到 useEffect 里同步最稳。

❌ 误区5:”useEffectEvent 已经稳定了,可以替代 useCallback”

两个不同维度的东西。useCallback 是为了稳定引用(可以给子组件当 props);useEffectEvent 返回的函数身份每次渲染都故意变化,而且只能在 effect 里调用、不能传给别的组件,它压根不是通用回调工具——何况它现在还在 canary 阶段。

一句话总结

自定义 Hook 就是”把 useState/useEffect 打包成一个可复用的函数”:命名 use 开头、状态各自独立、副作用一定清理、回调统一用 ref 兜住最新值;记住每次渲染都是快照,闭包读到旧值不是 React 的 bug,是你把值锁在了过去。

本文由作者按照 CC BY 4.0 进行授权