React Hooks 全面解析:底层原理与自定义 Hook 设计

React 16.8(2019年2月)发布的 Hooks 是 React 历史上最重要的一次革新。本篇从 Fiber 架构的链表数据结构出发,深入每个核心 Hook 的内部实现,剖析闭包陷阱的根本成因,并系统讲解自定义 Hook 的六大设计模式。读完此篇,你将不仅"会用" Hooks,更能"看透"它。


一、名词解释

术语解释
FiberReact 16 重写的核心架构单元,每个组件对应一个 Fiber 节点,保存组件的类型、props、state 等所有信息
memoizedStateFiber 节点上存放 Hook 链表头节点的字段,所有 Hook 状态通过此链表串联
Hook 节点链表中的每个节点,结构为 { memoizedState, baseState, queue, next },next 指向下一个 Hook
WorkInProgress Fiber正在构建的新 Fiber 树节点,与 current Fiber 对应,双缓冲机制
UpdateQueueHook 节点内存放待处理更新的队列,dispatch 触发的更新先入队,渲染时批量消费
闭包陷阱(Stale Closure)函数组件每次渲染都创建新的函数作用域,旧的 effect/callback 捕获了旧渲染的变量,无法感知最新状态
依赖数组(deps)useEffect / useCallback / useMemo 第二个参数,React 用 Object.is 逐项比较来决定是否重新执行
副作用(Side Effect)组件渲染之外的操作,如网络请求、DOM 操作、订阅事件、设置定时器等
清理函数(Cleanup)useEffect 返回的函数,在下次 effect 执行前或组件卸载时调用,用于取消订阅/清除定时器
惰性初始化(Lazy Initialization)useState(fn) 传函数时只在首次渲染执行,避免每次渲染重复计算昂贵的初始值
自定义 Hookuse 开头、内部调用其他 Hook 的普通函数,实现有状态逻辑的跨组件复用
useReducer类 Redux 的状态管理 Hook,适合复杂状态逻辑,useState 本质上是其简化版
useLayoutEffectuseEffect 相似但同步执行于 DOM 变更后、浏览器绘制前,用于测量 DOM 尺寸
Object.isES6 的严格相等算法,与 === 的区别在于 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]!
}

flagtrue 变为 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

这就是 useEffectuseLayoutEffect 的根本区别:执行时机一个在 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 引用、可变值存储
useEffectEffect 对象 {tag,create,destroy,deps}异步(paint 之后)数据请求、订阅、定时器
useLayoutEffectEffect 对象同步(paint 之前)DOM 测量、动画定位
useCallback[fn, deps] 元组同步(渲染中)稳定函数引用传给子组件
useMemo[value, deps] 元组同步(渲染中)缓存昂贵计算结果
useContextcontext 当前值是(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];
}

精读要点

  1. 惰性初始化优化useState(() => expensive()) 只在 mount 时执行一次,后续更新跳过——源码里 if (typeof initialState === 'function') 是这个特性的实现。
  2. dispatchSetState 闭包绑定bind(null, currentlyRenderingFiber, queue) 把当前 fiber 和 queue 提前绑死。这就是为什么你可以把 setCount 传给子组件而它依然能更新父组件——闭包里锁死了父 fiber。
  3. 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');  // 只看到最终值
  });
});

测试覆盖率目标

类型目标覆盖率
业务自定义 Hookline ≥ 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 调度)
特性useEffectuseLayoutEffect
执行时机浏览器绘制之后(异步)浏览器绘制之前(同步)
是否阻塞绘制
等价类生命周期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 有价值的两个条件同时满足:

  1. 函数被传给了 React.memo 包裹的子组件(否则子组件无论如何都会重渲染,稳定函数引用无意义)
  2. 函数的创建成本相对于比较依赖数组的成本有意义(通常情况下函数创建很廉价)

useMemo 有价值的条件:

  1. 计算本身确实昂贵(大数组的过滤/排序、复杂数学运算),经 profiler 确认
  2. 或者用于稳定对象/数组引用,防止子组件重渲染或 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,还要有 loadingerror,以及派生状态(isSuccessisIdle 等),方便使用方处理各种 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: nullerror: 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(() =&gt; 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(() =&gt; { countRef.current = count })<br>
        setTimeout(() =&gt; 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>

十、参考资料

  1. React 官方文档 - Hooks 参考:https://react.dev/reference/react
  2. React 源码 - ReactFiberHooks.js:https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js
  3. Dan Abramov - A Complete Guide to useEffect:https://overreacted.io/a-complete-guide-to-useeffect/(深入理解 useEffect 和闭包陷阱的必读文章)
  4. Dan Abramov - Making setInterval Declarative with React Hooks:https://overreacted.io/making-setinterval-declarative-with-react-hooks/(useInterval 设计来源)
  5. React RFC - React Hooks:https://github.com/reactjs/rfcs/blob/main/text/0068-react-hooks.md(Hooks 设计决策原文)
  6. ahooks 源码:https://github.com/alibaba/hooks(阿里巴巴开源的生产级自定义 Hooks 库,学习最佳实践)
  7. react-use 源码:https://github.com/streamich/react-use(社区最受欢迎的 Hooks 工具库)
  8. React Fiber Architecture:https://github.com/acdlite/react-fiber-architecture(React Fiber 设计文档)
  9. eslint-plugin-react-hooks:https://www.npmjs.com/package/eslint-plugin-react-hooks(Hooks 规则的 ESLint 插件)
  10. React DevTools Profiler 使用指南:https://react.dev/learn/react-developer-tools(性能分析工具,验证 useMemo/useCallback 效果)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值