Appearance
React 常见 Hook 使用场景与注意事项
React 16.8 引入 Hooks 之后,组件组织方式从生命周期分发转向按逻辑关注点拆分。这一变化比语法层面的函数组件替代更关键。
class 组件的主要问题在于逻辑复用和组织粒度。HOC 和 render props 可以复用逻辑,但会增加组件树层级,也会让 TypeScript 推导更复杂。同一段业务逻辑还会分散在 componentDidMount、componentDidUpdate、componentWillUnmount 等生命周期里,数据请求、事件绑定和 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 里 {} === {} 永远为 false。options 作为一个对象字面量,每次渲染都会被重新创建,导致 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])修法就三种:
- 如果不需要 effect,就别用——直接在事件处理中计算
- 用函数式更新:
setCount(prev => prev + 1) - 如果依赖是对象/数组,考虑
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:什么时候该用,什么时候是过度优化
核心是引用稳定性
useMemo 和 useCallback 的目的是引用稳定性——当依赖不变时,返回相同的引用。它们不是"让计算变快"的工具。
jsx
const handleClick = useCallback((id) => {
setSelectedId(id)
}, [])
// 等价于
const handleClick = useMemo(() => (id) => {
setSelectedId(id)
}, [])useCallback(fn, deps) 在底层就是 useMemo(() => fn, deps)。
真正需要用的场景
- 值被传递给
React.memo包裹的子组件——引用变化会使 memo 失去作用 - 值出现在
useEffect的依赖数组中——引用不稳定导致 effect 频繁触发 - 值被作为其他 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 引入了并发渲染,useTransition 和 useDeferredValue 是与之配套的 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 上挂的是 mountState、mountEffect 等,HooksDispatcherOnUpdate 上挂的是 updateState、updateEffect 等。
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 = updatequeue.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]
}这里面有几个值得注意的点:
- 低优先级 update 被跳过:如果 update 的 lane 不够高(不在当前渲染的 lanes 中),会被跳过。被跳过的 update 留在 baseQueue 中,下次渲染重新处理。
- 被跳过 update 之后的所有 update 也必须重新处理:因为跳过后 baseState 变了,后续 update 的结果可能不同。
- 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 代码最终跑在浏览器里,理解浏览器的事件循环模型对于理解 useEffect 和 useLayoutEffect 的行为差异至关重要。
一个渲染帧里发生了什么
浏览器的一帧(假设 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 耗时 | 帧预算剩余 |
|---|---|---|---|
| 10 | 0.1ms | 1ms | 15.6ms ✓ |
| 50 | 0.1ms | 5ms | 11.6ms ✓ |
| 200 | 0.1ms | 20ms | -3.4ms ✗ 掉帧 |
| 50 | 0.5ms | 25ms | -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 的帧预算分配
useTransition 和 useDeferredValue 是 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 次数异常时,排查优先级:
- 父组件重渲染导致的被动 render(最常见)→ 检查父组件的 state 是否在频繁变化,是否可以通过 "state 下沉" 减少影响范围
- Context value 频繁变化 → 检查 Provider 的 value 对象是否每次渲染都在重建(
<Provider value=>→ 改为拆分或多 Provider) - useEffect 的依赖触发了连锁 setState → 检查组件的 effect 依赖链,消除 "effect A 的 setState 触发 effect B 的 setState" 的级联
- 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 |
| 性能控制 | shouldComponentUpdate | React.memo + useMemo |
迁移中最容易犯的错误是 1:1 映射——把一个 componentDidMount 直接翻译成一个 useEffect(() => {}, [])。正确做法是先梳理这个生命周期里包含了几种逻辑关注点,然后为每种关注点创建独立的 effect 或自定义 Hook。
实际迁移优先级:
- 先迁叶子组件(依赖少,风险低)
- 再迁中间层组件(包含业务逻辑的容器组件)
- 最后迁根组件(涉及全局状态管理)
自定义 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 抽象满足:
- 返回的值是组件的直接需求,不需要二次处理
- 内部 useEffect 数量 ≤ 3(超过 3 个考虑是否可以拆解)
- 有明确的"所有权"——每个 Hook 负责一个清晰的功能关注点
- 函数签名简单,参数不超过 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 相关问题的几个实用技巧:
whyDidYouRender:在开发环境追踪不必要的重渲染,比手动插console.log高效得多。React DevTools Profiler 的 "Highlight updates when components render":直接看到哪些组件在重渲染,配合 slow-motion 模式逐帧分析。
Sentry / 前端监控中的长任务追踪:如果一个长任务(>50ms)频繁出现,大概率是某处同步渲染或
useLayoutEffect中的重计算阻塞了主线程。条件性的
console.trace:当组件渲染次数异常时,在组件顶部加:jsxif (process.env.NODE_ENV === 'development') { console.count('ComponentName render') }比用
useEffect记录更准确,因为console.count在 render 阶段就执行了。避免在生产环境用
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 拆分到多细算"过度优化"?
没有固定答案,但有个实用原则:按更新频率分组,而不是按数据类型分组。如果 user 和 theme 更新频率都很低,放一个 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。具体信号:
- 一个事件处理中有多个
setState调用,且它们之间存在逻辑依赖 - 多个 state 总是在一起更新(比如
loading、error、data三件套) - 需要测试状态转换逻辑——reducer 是纯函数,单测成本远低于测试组件
- 需要时间旅行调试(每个 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 的"坑"就自然消解了。
