首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >React Hooks 进阶:自定义 Hooks 封装技巧(附 5 个生产级案例)

React Hooks 进阶:自定义 Hooks 封装技巧(附 5 个生产级案例)

作者头像
fruge365
发布2025-12-15 14:04:36
发布2025-12-15 14:04:36
1990
举报

React Hooks 进阶:自定义 Hooks 封装技巧(附 5 个生产级案例)


封装原则

  • 输入与依赖显式化,输出稳定且可测试
  • 清理与中断内建,避免泄漏与竞态
  • SSR 友好与跨环境降级
  • 支持场景化参数(节流/防抖的 leading/trailing/maxWait,异步的取消/重试/缓存)

案例一:useDebouncedValue 与 useDebounceFn

代码语言:javascript
复制
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'

type DebounceOptions = { leading?: boolean; trailing?: boolean; maxWait?: number }

export function useDebouncedValue<T>(value: T, delay = 200) {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(timer)
  }, [value, delay])
  return debounced
}

export function useDebounceFn<T extends (...args: any[]) => any>(fn: T, wait = 200, options: DebounceOptions = {}) {
  const lastArgs = useRef<any[] | null>(null)
  const lastCallTime = useRef(0)
  const lastInvokeTime = useRef(0)
  const timer = useRef<any>(null)
  const leading = options.leading === true
  const trailing = options.trailing !== false
  const maxWait = typeof options.maxWait === 'number' ? options.maxWait : 0
  const invoke = useCallback((time: number) => {
    const args = lastArgs.current
    lastArgs.current = null
    lastInvokeTime.current = time
    return fn.apply(null, args as any)
  }, [fn])
  const remainingWait = useCallback((time: number) => {
    const sinceLastCall = time - lastCallTime.current
    const sinceLastInvoke = time - lastInvokeTime.current
    const waitTime = wait - sinceLastCall
    return maxWait ? Math.min(waitTime, maxWait - sinceLastInvoke) : waitTime
  }, [wait, maxWait])
  const shouldInvoke = useCallback((time: number) => {
    const sinceLastCall = time - lastCallTime.current
    const sinceLastInvoke = time - lastInvokeTime.current
    return lastCallTime.current === 0 || sinceLastCall >= wait || sinceLastCall < 0 || (maxWait && sinceLastInvoke >= maxWait)
  }, [wait, maxWait])
  const debounced = useMemo(() => {
    const f = (...args: any[]) => {
      const time = Date.now()
      const isInvoking = shouldInvoke(time)
      lastArgs.current = args
      lastCallTime.current = time
      if (!timer.current) {
        if (leading) invoke(time)
        timer.current = setTimeout(timerExpired, remainingWait(time))
      } else if (maxWait) {
        timer.current = setTimeout(timerExpired, remainingWait(time))
      }
    }
    function timerExpired() {
      const time = Date.now()
      if (shouldInvoke(time)) {
        timer.current = null
        if (trailing && lastArgs.current) invoke(time)
      } else {
        timer.current = setTimeout(timerExpired, remainingWait(time))
      }
    }
    ;(f as any).cancel = () => { if (timer.current) clearTimeout(timer.current); timer.current = null; lastArgs.current = null; lastCallTime.current = 0; lastInvokeTime.current = 0 }
    ;(f as any).flush = () => { if (timer.current) { clearTimeout(timer.current); timer.current = null; return trailing && lastArgs.current ? invoke(Date.now()) : undefined } }
    return f as T & { cancel: () => void; flush: () => any }
  }, [invoke, remainingWait, shouldInvoke, leading, trailing, maxWait])
  useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])
  return debounced
}

案例二:useThrottleFn

代码语言:javascript
复制
import { useMemo, useRef } from 'react'

type ThrottleOptions = { leading?: boolean; trailing?: boolean }

export function useThrottleFn<T extends (...args: any[]) => any>(fn: T, wait = 200, options: ThrottleOptions = {}) {
  const timer = useRef<any>(null)
  const lastArgs = useRef<any[] | null>(null)
  const lastInvoke = useRef(0)
  const leading = options.leading !== false
  const trailing = options.trailing !== false
  return useMemo(() => {
    const f = (...args: any[]) => {
      const time = Date.now()
      if (!lastInvoke.current && !leading) lastInvoke.current = time
      const remaining = wait - (time - lastInvoke.current)
      lastArgs.current = args
      if (remaining <= 0 || remaining > wait) {
        if (timer.current) { clearTimeout(timer.current); timer.current = null }
        lastInvoke.current = time
        return fn.apply(null, lastArgs.current as any)
      }
      if (!timer.current && trailing) {
        timer.current = setTimeout(() => { timer.current = null; if (trailing && lastArgs.current) { lastInvoke.current = Date.now(); fn.apply(null, lastArgs.current as any) } }, remaining)
      }
    }
    ;(f as any).cancel = () => { if (timer.current) clearTimeout(timer.current); timer.current = null; lastArgs.current = null; lastInvoke.current = 0 }
    return f as T & { cancel: () => void }
  }, [fn, wait, leading, trailing])
}

案例三:useRequest(取消、重试、缓存)

代码语言:javascript
复制
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

type RequestOptions<T, P extends any[]> = {
  manual?: boolean
  deps?: any[]
  retry?: { times?: number; delay?: number; factor?: number }
  cacheKey?: string
  staleTime?: number
  onSuccess?: (data: T) => void
  onError?: (e: any) => void
}

type Result<T> = { data: T | undefined; error: any; loading: boolean; run: (...args: any[]) => Promise<T>; cancel: () => void; reset: () => void }

const cache = new Map<string, { data: any; time: number }>()

export function useRequest<T, P extends any[]>(service: (...args: P) => Promise<T>, options: RequestOptions<T, P> = {}): Result<T> {
  const { manual, deps = [], retry, cacheKey, staleTime = 0, onSuccess, onError } = options
  const [data, setData] = useState<T | undefined>(undefined)
  const [error, setError] = useState<any>(null)
  const [loading, setLoading] = useState(false)
  const ctrl = useRef<AbortController | null>(null)
  const run = useCallback(async (...args: any[]) => {
    if (cacheKey && staleTime > 0) {
      const hit = cache.get(cacheKey)
      if (hit && Date.now() - hit.time < staleTime) { setData(hit.data); return hit.data }
    }
    setLoading(true)
    setError(null)
    ctrl.current?.abort()
    ctrl.current = new AbortController()
    let times = retry?.times ?? 0
    let delay = retry?.delay ?? 0
    let factor = retry?.factor ?? 2
    while (true) {
      try {
        const res = await service(...args, { signal: ctrl.current.signal } as any)
        setData(res)
        if (cacheKey) cache.set(cacheKey, { data: res, time: Date.now() })
        onSuccess?.(res)
        setLoading(false)
        return res
      } catch (e) {
        if (ctrl.current?.signal.aborted) { setLoading(false); throw e }
        if (times > 0) { await new Promise(r => setTimeout(r, delay)); times -= 1; delay = delay * (factor || 2); continue }
        setError(e)
        onError?.(e)
        setLoading(false)
        throw e
      }
    }
  }, [service, cacheKey, staleTime, retry?.times, retry?.delay, retry?.factor, onSuccess, onError])
  const cancel = useCallback(() => { ctrl.current?.abort() }, [])
  const reset = useCallback(() => { setData(undefined); setError(null) }, [])
  useEffect(() => { if (!manual) run() }, deps)
  return useMemo(() => ({ data, error, loading, run, cancel, reset }), [data, error, loading, run, cancel, reset])
}

案例四:useEventListener(自动清理与最新回调)

代码语言:javascript
复制
import { useEffect, useLayoutEffect, useRef } from 'react'

type Target = HTMLElement | Window | Document | null

function useLatest<T>(value: T) {
  const ref = useRef(value)
  useLayoutEffect(() => { ref.current = value })
  return ref
}

export function useEventListener<K extends keyof WindowEventMap>(target: Target, type: K, handler: (ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions) {
  const latest = useLatest(handler)
  useEffect(() => {
    const el = target || window
    const fn = (e: any) => latest.current(e)
    el.addEventListener(type, fn, options)
    return () => el.removeEventListener(type, fn, options as any)
  }, [target, type, options])
}

案例五:useLocalStorageState(跨标签页同步)

代码语言:javascript
复制
import { useCallback, useEffect, useMemo, useState } from 'react'

type Serializer<T> = { read: (v: string | null) => T; write: (v: T) => string }

const defaultSerializer: Serializer<any> = { read: v => v ? JSON.parse(v) : undefined, write: v => JSON.stringify(v) }

export function useLocalStorageState<T>(key: string, initial?: T, serializer: Serializer<T> = defaultSerializer) {
  const read = () => typeof window === 'undefined' ? initial : serializer.read(window.localStorage.getItem(key)) ?? initial
  const [state, setState] = useState<T | undefined>(() => read())
  useEffect(() => { if (typeof window === 'undefined') return; window.localStorage.setItem(key, serializer.write(state as any)) }, [key, state])
  useEffect(() => { if (typeof window === 'undefined') return; const fn = (e: StorageEvent) => { if (e.key === key) setState(serializer.read(e.newValue)) }; window.addEventListener('storage', fn); return () => window.removeEventListener('storage', fn) }, [key])
  const reset = useCallback(() => setState(initial), [initial])
  return useMemo(() => [state, setState, reset] as const, [state, reset])
}

封装技巧清单

  • 参数化场景与默认值,暴露最小可用接口与可选扩展
  • 使用 useRef 持有跨渲染的状态,useLayoutEffect 保证最新引用
  • 清理与中断内建:事件、定时器、请求的清理与取消
  • SSR 与跨标签页兼容:判断 window 存在与使用 storage 事件同步
  • 性能优先:防抖节流用稳定函数与合并更新,异步缓存与重试可配置

使用示例与最佳实践

代码语言:javascript
复制
import React, { useState } from 'react'
import { useDebouncedValue, useDebounceFn, useThrottleFn, useRequest, useEventListener, useLocalStorageState } from './hooks'

export default function Demo() {
  const [query, setQuery] = useState('')
  const debouncedQuery = useDebouncedValue(query, 300)
  const search = useDebounceFn(() => console.log('search', debouncedQuery), 300, { leading: false, trailing: true })
  const logScroll = useThrottleFn(() => console.log('scroll'), 200)
  useEventListener(window, 'scroll', logScroll, { passive: true })
  const [token, setToken] = useLocalStorageState<string>('token', '')
  const { data, loading, run, cancel } = useRequest(async () => fetch(`/api?q=${debouncedQuery}`).then(r => r.json()), { deps: [debouncedQuery], retry: { times: 2, delay: 200 } })
  return (
    <div>
      <input value={query} onChange={e => { setQuery(e.target.value); search() }} />
      <button onClick={() => setToken('new')}>SetToken</button>
      {loading ? 'Loading...' : JSON.stringify(data)}
      <button onClick={() => cancel()}>Cancel</button>
    </div>
  )
}

高级工具与模式

useStableCallback(稳定引用且获取最新逻辑)
代码语言:javascript
复制
import { useLayoutEffect, useRef, useCallback } from 'react'
export function useStableCallback<T extends (...args: any[]) => any>(fn: T) {
  const ref = useRef(fn)
  useLayoutEffect(() => { ref.current = fn })
  return useCallback((...args: any[]) => ref.current(...args), [])
}
useAsyncMemo(异步派生且支持中断)
代码语言:javascript
复制
import { useEffect, useMemo, useRef, useState } from 'react'
export function useAsyncMemo<T>(factory: () => Promise<T>, deps: any[], initial?: T) {
  const [state, setState] = useState<T | undefined>(initial)
  const ctrl = useRef<AbortController | null>(null)
  useEffect(() => {
    ctrl.current?.abort()
    ctrl.current = new AbortController()
    factory().then(v => { if (!ctrl.current?.signal.aborted) setState(v) })
    return () => { ctrl.current?.abort() }
  }, deps)
  return useMemo(() => state, [state])
}

useRequestPlus(去重、并发窗口、SWR)

代码语言:javascript
复制
import { useCallback, useMemo, useRef, useState } from 'react'
type PlusOptions<T, P extends any[]> = { cacheKey?: string; staleTime?: number; dedup?: boolean; limit?: number; retry?: { times?: number; delay?: number; factor?: number } }
type PlusResult<T> = { data: T | undefined; error: any; loading: boolean; run: (...args: any[]) => Promise<T>; cancel: () => void }
const inflight = new Map<string, Promise<any>>()
const cache2 = new Map<string, { data: any; time: number }>()
export function useRequestPlus<T, P extends any[]>(service: (...args: P) => Promise<T>, options: PlusOptions<T, P> = {}): PlusResult<T> {
  const { cacheKey, staleTime = 0, dedup, limit = 0, retry } = options
  const [data, setData] = useState<T | undefined>(undefined)
  const [error, setError] = useState<any>(null)
  const [loading, setLoading] = useState(false)
  const ctrl = useRef<AbortController | null>(null)
  const active = useRef(0)
  const queue = useRef<Array<() => void>>([])
  const drain = useCallback(() => {
    while (limit > 0 && active.current < limit && queue.current.length) {
      const fn = queue.current.shift()!
      active.current++
      fn()
    }
  }, [limit])
  const cancel = useCallback(() => { ctrl.current?.abort() }, [])
  const run = useCallback(async (...args: any[]) => {
    const key = cacheKey ? `${cacheKey}:${JSON.stringify(args)}` : undefined
    if (key && staleTime > 0) {
      const hit = cache2.get(key)
      if (hit && Date.now() - hit.time < staleTime) { setData(hit.data); return hit.data }
    }
    setLoading(true)
    setError(null)
    ctrl.current?.abort()
    ctrl.current = new AbortController()
    const exec = () => new Promise<T>(async (resolve, reject) => {
      try {
        if (dedup && key) {
          const exist = inflight.get(key)
          if (exist) { const res = await exist; setData(res); setLoading(false); resolve(res); active.current--; drain(); return }
        }
        let times = retry?.times ?? 0
        let delay = retry?.delay ?? 0
        let factor = retry?.factor ?? 2
        const promise = (service as any)(...args, { signal: ctrl.current!.signal }) as Promise<T>
        if (key && dedup) inflight.set(key, promise)
        let res: T | undefined
        while (true) {
          try { res = await promise; break } catch (e) { if (ctrl.current?.signal.aborted) { reject(e); if (key && dedup) inflight.delete(key); setLoading(false); active.current--; drain(); return } if (times > 0) { await new Promise(r => setTimeout(r, delay)); times -= 1; delay = delay * (factor || 2); continue } setError(e); setLoading(false); reject(e); if (key && dedup) inflight.delete(key); active.current--; drain(); return }
        }
        setData(res as T)
        if (key) cache2.set(key, { data: res, time: Date.now() })
        if (key && dedup) inflight.delete(key)
        setLoading(false)
        resolve(res as T)
        active.current--
        drain()
      } catch (e) { reject(e as any); active.current--; drain() }
    })
    if (limit > 0) { queue.current.push(() => { exec().catch(() => {}) }); drain(); return exec() }
    return exec()
  }, [cacheKey, staleTime, dedup, limit, retry?.times, retry?.delay, retry?.factor, drain, service])
  return useMemo(() => ({ data, error, loading, run, cancel }), [data, error, loading, run, cancel])
}

常见坑与修复

  • 闭包陈旧:事件监听与防抖/节流需使用最新回调,避免调用旧逻辑。
  • 依赖数组错误:useMemo/useCallback/useEffect 严格填写依赖,确保状态与副作用一致。
  • SSR/Hydration:服务端不访问 window/document,客户端判断后再注册事件;LocalStorage 需降级。
  • 取消与回收:所有异步任务在组件卸载或依赖变化时中断,避免覆盖新状态。

测试与验证清单

  • 单元测试:使用 @testing-library/react 验证防抖与节流时序、请求取消与重试、事件清理。
  • 性能测试:在高频事件下观察渲染次数与回调调用次数,确保节流有效。
  • 兼容性:在无 window 环境执行,确保降级路径安全。

更多生产级案例

useInfiniteScroll(交叉观察与请求合并)
代码语言:javascript
复制
import { useEffect, useRef, useState } from 'react'
export function useInfiniteScroll(load: () => Promise<any>, hasMore: boolean) {
  const sentinel = useRef<HTMLDivElement | null>(null)
  const [loading, setLoading] = useState(false)
  useEffect(() => {
    if (!sentinel.current || !hasMore) return
    const io = new IntersectionObserver(async entries => {
      if (entries[0].isIntersecting && !loading) { setLoading(true); await load(); setLoading(false) }
    })
    io.observe(sentinel.current)
    return () => io.disconnect()
  }, [hasMore, loading, load])
  return { sentinel, loading }
}
useIntersectionObserver(通用观察)
代码语言:javascript
复制
import { useEffect, useRef, useState } from 'react'
export function useIntersectionObserver(rootMargin = '0px') {
  const ref = useRef<HTMLElement | null>(null)
  const [visible, setVisible] = useState(false)
  useEffect(() => {
    if (!ref.current) return
    const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting), { rootMargin })
    io.observe(ref.current)
    return () => io.disconnect()
  }, [rootMargin])
  return { ref, visible }
}
useControlled(受控与非受控合一)
代码语言:javascript
复制
import { useCallback, useMemo, useState } from 'react'
export function useControlled<T>(propsValue: T | undefined, defaultValue: T, onChange?: (v: T) => void) {
  const [inner, setInner] = useState<T>(defaultValue)
  const value = propsValue !== undefined ? propsValue : inner
  const set = useCallback((v: T) => { if (propsValue === undefined) setInner(v); onChange?.(v) }, [propsValue, onChange])
  return useMemo(() => [value, set] as const, [value, set])
}

与社区库的对比与取舍

  • ahooksswrreact-query 提供成熟实现与生态;本文示例强调轻量与可控,便于按需裁剪。
  • 当业务复杂度升高,建议采用社区库并在其基础上做适配与治理。

常见坑与修复

  • 闭包陈旧:事件监听与防抖/节流需使用最新回调(如 useLatest),避免调用旧逻辑。
  • 依赖数组错误:useMemo/useCallback/useEffect 严格填写依赖,确保状态与副作用一致。
  • SSR/Hydration:服务端不访问 window/document,客户端判断后再注册事件;LocalStorage 需降级。
  • 取消与回收:所有异步任务在组件卸载或依赖变化时中断,避免覆盖新状态。

测试与验证清单

  • 单元测试:使用 @testing-library/react 验证防抖/节流时序、请求取消与重试、事件清理。
  • 性能测试:在高频事件下观察渲染次数与回调调用次数,确保节流有效。
  • 兼容性:在无 window 环境(JSDOM/SSR)执行,确保降级路径安全。

更多生产级案例

useInfiniteScroll(交叉观察 + 请求合并)
代码语言:javascript
复制
import { useEffect, useRef, useState } from 'react'
export function useInfiniteScroll(load: () => Promise<any>, hasMore: boolean) {
  const sentinel = useRef<HTMLDivElement | null>(null)
  const [loading, setLoading] = useState(false)
  useEffect(() => {
    if (!sentinel.current || !hasMore) return
    const io = new IntersectionObserver(async entries => {
      if (entries[0].isIntersecting && !loading) { setLoading(true); await load(); setLoading(false) }
    })
    io.observe(sentinel.current)
    return () => io.disconnect()
  }, [hasMore, loading, load])
  return { sentinel, loading }
}
useIntersectionObserver(通用观察)
代码语言:javascript
复制
import { useEffect, useRef, useState } from 'react'
export function useIntersectionObserver(rootMargin = '0px') {
  const ref = useRef<HTMLElement | null>(null)
  const [visible, setVisible] = useState(false)
  useEffect(() => {
    if (!ref.current) return
    const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting), { rootMargin })
    io.observe(ref.current)
    return () => io.disconnect()
  }, [rootMargin])
  return { ref, visible }
}
useControlled(受控/非受控合一)
代码语言:javascript
复制
import { useCallback, useMemo, useState } from 'react'
export function useControlled<T>(propsValue: T | undefined, defaultValue: T, onChange?: (v: T) => void) {
  const [inner, setInner] = useState<T>(defaultValue)
  const value = propsValue !== undefined ? propsValue : inner
  const set = useCallback((v: T) => { if (propsValue === undefined) setInner(v); onChange?.(v) }, [propsValue, onChange])
  return useMemo(() => [value, set] as const, [value, set])
}

与社区库的对比与取舍

  • ahooks/swr/react-query 提供成熟实现与生态;本文示例强调轻量与可控、便于按需裁剪。
  • 当业务复杂度升高,建议直接采用社区库并在其基础上做适配与治理。
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-12-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • React Hooks 进阶:自定义 Hooks 封装技巧(附 5 个生产级案例)
    • 封装原则
    • 案例一:useDebouncedValue 与 useDebounceFn
    • 案例二:useThrottleFn
    • 案例三:useRequest(取消、重试、缓存)
    • 案例四:useEventListener(自动清理与最新回调)
    • 案例五:useLocalStorageState(跨标签页同步)
    • 封装技巧清单
    • 使用示例与最佳实践
    • 高级工具与模式
      • useStableCallback(稳定引用且获取最新逻辑)
      • useAsyncMemo(异步派生且支持中断)
    • useRequestPlus(去重、并发窗口、SWR)
    • 常见坑与修复
    • 测试与验证清单
    • 更多生产级案例
      • useInfiniteScroll(交叉观察与请求合并)
      • useIntersectionObserver(通用观察)
      • useControlled(受控与非受控合一)
    • 与社区库的对比与取舍
    • 常见坑与修复
    • 测试与验证清单
    • 更多生产级案例
      • useInfiniteScroll(交叉观察 + 请求合并)
      • useIntersectionObserver(通用观察)
      • useControlled(受控/非受控合一)
    • 与社区库的对比与取舍
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档