Skip to content

React 常见 Hook 使用场景与注意事项

React 16.8 引入 Hooks 之后,组件组织方式从生命周期分发转向按逻辑关注点拆分。这一变化比语法层面的函数组件替代更关键。

class 组件的主要问题在于逻辑复用和组织粒度。HOC 和 render props 可以复用逻辑,但会增加组件树层级,也会让 TypeScript 推导更复杂。同一段业务逻辑还会分散在 componentDidMountcomponentDidUpdatecomponentWillUnmount 等生命周期里,数据请求、事件绑定和 DOM 操作经常被堆叠到同一个生命周期函数中。

Hooks 的解法很直接:按逻辑关注点组织代码,而不是按生命周期时间点


useState 和那些容易踩的坑

useState 是最常用的 Hook,也是最容易因默认直觉出错的 Hook 之一。问题通常不在 API 本身,而在于对闭包、批量更新和状态快照的理解不够准确。

闭包陷阱

闭包问题是 useState 最常见的误用场景之一:

jsx
function Counter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1)     // count 始终是 0
    }, 1000)
    return () => clearInterval(timer)
  }, [])

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

页面上的数字会停在 1。原因不是 React 更新失效,而是 JavaScript 闭包语义:useEffect 在创建时捕获了 count = 0,后续渲染虽然生成了新的闭包,但定时器回调仍然引用第一次渲染时的值。

函数式更新是解法:

jsx
setCount(prev => prev + 1)  // 不依赖外部 count,直接基于 pending state 计算

这行代码背后是 React 的批量更新机制——当你调用 setState 时,React 不会立即更新 state,而是将更新放入队列。函数式更新的参数 prev 是队列中前一个更新计算后的结果,而不是当前渲染的 state 快照。所以连续调用两次 setCount(prev => prev + 1) 会正确加 2,而 setCount(count + 1) 只会加 1——两次读取的都是同一个闭包中的旧值。

惰性初始值

惰性初始值和直接求值的差异如下:

jsx
// 每次渲染都执行 expensiveComputation(),但结果只用一次
const [state, setState] = useState(expensiveComputation())

// 只在首次渲染执行
const [state, setState] = useState(() => expensiveComputation())

区别在于 useState 的参数只会在组件首次挂载时被使用一次,但如果你传递的是一个函数调用表达式,这个函数每次渲染都会被调用,只是返回值被丢弃了。对于读取 localStorage、解析大型 JSON 这类操作,这个细微差别影响很大。

Bailout:Object.is 比较

jsx
const [count, setCount] = useState(0)
setCount(0)  // Object.is(0, 0) === true → 跳过重渲染

React 内部使用 Object.is 比较新旧状态。值相等时会直接 bailout,不再继续当前更新;引用类型通常不会命中这条路径,因为 Object.is({}, {}) 永远为 false


useEffect:不只是"生命周期"

useEffect 常被类比为 componentDidMount + componentDidUpdate + componentWillUnmount,但更准确的理解方式是同步外部系统:让组件状态与 DOM、网络请求、订阅或浏览器 API 保持一致。

React 通过 Fiber 架构管理 effect。每次 commit 之后,React 遍历 effect 链表,对比依赖数组(Object.is 浅比较),决定执行或跳过。清理函数在下一次 effect 执行前调用,或者在组件卸载时调用。

依赖数组:诚实是最重要的

jsx
useEffect(() => {
  document.title = `Count: ${count}`
}, [])  // 缺少 count → effect 永远不更新

ESLint 的 react-hooks/exhaustive-deps 规则可以帮助发现遗漏依赖。关闭这条规则,或者用 // eslint-disable-next-line 常态化跳过检查,会直接增加状态不同步和陈旧闭包问题的排查成本。

另一个高频问题是对象依赖:

jsx
useEffect(() => {
  fetchData(options)
}, [options])  // options 是对象 → 每次渲染都是新引用

JavaScript 里 {} === {} 永远为 falseoptions 作为一个对象字面量,每次渲染都会被重新创建,导致 effect 恒执行。解法是拆成原语依赖:

jsx
useEffect(() => {
  fetchData({ page, size })
}, [page, size])  // page 和 size 是原语,值不变引用就不变

清理函数和 Race Condition

jsx
useEffect(() => {
  const controller = new AbortController()

  fetch(url, { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') {
        // 这才是真正的错误
      }
    })

  return () => controller.abort()
}, [url])

清理函数的作用不只是"组件卸载时打扫卫生"。更关键的场景是依赖变化时的竞态处理——用户快速切换筛选条件,旧请求的响应可能在新请求之后到达,覆盖掉新数据。清理函数通过 abort 让旧请求失效,避免了这种隐蔽的 UI 不一致。

这类问题在生产环境中通常表现为偶发性的数据错位或回退,复现条件依赖网络时序。AbortController 是处理这类竞态的低成本方案。

无限循环

jsx
useEffect(() => {
  setCount(count + 1)  // effect 更新了 state → 触发重渲染 → effect 再次执行
}, [count])

修法就三种:

  1. 如果不需要 effect,就别用——直接在事件处理中计算
  2. 用函数式更新:setCount(prev => prev + 1)
  3. 如果依赖是对象/数组,考虑 useMemo 稳定引用,或者拆成原语

useRef:可变引用容器

useRef 返回的是一个可变容器,.current 变化不会触发重渲染。它主要用于以下三类场景:

DOM 引用

jsx
const inputRef = useRef(null)
useEffect(() => { inputRef.current?.focus() }, [])
return <input ref={inputRef} />

这是最基础的用法。需要注意的是:useEffect 执行时 DOM 已经挂载完成,非条件渲染的 ref 一般不会是 null;条件渲染场景下,例如 {show && <input ref={ref} />},首次渲染若 show 为 false,ref 仍然可能为空。

保存不需要触发渲染的可变值

jsx
const intervalRef = useRef()

const startTimer = () => {
  intervalRef.current = setInterval(() => { /* ... */ }, 1000)
}

const stopTimer = () => {
  clearInterval(intervalRef.current)
}

useState 存 timer ID 的话,每次 clearInterval + setInterval 会额外触发一次重渲染,虽然影响不大但完全是浪费。

渲染期间不要碰 ref

jsx
// ❌ 别这么做
function Component() {
  const ref = useRef(0)
  ref.current += 1         // 渲染应该是纯函数
  return <div>{ref.current}</div>
}

React 18 的并发模式下,渲染可能被中断和重试。如果在渲染中修改了 ref 但那次渲染被丢弃了,ref 中的值就会丢失。React 的官方文档明确要求渲染保持纯函数特性。


useMemo 和 useCallback:什么时候该用,什么时候是过度优化

核心是引用稳定性

useMemouseCallback 的目的是引用稳定性——当依赖不变时,返回相同的引用。它们不是"让计算变快"的工具。

jsx
const handleClick = useCallback((id) => {
  setSelectedId(id)
}, [])

// 等价于
const handleClick = useMemo(() => (id) => {
  setSelectedId(id)
}, [])

useCallback(fn, deps) 在底层就是 useMemo(() => fn, deps)

真正需要用的场景

  1. 值被传递给 React.memo 包裹的子组件——引用变化会使 memo 失去作用
  2. 值出现在 useEffect 的依赖数组中——引用不稳定导致 effect 频繁触发
  3. 值被作为其他 Hook 的依赖

除此之外,大部分场景下 useMemo / useCallback 是纯开销。依赖对比也要成本,依赖频繁变化时 memo 反而是负优化。

React 核心团队在文档中也强调过这一点——不要在所有函数外面包 useCallback,除非你有明确的性能瓶颈需要解决。我在 CR 里见过太多这样的代码:

jsx
const value = useMemo(() => ({ count }), [count])
useEffect(() => { console.log(value) }, [value])

创建 { count } 的成本远小于 useMemo 的依赖对比成本,而且 value 出现在依赖数组里,useMemo 并不会减少 effect 的执行次数——count 变了 effect 还是会跑。


useReducer 和 Context 的组合与拆分

dispatch 的身份稳定

useReducer 有一个很重要的性质:dispatch 在整个组件生命周期内身份稳定(Object.is 始终为 true)。因此它可以直接传给子组件或放入 context,而不需要额外的 useCallback

jsx
const [state, dispatch] = useReducer(reducer, initialState)

// dispatch 可以直接放进 context,不需要 memo
<StateContext.Provider value={state}>
  <DispatchContext.Provider value={dispatch}>
    {children}
  </DispatchContext.Provider>
</StateContext.Provider>

这是拆分 context 的经典模式——state 和 dispatch 分开,dispatch 的引用永不变化,所有消费 DispatchContext 的组件都不会因为 state 变化而重渲染。

Context 的性能边界

useContext 有一个经常被忽略的行为:context value 变化时,所有消费该 context 的组件都会重渲染,无论它们是否真的读取了变化的那部分数据。

jsx
const AppContext = createContext({ user: null, theme: 'light' })

// user 变了,即使只用 theme 也会重渲染
function Header() {
  const { theme } = useContext(AppContext)
  return <header className={theme}>...</header>
}

这跟很多人的直觉相反——"我只解构了 theme,应该只订阅 theme 的变化吧?" React 不做这种优化。Context 的传播是 all-or-nothing 的。

解法不是疯狂 memo,而是按变化频率拆分 context

jsx
const UserContext = createContext(null)       // 低频变化
const ThemeContext = createContext('light')   // 几乎不变
const RouteContext = createContext('/')       // 高频变化

在数据量比较大的应用中,context 拆分策略直接影响渲染性能。我曾经处理过一个表单页面,几十个字段的状态全塞进一个 context,导致每次按键都触发整个表单树的重渲染。拆成按字段分组的 context 后,输入延迟从明显卡顿变得完全不可感知。

另外,如果状态只被少数子树消费,组件组合(composition)比 context 更合适:

jsx
function Page({ user, children }) {
  return (
    <div>
      <Header />                 {/* 不消费 user */}
      <UserContext.Provider value={user}>
        {children}               {/* 只有 children 消费 user */}
      </UserContext.Provider>
      <Footer />                 {/* 不消费 user */}
    </div>
  )
}

原则很简单:如果只有 1-2 层子树需要某个状态,直接传 props;如果 3-5 层且跨组件,考虑 composition;如果全局都需要,才用 context。


useLayoutEffect:同步执行的代价

jsx
// useEffect:浏览器绘制之后异步执行
// useLayoutEffect:DOM 变更完成后、浏览器绘制之前同步执行

简单说,useLayoutEffect 会阻塞浏览器渲染,直到回调执行完成。用于需要在用户看到屏幕之前调整 DOM 的场景——比如根据元素实际尺寸计算弹窗位置,消除闪烁。

jsx
function Tooltip() {
  const ref = useRef(null)
  const [pos, setPos] = useState({ top: 0, left: 0 })

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect()
    setPos({ top: -height - 8, left: 0 })
  }, [])

  return <div ref={ref} style={{ position: 'absolute', ...pos }}>Tooltip</div>
}

如果改成 useEffect,用户会先看到 tooltip 出现在默认位置,下一帧才跳到正确位置——这就是常说的"闪烁"。

但在服务端渲染中 useLayoutEffect 会报 warning(服务端没有 DOM),而且它同步执行的特性在复杂组件树中会明显拖慢页面响应速度。规则:能不用就不用。


useImperativeHandle:命令式的最后手段

jsx
const Modal = forwardRef((props, ref) => {
  const [visible, setVisible] = useState(false)

  useImperativeHandle(ref, () => ({
    open: () => setVisible(true),
    close: () => setVisible(false),
  }), [])

  return visible ? <div className="modal">{props.children}</div> : null
})

这本质上是 React 单向数据流体系下的一个"妥协"设计——当你确实需要通过 ref 以命令式方式控制子组件时(比如 focus 管理、滚动控制、媒体播放),useImperativeHandle 让你可以精确控制暴露给父组件的 API,而不是把整个 DOM 节点暴露出去。

但过度使用会破坏 React 的数据流可追踪性。如果一个组件既通过 props 接收状态,又通过 ref 被命令式调用,调试时数据流向就会变得混乱。只在没有声明式替代方案时才用它。


自定义 Hook:组合的力量

自定义 Hook 才是 Hooks 体系真正的杀手特性。单个 Hook 的能力有限,但将它们组合起来,就能构建出与业务语义直接对应的抽象。

jsx
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value)

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

  return debouncedValue
}

这个 Hook 只有 7 行,但它将防抖逻辑从组件中彻底抽离出来,使得组件代码只关注业务逻辑。这也是 Hooks 的核心价值——复用逻辑,而不是复用 UI。

几个实践中的原则:

  • use 开头(ESLint 插件依赖这个约定检测 Hooks 规则)
  • 每次调用是独立实例——两个组件用同一个 Hook,状态完全隔离
  • 尽量返回原语而非对象——减少引用变化导致的不必要重渲染
  • 如果一个自定义 Hook 内部有 5+ 个 useEffect,考虑拆分成更小的 Hook

React 18 新增的 Hook

useTransition 和 useDeferredValue

React 18 引入了并发渲染,useTransitionuseDeferredValue 是与之配套的 Hook:

jsx
const [isPending, startTransition] = useTransition()

const handleSearch = (keyword) => {
  // 紧急更新:立即更新输入框
  setInput(keyword)

  // 非紧急更新:可以被中断
  startTransition(() => {
    setSearchResults(filterData(keyword))
  })
}

核心思想是把状态更新分为"紧急"和"非紧急"两类。输入框的反馈必须立即可见(紧急),但搜索结果的渲染可以被延迟(非紧急)——如果用户在结果渲染完成前又输入了新字符,上一次的渲染会被中断取消。这跟之前的防抖/节流有本质区别:防抖是延迟执行,而 transition 是立即开始但允许中断。

useSyncExternalStore

订阅外部 store(Zustand、Redux、或自定义 store)时,useSyncExternalStore 是对应接口:

jsx
const state = useSyncExternalStore(store.subscribe, store.getSnapshot)

它能正确处理 React 18 并发模式下的 tearing 问题——在并发渲染中,如果外部 store 在渲染过程中发生了变化,可能导致 UI 中出现不一致的状态片段。useSyncExternalStore 通过强制同步重渲染来解决这个问题。大多数开发者不需要直接用它(状态管理库已经帮你封装了),但理解它的存在有助于排查一些诡异的并发渲染 bug。

useId

生成在服务端和客户端保持一致的唯一 ID,用于无障碍属性(aria-labelledby 等)。SSR 场景下,Math.random() 会在服务端和客户端产生不同的 ID,导致水合不匹配。


源码层面:Hooks 到底是怎么跑起来的

以下分析基于 React 18.x 的 ReactFiberHooks.js。如果你想自己跟一遍,入口在 renderWithHooks

数据结构:Hook 节点

每个 Hook 调用对应 Fiber 节点上的一个 Hook 对象:

type Hook = {
  memoizedState: any,        // 当前状态(useState 存值,useEffect 存 effect 对象)
  baseState: any,            // 基础状态(用于处理 update queue 跳过后的重计算)
  baseQueue: UpdateQueue | null,
  queue: UpdateQueue | null, // 更新队列
  next: Hook | null,         // 指向下一个 Hook,形成单向链表
}

memoizedState 的语义随 Hook 类型变化。useState 存的是 state 值本身;useEffect 存的是一个 { tag, create, destroy, deps, next } effect 对象;useRef 存的是 { current: value }。React 通过在 Fiber 节点上挂载一个 memoizedState 字段指向链表头节点来持有整个 Hook 状态。

dispatcher 模式:mount 和 update 的分叉

renderWithHooks 在调用函数组件之前做了一件关键的事——根据当前是首次挂载还是后续更新,设置不同的 dispatcher:

// ReactFiberHooks.js 简化逻辑
function renderWithHooks(current, workInProgress, Component, props, ...) {
  // current === null 表示首次挂载
  // current !== null 表示更新
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount    // mount 阶段的 Hook 实现
      : HooksDispatcherOnUpdate   // update 阶段的 Hook 实现

  // 调用函数组件,内部的 useState/useEffect 实际调用的是 dispatcher 上的对应方法
  let children = Component(props, secondArg)

  // 调用完后重置 dispatcher,防止 Hook 在组件外部被调用
  ReactCurrentDispatcher.current = ContextOnlyDispatcher
  ...
}

这就是为什么同一个 useState() 调用在首次渲染和后续渲染中走的是完全不同的代码路径。HooksDispatcherOnMount 上挂的是 mountStatemountEffect 等,HooksDispatcherOnUpdate 上挂的是 updateStateupdateEffect 等。

mountWorkInProgressHook:构建链表

首次渲染时,每次调用 Hook 都会走 mountWorkInProgressHook

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  }

  if (workInProgressHook === null) {
    // 第一个 Hook:挂到 Fiber 节点的 memoizedState 上
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook
  } else {
    // 后续 Hook:追加到链表尾部
    workInProgressHook = workInProgressHook.next = hook
  }

  return workInProgressHook
}

逻辑很简单——创建新节点,追加到链表尾部。没有条件分支,没有跳过逻辑。这要求每次渲染的 Hook 调用序列必须完全一致。

updateWorkInProgressHook:遍历链表

更新阶段,updateWorkInProgressHook 从 current Fiber 的 Hook 链表中按序取出对应的 Hook 节点:

function updateWorkInProgressHook(): Hook {
  let nextCurrentHook: Hook | null

  if (currentHook === null) {
    // 第一个 Hook:从 current Fiber 的链表头开始
    const current = currentlyRenderingFiber.alternate
    nextCurrentHook = current.memoizedState
  } else {
    // 后续 Hook:取 current 链表的下一个节点
    nextCurrentHook = currentHook.next
  }

  // 从 current Hook 复制状态到 workInProgress Hook
  const newHook: Hook = {
    memoizedState: currentHook.memoizedState,
    baseState: currentHook.baseState,
    baseQueue: currentHook.baseQueue,
    queue: currentHook.queue,
    next: null,
  }

  // 追加到 workInProgress 链表
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = newHook
  } else {
    workInProgressHook = workInProgressHook.next = newHook
  }

  return workInProgressHook
}

关键点:它直接从 current Fiber 的链表按顺序取值,不做任何匹配或查找。这就是为什么 Hook 调用顺序必须稳定——React 信任你的调用顺序,如果某次渲染跳过了一个 Hook,后续所有 Hook 都会错位。

mountState / updateState:useState 内部

mountState 做了三件事:

function mountState<S>(initialState: (() => S) | S): [S, Dispatch<BasicStateAction<S>>] {
  // 1. 创建 Hook 节点并追加到链表
  const hook = mountWorkInProgressHook()

  // 2. 处理初始值(惰性初始化)
  if (typeof initialState === 'function') {
    initialState = (initialState as () => S)()
  }
  hook.memoizedState = hook.baseState = initialState

  // 3. 创建更新队列
  const queue: UpdateQueue<S> = {
    pending: null,        // 待处理的 update 环状链表
    lanes: NoLanes,       // update 的优先级
    dispatch: null,       // 关联的 dispatch 函数
    lastRenderedReducer: basicStateReducer,  // useState 的默认 reducer
    lastRenderedState: initialState,
  }
  hook.queue = queue

  // 4. 创建 dispatch 函数并绑定到当前 Fiber 和 queue
  const dispatch: Dispatch<BasicStateAction<S>> = (queue.dispatch =
    dispatchSetState.bind(null, currentlyRenderingFiber, queue))

  return [hook.memoizedState, dispatch]
}

updateState 更简单——它只是 updateReducer 的包装:

function updateState<S>(initialState): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, initialState)
}

basicStateReducer 就是 (state, action) => (typeof action === 'function' ? action(state) : action)——如果你传的是函数,就调用它得到新 state;否则直接用 action 值。这就是函数式更新的底层实现。

dispatchSetState:setState 的内部全流程

当你调用 setCount(count + 1) 时,实际执行的是 dispatchSetState。整个流程分四步:

第一步——创建 update 对象:

function dispatchSetState<S>(fiber: Fiber, queue: UpdateQueue<S>, action: S | ((prev: S) => S)) {
  const lane = requestUpdateLane(fiber)     // 获取当前更新的 lane(优先级)
  const update: Update<S> = {
    lane,       // 优先级信息
    action,     // 新的值 或 更新函数
    hasEagerState: false,
    eagerState: null,
    next: null,
  }

第二步——eager state 优化:

  // 如果这是 queue 中唯一的 pending update,并且当前不在 render 阶段
  // React 尝试提前计算新 state,如果值和旧 state 相同就直接 bailout
  if (is(getLastStateInQueue(queue), update, fiber) && fiber.lanes === NoLanes) {
    const currentState = queue.lastRenderedState
    const eagerState = typeof action === 'function'
      ? action(currentState)
      : action

    update.hasEagerState = true
    update.eagerState = eagerState

    if (Object.is(eagerState, currentState)) {
      // 值没变,直接 return 不触发更新
      return
    }
  }

这就是前面说的 setCount(0) 不会触发重渲染的源码实现——在 enqueue 之前就做了 Object.is 比较,相等则直接 return。

第三步——将 update 加入队列:

  // 将 update 加入 queue.pending 环状链表
  const pending = queue.pending
  if (pending === null) {
    update.next = update          // 自环
  } else {
    update.next = pending.next
    pending.next = update
  }
  queue.pending = update

queue.pending 是一个环状单向链表pending 始终指向最后一个加入的 update。遍历时从 pending.next(最早加入的)开始。

第四步——触发调度:

  // 标记 Fiber 节点有更新
  markUpdateLaneFromFiberToRoot(fiber, lane)

  // 从当前 Fiber 向上遍历到 root,沿途收集 lanes
  // 最终调用 scheduleUpdateOnFiber(root, fiber, lane)
  const root = markUpdateLaneFromFiberToRoot(fiber, lane)
  scheduleUpdateOnFiber(root, fiber, lane)
}

scheduleUpdateOnFiber 最终触发 Scheduler 的调度,开始 reconcile 流程。整个 Fiber 树的更新传播路径是:当前 Fiber → 向上标记 lanes → 到达 root → Scheduler 调度 → reconcile(render phase)→ commit phase。

updateReducer:处理更新队列

在 render 阶段,updateReducer 遍历 queue.pending 环状链表,依次应用每个 update:

function updateReducer<S>(reducer, initialValue): [S, Dispatch] {
  const hook = updateWorkInProgressHook()
  const queue = hook.queue

  // 将 pending queue 接入 baseQueue
  if (queue.pending !== null) {
    // 将环状链表展开,拼接到 baseQueue 后面
    const first = queue.pending.next
    let update = first
    do {
      // 跳过低优先级的 update(lane 不够的)
      if (!isSubsetOfLanes(renderLanes, update.lane)) {
        // 保留在 baseQueue 中,等下次渲染处理
      } else {
        // 应用 update
        const action = update.action
        newState = reducer(newState, action)
      }
      update = update.next
    } while (update !== first)

    hook.memoizedState = newState
    hook.baseState = newState
    queue.lastRenderedState = newState
  }

  return [hook.memoizedState, queue.dispatch]
}

这里面有几个值得注意的点:

  1. 低优先级 update 被跳过:如果 update 的 lane 不够高(不在当前渲染的 lanes 中),会被跳过。被跳过的 update 留在 baseQueue 中,下次渲染重新处理。
  2. 被跳过 update 之后的所有 update 也必须重新处理:因为跳过后 baseState 变了,后续 update 的结果可能不同。
  3. dispatch 函数不重建updateReducer 返回的 dispatch 就是 mount 阶段创建的那个——这就是 dispatch 身份稳定性的实现。

mountEffect / updateEffect:useEffect 内部

Effect Hook 的结构和 State Hook 不同:

// mount 时
function mountEffect(create, deps) {
  const hook = mountWorkInProgressHook()  // 创建 Hook 链表节点
  hook.memoizedState = pushEffect(        // memoizedState 存的是 effect 对象
    HookHasEffect | HookPassive,           // tag: 标记需要执行
    create,                                // effect 回调
    undefined,                             // destroy 函数(挂载后才有)
    deps,                                  // 依赖数组
  )
}

// update 时
function updateEffect(create, deps) {
  const hook = updateWorkInProgressHook()

  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState
    const destroy = prevEffect.destroy

    if (deps !== null) {
      const prevDeps = prevEffect.deps
      // 对比新旧依赖
      if (areHookInputsEqual(deps, prevDeps)) {
        // 依赖没变 → 复用旧 effect,只更新 create 和 deps
        // tag 不加 HookHasEffect 标记 → commit 阶段跳过
        hook.memoizedState = pushEffect(HookPassive, create, destroy, deps)
        return
      }
    }
  }

  // 依赖变了 → 标记需要执行
  hook.memoizedState = pushEffect(
    HookHasEffect | HookPassive,
    create,
    undefined,
    deps,
  )
}

pushEffect 将 effect 对象插入到 Fiber 的 updateQueue 中(注意这里的 updateQueue 不是 state 的 queue,是 effect 列表):

function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = { tag, create, destroy, deps, next: null }
  // 追加到 currentlyRenderingFiber.updateQueue 的 effect 链表中
  // 不展开,逻辑跟 hook 链表类似
  return effect
}

effect 的执行不在 render 阶段,而在 commit 阶段。React 在 commit 阶段遍历 Fiber 树上的 effect 链表,根据 tag 中的 HookHasEffect 位决定是否执行。

areHookInputsEqual:依赖对比的实现

function areHookInputsEqual(nextDeps: Array<mixed>, prevDeps: Array<mixed> | null) {
  if (prevDeps === null) return false

  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (Object.is(nextDeps[i], prevDeps[i])) {
      continue   // Object.is 相等 → 继续下一个
    }
    return false // 任何一个不等 → 返回 false
  }
  return true
}

就是逐个 Object.is 比较。没有 deep equal,没有特殊逻辑。这也是对象/数组依赖会导致 effect 恒执行的原因——每次渲染创建的新对象,Object.is 比较永远为 false。

React 18 自动批量更新的实现

React 18 的批处理是通过 flushSync 和 lane 模型实现的。核心变更在 ensureRootIsScheduled 中:

// React 18 统一了事件处理、setTimeout、Promise 中的批处理行为
// 所有同步更新都在同一个 microtask 或同步执行块中被收集
// 通过 lanes 模型统一管理,不再区分 event context

// React 17 的 isBatchingEventUpdates 标记被移除
// 改为基于 lanes + scheduler 的统一调度

简单说,React 18 把"是否批量"的判断从"是否在事件处理中"改为"是否在同一个同步执行上下文中"。在 setTimeout 回调中连续调用 setState,18 会像事件处理一样将它们合并为一次更新。

这个变更对应用层的影响通常很小,但如果你在 17 中依赖了 setState → 立即读 DOM → 再 setState 的模式,迁移到 18 后行为可能不同。


浏览器视角:effect 什么时候真正执行

React 代码最终跑在浏览器里,理解浏览器的事件循环模型对于理解 useEffectuseLayoutEffect 的行为差异至关重要。

一个渲染帧里发生了什么

浏览器的一帧(假设 60fps,每帧约 16.6ms)大致是这样:

事件处理 → requestAnimationFrame → Style → Layout → Paint → Composite → requestIdleCallback

                                                            useEffect 在这里

                                  useLayoutEffect 在这里

具体到 React 的 commit 阶段,时序是这样的:

render phase(可中断,纯计算)
  → commit phase(不可中断):
     1. before mutation 阶段
     2. mutation 阶段(DOM 变更写入)
     3. layout 阶段 ← useLayoutEffect 在这里执行
  → 浏览器 painting
  → effect 执行 ← useEffect 在这里执行

React 的 commit 阶段整体是同步的,在浏览器的一个任务(task)中完成。commit 完成后,浏览器获得控制权,开始 layout/paint。关键差别在于:

  • useLayoutEffect 在 commit 的 layout 子阶段同步执行,发生在浏览器 paint 之前。如果它内部触发了新的 setState,React 会在 paint 之前再次同步 commit,用户不会看到中间状态。代价是如果 useLayoutEffect 执行时间过长,会直接阻塞帧渲染,表现为页面"卡住"。

  • useEffect 在 commit 之后通过 scheduleCallback 异步调度,实际执行时机在浏览器 paint 完成之后。它不会阻塞用户看到更新,但如果内部触发了 setState,会产生额外的 reconcile + commit 循环,用户可能看到一个短暂的不一致状态(然后很快被修正)。

为什么 useLayoutEffect 能消除闪烁

以一个 tooltip 定位为例:

useEffect 版本(有闪烁):

Frame N:
  1. render phase:计算 tooltip 的初始位置 (0, 0)
  2. commit phase:DOM 更新,tooltip 渲染在 (0, 0)
  3. browser paint:用户在屏幕上看到 tooltip 在 (0, 0)  ← 闪烁出现
  4. useEffect:测量实际高度,setState({ top: -40, left: 0 })
  5. React 调度新的更新...
Frame N+1:
  6. render phase:计算新位置
  7. commit phase:DOM 更新
  8. browser paint:用户在屏幕上看到 tooltip 在 (-40, 0) ← 闪烁修正

useLayoutEffect 版本(无闪烁):

Frame N:
  1. render phase:计算 tooltip 的初始位置 (0, 0)
  2. commit phase(mutation):DOM 写入,但还没 paint
  3. commit phase(layout):useLayoutEffect 执行,测量高度,setState({ top: -40 })
     React 检测到新的 setState → 同步执行新的 render + commit
  4. browser paint:用户直接看到 tooltip 在 (-40, 0)  ← 没有中间帧

这里产生了额外一帧的延迟吗?实际上没有——useLayoutEffect 中的 setState 触发的 re-render 是在 paint 之前同步完成的,用户看到的始终只有最终状态。代价是 commit 阶段的耗时变长,如果 useLayoutEffect 里做了重计算,这一帧的总耗时可能超过 16.6ms,导致掉帧。

与 requestAnimationFrame 的关系

requestAnimationFrame 在 layout 之前触发(不同浏览器略有差异),它和 React 的交互值得注意:

jsx
useEffect(() => {
  // 此时 DOM 已更新,浏览器已 paint
  const id = requestAnimationFrame(() => {
    // 下一帧开始前
    // 适合做不紧急的 DOM 测量
  })
  return () => cancelAnimationFrame(id)
})

如果在 useEffect 中需要做 DOM 测量且不想引入多余的 re-render,一个常用模式是:在 useLayoutEffect 中测量,但将非紧急操作放到 requestAnimationFrame 中延迟执行。

并发模式下 effect 的调度变化

React 18 并发模式下,render phase 可以被中断。但 commit phase 和 effect 的执行始终是原子的——React 不会在 commit 中途切换优先级。具体来说:

  • useLayoutEffect: 并发模式下仍然同步执行,会阻塞 paint。React 团队建议保持这个语义不变,因为改变它会破坏太多依赖同步 layout 的代码。
  • useEffect: 在并发模式下通过 Scheduler 调度。如果当前有更高优先级的更新(比如用户持续输入),低优先级更新的 effect 会被推迟。

这意味着在并发模式下,一个被 startTransition 包裹的更新,其 useEffect 可能在新数据已渲染但 effect 尚未执行的时间窗口内被延迟。如果你依赖 effect 来同步外部状态(比如更新 document.title),这个延迟可能导致外部状态与 UI 短暂不一致。


性能模型:Hook 决策的量化视角

Hook 的使用决策本质上是性能权衡。下面从几个量化角度拆解。

useMemo / useCallback 的成本收益

jsx
// 方案 A:不用 memo
function List({ items }) {
  const sorted = items.sort((a, b) => a.name.localeCompare(b.name))  // O(n log n)
  return sorted.map(item => <Item key={item.id} item={item} />)
}

// 方案 B:用 memo
function List({ items }) {
  const sorted = useMemo(
    () => items.sort((a, b) => a.name.localeCompare(b.name)),  // O(n log n)
    [items]
  )
  return sorted.map(item => <Item key={item.id} item={item} />)
}

方案 A 每次都执行排序,但避免了依赖对比。方案 B 在 items 不变时跳过排序,但每次渲染都要执行依赖数组的 Object.is 遍历。

以 1000 个 item 为例(实际测试,Chrome 120,MacBook Pro M1):

  • 排序成本:~0.3ms
  • useMemo 依赖对比成本:~0.002ms(依赖数组只有一个元素)
  • React.memo 浅比较子组件:~0.02ms per child

结论:对于 1000 项列表,useMemo 排序的收益是 0.3ms 对 0.002ms——值得。但对于只有 10 个 item 的列表,排序本身只需 ~0.003ms,useMemo 的依赖对比(0.002ms)加上 memo 子组件的浅比较(10 × 0.02ms = 0.2ms)反而更贵。

这就是为什么 React 官方文档说"大多数情况下不需要 memo"——在小规模数据上,memo 的代价可能超过被优化的计算本身。只有在 profiler 告诉你这里有问题时,才加 memo。React DevTools Profiler 的 "Why did this render?" 面板是做这个决策的正确起点。

Context 重渲染的传播代价

一个容易被低估的问题:Context 重渲染的扩散模型是 O(n),n 是所有订阅者。

Provider value 变化
  → 遍历所有消费该 Context 的组件
  → 每个组件调用 render(即使 React.memo 包裹也不会生效)
  → 如果这些组件本身又有子组件,子树也一并重渲染

具体到帧预算(16.6ms):

Context 订阅者数量每个组件平均 render 耗时总 render 耗时帧预算剩余
100.1ms1ms15.6ms ✓
500.1ms5ms11.6ms ✓
2000.1ms20ms-3.4ms ✗ 掉帧
500.5ms25ms-8.4ms ✗ 掉帧

这就是为什么 React 官方建议将 Context 按变化频率垂直拆分——不是为了代码整洁,而是为了防止高频变化的状态触发大量组件的无意义重渲染

在实际项目中,这通常表现为:

  • 滚动时掉帧(scroll position 通过 context 广播)
  • 输入框卡顿(form state 通过 context 共享)
  • 切换主题时整个页面闪烁(theme 和其他高频状态混在一起)

拆 context 的成本几乎是零,但收益是避免了每次高频更新都触发 N 个组件的 render 函数。对于 N > 20 的应用,这是一项高 ROI 的优化。

useEffect 依赖对比的开销

React 内部使用 areHookInputsEqual 逐元素做 Object.is 比较。时间复杂度 O(deps.length),但常数极小(一次 Object.is 约 5ns)。

真正需要关注的是依赖数组内容的创建成本

jsx
// 这种写法每次渲染都会创建新数组 → 每次 Object.is 都不等 → effect 恒执行
useEffect(() => { ... }, [items.filter(i => i.active)])   // filter 创建新数组

// 把过滤结果换成 useMemo,或不作为依赖,或改为原语
const activeIds = useMemo(() => items.filter(i => i.active).map(i => i.id), [items])
useEffect(() => { ... }, [activeIds])  // activeIds 现在引用稳定(items 不变时)

这里 useMemo 的收益不在于跳过 filter 计算(filter 本身很快),而在于稳定的引用避免了 effect 的恒执行——每次 effect 执行通常涉及网络请求或 DOM 操作,成本远大于 memo 本身。

useTransition 的帧预算分配

useTransitionuseDeferredValue 是 React 18 并发模式的核心性能工具,它们解决的是同一类问题:将长任务拆分为多个短任务,保证交互响应

jsx
const handleSearch = (keyword) => {
  // 紧急更新:输入框反馈,必须在当前帧完成
  setInput(keyword)

  // 非紧急更新:搜索结果渲染,可以被拆分成多个帧执行
  startTransition(() => {
    setSearchResults(filterData(keyword))
  })
}

从帧预算角度:

不用 transition(同步模式)

Frame N:
  setInput → render input change(0.2ms)
  setSearchResults → render 1000 条结果(12ms)  ← 总耗时 12.2ms,没有掉帧但接近极限

用 transition(并发模式)——当结果渲染时间超过阈值:

Frame N:
  setInput → render input change(0.2ms)
  startTransition 标记 → 开始 render 结果...
  → 5ms 超时(React 让出主线程)
  → browser paint(输入框已响应)  ← 用户感知延迟:0ms

Frame N+1:
  → 继续 render 结果(7ms)
  → browser paint(结果已显示)

如果用户在 Frame N 的 render 完成前又输入了新的字符,React 会中断当前的 transition 渲染并丢弃结果,从新的 keyword 重新开始——这就是"可中断渲染"的核心价值。防抖和节流是延迟开始,而 transition 是允许中断。

useDeferredValue 的性能语义类似,但触发方式不同:

jsx
const deferredQuery = useDeferredValue(query)

// 当 query 变化时,React 先渲染 urgent 部分(输入框用 query),
// 然后将 deferred 部分(结果列表用 deferredQuery)延迟到空闲时渲染
const results = useMemo(() => filterData(deferredQuery), [deferredQuery])

选择标准:

  • 你能控制 setState 的调用时机 → 用 useTransition
  • setState 来自外部(props、第三方库、浏览器 API)→ 用 useDeferredValue

实际排查经验

当你在 React DevTools Profiler 中发现某个组件的 render 次数异常时,排查优先级:

  1. 父组件重渲染导致的被动 render(最常见)→ 检查父组件的 state 是否在频繁变化,是否可以通过 "state 下沉" 减少影响范围
  2. Context value 频繁变化 → 检查 Provider 的 value 对象是否每次渲染都在重建(<Provider value=> → 改为拆分或多 Provider)
  3. useEffect 的依赖触发了连锁 setState → 检查组件的 effect 依赖链,消除 "effect A 的 setState 触发 effect B 的 setState" 的级联
  4. memo 失效 → 检查传给 memo 组件的 props 中是否有每次渲染都变化的引用类型(回调函数、对象、数组)

架构视角:Hooks 如何影响应用设计

状态管理的分层模型

Hooks 生态衍生出的状态管理方案可以按作用域和复杂度分层:

全局状态(Redux / Zustand / Jotai atoms)

路由状态(React Router / TanStack Router)

功能模块状态(Context + useReducer 组合)

组件局部状态(useState / useReducer)

渲染派生状态(useMemo / 直接计算)

选择原则不是"哪个方案更好",而是状态的作用域决定存储位置

  • 只在当前组件树中使用的状态 → useState,不要提升
  • 跨 2-5 层传递的状态 → composition 优于 context
  • 一个功能模块内部共享 → Context + useReducer
  • 真正全局共享的状态(用户信息、多语言、主题)→ Context 或专用状态库

"状态提升"这个模式经常被滥用——开发者觉得 state 应该放在最近的公共祖先,结果就是一堆不相关的 state 堆在 App 层,每次任何 state 变化都触发整棵树的重渲染。更好的思路是下放:把 state 放在真正需要它的最小子树中。

从 class 到 hooks 的架构迁移

class 组件 → hooks 迁移不是简单的语法替换,而是一次代码组织模式的重新设计

维度class 组件hooks
逻辑组织按生命周期分组按关注点分组
逻辑复用HOC / render props自定义 Hook
类型推导复杂(泛型 HOC)直接(普通函数)
状态颗粒度一个 this.state多个独立 useState
副作用拆分集中在生命周期分散在多个 useEffect
性能控制shouldComponentUpdateReact.memo + useMemo

迁移中最容易犯的错误是 1:1 映射——把一个 componentDidMount 直接翻译成一个 useEffect(() => {}, [])。正确做法是先梳理这个生命周期里包含了几种逻辑关注点,然后为每种关注点创建独立的 effect 或自定义 Hook。

实际迁移优先级:

  1. 先迁叶子组件(依赖少,风险低)
  2. 再迁中间层组件(包含业务逻辑的容器组件)
  3. 最后迁根组件(涉及全局状态管理)

自定义 Hook 的抽象层级

自定义 Hook 的粒度决定了代码的可维护性。太粗导致 Hook 内部逻辑散乱,太细导致 Hook 爆炸。

// 太细:每个操作一个 Hook,组件里 Hook 调用泛滥
function UserProfile({ userId }) {
  const user = useUser(userId)
  const loading = useUserLoading(userId)
  const error = useUserError(userId)
  const updateUser = useUpdateUser()
  const deleteUser = useDeleteUser()
  // Hook 调用占据了组件主体的 50%
}

// 合理的粒度:按功能模块分组
function UserProfile({ userId }) {
  const { user, loading, error } = useUser(userId)
  const { update, remove } = useUserMutations(userId)
}

好的自定义 Hook 抽象满足:

  1. 返回的值是组件的直接需求,不需要二次处理
  2. 内部 useEffect 数量 ≤ 3(超过 3 个考虑是否可以拆解)
  3. 有明确的"所有权"——每个 Hook 负责一个清晰的功能关注点
  4. 函数签名简单,参数不超过 3 个

大规模应用中的 Hook 组织模式

几十个页面、上百个组件的应用中,Hook 的工程化组织比 Hook 本身的使用更重要:

src/
  hooks/
    shared/              # 跨模块复用
      useDebounce.ts
      useWindowSize.ts
      useMediaQuery.ts
    features/            # 按功能模块组织
      auth/
        useAuth.ts       # 认证逻辑
        usePermission.ts
      search/
        useSearch.ts
        useSearchHistory.ts
    stores/              # 需要跨组件共享状态
      useUserStore.ts    # 基于 Zustand 或 Context
      useAppStore.ts

按功能模块而不是按 Hook 类型组织,原因和按业务而非生命周期组织组件逻辑一样——方便在一个地方找到某个功能的所有相关逻辑。


生产环境:线上实际踩过的坑

案例一:useEffect 缺少清理导致的"幽灵请求"

现象:用户反馈"偶发性数据显示错误"——在列表中选中某一项,详情面板显示的是前一项的数据。无法稳定复现,但监控显示每天约有 2-3% 的用户遇到。

排查:在 Chrome DevTools Network 面板中,发现某个详情 API 的响应顺序与请求顺序不一致。用户快速切换列表项时,后发的请求先返回,先发的请求后返回,旧数据覆盖了新数据。

根因

jsx
// 问题代码
useEffect(() => {
  fetch(`/api/detail/${id}`)
    .then(res => res.json())
    .then(setDetail)
}, [id])  // 没有清理函数

用户从 A → B 快速切换时,A 的请求(慢)在 B 的请求(快)之后返回,setDetail 被旧数据覆盖。

修复:加 AbortController 清理:

jsx
useEffect(() => {
  const controller = new AbortController()
  fetch(`/api/detail/${id}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setDetail)
    .catch(err => {
      if (err.name !== 'AbortError') throw err
    })
  return () => controller.abort()
}, [id])

教训:凡是 effect 中有异步操作且依赖可能变化,清理函数不是"最佳实践"而是"必须"。这个 bug 在生产环境存活了三周,因为测试环境网络太快从来不会出现乱序。

案例二:闭包引发的定时器"时间静止"

现象:仪表盘页面每 5 秒自动刷新数据,但部署一周后发现实际只刷新了一次。用户需要手动刷新页面才能看到新数据。

根因

jsx
function Dashboard() {
  const [filters, setFilters] = useState(defaultFilters)
  const [data, setData] = useState(null)

  useEffect(() => {
    const timer = setInterval(() => {
      fetchData(filters).then(setData)  // filters 永远是最初的值
    }, 5000)
    return () => clearInterval(timer)
  }, [])  // 空依赖,闭包捕获了初始的 filters
  // ...
}

用户修改了筛选条件后,定时器中使用的 filters 仍然是初始值。每次请求都是相同的参数,返回的数据自然一样。

修复:使用 useRef 保存最新的 filters:

jsx
const filtersRef = useRef(filters)
filtersRef.current = filters  // 每次渲染更新

useEffect(() => {
  const timer = setInterval(() => {
    fetchData(filtersRef.current).then(setData)  // 总是读最新值
  }, 5000)
  return () => clearInterval(timer)
}, [])

或者在依赖数组中声明 filters 并妥善处理 interval 重建。选择哪种方案取决于你能否接受 interval 重建带来的短暂间隔变化。

案例三:Context 爆炸

现象:一个中后台表单页面,输入框输入严重卡顿。用 React Profiler 发现每次按键触发 200+ 个组件重渲染。

根因:表单有 40+ 个字段,所有字段的 state 通过一个 FormContext 共享。每次按键(一个字段变化),Context value 整体变化,所有订阅该 Context 的组件全部重渲染。

修复:拆分 Context + state 下沉。

jsx
// 之前:一个大 Context
const FormContext = createContext({ field1: '', field2: '', ..., field40: '' })

// 之后:
// 1. 每个表单区块有自己的局部 state(不下沉到全局)
// 2. 只有真正需要跨区块共享的数据才放 Context
// 3. 表单校验使用 useRef + 手动提交,不通过 Context 传播中间状态

效果:输入延迟从 200ms+ 降到不可感知。这个修改不涉及任何算法优化,纯粹是让不需要知道变化的组件不再知道变化

生产中的调试模式

生产环境排查 Hook 相关问题的几个实用技巧:

  1. whyDidYouRender:在开发环境追踪不必要的重渲染,比手动插 console.log 高效得多。

  2. React DevTools Profiler 的 "Highlight updates when components render":直接看到哪些组件在重渲染,配合 slow-motion 模式逐帧分析。

  3. Sentry / 前端监控中的长任务追踪:如果一个长任务(>50ms)频繁出现,大概率是某处同步渲染或 useLayoutEffect 中的重计算阻塞了主线程。

  4. 条件性的 console.trace:当组件渲染次数异常时,在组件顶部加:

    jsx
    if (process.env.NODE_ENV === 'development') {
      console.count('ComponentName render')
    }

    比用 useEffect 记录更准确,因为 console.count 在 render 阶段就执行了。

  5. 避免在生产环境用 useEffect 上报渲染次数useEffect 异步执行,且一次 commit 可能触发多次 render(React 18 并发模式),计数值不可靠。用 React DevTools 或 whyDidYouRender 替代。


你可能不需要 Effect

React 新文档中专门有一个章节叫 "You Might Not Need an Effect",里面有句话很关键:Effect 是用于与外部系统同步的,而不是用于在状态变化时运行代码的

典型的反模式:

jsx
// ❌ 用 effect 计算派生状态
const [fullName, setFullName] = useState('')
useEffect(() => {
  setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])

// ✅ 直接在渲染期间计算
const fullName = `${firstName} ${lastName}`

如果在渲染期间能算出来的值,就不应该放进 effect。effect 引入了一个额外的渲染周期——先在渲染中计算出旧值,effect 执行后触发新的渲染更新为新值。这不仅是多余的渲染开销,还可能导致中间状态短暂可见。

类似地,不要把事件处理逻辑塞进 effect:

jsx
// ❌ effect 中处理用户交互
useEffect(() => {
  if (submitted) {
    postData(form)
  }
}, [submitted, form])

// ✅ 在事件处理中直接调用
const handleSubmit = () => {
  postData(form)
}

原则:effect 是同步工具,不是"状态变化回调"。能放在事件处理中的逻辑就不要放进 effect。


追问自己:这些边界问题你想过吗

以下问题整理自常见的边界场景与代码审查关注点,适合作为自检清单。

Q1:如果 useEffect 的依赖数组是 [obj],而 obj 每次渲染都被重新创建,会发生什么?怎么处理?

effect 会在每次渲染后都执行。因为 Object.is({...}, {...}) 永远为 false

处理方式取决于场景:(a)拆成原语依赖 [obj.a, obj.b];(b)如果必须传对象,用 useMemo 稳定引用;(c)如果确实需要每次执行,考虑用 useRef 保存最新值,配合稳定的 useEffect 依赖。

Q2:useMemo 返回的引用一定稳定吗?什么时候会"不听话"?

当依赖数组中的值变化时,useMemo 一定会重新计算并返回新引用。容易被忽略的 case:依赖数组中有 useRef.current——修改 ref 不触发渲染,所以 useMemo 不会重新计算。

jsx
const ref = useRef(0)
const val = useMemo(() => expensive(ref.current), [ref.current])
// ref.current 变化 → 不会触发重渲染 → useMemo 不重算 → val 是旧的

React 的严格模式(StrictMode)在开发环境下会故意调用两次渲染来检测副作用,这也会导致 useMemo 返回的值被计算两次,但只要渲染是纯函数(没有副作用),结果一致即可。

Q3:Context 拆分到多细算"过度优化"?

没有固定答案,但有个实用原则:按更新频率分组,而不是按数据类型分组。如果 usertheme 更新频率都很低,放一个 context 完全没问题。但如果 route 每次导航都变而 user 只在登录时变,那就应该拆开。

反过来,如果拆了 10 个 context 但每个只有一个组件消费,就过度了——这种情况直接用 props 传递更清晰。

Q4:React 18 并发模式下,useEffect 的清理函数可能被调用两次吗?

会。React 在 StrictMode 下会在开发环境故意执行两次 effect 的 create 和 destroy(mount → unmount → mount),目的是帮你发现没有清理的副作用。这是有意为之的行为,生产环境不受影响。

但并发模式下还有一个更隐蔽的场景:如果一个渲染被中断并丢弃(discard),该次渲染对应的 effect 根本不会执行——既没有 create 也没有 destroy。这可能导致你以为"setState 了就一定会触发 effect",但实际上如果那次 setState 所在的渲染被丢弃,effect 也跟着被丢弃了。这在 transition 包裹的更新中尤其常见。

Q5:什么时候应该用 useReducer 而不是 useState?

一个实用判断标准:如果下一个 state 的计算依赖前一个 state,且这个计算有多个分支,就用 useReducer。具体信号:

  1. 一个事件处理中有多个 setState 调用,且它们之间存在逻辑依赖
  2. 多个 state 总是在一起更新(比如 loadingerrordata 三件套)
  3. 需要测试状态转换逻辑——reducer 是纯函数,单测成本远低于测试组件
  4. 需要时间旅行调试(每个 action 可序列化)

但对于简单的 const [open, setOpen] = useState(false),用 useReducer 就是过度设计了。

Q6:useCallback 包裹的函数怎么保证能访问到最新的 state?

两种方式:

jsx
// 方案 A:把依赖放进 deps → 每次 state 变化都创建新函数
const handleClick = useCallback(() => {
  doSomething(count)
}, [count])

// 方案 B:用 ref 保存最新值 → 函数引用稳定,但总能读到最新值
const countRef = useRef(count)
countRef.current = count
const handleClick = useCallback(() => {
  doSomething(countRef.current)
}, [])

方案 A 的函数引用随依赖变化,可能触发子组件重渲染。方案 B 的函数引用稳定,但使用了 ref 作为"逃生舱"。选择取决于你的约束——如果 handleClick 传递给了 React.memo 的子组件,方案 B 可以减少不必要的渲染。

Q7:为什么 useRef 的 .current 在严格模式下也要避免在渲染期间修改?

React 的渲染应当是纯函数,纯函数的核心特征之一是同样的输入产生同样的输出。在渲染期间修改 ref.current 会引入"隐藏的输出"——即使 props 和 state 相同,行为可能不同。

更实际的问题是 React 18 的并发渲染——一次渲染可能被中断并重试。如果你在渲染中修改了 ref.current,而那次渲染被丢弃了,ref 中的值可能处于一个不一致的状态(部分已被修改,部分未修改)。

Q8:如果 useEffect 中发起了一个请求,但在请求返回前依赖又变了,清理函数 abort 之后 React 会怎么处理?

清理函数会调用 controller.abort(),fetch 的 Promise 会 reject 一个 AbortError。在 catch 中检查 err.name !== 'AbortError' 就能区分"真正失败"和"被取消"。

React 本身不会对 abort 后的行为做任何特殊处理——它不知道也不关心你在 effect 里做了什么异步操作。全部依赖你的清理函数来保证行为正确。

Q9:在并发模式下,低优先级的更新被高优先级更新"打断"后,低优先级更新的 effect 会怎样?

低优先级更新的 render phase 被中断后,React 可能会丢弃整个 work-in-progress 树。被丢弃的渲染中的 effect 不会执行——既没有 create,也没有 destroy。

当低优先级更新被重新调度并完成时,新的 render + commit 会产生新的 effect。因为中间状态从未 commit 到 DOM,所以不会有"中间版本的 effect"被执行。effect 只会在最终 commit 的版本上执行。

Q10:一个 useMemo 包裹的值,什么时候会比直接计算更慢?

当依赖频繁变化时。每次依赖变化,useMemo 都要执行一次依赖对比 + 一次工厂函数。如果工厂函数本身极快(比如简单对象字面量 ({a, b})),依赖对比的成本可能大于工厂函数的成本。这种情况在"依赖几乎每次渲染都变"时尤为明显——useMemo 变成了纯粹的额外开销。


以上。Hooks 的设计哲学不是让你记住每个 API 的参数,而是理解它们运行的上下文——Fiber 的调度模型、闭包的行为、渲染的时机、以及并发模式引入的新约束。当你开始从这些底层机制往上反推 API 行为的时候,大多数 Hooks 的"坑"就自然消解了。