
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
}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])
}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])
}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])
}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 保证最新引用window 存在与使用 storage 事件同步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>
)
}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), [])
}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])
}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 严格填写依赖,确保状态与副作用一致。window/document,客户端判断后再注册事件;LocalStorage 需降级。@testing-library/react 验证防抖与节流时序、请求取消与重试、事件清理。window 环境执行,确保降级路径安全。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 }
}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 }
}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 提供成熟实现与生态;本文示例强调轻量与可控,便于按需裁剪。useLatest),避免调用旧逻辑。useMemo/useCallback/useEffect 严格填写依赖,确保状态与副作用一致。window/document,客户端判断后再注册事件;LocalStorage 需降级。@testing-library/react 验证防抖/节流时序、请求取消与重试、事件清理。window 环境(JSDOM/SSR)执行,确保降级路径安全。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 }
}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 }
}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 提供成熟实现与生态;本文示例强调轻量与可控、便于按需裁剪。