React Hooks 原理深度解析:从会用到真正懂

声明:本文涉及的"源码"部分均为大幅简化的伪代码 ,目的是帮助理解核心机制,并非真实的 React 源码。真实源码涉及 Fiber 架构、优先级调度、并发模式等大量工程细节,如需深入请直接阅读 react/packages/react-reconciler


一、你需要先知道的背景

React Hooks 的所有行为,都建立在一个基础设施上:Fiber 节点上的 hooks 链表

每个函数组件对应一个 Fiber 节点。Fiber 上有个 memoizedState 字段,它是一条单向链表,每次调用 Hook 都会在链表上挂一个节点。

yaml 复制代码
FiberNode {
  memoizedState: Hook → Hook → Hook → null
                  ↑         ↑         ↑
               useState   useEffect  useCallback
}

这就是为什么 Hook 不能放在 if/for 里 ------每次渲染必须以相同的顺序调用相同数量的 Hook,才能保证链表节点和调用一一对应。如果顺序变了,第二次渲染时 useState 拿到的可能是上次 useEffect 留下的数据,全乱套。

jsx 复制代码
// ❌ 这样会出问题
function Bad({ show }) {
  if (show) {
    const [count, setCount] = useState(0) // 条件渲染时链表顺序不一致
  }
  useEffect(() => {}, [])
}

// ✅ 正确做法
function Good({ show }) {
  const [count, setCount] = useState(0) // 始终在最顶层
  useEffect(() => {
    if (!show) return
  }, [show])
}

理解了这个,下面每个 Hook 的行为就都好解释了。


二、useState

基本用法(快速回顾)

jsx 复制代码
const [count, setCount] = useState(0)
const [user, setUser] = useState<User | null>(null)

// 函数式更新:当新值依赖旧值时用这个
setCount(prev => prev + 1)

// 惰性初始化:初始值计算很贵时用这个
const [data, setData] = useState(() => expensiveCompute())

简化原理

下面是极度简化的模型,帮助理解"为什么 useState 能记住值":

js 复制代码
// ⚠️ 这是伪代码,用于理解概念,不是真实源码
let currentFiber = null
let hookIndex = 0

function useState(initialValue) {
  const hooks = currentFiber.memoizedState
  
  // 首次渲染:初始化这个 hook 节点
  if (!hooks[hookIndex]) {
    hooks[hookIndex] = {
      state: typeof initialValue === 'function' ? initialValue() : initialValue,
      queue: []
    }
  }
  
  const hook = hooks[hookIndex]
  hookIndex++

  // 处理更新队列里积累的 setState 调用
  hook.queue.forEach(action => {
    hook.state = typeof action === 'function' ? action(hook.state) : action
  })
  hook.queue = []

  const setState = (action) => {
    hook.queue.push(action)
    scheduleRender(currentFiber) // 触发重新渲染
  }

  return [hook.state, setState]
}

几个关键点

  1. 为什么 setState 不是同步的 :调用 setState 只是把 action 推进队列,然后触发调度,不是立刻改值。所以在同一个事件处理里连续调用多次 setState,会被批量合并处理(React 18 所有场景都自动批处理了)。
jsx 复制代码
function handleClick() {
  setCount(c => c + 1)
  setCount(c => c + 1)
  setCount(c => c + 1)
  // 最终只触发一次重渲染,count 增加 3
  // 注意:要用函数式更新才能拿到最新值!
  
  // ❌ 下面这样写,三次都是基于同一个快照值 count
  // setCount(count + 1) // 最终只增加 1
}
  1. 为什么初始值只用一次 :因为首次渲染后,hook 节点已经在链表里了,后续渲染直接读链表,不再看 initialValue 参数。

三、useEffect

基本用法

jsx 复制代码
useEffect(() => {
  // 副作用逻辑
  const sub = eventBus.subscribe('change', handler)
  
  return () => {
    // 清理函数:下次 effect 执行前 或 组件卸载时 调用
    sub.unsubscribe()
  }
}, [dependency1, dependency2])

依赖比较原理

React 用的是浅比较(Object.is,不是深比较:

js 复制代码
// React 内部比较依赖数组的方式(简化)
function areHookInputsEqual(nextDeps, prevDeps) {
  if (prevDeps === null) return false
  for (let i = 0; i < nextDeps.length; i++) {
    if (Object.is(nextDeps[i], prevDeps[i])) continue
    return false
  }
  return true
}

Object.is 对基本类型是值比较,对引用类型是引用比较。这是很多 bug 的根源:

jsx 复制代码
function Component({ config }) {
  useEffect(() => {
    doSomething(config)
  }, [config]) // config 是对象的话,每次父组件渲染都是新引用!
}

// 父组件
function Parent() {
  return <Component config={{ timeout: 3000 }} /> // 每次渲染都是新对象 → effect 每次都触发
}

执行时机

markdown 复制代码
渲染阶段(纯计算,不能有副作用)
    ↓
提交阶段(把变更刷入 DOM)
    ↓
浏览器绘制
    ↓
useEffect 的清理函数(上一次的)
    ↓
useEffect 的副作用函数(这一次的)

useLayoutEffect 的执行时机在"提交阶段"和"浏览器绘制"之间,是同步的,会阻塞渲染。除非你需要在 DOM 更新后、绘制前同步读取 DOM 尺寸,否则都用 useEffect

常见坑:闭包陷阱

jsx 复制代码
function Timer() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count) // ❌ 始终打印 0!闭包捕获了初始值
      setCount(count + 1) // ❌ 始终是 0 + 1 = 1
    }, 1000)
    return () => clearInterval(id)
  }, []) // 空依赖,effect 只跑一次,count 永远是初始值 0

  return <div>{count}</div>
}

// ✅ 解法一:函数式更新,不依赖外部 count
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1) // 始终拿到最新值
  }, 1000)
  return () => clearInterval(id)
}, [])

// ✅ 解法二:把 count 加入依赖(每次都重建 interval,不推荐)
// ✅ 解法三:用 useRef 存最新值

四、useRef

两种常见用途

jsx 复制代码
// 用途一:持有 DOM 引用
const inputRef = useRef<HTMLInputElement>(null)
<input ref={inputRef} />
inputRef.current.focus()

// 用途二:存储"不需要触发重渲染"的可变值
const timerRef = useRef<NodeJS.Timeout | null>(null)
const countRef = useRef(0) // 改变它不会重渲染

原理

useRef 本质上就是在 hooks 链表上存了一个 { current: initialValue } 的普通对象:

js 复制代码
// 简化原理(伪代码)
function useRef(initialValue) {
  const hook = getOrCreateHook()
  if (isFirstRender) {
    hook.memoizedState = { current: initialValue }
  }
  return hook.memoizedState // 每次渲染返回同一个对象引用
}

因为每次渲染返回的是同一个对象 ,改 .current 不会触发重渲染,但读 .current 始终能拿到最新值。

解决闭包陷阱的正统方式

jsx 复制代码
function useLatest<T>(value: T) {
  const ref = useRef(value)
  // 每次渲染同步更新 ref,这个操作在渲染阶段是安全的
  ref.current = value
  return ref
}

function Timer() {
  const [count, setCount] = useState(0)
  const countRef = useLatest(count)

  useEffect(() => {
    const id = setInterval(() => {
      console.log(countRef.current) // ✅ 始终是最新值
    }, 1000)
    return () => clearInterval(id)
  }, []) // 依赖不需要加 count

  return <div>{count}</div>
}

五、useMemo 和 useCallback

这两个放一起讲,因为本质是同一件事。

useMemo --- 缓存计算结果

jsx 复制代码
const expensiveValue = useMemo(() => {
  return heavyCompute(a, b) // 只有 a 或 b 变化时才重新计算
}, [a, b])

useCallback --- 缓存函数引用

jsx 复制代码
const handleClick = useCallback(() => {
  doSomething(userId)
}, [userId])

// useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
// 字面上:useMemo 缓存"值",useCallback 缓存"函数",底层完全一样

简化原理

js 复制代码
// 伪代码
function useMemo(factory, deps) {
  const hook = getOrCreateHook()
  
  if (isFirstRender || !areHookInputsEqual(deps, hook.memoizedState[1])) {
    const value = factory()
    hook.memoizedState = [value, deps]
    return value
  }
  
  return hook.memoizedState[0] // 依赖没变,返回缓存值
}

什么时候真正需要它们

误区 :很多人以为 useCallback/useMemo 能提升性能,到处用。实际上滥用反而有开销(每次渲染都要比较依赖数组)。

真正需要用的场景

jsx 复制代码
// 场景一:传给用了 React.memo 的子组件
const Child = React.memo(({ onClick }) => {
  console.log('Child 渲染')
  return <button onClick={onClick}>点击</button>
})

function Parent() {
  const [count, setCount] = useState(0)
  
  // ❌ 没有 useCallback:每次 Parent 渲染都产生新函数引用
  // → Child 的 memo 比较发现 onClick 变了 → 重渲染,memo 白加了
  const handleClick = () => console.log('clicked')
  
  // ✅ 用 useCallback:函数引用稳定,Child 的 memo 才能生效
  const handleClick = useCallback(() => console.log('clicked'), [])
  
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <Child onClick={handleClick} />
    </>
  )
}

// 场景二:作为其他 Hook 的依赖
useEffect(() => {
  fetchData(params)
}, [params]) // params 如果是对象,每次都是新引用,会死循环

// ✅ 用 useMemo 稳定引用
const stableParams = useMemo(() => ({ page, size }), [page, size])
useEffect(() => {
  fetchData(stableParams)
}, [stableParams])

// 场景三:真的很贵的计算(筛选大列表、复杂数学运算等)
const filteredList = useMemo(
  () => bigList.filter(item => item.name.includes(keyword)),
  [bigList, keyword]
)

六、useReducer

什么时候用 useReducer 而不是 useState

  • 状态逻辑复杂,多个子状态相互关联
  • 下一个状态依赖多个当前状态字段
  • 需要集中管理状态变更逻辑(方便测试)
jsx 复制代码
type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET'; payload: number }

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT': return state + 1
    case 'DECREMENT': return state - 1
    case 'RESET':     return action.payload
    default:          return state
  }
}

function Counter() {
  const [count, dispatch] = useReducer(reducer, 0)
  
  return (
    <>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
      <span>{count}</span>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'RESET', payload: 0 })}>重置</button>
    </>
  )
}

实战:表单状态管理

tsx 复制代码
interface FormState {
  values: Record<string, any>
  errors: Record<string, string>
  touched: Record<string, boolean>
  isSubmitting: boolean
}

type FormAction =
  | { type: 'SET_VALUE'; field: string; value: any }
  | { type: 'SET_ERROR'; field: string; error: string }
  | { type: 'TOUCH'; field: string }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_END' }
  | { type: 'RESET' }

const initialState: FormState = {
  values: {}, errors: {}, touched: {}, isSubmitting: false
}

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_VALUE':
      return { ...state, values: { ...state.values, [action.field]: action.value } }
    case 'SET_ERROR':
      return { ...state, errors: { ...state.errors, [action.field]: action.error } }
    case 'TOUCH':
      return { ...state, touched: { ...state.touched, [action.field]: true } }
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true }
    case 'SUBMIT_END':
      return { ...state, isSubmitting: false }
    case 'RESET':
      return initialState
    default:
      return state
  }
}

// reducer 是纯函数,测试超级简单
test('SET_VALUE should update values', () => {
  const next = formReducer(initialState, { type: 'SET_VALUE', field: 'name', value: '张三' })
  expect(next.values.name).toBe('张三')
  expect(next.errors).toEqual({}) // 其他字段不受影响
})

七、useContext

基本用法

tsx 复制代码
// 1. 创建 Context(给个默认值,方便类型推断)
const ThemeContext = createContext<'light' | 'dark'>('light')

// 2. Provider 提供值
function App() {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')
  return (
    <ThemeContext.Provider value={theme}>
      <Layout />
    </ThemeContext.Provider>
  )
}

// 3. 任意深度的子组件消费
function Button() {
  const theme = useContext(ThemeContext)
  return <button className={theme}>按钮</button>
}

性能陷阱:Context 值变化会让所有消费者重渲染

tsx 复制代码
// ❌ 常见反模式:把整个对象作为 Context value
function AuthProvider({ children }) {
  const [user, setUser] = useState(null)
  const [token, setToken] = useState('')
  
  // 每次渲染都产生新对象引用 → 所有消费者都会重渲染
  return (
    <AuthContext.Provider value={{ user, token, setUser, setToken }}>
      {children}
    </AuthContext.Provider>
  )
}

// ✅ 优化:拆分 Context,或用 useMemo 稳定 value
function AuthProvider({ children }) {
  const [user, setUser] = useState(null)
  const [token, setToken] = useState('')
  
  const value = useMemo(
    () => ({ user, token, setUser, setToken }),
    [user, token]
  )
  
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

// ✅ 更好:把"数据"和"操作"拆成两个 Context
// 操作函数(dispatch)通常不变,拆开后只订阅数据的组件才会在数据变化时重渲染
const AuthDataContext = createContext(null)
const AuthActionsContext = createContext(null)

八、forwardRef 和 useImperativeHandle

为什么需要 forwardRef

父组件想拿子组件的 DOM ref,直接传 ref prop 是不行的------ref 在 React 里是保留字,不会出现在组件的 props 里。

jsx 复制代码
// ❌ ref 拿不到
function Input({ ref, ...props }) { // ref 这里是 undefined!
  return <input ref={ref} {...props} />
}

// ✅ 用 forwardRef 包一层
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  return <input ref={ref} {...props} />
})

// 父组件
function Form() {
  const inputRef = useRef<HTMLInputElement>(null)
  return (
    <>
      <Input ref={inputRef} placeholder="请输入" />
      <button onClick={() => inputRef.current?.focus()}>聚焦</button>
    </>
  )
}

useImperativeHandle --- 暴露子组件的方法,而不是 DOM

有时候你不想把整个 DOM 节点暴露给父组件,而是只暴露几个具体的操作:

tsx 复制代码
interface DialogHandle {
  open: () => void
  close: () => void
  setTitle: (title: string) => void
}

const Dialog = forwardRef<DialogHandle, DialogProps>((props, ref) => {
  const [visible, setVisible] = useState(false)
  const [title, setTitle] = useState(props.title ?? '')

  // 向外暴露受控的接口,而不是内部 DOM
  useImperativeHandle(ref, () => ({
    open: () => setVisible(true),
    close: () => setVisible(false),
    setTitle: (t) => setTitle(t)
  }), [])

  return visible ? (
    <div className="dialog">
      <h2>{title}</h2>
      {props.children}
    </div>
  ) : null
})

// 父组件
function Page() {
  const dialogRef = useRef<DialogHandle>(null)

  return (
    <>
      <button onClick={() => {
        dialogRef.current?.setTitle('确认删除?')
        dialogRef.current?.open()
      }}>
        删除
      </button>
      <Dialog ref={dialogRef}>
        <p>此操作不可恢复</p>
      </Dialog>
    </>
  )
}

九、自定义 Hook:组合复用逻辑

自定义 Hook 就是把 Hook 调用提取成函数,函数名必须以 use 开头(这样 lint 规则才能检查 Hook 使用规范)。

封装一:useLocalStorage

tsx 复制代码
function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key)
      return item ? JSON.parse(item) : initialValue
    } catch {
      return initialValue
    }
  })

  const setValue = useCallback((value: T | ((prev: T) => T)) => {
    setStoredValue(prev => {
      const next = typeof value === 'function' ? (value as Function)(prev) : value
      try {
        window.localStorage.setItem(key, JSON.stringify(next))
      } catch (e) {
        console.error('localStorage 写入失败:', e)
      }
      return next
    })
  }, [key])

  return [storedValue, setValue] as const
}

// 用起来和 useState 一模一样,但自动持久化
const [theme, setTheme] = useLocalStorage('theme', 'light')

封装二:useFetch(带取消、加载状态)

tsx 复制代码
interface FetchState<T> {
  data: T | null
  loading: boolean
  error: Error | null
}

function useFetch<T>(url: string, deps: any[] = []) {
  const [state, setState] = useState<FetchState<T>>({
    data: null, loading: true, error: null
  })

  useEffect(() => {
    const controller = new AbortController()
    setState(s => ({ ...s, loading: true, error: null }))

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json() as Promise<T>
      })
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => {
        if (err.name === 'AbortError') return // 组件卸载,忽略
        setState({ data: null, loading: false, error: err })
      })

    return () => controller.abort() // 组件卸载或 url 变化时取消请求
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [url, ...deps])

  return state
}

// 使用
function UserProfile({ userId }: { userId: number }) {
  const { data, loading, error } = useFetch<User>(`/api/users/${userId}`, [userId])

  if (loading) return <Skeleton />
  if (error)   return <ErrorView message={error.message} />
  if (!data)   return null

  return <div>{data.name}</div>
}

封装三:useDebounce

tsx 复制代码
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay)
    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

// 搜索框防抖
function SearchBox() {
  const [keyword, setKeyword] = useState('')
  const debouncedKeyword = useDebounce(keyword, 300)

  const { data } = useFetch(`/api/search?q=${debouncedKeyword}`, [debouncedKeyword])

  return (
    <input
      value={keyword}
      onChange={e => setKeyword(e.target.value)}
      placeholder="搜索..."
    />
  )
}

封装四:useEventListener

tsx 复制代码
function useEventListener<K extends keyof WindowEventMap>(
  eventName: K,
  handler: (event: WindowEventMap[K]) => void,
  target: EventTarget = window,
  options?: boolean | AddEventListenerOptions
) {
  const handlerRef = useRef(handler)
  handlerRef.current = handler // 始终保持最新的 handler,无需加入依赖

  useEffect(() => {
    const listener = (event: Event) => handlerRef.current(event as WindowEventMap[K])
    target.addEventListener(eventName, listener, options)
    return () => target.removeEventListener(eventName, listener, options)
  }, [eventName, target, options]) // handler 不需要加,用 ref 规避了
}

// 使用
function App() {
  const [mousePos, setMousePos] = useState({ x: 0, y: 0 })

  useEventListener('mousemove', (e) => {
    setMousePos({ x: e.clientX, y: e.clientY })
  })

  useEventListener('keydown', (e) => {
    if (e.key === 'Escape') closeModal()
  })

  return <div>鼠标位置: {mousePos.x}, {mousePos.y}</div>
}

十、常见误区速查

误区 正确理解
useEffect 空依赖 = 只执行一次 严格模式开发环境会执行两次(故意的,用来检测副作用是否有清理)
useMemo/useCallback 越多越好 滥用反而有额外开销,只在真正需要稳定引用或避免重计算时用
setState 后立刻读取新值 读取的还是旧值,新值在下次渲染才生效
依赖数组里对象/数组每次都不同 浅比较引用,每次渲染的字面量对象都是新引用,用 useMemo 稳定
useRef 改变会触发重渲染 不会,这正是 ref 存在的意义
useContext 只有值变化时才重渲染 Context value 用 === 比较,对象每次都是新引用就会每次重渲染
自定义 Hook 一定要以 use 开头 规范要求(eslint-plugin-react-hooks 会检查),不以 use 开头就不算 Hook,lint 不会分析里面的 Hook 调用

十一、总结

React Hooks 的设计哲学是:用函数组合代替继承,用 hooks 链表实现状态持久化

理解了核心机制后,很多"奇怪"的行为都变得合理:

  • Hook 顺序不能变 → 链表顺序决定了每个 hook 拿到哪个节点
  • setState 不同步 → 先进队列,统一调度批处理
  • 闭包陷阱 → effect 里捕获的是调用时的快照,用 ref 突破
  • 依赖数组 → 浅比较引用,对象类型要注意稳定性

遇到性能问题,先用 React DevTools Profiler 定位瓶颈,再针对性地用 memo/useCallback/useMemo,不要盲目添加。


有什么问题或者补充欢迎评论区交流。

相关推荐
JerrySir1 小时前
密码没泄露,2FA 也没失效:Chrome 146 为什么还要让 Cookie“搬不走”?
前端
触底反弹1 小时前
Vue 实战:手把手教你实现 ChatGPT 同款打字机效果
前端·javascript·人工智能
BreezeJiang1 小时前
Vue 3 流式输出实战:ReadableStream + DeepSeek API 实现打字机对话
前端
用户EasyAdminBlazor2 小时前
EasyAdminBlazor 第十篇:数据导入导出——批量处理不再麻烦
前端·后端
饮茶三千2 小时前
电子保函模板编辑器实战二:HTML→FTL 编译转换的设计与实现
前端·vue.js·设计模式
ricardo19732 小时前
SSR / SSG 性能对比:Next.js 三种渲染模式实测数据
前端·面试
ikoala2 小时前
Codex 小白入门:从安装到插件、MCP、Skills,一篇把配置讲明白
前端·javascript·后端
雪隐2 小时前
用Flutter做背单词APP-04一个人,2周,从零撸出一个背单词App
前端·人工智能·后端
OpenTiny社区3 小时前
Skill 设计:封装、评测、运营,一次讲透
前端·github