React 16.8(2019年2月)发布的 Hooks 是 React 历史上最重要的一次革新。本篇从 Fiber 架构的链表数据结构出发,深入每个核心 Hook 的内部实现,剖析闭包陷阱的根本成因,并系统讲解自定义 Hook 的六大设计模式。读完此篇,你将不仅"会用" Hooks,更能"看透"它。
一、名词解释
| 术语 | 解释 |
|---|---|
| Fiber | React 16 重写的核心架构单元,每个组件对应一个 Fiber 节点,保存组件的类型、props、state 等所有信息 |
| memoizedState | Fiber 节点上存放 Hook 链表头节点的字段,所有 Hook 状态通过此链表串联 |
| Hook 节点 | 链表中的每个节点,结构为 { memoizedState, baseState, queue, next },next 指向下一个 Hook |
| WorkInProgress Fiber | 正在构建的新 Fiber 树节点,与 current Fiber 对应,双缓冲机制 |
| UpdateQueue | Hook 节点内存放待处理更新的队列,dispatch 触发的更新先入队,渲染时批量消费 |
| 闭包陷阱(Stale Closure) | 函数组件每次渲染都创建新的函数作用域,旧的 effect/callback 捕获了旧渲染的变量,无法感知最新状态 |
| 依赖数组(deps) | useEffect / useCallback / useMemo 第二个参数,React 用 Object.is 逐项比较来决定是否重新执行 |
| 副作用(Side Effect) | 组件渲染之外的操作,如网络请求、DOM 操作、订阅事件、设置定时器等 |
| 清理函数(Cleanup) | useEffect 返回的函数,在下次 effect 执行前或组件卸载时调用,用于取消订阅/清除定时器 |
| 惰性初始化(Lazy Initialization) | useState(fn) 传函数时只在首次渲染执行,避免每次渲染重复计算昂贵的初始值 |
| 自定义 Hook | 以 use 开头、内部调用其他 Hook 的普通函数,实现有状态逻辑的跨组件复用 |
| useReducer | 类 Redux 的状态管理 Hook,适合复杂状态逻辑,useState 本质上是其简化版 |
| useLayoutEffect | 与 useEffect 相似但同步执行于 DOM 变更后、浏览器绘制前,用于测量 DOM 尺寸 |
| Object.is | ES6 的严格相等算法,与 === 的区别在于 Object.is(NaN, NaN) === true,React 依赖数组比较使用此方法 |
| 批处理(Batching) | 将多次 setState 调用合并为一次渲染,React 18 中所有场景都自动批处理 |
| 防抖(Debounce) | 连续触发时重置计时器,只在最后一次触发后延迟执行,适用于搜索输入 |
| 节流(Throttle) | 固定时间间隔内只执行一次,适用于滚动/鼠标移动事件 |
| IntersectionObserver | 浏览器原生 API,异步监听元素与视口的交叉状态,用于懒加载和无限滚动 |
二、底层原理深度解析
2.1 Fiber 架构与 Hook 链表
要理解 Hooks 为什么有那些规则,必须先看清楚 React 内部如何存储 Hook 状态。
每个函数组件对应一个 Fiber 节点。Fiber 节点上有一个 memoizedState 字段,它是整个 Hook 链表的头节点指针。组件中每调用一次 Hook,就在链表末尾追加一个新节点。
// Fiber 节点(简化版)
const fiber = {
type: MyComponent, // 组件函数
memoizedState: null, // Hook 链表头节点,初始为 null
pendingProps: {},
memoizedProps: {},
// ...其他字段
}
// 每个 Hook 节点的结构(来自 React 源码 ReactFiberHooks.js)
const hook = {
memoizedState: any, // 当前已提交的状态值
baseState: any, // 基础状态(用于优先级相关的中断重放)
baseQueue: Update | null, // 基础更新队列
queue: UpdateQueue | null,// 待处理的更新队列
next: Hook | null, // 指向下一个 Hook 节点
}
假设有以下组件:
function Counter() {
const [count, setCount] = useState(0) // Hook 1
const [name, setName] = useState('Alice') // Hook 2
const timerRef = useRef(null) // Hook 3
useEffect(() => { // Hook 4
document.title = `${name}: ${count}`
}, [name, count])
return <div>{count}</div>
}
首次渲染后,Fiber 的 memoizedState 链表结构如下:
fiber.memoizedState
│
▼
┌─────────────────────────────┐
│ Hook 1 (useState: count) │
│ memoizedState: 0 │
│ queue: { dispatch: setCount│
│ pending: null } │
│ next: ──────────────────── │──▶ Hook 2
└─────────────────────────────┘
┌─────────────────────────────┐
│ Hook 2 (useState: name) │
│ memoizedState: 'Alice' │
│ queue: { dispatch: setName │
│ pending: null } │
│ next: ──────────────────── │──▶ Hook 3
└─────────────────────────────┘
┌─────────────────────────────┐
│ Hook 3 (useRef) │
│ memoizedState: { current: │
│ null } │
│ queue: null │
│ next: ──────────────────── │──▶ Hook 4
└─────────────────────────────┘
┌─────────────────────────────┐
│ Hook 4 (useEffect) │
│ memoizedState: { │
│ tag: HookHasEffect, │
│ create: () => {...}, │
│ destroy: undefined, │
│ deps: ['Alice', 0], │
│ next: ... │
│ } │
│ next: null ◀── 链表尾部 │
└─────────────────────────────┘

2.2 mountWorkInProgressHook vs updateWorkInProgressHook
React 内部针对首次渲染(mount)和后续更新(update)使用了两套不同的 dispatcher:
// 首次渲染 —— 创建新的 Hook 节点并追加到链表
function mountWorkInProgressHook() {
const hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
}
if (workInProgressHook === null) {
// 这是链表的第一个 Hook
currentlyRenderingFiber.memoizedState = workInProgressHook = hook
} else {
// 追加到链表末尾
workInProgressHook = workInProgressHook.next = hook
}
return workInProgressHook
}
// 更新渲染 —— 按顺序取出已有的 Hook 节点(不创建新的)
function updateWorkInProgressHook() {
// 从 current fiber 的链表中取出对应位置的 Hook
let nextCurrentHook
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate // current fiber
nextCurrentHook = current !== null ? current.memoizedState : null
} else {
nextCurrentHook = currentHook.next // 沿链表向后移动
}
currentHook = nextCurrentHook // ← 关键:顺序取,不能跳过
// 基于旧节点创建新节点(workInProgress)
const newHook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,
next: null,
}
// ...追加到 workInProgress 链表
return newHook
}
关键结论:updateWorkInProgressHook 通过顺序遍历来匹配每次渲染中的 Hook 与链表节点。React 没有存储 “这是哪个 useState” 的名字,它只知道"第1个Hook、第2个Hook、第3个Hook……"。
2.3 为什么不能在条件/循环中调用 Hooks
现在来看违规后发生了什么:
// 危险代码!(仅演示,不要这样写)
function BrokenComponent({ flag }: { flag: boolean }) {
const [a, setA] = useState(1) // 每次都是链表 [0]
if (flag) {
const [b, setB] = useState(2) // flag=true 时是链表 [1]
}
const [c, setC] = useState(3) // flag=true 时是 [2],flag=false 时是 [1]!
}
当 flag 从 true 变为 false 时:
首次渲染(flag=true)链表: [a=1] → [b=2] → [c=3]
0 1 2
再次渲染(flag=false)取节点:
useState(1) → 取 [0] → a = 1 ✓
// if 跳过,没有取 [1]
useState(3) → 取 [1] → c = 2 ✗(取到的是旧的 b 的值!)
c 的状态槽位取到了 b 的数据,发生了状态错乱。ESLint 的 eslint-plugin-react-hooks 正是通过静态分析调用顺序来提前发现这类错误。
2.4 useState 的内部实现
useState 本质上是 useReducer 的语法糖:
// React 源码中 useState 的实现(简化)
function useState(initialState) {
// 内置的 reducer:直接返回 action 作为新状态
return useReducer(basicStateReducer, initialState)
}
function basicStateReducer(state, action) {
// action 就是新值,或者是 (prevState) => newState 函数
return typeof action === 'function' ? action(state) : action
}
dispatch(即 setState)触发时,将 action 放入 hook.queue.pending,然后调度一次重新渲染。重新渲染时,消费队列中所有 action 得出最终状态。
// mount 阶段的 useState 初始化
function mountState(initialState) {
const hook = mountWorkInProgressHook()
// 惰性初始化:如果 initialState 是函数,立即调用
if (typeof initialState === 'function') {
initialState = initialState()
}
hook.memoizedState = hook.baseState = initialState
const queue = {
pending: null, // 环形链表,存放待处理的 Update
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState,
}
hook.queue = queue
const dispatch = (queue.dispatch = dispatchSetState.bind(
null,
currentlyRenderingFiber,
queue
))
return [hook.memoizedState, dispatch]
}
2.5 useEffect 的 Effect 对象结构
useEffect 的 Hook 节点的 memoizedState 不是值,而是一个 Effect 对象:
// Effect 对象结构(来自 React 源码)
const effect = {
tag: HookPassive | HookHasEffect, // 标记类型和是否需要执行
create: () => { // 用户传入的 effect 函数
// ...副作用逻辑
return destroyFn // 返回的清理函数
},
destroy: undefined, // 上次执行后返回的清理函数
deps: [dep1, dep2], // 依赖数组
next: Effect, // 指向下一个 Effect(环形链表)
}
React 在 commit 阶段完成 DOM 操作后,异步地(通过 MessageChannel 调度,在浏览器绘制之后)执行 effect:
渲染阶段(render phase)
└── 调用组件函数,收集所有 Effect 对象
提交阶段(commit phase)
├── beforeMutation:处理 snapshot
├── mutation:执行 DOM 操作
└── layout:执行 useLayoutEffect(同步!)
浏览器绘制(paint)
异步调度阶段(passive effects)
└── 执行 useEffect(在 paint 之后)
├── 先执行上次 effect 的 destroy(清理函数)
└── 再执行本次 effect 的 create
这就是 useEffect 与 useLayoutEffect 的根本区别:执行时机一个在 paint 之后(异步),一个在 paint 之前(同步)。
2.6 useRef 的最简实现
// mount 阶段
function mountRef(initialValue) {
const hook = mountWorkInProgressHook()
const ref = { current: initialValue } // 就是这么简单的一个对象
hook.memoizedState = ref
return ref
}
// update 阶段
function updateRef() {
const hook = updateWorkInProgressHook()
return hook.memoizedState // 直接返回同一个对象引用,不创建新对象
}
useRef 返回的对象在整个组件生命周期内始终是同一个引用。修改 ref.current 不会触发重渲染,因为 React 根本不知道 ref.current 改变了——它只监测 Hook 节点的 memoizedState(引用本身),而引用没有变。
2.7 useCallback 与 useMemo 的内部结构
// useCallback:memoizedState 存储的是 [fn, deps] 元组
function mountCallback(callback, deps) {
const hook = mountWorkInProgressHook()
hook.memoizedState = [callback, deps] // 直接存函数本身
return callback
}
function updateCallback(callback, deps) {
const hook = updateWorkInProgressHook()
const [prevCallback, prevDeps] = hook.memoizedState
if (areHookInputsEqual(deps, prevDeps)) {
return prevCallback // deps 没变,返回旧函数引用
}
hook.memoizedState = [callback, deps]
return callback // deps 变了,返回新函数
}
// useMemo:memoizedState 存储的是 [value, deps] 元组
function mountMemo(nextCreate, deps) {
const hook = mountWorkInProgressHook()
const value = nextCreate() // 立即执行,得到计算结果
hook.memoizedState = [value, deps] // 存结果,不是函数
return value
}
function updateMemo(nextCreate, deps) {
const hook = updateWorkInProgressHook()
const [prevValue, prevDeps] = hook.memoizedState
if (areHookInputsEqual(deps, prevDeps)) {
return prevValue // deps 没变,返回缓存的值
}
const value = nextCreate() // deps 变了,重新计算
hook.memoizedState = [value, deps]
return value
}
// 依赖比较:Object.is 逐项比较
function areHookInputsEqual(nextDeps, prevDeps) {
if (prevDeps === null) return false
for (let i = 0; i < prevDeps.length; i++) {
if (!Object.is(nextDeps[i], prevDeps[i])) return false
}
return true
}
useCallback(fn, deps) 等价于 useMemo(() => fn, deps),区别仅在于 useMemo 执行函数取返回值,而 useCallback 直接存函数。
三、各 Hook 市面实际应用
3.1 useState —— 表单状态管理
// 实际项目中的表单状态管理
interface LoginForm {
email: string
password: string
rememberMe: boolean
}
function LoginPage() {
const [form, setForm] = useState<LoginForm>({
email: '',
password: '',
rememberMe: false,
})
const [errors, setErrors] = useState<Partial<LoginForm>>({})
// 通用字段更新函数,避免为每个字段写单独的 handler
const handleChange = (field: keyof LoginForm) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value
setForm(prev => ({ ...prev, [field]: value }))
// 清除该字段的错误
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }))
}
}
const validate = (): boolean => {
const newErrors: Partial<LoginForm> = {}
if (!form.email.includes('@')) newErrors.email = '邮箱格式不正确'
if (form.password.length < 6) newErrors.password = '密码至少6位'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
// 提交逻辑...
}
return (
<form onSubmit={handleSubmit}>
<input
value={form.email}
onChange={handleChange('email')}
placeholder="邮箱"
/>
{errors.email && <span className="error">{errors.email}</span>}
<input
type="password"
value={form.password}
onChange={handleChange('password')}
placeholder="密码"
/>
{errors.password && <span className="error">{errors.password}</span>}
<label>
<input
type="checkbox"
checked={form.rememberMe}
onChange={handleChange('rememberMe')}
/>
记住我
</label>
<button type="submit">登录</button>
</form>
)
}
3.2 useEffect —— 数据请求与 WebSocket 订阅
// 数据请求(实际项目标准写法)
function PatientList({ wardId }: { wardId: string }) {
const [patients, setPatients] = useState<Patient[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false // 竞态条件防护:组件卸载或 wardId 改变时取消更新
const fetchPatients = async () => {
try {
setLoading(true)
setError(null)
const data = await patientService.getByWard(wardId)
if (!cancelled) { // 只有当前请求仍然有效时才更新状态
setPatients(data)
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : '请求失败')
}
} finally {
if (!cancelled) setLoading(false)
}
}
fetchPatients()
return () => {
cancelled = true // 清理:标记请求已取消
}
}, [wardId]) // wardId 改变时重新请求
if (loading) return <Skeleton />
if (error) return <ErrorBoundary message={error} />
return <PatientTable data={patients} />
}
// WebSocket 实时推送(医通项目典型场景)
function BedMonitor({ bedId }: { bedId: string }) {
const [vitals, setVitals] = useState<VitalSigns | null>(null)
useEffect(() => {
const ws = new WebSocket(`wss://api.hospital.com/monitor/${bedId}`)
ws.onopen = () => console.log(`床位 ${bedId} 监控已连接`)
ws.onmessage = (event) => {
const data: VitalSigns = JSON.parse(event.data)
setVitals(data)
}
ws.onerror = (err) => console.error('监控连接错误', err)
// 清理函数:bedId 改变或组件卸载时关闭 WebSocket
return () => {
ws.close()
console.log(`床位 ${bedId} 监控已断开`)
}
}, [bedId])
if (!vitals) return <div>等待数据...</div>
return (
<div>
<p>心率: {vitals.heartRate} bpm</p>
<p>血压: {vitals.bloodPressure}</p>
<p>体温: {vitals.temperature}°C</p>
</div>
)
}
3.3 useLayoutEffect —— 测量 DOM 与动画定位
// Tooltip 组件:需要在渲染后立即获取尺寸,避免闪烁
function Tooltip({ children, content }: TooltipProps) {
const tooltipRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState({ top: 0, left: 0 })
// 必须用 useLayoutEffect,否则 tooltip 会先出现在错误位置再跳到正确位置(闪烁)
useLayoutEffect(() => {
if (!tooltipRef.current) return
const rect = tooltipRef.current.getBoundingClientRect()
// 计算 tooltip 不超出视口的位置
const top = rect.top > 100 ? rect.top - 40 : rect.bottom + 8
const left = Math.min(rect.left, window.innerWidth - 200)
setPosition({ top, left })
}) // 每次渲染后同步执行,确保位置始终正确
return (
<div ref={tooltipRef} style={{ position: 'relative' }}>
{children}
<div
style={{
position: 'fixed',
top: position.top,
left: position.left,
}}
className="tooltip"
>
{content}
</div>
</div>
)
}

3.4 useCallback —— 防止子组件不必要的重渲染
// 父组件向子组件传递回调时,配合 React.memo 使用
const ExpensiveChild = React.memo(({ onItemClick }: { onItemClick: (id: string) => void }) => {
console.log('ExpensiveChild 渲染') // 没有 useCallback 时每次父组件渲染都会打印
return (
<ul>
{['A', 'B', 'C'].map(id => (
<li key={id} onClick={() => onItemClick(id)}>{id}</li>
))}
</ul>
)
})
function Parent() {
const [count, setCount] = useState(0)
const [selectedId, setSelectedId] = useState<string | null>(null)
// 没有 useCallback:每次 count 变化,Parent 重渲染,
// onItemClick 是新函数 → ExpensiveChild 的 prop 变化 → 触发重渲染
// const onItemClick = (id: string) => setSelectedId(id)
// 有 useCallback:函数引用稳定,ExpensiveChild 不会因 count 变化而重渲染
const onItemClick = useCallback((id: string) => {
setSelectedId(id)
}, []) // 没有依赖,函数永远稳定
return (
<div>
<button onClick={() => setCount(c => c + 1)}>count: {count}</button>
<p>选中: {selectedId}</p>
<ExpensiveChild onItemClick={onItemClick} />
</div>
)
}
3.5 useMemo —— 昂贵计算缓存
// 大数据量过滤/排序场景
function PatientDashboard({ patients }: { patients: Patient[] }) {
const [searchTerm, setSearchTerm] = useState('')
const [sortBy, setSortBy] = useState<'name' | 'age' | 'admissionDate'>('name')
const [filterWard, setFilterWard] = useState<string>('all')
// 过滤+排序是 O(n log n) 操作,patients 可能有几千条
// 用 useMemo 缓存,只在真正的依赖变化时重新计算
const processedPatients = useMemo(() => {
let result = [...patients]
// 过滤
if (filterWard !== 'all') {
result = result.filter(p => p.wardId === filterWard)
}
if (searchTerm) {
const term = searchTerm.toLowerCase()
result = result.filter(p =>
p.name.toLowerCase().includes(term) ||
p.id.includes(term)
)
}
// 排序
result.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name)
if (sortBy === 'age') return a.age - b.age
return new Date(a.admissionDate).getTime() - new Date(b.admissionDate).getTime()
})
return result
}, [patients, searchTerm, sortBy, filterWard]) // 任一变化才重算
return (
<div>
<SearchBar value={searchTerm} onChange={setSearchTerm} />
<WardFilter value={filterWard} onChange={setFilterWard} />
<SortControl value={sortBy} onChange={setSortBy} />
<p>共 {processedPatients.length} 条记录</p>
<VirtualList items={processedPatients} />
</div>
)
}
3.6 useContext —— 主题与全局状态
// 主题系统(真实项目架构)
interface Theme {
primaryColor: string
fontSize: 'small' | 'medium' | 'large'
mode: 'light' | 'dark'
}
const ThemeContext = React.createContext<{
theme: Theme
setTheme: (theme: Theme) => void
} | null>(null)
// 自定义 Hook 封装 context 访问,提供更友好的错误信息
function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme 必须在 ThemeProvider 内部使用')
return ctx
}
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>({
primaryColor: '#1890ff',
fontSize: 'medium',
mode: 'light',
})
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
// 深层子组件直接消费,无需 prop drilling
function Header() {
const { theme, setTheme } = useTheme()
return (
<header style={{ background: theme.primaryColor }}>
<button onClick={() =>
setTheme(prev => ({ ...prev, mode: prev.mode === 'light' ? 'dark' : 'light' }))
}>
切换到 {theme.mode === 'light' ? '深色' : '浅色'} 模式
</button>
</header>
)
}
四、自定义 Hook 设计模式
4.1 useWindowSize —— 响应式窗口尺寸
// 实际项目:响应式布局判断
interface WindowSize {
width: number
height: number
isMobile: boolean
isTablet: boolean
isDesktop: boolean
}
function useWindowSize(): WindowSize {
const [size, setSize] = useState<WindowSize>(() => ({
width: window.innerWidth,
height: window.innerHeight,
isMobile: window.innerWidth < 768,
isTablet: window.innerWidth >= 768 && window.innerWidth < 1024,
isDesktop: window.innerWidth >= 1024,
}))
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth
const height = window.innerHeight
setSize({
width,
height,
isMobile: width < 768,
isTablet: width >= 768 && width < 1024,
isDesktop: width >= 1024,
})
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return size
}
// 使用
function Layout() {
const { isMobile, isDesktop } = useWindowSize()
return (
<div>
{isDesktop && <Sidebar />}
<main>
{isMobile ? <MobileNav /> : <DesktopNav />}
<Outlet />
</main>
</div>
)
}
4.2 useDebounce —— 防抖搜索
// 防抖 Hook:延迟响应高频输入
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
// 每次 value 变化时,设置一个定时器
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
// 清理函数:在下次 effect 执行前取消上次的定时器
// 这正是防抖的核心:连续变化时,旧定时器被不断取消
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
// 使用:搜索框防抖
function SearchPage() {
const [inputValue, setInputValue] = useState('')
const debouncedSearch = useDebounce(inputValue, 500) // 500ms 防抖
// 只在防抖值变化时发请求(用户停止输入 500ms 后)
useEffect(() => {
if (debouncedSearch) {
searchAPI(debouncedSearch).then(setResults)
}
}, [debouncedSearch])
return (
<input
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="搜索患者姓名或ID..."
/>
)
}
4.3 usePrevious —— 访问上一次渲染的值
// 经典的 "前一次值" Hook,巧妙利用 useRef 的特性
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined)
// useEffect 在渲染后执行,所以更新 ref 发生在本次渲染的 return 之后
// 下次渲染开始时,ref.current 就是上一次渲染的值
useEffect(() => {
ref.current = value
}) // 无依赖数组:每次渲染后都更新
return ref.current // 返回的是更新前的值(因为 effect 还没执行)
}
// 使用:检测值变化方向
function CounterWithTrend() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count)
const trend = prevCount === undefined
? '初始'
: count > prevCount ? '↑ 上升'
: count < prevCount ? '↓ 下降'
: '→ 不变'
return (
<div>
<p>当前值: {count},趋势: {trend}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<button onClick={() => setCount(c => c - 1)}>-1</button>
</div>
)
}
4.4 useInterval —— 安全的 setInterval
// 解决 setInterval + Hooks 的闭包问题
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback)
// 每次渲染都同步更新 ref 中的 callback
// 这样 interval 内部始终能访问到最新的 callback,不会有闭包问题
useLayoutEffect(() => {
savedCallback.current = callback
})
useEffect(() => {
if (delay === null) return // delay 为 null 时暂停 interval
const id = setInterval(() => {
savedCallback.current() // 始终调用最新版本的 callback
}, delay)
return () => clearInterval(id)
}, [delay]) // 只依赖 delay,不依赖 callback(因为 callback 通过 ref 传递)
}
// 使用:计时器
function StopWatch() {
const [time, setTime] = useState(0)
const [running, setRunning] = useState(false)
useInterval(
() => setTime(t => t + 1),
running ? 1000 : null // null 表示暂停
)
return (
<div>
<p>{time} 秒</p>
<button onClick={() => setRunning(r => !r)}>
{running ? '暂停' : '开始'}
</button>
<button onClick={() => { setRunning(false); setTime(0) }}>重置</button>
</div>
)
}
4.5 useAsync —— 封装异步请求
// 通用异步状态 Hook
type AsyncStatus = 'idle' | 'pending' | 'success' | 'error'
interface AsyncState<T> {
status: AsyncStatus
data: T | null
error: Error | null
}
function useAsync<T>(asyncFn: () => Promise<T>, immediate = true) {
const [state, setState] = useState<AsyncState<T>>({
status: 'idle',
data: null,
error: null,
})
// 用 useCallback 稳定 execute 引用
const execute = useCallback(async () => {
setState({ status: 'pending', data: null, error: null })
try {
const data = await asyncFn()
setState({ status: 'success', data, error: null })
return data
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
setState({ status: 'error', data: null, error: err })
throw err
}
}, [asyncFn])
useEffect(() => {
if (immediate) execute()
}, [execute, immediate])
return {
...state,
execute,
isIdle: state.status === 'idle',
isPending: state.status === 'pending',
isSuccess: state.status === 'success',
isError: state.status === 'error',
}
}
// 使用:医通项目的用户列表
function DoctorList() {
const fetchDoctors = useCallback(() => doctorAPI.getAll(), [])
const { data: doctors, isPending, isError, error, execute: refetch } = useAsync(fetchDoctors)
return (
<div>
{isPending && <Loading />}
{isError && (
<div>
错误: {error?.message}
<button onClick={refetch}>重试</button>
</div>
)}
{doctors?.map(doc => <DoctorCard key={doc.id} doctor={doc} />)}
</div>
)
}
4.6 useIntersectionObserver —— 懒加载与无限滚动
// 封装 IntersectionObserver API
function useIntersectionObserver(
options: IntersectionObserverInit = {}
): [React.RefObject<HTMLDivElement>, boolean] {
const ref = useRef<HTMLDivElement>(null)
const [isIntersecting, setIsIntersecting] = useState(false)
useEffect(() => {
const element = ref.current
if (!element) return
const observer = new IntersectionObserver(
([entry]) => setIsIntersecting(entry.isIntersecting),
{ threshold: 0.1, ...options }
)
observer.observe(element)
return () => observer.disconnect()
}, [options.threshold, options.rootMargin]) // 只依赖稳定的基本类型
return [ref, isIntersecting]
}
// 使用场景一:图片懒加载
function LazyImage({ src, alt }: { src: string; alt: string }) {
const [ref, isVisible] = useIntersectionObserver()
const [loaded, setLoaded] = useState(false)
return (
<div ref={ref} style={{ minHeight: 200 }}>
{isVisible && (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
/>
)}
</div>
)
}
// 使用场景二:无限滚动
function InfinitePatientList() {
const [patients, setPatients] = useState<Patient[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [sentinelRef, isSentinelVisible] = useIntersectionObserver()
useEffect(() => {
if (!isSentinelVisible || !hasMore) return
// 哨兵元素进入视口时加载下一页
patientAPI.getPage(page).then(data => {
setPatients(prev => [...prev, ...data.items])
setHasMore(data.hasNextPage)
setPage(p => p + 1)
})
}, [isSentinelVisible])
return (
<div>
{patients.map(p => <PatientCard key={p.id} patient={p} />)}
{/* 哨兵元素:滚动到这里触发加载 */}
<div ref={sentinelRef}>
{hasMore ? <Loading /> : <p>已加载全部数据</p>}
</div>
</div>
)
}

五、实战要点与常见陷阱
5.1 过期闭包(Stale Closure)—— 最高频陷阱
问题:每次渲染都创建新的函数,旧函数捕获了旧的变量值。
// 陷阱示例:经典的 setTimeout 闭包问题
function CounterBug() {
const [count, setCount] = useState(0)
const handleClick = () => {
setTimeout(() => {
// 这里的 count 是点击时那次渲染的值
// 3秒后即使 count 已经变化了,这里仍然显示旧值
alert(`count is: ${count}`) // 始终显示点击时的 count,不是最新值
}, 3000)
}
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<button onClick={handleClick}>3秒后显示count</button>
</div>
)
}
解决方案一:函数式更新(当只需要最新状态时)
// 方案一:用函数式更新,不依赖闭包中的旧值
const handleIncrement = () => {
setTimeout(() => {
setCount(prev => prev + 1) // prev 始终是最新值,不是闭包捕获的旧值
}, 3000)
}
解决方案二:useRef 保存最新值(需要读取最新值时)
// 方案二:用 ref 保存最新值,ref 不受闭包影响
function CounterFix() {
const [count, setCount] = useState(0)
const countRef = useRef(count)
// 每次渲染同步更新 ref
useEffect(() => {
countRef.current = count
})
const handleClick = () => {
setTimeout(() => {
// countRef.current 始终是最新值
alert(`count is: ${countRef.current}`)
}, 3000)
}
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<button onClick={handleClick}>3秒后显示count</button>
</div>
)
}
5.2 useEffect 无限循环
// 陷阱:对象/函数作为依赖,每次渲染都是新引用
function InfiniteLoop() {
const [data, setData] = useState([])
// options 每次渲染都是新对象 → effect 重复执行 → setData 触发渲染 → 无限循环
const options = { page: 1, size: 10 }
useEffect(() => {
fetchData(options).then(setData)
}, [options]) // 每次渲染 options !== options(引用不同)
}
// 修复方案一:将对象移入 effect 内部(最简单)
useEffect(() => {
fetchData({ page: 1, size: 10 }).then(setData)
}, []) // 不依赖任何外部对象
// 修复方案二:依赖基本类型值而非对象
function Fixed({ page, size }: { page: number; size: number }) {
useEffect(() => {
fetchData({ page, size }).then(setData)
}, [page, size]) // page 和 size 是基本类型,比较稳定
}
// 修复方案三:用 useMemo 稳定对象引用
function Fixed2() {
const [page, setPage] = useState(1)
const options = useMemo(() => ({ page, size: 10 }), [page]) // 引用稳定
useEffect(() => {
fetchData(options).then(setData)
}, [options]) // options 引用只在 page 变化时才改变
}
5.3 useCallback 的误用与过度优化
// 误区:并非所有函数都需要 useCallback
function SimpleParent() {
const [count, setCount] = useState(0)
// 这里 useCallback 完全没必要!
// SimpleChild 没有 React.memo 包裹,每次父组件渲染它都会重渲染
// useCallback 只是浪费了比较的开销
const handleClick = useCallback(() => {
console.log('clicked')
}, [])
return (
<div>
<SimpleChild onClick={handleClick} /> {/* 没有 React.memo */}
<button onClick={() => setCount(c => c + 1)}>{count}</button>
</div>
)
}
// useCallback 真正有价值的场景:
// 1. 子组件被 React.memo 包裹
// 2. 函数作为 useEffect/useMemo/其他 useCallback 的依赖
// 实际上,useCallback 过度使用会让代码更难读,优化收益也可能为负
// 原则:先写清晰的代码,只在 React DevTools Profiler 发现性能问题时才优化
5.4 cleanup 函数的竞态条件
// 经典竞态条件:快速切换 userId 时,旧请求可能比新请求后返回
function UserProfile({ userId }: { userId: string }) {
const [profile, setProfile] = useState<User | null>(null)
useEffect(() => {
// 问题:如果 userId 快速从 1 → 2 → 3
// 可能请求3先返回,然后请求1返回并覆盖了正确的数据
fetchUser(userId).then(setProfile) // 没有竞态保护!
}, [userId])
}
// 修复:使用 cancelled 标志
function UserProfileFixed({ userId }: { userId: string }) {
const [profile, setProfile] = useState<User | null>(null)
useEffect(() => {
let cancelled = false
fetchUser(userId).then(data => {
if (!cancelled) setProfile(data) // 只有当前请求仍然有效才更新
})
return () => { cancelled = true } // userId 改变时取消旧请求的更新
}, [userId])
}
// 更现代的方案:AbortController
function UserProfileAbort({ userId }: { userId: string }) {
const [profile, setProfile] = useState<User | null>(null)
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setProfile)
.catch(err => {
if (err.name !== 'AbortError') console.error(err)
})
return () => controller.abort() // 真正取消网络请求
}, [userId])
}
5.5 useReducer 替代复杂的 useState
// 当状态之间有紧密关联时,useReducer 比多个 useState 更清晰
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string }
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; error: string }
| { type: 'RESET' }
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START': return { status: 'loading' }
case 'FETCH_SUCCESS': return { status: 'success', data: action.payload }
case 'FETCH_ERROR': return { status: 'error', error: action.error }
case 'RESET': return { status: 'idle' }
default: return state
}
}
function useFetch<T>(url: string) {
const [state, dispatch] = useReducer(fetchReducer<T>, { status: 'idle' })
useEffect(() => {
dispatch({ type: 'FETCH_START' })
fetch(url)
.then(r => r.json())
.then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(err => dispatch({ type: 'FETCH_ERROR', error: err.message }))
}, [url])
return state
}
5.6 依赖数组 —— exhaustive-deps 的正确处理
// eslint-disable-next-line react-hooks/exhaustive-deps 的使用场景
// 场景一:onlyOnMount(故意只在挂载时执行,不随依赖重新执行)
// 正确做法:将函数调用移入 effect,而非禁用规则
useEffect(() => {
// 如果 initialize 在整个生命周期中引用不变,用 useRef 存它
const initFn = initializeRef.current
initFn()
}, []) // ✓ 如果 initialize 来自 ref,不需要加入依赖
// 场景二:回调 props 不应该作为 effect 的依赖
// 因为父组件可能每次渲染都传新函数,导致 effect 无限执行
function Child({ onMount }: { onMount: () => void }) {
const onMountRef = useRef(onMount)
useLayoutEffect(() => { onMountRef.current = onMount })
useEffect(() => {
onMountRef.current() // 通过 ref 调用,不加入依赖
}, [])
}
六、本章小结
各 Hook 对比速查表
| Hook | 存储内容(memoizedState) | 触发重渲染 | 执行时机 | 典型用途 |
|---|---|---|---|---|
useState | 当前状态值 | 是 | 同步(渲染中) | 组件内 UI 状态 |
useReducer | [state, dispatch] | 是 | 同步(渲染中) | 复杂/关联状态逻辑 |
useRef | { current: value } 对象 | 否 | 同步(渲染中) | DOM 引用、可变值存储 |
useEffect | Effect 对象 {tag,create,destroy,deps} | 否 | 异步(paint 之后) | 数据请求、订阅、定时器 |
useLayoutEffect | Effect 对象 | 否 | 同步(paint 之前) | DOM 测量、动画定位 |
useCallback | [fn, deps] 元组 | 否 | 同步(渲染中) | 稳定函数引用传给子组件 |
useMemo | [value, deps] 元组 | 否 | 同步(渲染中) | 缓存昂贵计算结果 |
useContext | context 当前值 | 是(Provider value 变化时) | 同步(渲染中) | 消费全局状态/主题/用户信息 |
Hook 选择决策树
需要在渲染间保持数据?
├── 变化时需要触发 UI 更新?
│ ├── 状态逻辑简单 → useState
│ └── 状态之间有关联/复杂转换逻辑 → useReducer
└── 变化时不需要更新 UI?
└── useRef
需要执行副作用(异步/DOM/订阅)?
├── 需要在浏览器绘制前同步执行(测量DOM)? → useLayoutEffect
└── 普通副作用(请求/订阅/定时器) → useEffect
需要缓存?
├── 缓存函数引用(传给被 memo 包裹的子组件) → useCallback
└── 缓存计算结果(昂贵计算) → useMemo
需要消费上下文? → useContext
七、记忆口诀
Hook 链表顺序口诀:
“挂载创建按顺序,更新取用靠位置;条件循环断链路,状态错乱难追踪。”
useEffect 三种模式口诀:
“无数组,每次跑;空数组,只一次;有依赖,变才执行,前次清理先行。”
useState vs useRef 选择口诀:
“改变要界面动,用 state 存值;改变不重渲,用 ref 存值。”
useCallback vs useMemo 区分口诀:
“Callback 缓存函,Memo 缓存值;都是依赖数组,Object.is 来决定。”
闭包陷阱口诀:
“旧函数抱旧值,ref 拿最新;函数式更新好,prev 总是新。”
自定义 Hook 设计口诀:
“use 打头命名,单一职责行;元组返两值,对象返多个;层层可组合,逻辑易复用。”
清理函数时机口诀:
“下次 effect 前,先跑清理;卸载也清理;两次各执行,副作用不泄漏。”
React 18 Hooks 源码深度追溯(基于 react@18.2.0)
文件:
packages/react-reconciler/src/ReactFiberHooks.new.js
1. 双 Dispatcher 设计
React 用两个 Dispatcher 区分 mount 和 update:
// ReactFiberHooks.new.js:2685
const HooksDispatcherOnMount: Dispatcher = {
useState: mountState,
useEffect: mountEffect,
useMemo: mountMemo,
useCallback: mountCallback,
useRef: mountRef,
// ... 18个 Hook
};
const HooksDispatcherOnUpdate: Dispatcher = {
useState: updateState,
useEffect: updateEffect,
useMemo: updateMemo,
useCallback: updateCallback,
useRef: updateRef,
// ... 18个 Hook
};
精读:
- 首次渲染走
HooksDispatcherOnMount,创建 hook 链表节点 - 后续更新走
HooksDispatcherOnUpdate,从已有链表节点读取 - 通过全局变量
ReactCurrentDispatcher.current切换,所以你在组件外调 useState 会报错——此时 dispatcher 是ContextOnlyDispatcher
2. mountState 源码精读
// ReactFiberHooks.new.js:1659
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = (initialState: any)(); // 惰性初始化
}
hook.memoizedState = hook.baseState = initialState;
const queue: UpdateQueue<S, BasicStateAction<S>> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
};
hook.queue = queue;
const dispatch: Dispatch<BasicStateAction<S>> = (queue.dispatch =
(dispatchSetState.bind(
null,
currentlyRenderingFiber,
queue,
): any));
return [hook.memoizedState, dispatch];
}
精读要点:
- 惰性初始化优化:
useState(() => expensive())只在 mount 时执行一次,后续更新跳过——源码里if (typeof initialState === 'function')是这个特性的实现。 - dispatchSetState 闭包绑定:
bind(null, currentlyRenderingFiber, queue)把当前 fiber 和 queue 提前绑死。这就是为什么你可以把setCount传给子组件而它依然能更新父组件——闭包里锁死了父 fiber。 - lastRenderedReducer:useState 本质上是
useReducer(basicStateReducer, initial)——basicStateReducer就是(state, action) => typeof action === 'function' ? action(state) : action。这就是函数式 setState 和值式 setState 共用一套机制的原因。
3. mountWorkInProgressHook 链表构建
// ReactFiberHooks.new.js:631
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null, // 链表指针
};
if (workInProgressHook === null) {
// 第一个 Hook:挂到 fiber 上
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// 后续 Hook:链到上一个 Hook 后面
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
精读要点:
- Hook 是单向链表,挂在
fiber.memoizedState上 - 链表顺序敏感:每次渲染都从头遍历,所以
if (cond) useState()会让顺序错位——这就是 Hooks 规则的源码证据 - ESLint 插件
react-hooks/rules-of-hooks就是为了在编译期发现违反顺序的代码
4. dispatchSetState 触发更新
// ReactFiberHooks.new.js:2380
function dispatchSetState<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
): void {
const lane = requestUpdateLane(fiber);
const update: Update<S, A> = {
lane,
action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
if (isRenderPhaseUpdate(fiber)) {
// 渲染过程中的 setState:加入 renderPhaseUpdates
enqueueRenderPhaseUpdate(queue, update);
} else {
const alternate = fiber.alternate;
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
// === 性能优化:渲染前预计算 ===
// 如果当前 fiber 没有其他更新,提前算一次新 state
// 如果新旧 state 用 Object.is 相等,直接 bailout 跳过整次渲染
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
try {
const currentState: S = (queue.lastRenderedState: any);
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// bailout: state 没变,根本不调度渲染
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return;
}
} catch (error) { /* ... */ }
}
}
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
const eventTime = requestEventTime();
scheduleUpdateOnFiber(root, fiber, lane, eventTime);
}
}
}
精读要点:
- eager bailout 优化:
setCount(count)(设回相同值)根本不会触发渲染——源码里is(eagerState, currentState)直接返回 - 这就是为什么你可以放心写
useEffect(() => { setData(x) }, [x])而不怕死循环——只要 x 相同就不渲染
React 18/19 新 Hooks 全面解析
useTransition (React 18)
把非紧急更新标记为"过渡",避免阻塞用户输入:
import { useState, useTransition } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Item[]>([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value); // 紧急:立即更新输入框
startTransition(() => {
// 非紧急:可被打断的搜索结果计算
setResults(filterLargeList(e.target.value));
});
};
return (
<>
<input value={query} onChange={handleSearch} />
{isPending && <Spinner />}
<ResultList items={results} />
</>
);
}
底层原理:startTransition 内的 setState 被分配 TransitionLane 优先级,可被 SyncLane(用户输入)打断。
useDeferredValue (React 18)
延迟传递一个"过时但能用"的值:
function Page() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// query 立即更新,deferredQuery 延迟更新
// 通常配合 React.memo 使用,减少子组件渲染
return <Results query={deferredQuery} />;
}
与 useTransition 的区别:
useTransition:在更新源头(setState 包裹)控制优先级useDeferredValue:在消费端(props 包裹)控制优先级,适合无法控制 setState 的场景(如来自 props 的值)
useId (React 18)
生成稳定的、SSR 安全的唯一 ID:
function FormField({ label }: { label: string }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
为什么不用 Math.random():SSR 时服务端和客户端生成的 ID 不一致,导致 hydration 报错。useId 基于 fiber 的位置生成确定性 ID。
useSyncExternalStore (React 18)
订阅外部 store(Redux、Zustand 都用它实现):
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine, // getSnapshot
() => true // getServerSnapshot (SSR)
);
}
为什么不用 useState + useEffect:在并发模式下,组件可能多次渲染同一份 state,导致"撕裂"(tearing)。useSyncExternalStore 保证所有组件读到一致快照。
use() (React 19)
直接在组件中 await Promise:
import { use, Suspense } from 'react';
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // 抛出 Promise,被 Suspense 捕获
return <div>{user.name}</div>;
}
function App() {
const userPromise = fetchUser(123);
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
关键差异:use() 可以在条件、循环、try-catch 中调用——这是 React Hooks 史上第一次破例。
useOptimistic (React 19)
乐观更新,用于表单和列表:
function TodoList({ todos, addTodo }: Props) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, { ...newTodo, pending: true }]
);
const formAction = async (formData: FormData) => {
const newTodo = { id: crypto.randomUUID(), text: formData.get('text') as string };
addOptimisticTodo(newTodo); // 立即更新 UI
await addTodo(newTodo); // 真实请求
};
return (
<form action={formAction}>
<input name="text" />
{optimisticTodos.map(todo => (
<div key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.text}
</div>
))}
</form>
);
}
useActionState (React 19)
表单状态管理(取代 useFormState):
async function submitForm(prevState: State, formData: FormData) {
try {
const result = await api.submit(formData);
return { success: true, data: result };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
function LoginForm() {
const [state, formAction, isPending] = useActionState(submitForm, { success: false });
return (
<form action={formAction}>
<input name="username" />
<input name="password" type="password" />
<button disabled={isPending}>{isPending ? '登录中...' : '登录'}</button>
{state.error && <p className="error">{state.error}</p>}
</form>
);
}
Hooks 单元测试实战(Jest + React Testing Library)
环境配置
npm i -D @testing-library/react @testing-library/jest-dom @testing-library/user-event \
@testing-library/react-hooks jest jest-environment-jsdom @types/jest \
msw whatwg-fetch
jest.config.ts:
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEach: ['<rootDir>/jest.setup.ts'],
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
'^@/(.*)$': '<rootDir>/src/$1',
},
};
export default config;
测试 1: useCounter(基础状态 Hook)
// useCounter.ts
import { useState, useCallback } from 'react';
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, decrement, reset };
}
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('应该使用默认初始值 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('应该接受自定义初始值', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increment 应该使 count + 1', () => {
const { result } = renderHook(() => useCounter(0));
act(() => { result.current.increment(); });
expect(result.current.count).toBe(1);
});
it('reset 应该回到初始值', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(7);
act(() => { result.current.reset(); });
expect(result.current.count).toBe(5);
});
});
测试 2: useFetch(带 MSW 模拟 API)
// useFetch.ts
import { useState, useEffect } from 'react';
export function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error('Network error');
return r.json();
})
.then(setData)
.catch(e => {
if (e.name !== 'AbortError') setError(e);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// useFetch.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { useFetch } from './useFetch';
const server = setupServer(
rest.get('https://api.test/users/:id', (req, res, ctx) =>
res(ctx.json({ id: req.params.id, name: 'Alice' }))
)
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('useFetch', () => {
it('应该返回成功数据', async () => {
const { result } = renderHook(() => useFetch('https://api.test/users/1'));
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual({ id: '1', name: 'Alice' });
expect(result.current.error).toBeNull();
});
it('应该处理 4xx 错误', async () => {
server.use(
rest.get('https://api.test/users/:id', (req, res, ctx) => res(ctx.status(404)))
);
const { result } = renderHook(() => useFetch('https://api.test/users/999'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error?.message).toBe('Network error');
});
it('卸载时应该中止请求', async () => {
const { unmount } = renderHook(() => useFetch('https://api.test/users/1'));
unmount();
// 不会有 act warning 即说明 abort 成功
});
});
测试 3: useDebounce(带 jest.useFakeTimers)
// useDebounce.test.ts
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('在延迟期间不应该更新', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'a', delay: 500 } }
);
expect(result.current).toBe('a');
rerender({ value: 'ab', delay: 500 });
expect(result.current).toBe('a'); // 还没到延迟
act(() => { jest.advanceTimersByTime(499); });
expect(result.current).toBe('a'); // 还没到延迟
act(() => { jest.advanceTimersByTime(1); });
expect(result.current).toBe('ab'); // 触发更新
});
it('快速连续变化应该只更新最后一次', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'a' } }
);
rerender({ value: 'b' });
act(() => { jest.advanceTimersByTime(100); });
rerender({ value: 'c' });
act(() => { jest.advanceTimersByTime(100); });
rerender({ value: 'd' });
act(() => { jest.advanceTimersByTime(300); });
expect(result.current).toBe('d'); // 只看到最终值
});
});
测试覆盖率目标
| 类型 | 目标覆盖率 |
|---|---|
| 业务自定义 Hook | line ≥ 90%, branch ≥ 85% |
| 工具函数 | line ≥ 95% |
| UI 组件 | line ≥ 70%(重点:分支路径) |
在 package.json 加:
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage",
"test:watch": "jest --watch"
}
八、面试考点精讲
Q1:React Hooks 为什么不能在条件语句或循环中调用?请从底层原理解释。
答:
React 的 Hooks 状态通过 Fiber 节点上的链表(fiber.memoizedState)存储。链表中的每个节点对应一个 Hook 调用,节点之间通过 next 指针串联。
首次渲染(mount)时,React 通过 mountWorkInProgressHook 函数按调用顺序依次创建节点并追加到链表。后续渲染(update)时,通过 updateWorkInProgressHook 按相同顺序遍历链表,取出对应节点来读取状态。
React 没有"名字"来标识每个 Hook(不像 Vue3 的 reactive API 那样可以任意位置调用),它唯一的标识方式就是调用顺序(第1个、第2个、第3个……)。
如果在条件语句中调用 Hook:
- 条件为
true时,链表是[A] → [B] → [C] - 条件变为
false时,B被跳过,但链表还是原来的[A] → [B] → [C] - update 阶段顺序取节点:第1个 Hook 取到
A✓,第2个 Hook (本应是C)却取到了B✗ - 状态错乱,React 无法正确恢复组件状态
ESLint 的 eslint-plugin-react-hooks 通过静态代码分析在编译期发现这类违规,提前报错。
Q2:useEffect 和 useLayoutEffect 的区别是什么?各自适用什么场景?
答:
执行时机不同(这是本质区别):
React 渲染流程:
render(调用组件函数)
↓
commit(DOM 操作)
├── beforeMutation
├── mutation(真正的 DOM 更新)
└── layout ← useLayoutEffect 同步执行(此时 DOM 已更新但浏览器还未绘制)
↓
浏览器绘制(paint)
↓
useEffect 异步执行(通过 MessageChannel 调度)
| 特性 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | 浏览器绘制之后(异步) | 浏览器绘制之前(同步) |
| 是否阻塞绘制 | 否 | 是 |
| 等价类生命周期 | componentDidMount + componentDidUpdate(异步版) | componentDidMount + componentDidUpdate(同步版) |
适用场景:
useEffect:绝大多数副作用(网络请求、事件订阅、定时器)。它是非阻塞的,不影响用户看到界面的时间。useLayoutEffect:需要在浏览器绘制前同步读取或修改 DOM 的场景,如:Tooltip/Popover 定位计算(避免位置闪烁)、测量元素尺寸后立即更新状态、第三方动画库的 DOM 初始化。
注意:useLayoutEffect 在 SSR(服务端渲染)时会产生警告,因为服务端没有 DOM,无法执行布局效果。SSR 场景应用 useEffect 或动态导入。
Q3:解释 useCallback 和 useMemo 的内部实现,并说明什么时候应该使用它们?
答:
内部实现:
两者都将 [value/fn, deps] 存储在 Hook 节点的 memoizedState 中。每次渲染时用 Object.is 逐项比较新旧 deps,若全部相同则返回缓存值,否则重新计算/创建。
// useCallback 本质
const fn = useCallback(() => doSomething(a, b), [a, b])
// 等价于(返回函数本身,不执行)
const fn = useMemo(() => () => doSomething(a, b), [a, b])
// useMemo 本质
const value = useMemo(() => expensiveCalc(a, b), [a, b])
// 存储的是 expensiveCalc(a,b) 的执行结果
何时使用:
useCallback 有价值的两个条件同时满足:
- 函数被传给了
React.memo包裹的子组件(否则子组件无论如何都会重渲染,稳定函数引用无意义) - 函数的创建成本相对于比较依赖数组的成本有意义(通常情况下函数创建很廉价)
useMemo 有价值的条件:
- 计算本身确实昂贵(大数组的过滤/排序、复杂数学运算),经 profiler 确认
- 或者用于稳定对象/数组引用,防止子组件重渲染或 useEffect 无限循环
反模式(不要这样做):
// 不需要 useCallback:子组件没有 React.memo
const handler = useCallback(() => setState(true), [])
// 不需要 useMemo:计算非常简单
const doubled = useMemo(() => count * 2, [count]) // 直接 count * 2 就好
// 原则:先写清晰代码,用 React DevTools Profiler 发现问题后再优化
Q4:什么是闭包陷阱?请给出三种解决方案并说明适用场景。
答:
闭包陷阱是指:函数组件每次渲染都创建一个新的函数作用域,作用域内的变量(如 state、props)是当次渲染的快照。如果一个 effect 或 callback 在渲染后延迟执行(如 setTimeout、事件处理),它捕获的是创建时的变量值,而非执行时的最新值。
解决方案一:函数式更新(适用于:只需要最新的 state 值做更新操作)
// 不需要读取最新 state,只需要基于最新 state 更新
setTimeout(() => {
setCount(prev => prev + 1) // prev 由 React 提供,永远是最新值
}, 1000)
优点:最简洁;缺点:只能用于状态更新,无法"读取"最新值
解决方案二:useRef 保存最新值(适用于:需要读取最新值的场景)
const countRef = useRef(count)
useEffect(() => { countRef.current = count }) // 每次渲染同步更新
setTimeout(() => {
console.log(countRef.current) // 始终读到最新值
}, 1000)
优点:可以在任何地方读取最新值;缺点:需要维护 ref 与 state 的同步
解决方案三:将依赖放入 useEffect 依赖数组(适用于:副作用应该在依赖变化时重新执行)
// 不要逃避依赖,让 effect 重新执行来"刷新"闭包
useEffect(() => {
const timer = setTimeout(() => {
sendAnalytics(count) // count 变化时 effect 重新执行,closure 自然刷新
}, 1000)
return () => clearTimeout(timer)
}, [count]) // 正确声明依赖
优点:最符合 React 心智模型;缺点:可能导致 effect 比预期更频繁执行
Q5:如何设计一个生产级的自定义 Hook?以 useRequest 为例说明设计思路和关键细节。
答:
生产级自定义 Hook 需要考虑以下维度:
1. 状态完备性:不仅有 data,还要有 loading、error,以及派生状态(isSuccess、isIdle 等),方便使用方处理各种 UI 状态。
2. 竞态条件处理:快速切换依赖时(如 userId),旧请求可能后于新请求返回,用 cancelled 标志或 AbortController 防止状态错乱。
3. 引用稳定性:内部函数用 useCallback 包裹,避免作为 useEffect 依赖时引发无限循环。
4. 手动触发与自动触发:提供 execute 方法供手动调用,同时支持 immediate 参数控制是否自动执行。
5. 泛型支持:用 TypeScript 泛型使返回的 data 类型安全。
function useRequest<T>(
requestFn: () => Promise<T>, // 请求函数
deps: DependencyList = [], // 请求函数的依赖
options: {
immediate?: boolean // 是否自动执行,默认 true
onSuccess?: (data: T) => void
onError?: (err: Error) => void
} = {}
) {
const { immediate = true, onSuccess, onError } = options
const [state, dispatch] = useReducer(fetchReducer<T>, { status: 'idle' })
// 用 useCallback 稳定引用,deps 是请求函数自己的依赖
const execute = useCallback(async () => {
dispatch({ type: 'FETCH_START' })
try {
const data = await requestFn()
dispatch({ type: 'FETCH_SUCCESS', payload: data })
onSuccess?.(data)
return data
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
dispatch({ type: 'FETCH_ERROR', error: error.message })
onError?.(error)
throw error
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps) // 依赖调用者传入的 deps,而不是 requestFn(因为 requestFn 每次渲染都是新引用)
useEffect(() => {
if (immediate) execute()
}, [execute, immediate])
return {
...state,
execute,
isIdle: state.status === 'idle',
isPending: state.status === 'loading',
isSuccess: state.status === 'success',
isError: state.status === 'error',
data: state.status === 'success' ? state.data : null,
error: state.status === 'error' ? state.error : null,
}
}
关键设计决策说明:
deps而非requestFn作为useCallback依赖:因为函数每次渲染都是新引用,如果直接依赖requestFn,effect 会无限执行。调用方通过deps明确指定哪些变量变化才重新请求。useReducer而非多个useState:loading/data/error 三个状态高度关联,用 reducer 保证状态转换的原子性(不会出现loading: false同时data: null且error: null的中间状态)。- 回调(onSuccess/onError)用可选链调用:调用方可能不需要这些回调,不应强制传入。
九、交互式 HTML 演示
将以下代码保存为 hooks-demo.html,在浏览器中打开即可运行:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React Hooks 底层原理可视化</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', 'PingFang SC', sans-serif;
background: #0f1117;
color: #e2e8f0;
padding: 24px;
min-height: 100vh;
}
h1 {
font-size: 24px;
color: #61dafb;
margin-bottom: 8px;
text-align: center;
}
.subtitle {
text-align: center;
color: #718096;
margin-bottom: 32px;
font-size: 14px;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.tab-btn {
padding: 8px 16px;
border: 1px solid #4a5568;
background: #1a202c;
color: #a0aec0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.tab-btn.active {
background: #61dafb;
color: #0f1117;
border-color: #61dafb;
font-weight: 600;
}
.tab-btn:hover:not(.active) {
border-color: #61dafb;
color: #61dafb;
}
.panel { display: none; }
.panel.active { display: block; }
/* 链表可视化 */
.fiber-container {
background: #1a202c;
border: 1px solid #2d3748;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.fiber-title {
font-size: 13px;
color: #718096;
margin-bottom: 16px;
font-family: monospace;
}
.fiber-node {
display: inline-block;
background: #2d3748;
border: 2px solid #4a5568;
border-radius: 8px;
padding: 12px 16px;
margin: 4px;
font-family: monospace;
font-size: 12px;
min-width: 160px;
vertical-align: top;
transition: all 0.3s;
}
.fiber-node.hook-useState { border-color: #48bb78; }
.fiber-node.hook-useEffect { border-color: #ed8936; }
.fiber-node.hook-useRef { border-color: #9f7aea; }
.fiber-node.hook-useCallback { border-color: #4299e1; }
.fiber-node.hook-useMemo { border-color: #f6ad55; }
.fiber-node.highlighted { box-shadow: 0 0 0 3px rgba(97, 218, 251, 0.5); transform: scale(1.02); }
.fiber-node .node-title {
font-weight: 700;
margin-bottom: 8px;
font-size: 11px;
}
.fiber-node.hook-useState .node-title { color: #48bb78; }
.fiber-node.hook-useEffect .node-title { color: #ed8936; }
.fiber-node.hook-useRef .node-title { color: #9f7aea; }
.fiber-node.hook-useCallback .node-title { color: #4299e1; }
.fiber-node.hook-useMemo .node-title { color: #f6ad55; }
.fiber-node .field { color: #a0aec0; margin: 2px 0; }
.fiber-node .field .key { color: #68d391; }
.fiber-node .field .val { color: #fbd38d; }
.arrow {
display: inline-block;
vertical-align: middle;
color: #718096;
font-size: 20px;
margin: 0 4px;
padding-top: 20px;
}
.linked-list {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 4px;
}
/* 控制面板 */
.controls {
display: flex;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
align-items: center;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary { background: #61dafb; color: #0f1117; }
.btn-primary:hover { background: #4dc9e8; }
.btn-danger { background: #fc8181; color: #0f1117; }
.btn-danger:hover { background: #f56565; }
.btn-warning { background: #f6ad55; color: #0f1117; }
.btn-warning:hover { background: #ed8936; }
.btn-success { background: #68d391; color: #0f1117; }
.btn-success:hover { background: #48bb78; }
/* 状态显示 */
.state-display {
background: #1a202c;
border: 1px solid #2d3748;
border-radius: 8px;
padding: 16px;
font-family: monospace;
font-size: 13px;
line-height: 1.8;
}
.state-display .label { color: #718096; }
.state-display .value { color: #fbd38d; font-weight: 700; }
.state-display .good { color: #68d391; }
.state-display .bad { color: #fc8181; }
/* 日志 */
.log-container {
background: #0d1117;
border: 1px solid #2d3748;
border-radius: 8px;
padding: 16px;
max-height: 200px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
}
.log-entry {
padding: 3px 0;
border-bottom: 1px solid #1a202c;
display: flex;
gap: 8px;
}
.log-time { color: #4a5568; min-width: 60px; }
.log-mount { color: #68d391; }
.log-update { color: #61dafb; }
.log-cleanup { color: #fc8181; }
.log-render { color: #f6ad55; }
/* 效果演示区 */
.demo-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.demo-card {
background: #1a202c;
border: 1px solid #2d3748;
border-radius: 10px;
padding: 20px;
}
.demo-card h3 {
font-size: 14px;
color: #61dafb;
margin-bottom: 12px;
font-family: monospace;
}
.counter-display {
font-size: 48px;
font-weight: 900;
text-align: center;
color: #61dafb;
margin: 16px 0;
font-variant-numeric: tabular-nums;
}
.render-count {
text-align: center;
color: #718096;
font-size: 12px;
margin-bottom: 12px;
}
.hook-order-demo {
font-family: monospace;
font-size: 12px;
}
.hook-order-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
margin: 4px 0;
border-radius: 4px;
transition: all 0.3s;
}
.hook-order-item.called { background: #1c3a2a; border-left: 3px solid #48bb78; }
.hook-order-item.skipped { background: #3a1c1c; border-left: 3px solid #fc8181; }
.hook-order-item.normal { background: #1a202c; border-left: 3px solid #4a5568; }
.hook-index {
color: #718096;
min-width: 24px;
font-size: 11px;
}
/* 闭包陷阱演示 */
.closure-demo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.closure-box {
background: #1a202c;
border: 1px solid #2d3748;
border-radius: 8px;
padding: 16px;
}
.closure-box h4 {
font-size: 13px;
margin-bottom: 12px;
font-family: monospace;
}
.closure-box.bad h4 { color: #fc8181; }
.closure-box.good h4 { color: #68d391; }
.snapshot-value {
font-size: 36px;
font-weight: 900;
text-align: center;
margin: 12px 0;
font-variant-numeric: tabular-nums;
}
.snapshot-value.stale { color: #fc8181; }
.snapshot-value.fresh { color: #68d391; }
.snapshot-label {
text-align: center;
font-size: 11px;
color: #718096;
margin-bottom: 12px;
font-family: monospace;
}
.alert-box {
background: #2d1f00;
border: 1px solid #ed8936;
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
color: #f6ad55;
margin-top: 8px;
min-height: 40px;
}
/* useEffect 执行时序 */
.timeline {
position: relative;
padding-left: 24px;
}
.timeline::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 2px;
background: #2d3748;
}
.timeline-item {
position: relative;
margin-bottom: 12px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
opacity: 0.3;
transition: all 0.4s;
}
.timeline-item.active {
opacity: 1;
}
.timeline-item::before {
content: '';
position: absolute;
left: -20px;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: #4a5568;
}
.timeline-item.active::before { background: #61dafb; }
.timeline-item.render { background: #1c2d3a; }
.timeline-item.commit { background: #1c3a2a; }
.timeline-item.paint { background: #2d1c3a; }
.timeline-item.effect { background: #3a2d1c; }
.timeline-item.cleanup { background: #3a1c1c; }
.timeline-label {
font-weight: 700;
font-family: monospace;
}
.timeline-item.render .timeline-label { color: #61dafb; }
.timeline-item.commit .timeline-label { color: #68d391; }
.timeline-item.paint .timeline-label { color: #9f7aea; }
.timeline-item.effect .timeline-label { color: #ed8936; }
.timeline-item.cleanup .timeline-label { color: #fc8181; }
.section-title {
font-size: 15px;
font-weight: 700;
color: #a0aec0;
margin-bottom: 12px;
margin-top: 20px;
font-family: monospace;
}
@media (max-width: 600px) {
.demo-grid, .closure-demo { grid-template-columns: 1fr; }
.linked-list { flex-direction: column; }
}
</style>
</head>
<body>
<h1>⚛ React Hooks 底层原理可视化</h1>
<p class="subtitle">交互式演示:Hook 链表结构 · 执行时序 · 闭包陷阱</p>
<div class="tabs">
<button class="tab-btn active" onclick="showTab('linked-list')">Hook 链表结构</button>
<button class="tab-btn" onclick="showTab('counter')">useState 重渲染</button>
<button class="tab-btn" onclick="showTab('effect-timeline')">useEffect 执行时序</button>
<button class="tab-btn" onclick="showTab('closure-trap')">闭包陷阱对比</button>
<button class="tab-btn" onclick="showTab('hook-order')">Hook 顺序破坏</button>
</div>
<!-- Tab 1: Hook 链表结构 -->
<div id="tab-linked-list" class="panel active">
<div class="fiber-container">
<div class="fiber-title">📌 fiber.memoizedState → Hook 链表(点击节点查看详情)</div>
<div class="linked-list" id="hook-chain">
<!-- 动态渲染 -->
</div>
</div>
<div class="state-display" id="hook-detail">
<span class="label">← 点击上方任意 Hook 节点查看其内部字段详情</span>
</div>
</div>
<!-- Tab 2: useState 重渲染计数 -->
<div id="tab-counter" class="panel">
<div class="demo-grid">
<div class="demo-card">
<h3>useState Counter</h3>
<div class="render-count" id="render-count">渲染次数: 1</div>
<div class="counter-display" id="counter-val">0</div>
<div class="controls" style="justify-content:center">
<button class="btn btn-primary" onclick="counterAction('inc')">+1</button>
<button class="btn btn-danger" onclick="counterAction('dec')">-1</button>
<button class="btn btn-warning" onclick="counterAction('reset')">重置</button>
<button class="btn btn-success" onclick="counterAction('same')">设置相同值(不重渲)</button>
</div>
</div>
<div class="demo-card">
<h3>Hook 节点状态(实时更新)</h3>
<div class="state-display" id="counter-state">
<div><span class="label">hook[0].memoizedState: </span><span class="value" id="cs-count">0</span></div>
<div><span class="label">hook[0].queue.pending: </span><span class="value" id="cs-pending">null</span></div>
<div><span class="label">fiber.memoizedState === hook[0]: </span><span class="good">true</span></div>
<div style="margin-top:8px"><span class="label">最后一次操作: </span><span class="value" id="cs-action">-</span></div>
<div><span class="label">是否触发重渲染: </span><span id="cs-rerender">-</span></div>
</div>
</div>
</div>
<div class="section-title">执行日志</div>
<div class="log-container" id="counter-log"></div>
</div>
<!-- Tab 3: useEffect 执行时序 -->
<div id="tab-effect-timeline" class="panel">
<div class="demo-grid">
<div class="demo-card">
<h3>模拟渲染流程</h3>
<div class="controls" style="flex-direction:column;align-items:flex-start">
<button class="btn btn-primary" onclick="runTimeline('mount')">模拟首次挂载</button>
<button class="btn btn-warning" onclick="runTimeline('update')">模拟状态更新</button>
<button class="btn btn-danger" onclick="runTimeline('unmount')">模拟组件卸载</button>
</div>
<div style="margin-top:16px;font-size:12px;color:#718096">
<p>选择一种场景,观察右侧时序图中各阶段的激活顺序。</p>
<p style="margin-top:8px">useLayoutEffect 在 paint 之前同步执行,</p>
<p>useEffect 在 paint 之后异步执行。</p>
</div>
</div>
<div class="demo-card">
<h3>执行时序图</h3>
<div class="timeline" id="timeline-viz">
<div class="timeline-item render" data-id="render">
<div class="timeline-label">render()</div>
<div style="font-size:11px;color:#718096;margin-top:4px">调用组件函数,计算新的 JSX</div>
</div>
<div class="timeline-item commit" data-id="commit">
<div class="timeline-label">commit - mutation</div>
<div style="font-size:11px;color:#718096;margin-top:4px">将变更应用到真实 DOM</div>
</div>
<div class="timeline-item commit" data-id="layout">
<div class="timeline-label">useLayoutEffect ⚡ (同步)</div>
<div style="font-size:11px;color:#718096;margin-top:4px">DOM 已更新,浏览器尚未绘制</div>
</div>
<div class="timeline-item paint" data-id="paint">
<div class="timeline-label">浏览器绘制 (paint)</div>
<div style="font-size:11px;color:#718096;margin-top:4px">用户看到更新后的界面</div>
</div>
<div class="timeline-item effect" data-id="effect">
<div class="timeline-label">useEffect (异步)</div>
<div style="font-size:11px;color:#718096;margin-top:4px">MessageChannel 调度,不阻塞渲染</div>
</div>
<div class="timeline-item cleanup" data-id="cleanup" style="display:none">
<div class="timeline-label">cleanup 函数执行</div>
<div style="font-size:11px;color:#718096;margin-top:4px">取消订阅、清除定时器</div>
</div>
</div>
</div>
</div>
<div class="section-title">执行日志</div>
<div class="log-container" id="effect-log"></div>
</div>
<!-- Tab 4: 闭包陷阱对比 -->
<div id="tab-closure-trap" class="panel">
<div class="controls">
<button class="btn btn-primary" onclick="closureInc()">+1 计数</button>
<button class="btn btn-warning" onclick="triggerStaleAlert()">【有陷阱】3秒后显示count</button>
<button class="btn btn-success" onclick="triggerFreshAlert()">【已修复】3秒后显示count</button>
</div>
<div class="closure-demo">
<div class="closure-box bad">
<h4>❌ 过期闭包(Stale Closure)</h4>
<div class="snapshot-label">捕获的旧值 (setTimeout 创建时)</div>
<div class="snapshot-value stale" id="stale-val">0</div>
<div style="font-size:11px;color:#718096;font-family:monospace;margin-bottom:8px">
const [count] = useState(0)<br>
setTimeout(() => alert(count), 3000)
</div>
<div class="alert-box" id="stale-msg">点击"有陷阱"按钮,然后快速点击多次"+1",观察3秒后弹出的值</div>
</div>
<div class="closure-box good">
<h4>✅ useRef 修复(最新值)</h4>
<div class="snapshot-label">ref.current(始终最新)</div>
<div class="snapshot-value fresh" id="fresh-val">0</div>
<div style="font-size:11px;color:#718096;font-family:monospace;margin-bottom:8px">
const countRef = useRef(count)<br>
useEffect(() => { countRef.current = count })<br>
setTimeout(() => alert(countRef.current), 3000)
</div>
<div class="alert-box" id="fresh-msg">点击"已修复"按钮,然后快速点击多次"+1",观察3秒后弹出的值是最新的</div>
</div>
</div>
<div style="margin-top:16px" class="state-display">
<div><span class="label">当前 count: </span><span class="value" id="closure-count">0</span></div>
<div style="margin-top:4px;font-size:12px;color:#718096">
原理:setTimeout 的回调在创建时已经把 count 的值"拍了快照"存入闭包;
而 countRef 是同一个对象引用,读取 .current 始终能拿到最新值。
</div>
</div>
</div>
<!-- Tab 5: Hook 顺序破坏 -->
<div id="tab-hook-order" class="panel">
<div class="demo-grid">
<div class="demo-card">
<h3>控制 Hook 调用顺序</h3>
<div style="font-size:12px;color:#718096;margin-bottom:16px;font-family:monospace">
模拟在条件语句中调用 Hook 的危险行为<br>
<span style="color:#fc8181">切换"跳过Hook2",观察 Hook3 状态错乱</span>
</div>
<div class="controls" style="flex-direction:column;align-items:flex-start">
<label style="font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px">
<input type="checkbox" id="skip-hook2" onchange="updateHookOrder()" style="width:16px;height:16px">
跳过 Hook 2(模拟 if 条件为 false)
</label>
<button class="btn btn-primary" onclick="updateHookOrder()" style="margin-top:8px">模拟重新渲染</button>
</div>
</div>
<div class="demo-card">
<h3>Hook 链表匹配情况</h3>
<div class="hook-order-demo" id="hook-order-viz">
<!-- 动态渲染 -->
</div>
</div>
</div>
<div class="section-title">状态对比(错乱分析)</div>
<div class="state-display" id="hook-order-state">
<span class="label">点击上方"模拟重新渲染"查看 Hook 匹配结果</span>
</div>
</div>
<script>
// ===== 全局状态 =====
let renderCount = 1
let counterValue = 0
let closureCount = 0
let staleSnapshot = 0
let timelineRunning = false
// ===== Tab 切换 =====
function showTab(name) {
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'))
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'))
document.getElementById('tab-' + name).classList.add('active')
event.target.classList.add('active')
}
// ===== Tab 1: Hook 链表可视化 =====
const hookChainData = [
{
type: 'useState',
index: 0,
fields: {
memoizedState: '0',
baseState: '0',
'queue.dispatch': 'setCount()',
'queue.pending': 'null',
next: '→ Hook[1]'
},
detail: `// Hook[0]: useState(0)\n// fiber.memoizedState 指向此节点\nhook = {\n memoizedState: 0, // 当前 count 值\n baseState: 0, // 基础状态\n queue: {\n dispatch: setCount, // 你拿到的 setter\n pending: null, // 待处理的更新队列\n lastRenderedReducer: basicStateReducer,\n lastRenderedState: 0\n },\n next: Hook[1] // 指向下一个 Hook\n}`
},
{
type: 'useState',
index: 1,
fields: {
memoizedState: '"Alice"',
baseState: '"Alice"',
'queue.dispatch': 'setName()',
'queue.pending': 'null',
next: '→ Hook[2]'
},
detail: `// Hook[1]: useState('Alice')\nhook = {\n memoizedState: "Alice", // 当前 name 值\n baseState: "Alice",\n queue: {\n dispatch: setName,\n pending: null,\n },\n next: Hook[2]\n}`
},
{
type: 'useRef',
index: 2,
fields: {
memoizedState: '{ current: null }',
baseState: 'null',
queue: 'null',
next: '→ Hook[3]'
},
detail: `// Hook[2]: useRef(null)\n// useRef 的 memoizedState 就是那个对象本身\nhook = {\n memoizedState: { current: null }, // 始终是同一个对象引用\n baseState: null,\n queue: null, // useRef 没有更新队列\n next: Hook[3]\n}\n// 修改 ref.current 不会触发重渲染\n// 因为 React 只检查 hook.memoizedState 的引用\n// { current: null } 这个对象引用没有变`
},
{
type: 'useEffect',
index: 3,
fields: {
tag: 'HookPassive|HasEffect',
create: '() => { document.title... }',
destroy: 'undefined / () => {...}',
deps: '["Alice", 0]',
next: 'null (链表尾部)'
},
detail: `// Hook[3]: useEffect(() => {...}, [name, count])\n// memoizedState 是一个 Effect 对象,不是值\nhook.memoizedState = {\n tag: HookPassive | HookHasEffect,\n create: () => { // 用户传入的 effect 函数\n document.title = ...\n return () => { ... } // 清理函数\n },\n destroy: undefined, // 首次为 undefined,执行后变为清理函数\n deps: ["Alice", 0], // 依赖数组快照\n next: null // 链表尾部\n}`
}
]
function renderHookChain(highlightIndex = -1) {
const container = document.getElementById('hook-chain')
container.innerHTML = ''
hookChainData.forEach((hook, i) => {
if (i > 0) {
const arrow = document.createElement('span')
arrow.className = 'arrow'
arrow.textContent = '→'
container.appendChild(arrow)
}
const node = document.createElement('div')
node.className = `fiber-node hook-${hook.type}${i === highlightIndex ? ' highlighted' : ''}`
node.onclick = () => showHookDetail(i)
let html = `<div class="node-title">Hook[${hook.index}] ${hook.type}</div>`
for (const [key, val] of Object.entries(hook.fields)) {
html += `<div class="field"><span class="key">${key}</span>: <span class="val">${val}</span></div>`
}
node.innerHTML = html
container.appendChild(node)
})
}
function showHookDetail(index) {
renderHookChain(index)
document.getElementById('hook-detail').innerHTML =
`<pre style="white-space:pre-wrap;color:#a0aec0;font-size:12px">${hookChainData[index].detail}</pre>`
}
renderHookChain()
// ===== Tab 2: Counter =====
function counterLog(msg, type = 'render') {
const log = document.getElementById('counter-log')
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
log.innerHTML = `<div class="log-entry"><span class="log-time">${time}</span><span class="log-${type}">${msg}</span></div>` + log.innerHTML
}
function updateCounterUI(action, didRerender) {
document.getElementById('counter-val').textContent = counterValue
document.getElementById('cs-count').textContent = counterValue
document.getElementById('cs-pending').textContent = 'null (已消费)'
document.getElementById('cs-action').textContent = action
document.getElementById('cs-rerender').innerHTML = didRerender
? '<span class="good">是 ✓(值改变,触发重渲染)</span>'
: '<span class="bad">否 ✗(Object.is 相等,跳过渲染)</span>'
if (didRerender) {
renderCount++
document.getElementById('render-count').textContent = `渲染次数: ${renderCount}`
const display = document.getElementById('counter-val')
display.style.color = '#fbd38d'
setTimeout(() => { display.style.color = '#61dafb' }, 300)
}
}
function counterAction(action) {
const oldVal = counterValue
if (action === 'inc') {
counterValue++
updateCounterUI('setCount(prev => prev + 1)', true)
counterLog(`dispatch: count ${oldVal} → ${counterValue},触发重渲染`, 'update')
} else if (action === 'dec') {
counterValue--
updateCounterUI('setCount(prev => prev - 1)', true)
counterLog(`dispatch: count ${oldVal} → ${counterValue},触发重渲染`, 'update')
} else if (action === 'reset') {
counterValue = 0
const didRerender = oldVal !== 0
updateCounterUI('setCount(0)', didRerender)
if (didRerender) counterLog(`dispatch: count ${oldVal} → 0,触发重渲染`, 'update')
else counterLog(`dispatch: setCount(0),Object.is(0, 0)=true,跳过渲染`, 'render')
} else if (action === 'same') {
updateCounterUI(`setCount(${counterValue}) [相同值]`, false)
counterLog(`dispatch: setCount(${counterValue}),Object.is 相等,React 跳过渲染 ✓`, 'render')
}
}
counterLog('组件首次挂载,memoizedState 链表初始化', 'mount')
// ===== Tab 3: useEffect 时序 =====
function effectLog(msg, type) {
const log = document.getElementById('effect-log')
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
log.innerHTML = `<div class="log-entry"><span class="log-time">${time}</span><span class="log-${type}">${msg}</span></div>` + log.innerHTML
}
function resetTimeline() {
document.querySelectorAll('.timeline-item').forEach(el => el.classList.remove('active'))
}
function activateStep(id, delay) {
return new Promise(resolve => {
setTimeout(() => {
const el = document.querySelector(`[data-id="${id}"]`)
if (el) {
el.style.display = ''
el.classList.add('active')
}
resolve()
}, delay)
})
}
async function runTimeline(scenario) {
if (timelineRunning) return
timelineRunning = true
resetTimeline()
const cleanupEl = document.querySelector('[data-id="cleanup"]')
if (scenario === 'mount') {
effectLog('=== 首次挂载开始 ===', 'render')
cleanupEl.style.display = 'none'
await activateStep('render', 200); effectLog('render(): 调用组件函数,收集 Effect 对象', 'render')
await activateStep('commit', 800); effectLog('commit mutation: 执行 DOM 更新操作', 'mount')
await activateStep('layout', 1400); effectLog('useLayoutEffect: 同步执行(DOM已更新,未绘制)', 'mount')
await activateStep('paint', 2000); effectLog('浏览器绘制:用户看到页面更新', 'render')
await activateStep('effect', 2800); effectLog('useEffect: 异步执行,create() 函数被调用', 'mount')
effectLog('=== 首次挂载完成 ===', 'mount')
} else if (scenario === 'update') {
effectLog('=== 状态更新开始 ===', 'render')
cleanupEl.style.display = ''
await activateStep('render', 200); effectLog('render(): 依赖变化,重新调用组件函数', 'render')
await activateStep('commit', 800); effectLog('commit mutation: 对比并更新变化的 DOM', 'update')
await activateStep('layout', 1400); effectLog('useLayoutEffect cleanup: 先执行上次的 cleanup', 'cleanup')
await activateStep('layout', 1500); effectLog('useLayoutEffect create: 再执行本次 effect', 'update')
await activateStep('paint', 2000); effectLog('浏览器绘制:用户看到更新', 'render')
await activateStep('cleanup', 2500); effectLog('useEffect cleanup: 先执行上次 effect 的 destroy()', 'cleanup')
await activateStep('effect', 3000); effectLog('useEffect create: 执行本次 effect 函数', 'update')
effectLog('=== 更新完成 ===', 'update')
} else if (scenario === 'unmount') {
effectLog('=== 组件卸载开始 ===', 'cleanup')
cleanupEl.style.display = ''
await activateStep('render', 200); effectLog('父组件 render(),本组件不在新树中', 'render')
await activateStep('commit', 800); effectLog('commit: 从 DOM 中移除组件节点', 'cleanup')
await activateStep('cleanup', 1400); effectLog('useLayoutEffect cleanup: 同步执行清理', 'cleanup')
await activateStep('paint', 2000); effectLog('浏览器绘制:组件已从界面消失', 'render')
await activateStep('effect', 2600); effectLog('useEffect cleanup: 异步执行,取消订阅/清除定时器', 'cleanup')
effectLog('=== 卸载完成,所有副作用已清理 ===', 'cleanup')
}
timelineRunning = false
}
// ===== Tab 4: 闭包陷阱 =====
function closureInc() {
closureCount++
document.getElementById('closure-count').textContent = closureCount
document.getElementById('fresh-val').textContent = closureCount
// stale-val 和 fresh-val 初始相同,只有 setTimeout 触发时才体现差异
}
function triggerStaleAlert() {
const capturedCount = closureCount // 立即捕获当前值(闭包)
staleSnapshot = capturedCount
document.getElementById('stale-val').textContent = capturedCount
document.getElementById('stale-msg').innerHTML =
`⏳ 已捕获快照: <strong>${capturedCount}</strong>,3秒后显示此值(即使你继续点+1也不变)`
setTimeout(() => {
// 3秒后显示的是 capturedCount,不是最新的 closureCount
document.getElementById('stale-msg').innerHTML =
`⚠️ 显示的是旧值: <strong style="color:#fc8181">${capturedCount}</strong>(当前实际值: ${closureCount})`
document.getElementById('stale-val').textContent = capturedCount
}, 3000)
}
function triggerFreshAlert() {
// 用 ref 模拟:通过闭包引用同一个对象,.current 始终最新
const countRef = { current: closureCount }
// 模拟 useEffect(() => { countRef.current = count })
const updater = setInterval(() => {
countRef.current = closureCount // 每次都更新 ref
}, 50)
document.getElementById('fresh-msg').innerHTML =
`⏳ ref 已创建,继续点+1,3秒后读取 ref.current...`
setTimeout(() => {
clearInterval(updater)
document.getElementById('fresh-msg').innerHTML =
`✅ ref.current 读到最新值: <strong style="color:#68d391">${countRef.current}</strong>(与当前值 ${closureCount} 一致)`
document.getElementById('fresh-val').textContent = countRef.current
}, 3000)
}
// ===== Tab 5: Hook 顺序 =====
const mountHooks = [
{ name: 'useState(0)', state: 'count = 0' },
{ name: 'useState("Alice")', state: 'name = "Alice"' },
{ name: 'useRef(null)', state: 'ref = { current: null }' },
{ name: 'useEffect(...)', state: 'effect = { tag, create, deps }' },
]
let firstRenderDone = false
function updateHookOrder() {
const skipHook2 = document.getElementById('skip-hook2').checked
const viz = document.getElementById('hook-order-viz')
const stateEl = document.getElementById('hook-order-state')
if (!firstRenderDone) {
// 首次渲染
viz.innerHTML = mountHooks.map((h, i) =>
`<div class="hook-order-item called">
<span class="hook-index">[${i}]</span>
<span>${h.name} → <strong style="color:#fbd38d">${h.state}</strong></span>
</div>`
).join('') + `<div style="margin-top:8px;font-size:11px;color:#718096">↑ 首次渲染:链表按此顺序创建</div>`
stateEl.innerHTML = `<span class="good">首次渲染完成,链表: [hook0(count)] → [hook1(name)] → [hook2(ref)] → [hook3(effect)]</span>`
firstRenderDone = true
return
}
// 更新渲染(模拟条件跳过)
if (skipHook2) {
// Hook2(useState("Alice"))被跳过
const updateSequence = [
{ callIndex: 0, chainIndex: 0, name: 'useState(0)', called: true, expected: mountHooks[0].state, got: mountHooks[0].state, ok: true },
{ callIndex: null, chainIndex: 1, name: 'useState("Alice")', called: false, expected: mountHooks[1].state, got: '被跳过', ok: false },
{ callIndex: 1, chainIndex: 1, name: 'useRef(null)', called: true, expected: mountHooks[2].state, got: '从链表[1]取值 → ' + mountHooks[1].state + ' ⚠', ok: false },
{ callIndex: 2, chainIndex: 2, name: 'useEffect(...)', called: true, expected: mountHooks[3].state, got: '从链表[2]取值 → ' + mountHooks[2].state + ' ⚠', ok: false },
]
viz.innerHTML = updateSequence.map(item => {
if (!item.called) {
return `<div class="hook-order-item skipped">
<span class="hook-index" style="color:#fc8181">skip</span>
<span style="color:#fc8181">${item.name} 被 if 条件跳过</span>
</div>`
}
const color = item.ok ? '#68d391' : '#fc8181'
return `<div class="hook-order-item ${item.ok ? 'called' : 'skipped'}">
<span class="hook-index">[${item.callIndex}]</span>
<span>${item.name}<br>
<small style="color:#718096">取链表节点[${item.chainIndex}] → </small>
<strong style="color:${color}">${item.got}</strong>
</span>
</div>`
}).join('')
stateEl.innerHTML = `
<div class="bad">🚨 Hook 顺序错乱!</div>
<div style="margin-top:8px">
<span class="label">useRef 期望取到: </span><span class="good">${mountHooks[2].state}</span>
<span class="label">,实际取到: </span><span class="bad">${mountHooks[1].state}(name 的链表槽!)</span>
</div>
<div>
<span class="label">useEffect 期望取到: </span><span class="good">${mountHooks[3].state}</span>
<span class="label">,实际取到: </span><span class="bad">${mountHooks[2].state}(ref 的链表槽!)</span>
</div>
<div style="margin-top:8px;color:#f6ad55;font-size:12px">
这就是为什么 React 强制要求 Hook 调用顺序必须固定——链表的顺序是唯一的标识。
</div>
`
} else {
viz.innerHTML = mountHooks.map((h, i) =>
`<div class="hook-order-item called">
<span class="hook-index">[${i}]</span>
<span>${h.name} → <strong style="color:#68d391">${h.state} ✓</strong></span>
</div>`
).join('') + `<div style="margin-top:8px;font-size:11px;color:#718096">↑ Hook 顺序正确,所有节点一一对应</div>`
stateEl.innerHTML = `<span class="good">✓ Hook 顺序正确,所有链表节点与调用顺序一一对应,状态正常。</span>`
}
}
// 初始化
updateHookOrder()
</script>
</body>
</html>
十、参考资料
- React 官方文档 - Hooks 参考:https://react.dev/reference/react
- React 源码 - ReactFiberHooks.js:https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js
- Dan Abramov - A Complete Guide to useEffect:https://overreacted.io/a-complete-guide-to-useeffect/(深入理解 useEffect 和闭包陷阱的必读文章)
- Dan Abramov - Making setInterval Declarative with React Hooks:https://overreacted.io/making-setinterval-declarative-with-react-hooks/(useInterval 设计来源)
- React RFC - React Hooks:https://github.com/reactjs/rfcs/blob/main/text/0068-react-hooks.md(Hooks 设计决策原文)
- ahooks 源码:https://github.com/alibaba/hooks(阿里巴巴开源的生产级自定义 Hooks 库,学习最佳实践)
- react-use 源码:https://github.com/streamich/react-use(社区最受欢迎的 Hooks 工具库)
- React Fiber Architecture:https://github.com/acdlite/react-fiber-architecture(React Fiber 设计文档)
- eslint-plugin-react-hooks:https://www.npmjs.com/package/eslint-plugin-react-hooks(Hooks 规则的 ESLint 插件)
- React DevTools Profiler 使用指南:https://react.dev/learn/react-developer-tools(性能分析工具,验证 useMemo/useCallback 效果)

2508

被折叠的 条评论
为什么被折叠?



