Skip to content
React Hook
概述
React 16.8 引入 Hook,组件的组织方式从按生命周期分发转向按逻辑关注点拆分。伴随函数组件逐步替代类组件,逻辑复用方式的改变影响更为深远。
类组件模式下,逻辑复用主要依靠高阶组件(HOC)与 render props。这些模式虽然能够复用逻辑,但会加深组件树层级,同时增加 TypeScript 类型推导的复杂度。同一段业务逻辑经常分散在 componentDidMount、componentDidUpdate、componentWillUnmount 等生命周期方法中,数据请求、事件绑定和 DOM 操作被堆叠在同一个生命周期函数内。Hook 则提供了一种按逻辑关注点组织代码的方式。
useState
基本用法与闭包行为
useState 用于在函数组件中管理状态。由于 JavaScript 闭包语义与 React 批量更新机制的共同作用,直接使用当前值容易产生非预期行为。
以下组件期望计数器每秒递增 1:
jsx
function Counter() {
const [count, setCount] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
return <div>{count}</div>
}实际运行中数字始终停留在 1。原因在于 useEffect 创建时捕获的 count 值为 0,后续渲染虽然产生新的闭包,但定时器回调始终引用第一次渲染时的值。
函数式更新
使用函数式更新可以避免闭包陷阱:
jsx
setCount(prev => prev + 1)函数式更新不依赖当前渲染闭包中的 count 值,而是基于待处理状态计算。React 的批量更新机制会将更新放入队列,prev 是队列中前一个更新计算后的结果。连续调用两次 setCount(prev => prev + 1) 会正确增加 2,而两次 setCount(count + 1) 只增加 1,因为两次读取的都是同一个闭包中的旧值。
惰性初始值
useState 支持传入函数作为初始值计算器:
jsx
// 每次渲染都执行 expensiveComputation(),但结果仅在首次挂载时使用
const [state, setState] = useState(expensiveComputation())
// 只在首次渲染时执行
const [state, setState] = useState(() => expensiveComputation())useState 的参数只在组件首次挂载时被使用一次。如果直接传入函数调用表达式,该函数在每次渲染时都会执行,返回值会被丢弃。对于读取 localStorage、解析大型 JSON 等开销较大的操作,应使用惰性初始化形式。
状态更新的 Bailout
当新状态与当前状态相同时,React 会跳过重渲染。比较方式为 Object.is:
jsx
const [count, setCount] = useState(0)
setCount(0) // Object.is(0, 0) === true,跳过重渲染对于引用类型,Object.is({}, {}) 始终为 false,因此通常不会命中该优化。
useEffect
useEffect 用于同步外部系统,使组件状态与 DOM、网络请求、订阅或浏览器 API 保持一致。
React 通过 Fiber 架构管理 effect。每次 commit 之后,React 遍历 effect 链表,对比依赖数组中各依赖项的 Object.is 结果,决定执行或跳过。清理函数在下一次 effect 执行前调用,或在组件卸载时调用。
依赖数组
useEffect 的依赖数组应如实声明 effect 内使用的所有外部变量:
jsx
useEffect(() => {
document.title = `Count: ${count}`
}, []) // 缺少 count,effect 不会随 count 变化而更新ESLint 的 react-hooks/exhaustive-deps 规则可用于检测遗漏的依赖。忽略该规则容易引入状态不同步与过期闭包问题。
当依赖为对象时需注意引用变化:
jsx
useEffect(() => {
fetchData(options)
}, [options]) // options 每次渲染都是新对象,effect 恒执行由于 Object.is({...}, {...}) 始终为 false,依赖中的对象每次渲染都会导致 effect 执行。解决方法是将依赖展开为原始值:
jsx
useEffect(() => {
fetchData({ page, size })
}, [page, size]) // 仅当 page 或 size 值变化时才执行清理函数与请求竞态
在 effect 中发起异步操作时应提供清理函数,防止旧响应覆盖新数据:
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])清理函数的核心用途是处理依赖变化时的竞态:如果用户快速切换筛选条件,旧请求的响应可能在新请求之后到达,覆盖新数据。通过 AbortController.abort() 使旧请求失效,可避免 UI 显示不一致。这类问题通常表现为偶发数据错位或回退,取决于网络时序。
无限循环与修复
当 effect 内部更新自身依赖的状态时会形成无限循环:
jsx
useEffect(() => {
setCount(count + 1)
}, [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 的 .current 可能为 null。
保存不触发渲染的可变值
jsx
const intervalRef = useRef()
const startTimer = () => {
intervalRef.current = setInterval(() => { /* ... */ }, 1000)
}
const stopTimer = () => {
clearInterval(intervalRef.current)
}若使用 useState 存储定时器 ID,每次 clearInterval 和 setInterval 会额外触发一次重渲染。useRef 可避免这类不必要的渲染。
渲染期间避免修改 ref
jsx
// 不推荐在渲染期间修改 ref
function Component() {
const ref = useRef(0)
ref.current += 1
return <div>{ref.current}</div>
}React 要求渲染保持纯函数特性。在并发的渲染模式下,渲染可能被中断并重试,若在渲染过程中修改了 ref 而该次渲染被丢弃,ref 中的值可能丢失或不一致。
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等 Hook 的依赖数组中,引用不稳定会导致 effect 频繁触发。 - 值被用作其他 Hook 的依赖。
大部分场景下不需要 useMemo 或 useCallback。依赖对比本身也有开销,依赖频繁变化时 memo 反而增加成本。
低收益的典型用法:
jsx
const value = useMemo(() => ({ count }), [count])
useEffect(() => { console.log(value) }, [value])创建 { count } 对象开销很小,而 useMemo 需要进行依赖对比。value 出现在 effect 依赖数组中时,count 变化仍会触发 effect,useMemo 没有减少 effect 的执行次数。
性能成本
是否使用 memo 取决于计算开销与依赖对比开销的差值。
jsx
function List({ items }) {
const sorted = items.sort((a, b) => a.name.localeCompare(b.name))
return sorted.map(item => <Item key={item.id} item={item} />)
}如果 items 数量达到 1000,排序成本约 0.3ms,useMemo 依赖对比成本约 0.002ms,收益为正。如果 items 数量为 10,排序本身约 0.003ms,加上 React.memo 浅比较的总成本可能高于直接计算。是否需要优化应通过 React DevTools Profiler 判断。
useReducer 与 Context
dispatch 身份稳定
useReducer 返回的 dispatch 在组件生命周期内身份稳定(Object.is 始终为 true),可直接传递给子组件或放入 context:
jsx
const [state, dispatch] = useReducer(reducer, initialState)
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>将 state 与 dispatch 拆分为两个 context 后,消费 DispatchContext 的组件不会因 state 变化而重渲染。
Context 的性能边界
当 context 的 value 变化时,所有消费该 context 的组件都会重渲染,无论它们是否只读取了未变化的部分。
jsx
const AppContext = createContext({ user: null, theme: 'light' })
function Header() {
const { theme } = useContext(AppContext)
return <header className={theme}>...</header>
}
// user 变化时,Header 也会重渲染Context 值的传播是全量触发的。React 不会对解构字段做精细化订阅。解决方法是将 context 按变化频率拆分:
jsx
const UserContext = createContext(null) // 低频变化
const ThemeContext = createContext('light') // 几乎不变
const RouteContext = createContext('/') // 高频变化如果某一状态仅在 1-2 层子树中使用,直接传递 props 更合适;跨 3-5 层子树时,可以考虑组合模式;全局均需使用的状态才放入 context。当多个字段的状态集中在一个 context 中且部分字段更新频繁时,会导致大规模重渲染。按模块或更新频率拆分 context 可以显著降低渲染范围。
useLayoutEffect
useLayoutEffect 的签名与 useEffect 相同,但执行时机不同:useEffect 在浏览器绘制之后异步执行,useLayoutEffect 在 DOM 变更完成后、浏览器绘制之前同步执行。
该 Hook 适用于在用户看到画面之前需要调整 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 内部的 setState 触发重新渲染会在 paint 之前同步完成,用户直接看到最终状态,但会延长 commit 阶段耗时。
在服务端渲染环境中,useLayoutEffect 会显示警告(服务端没有 DOM),且同步执行特性在复杂组件树中会增加页面响应延迟。能用 useEffect 解决的场景不应使用 useLayoutEffect。
useImperativeHandle
useImperativeHandle 配合 forwardRef 向父组件暴露子组件的命令式控制方法:
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
})该方法适用于 focus 管理、滚动控制、媒体播放等难以用声明式方式表达的场景。如果组件既通过 props 接收状态,又通过 ref 接受命令式调用,数据流向会变得复杂。只有在没有声明式替代方案时才考虑使用 useImperativeHandle。
自定义 Hook
自定义 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 时应注意:
- 命名以
use开头(ESLint 依赖该约定检测 Hook 使用规则)。 - 每次调用是独立实例,多个组件使用同一个 Hook 时状态完全隔离。
- 返回原始值时优先返回原始值而非对象,以减少引用变化导致的不必要重渲染。
- 如果一个自定义 Hook 内部包含过多
useEffect,可以考虑拆分为更小的 Hook。
React 18 新增 Hook
useTransition 与 useDeferredValue
jsx
const [isPending, startTransition] = useTransition()
const handleSearch = (keyword) => {
setInput(keyword)
startTransition(() => {
setSearchResults(filterData(keyword))
})
}两者将状态更新分为紧急更新和非紧急更新。输入框的反馈需立即可见,标记为紧急;搜索结果渲染可被延迟,标记为非紧急。若用户在结果渲染完成前又输入新字符,上一次渲染会被中断并丢弃。与防抖/节流不同,transition 立即开始但允许中断。
useDeferredValue 适用于不能直接控制 setState 的场景(如状态来自 props 或第三方库),它将某个值标记为可延迟更新。
useSyncExternalStore
订阅外部 store(如 Zustand、Redux 或自定义 store)时应使用 useSyncExternalStore:
jsx
const state = useSyncExternalStore(store.subscribe, store.getSnapshot)它能正确处理并发渲染中的 tearing 问题:在并发渲染中,若外部 store 在渲染过程中发生变化,可能导致 UI 中出现不一致的状态片段。大多数状态管理库已封装此 Hook,开发者一般不需要直接调用。
useId
生成在服务端和客户端保持一致的唯一 ID,用于无障碍属性(如 aria-labelledby)。在 SSR 场景下,Math.random() 等方案会在服务端和客户端产生不同 ID,导致水合不匹配。
内部工作原理
以下分析基于 React 18.x 的 ReactFiberHooks.js,入口为 renderWithHooks。
Hook 数据结构
每次 Hook 调用对应 Fiber 节点上的一个 Hook 对象:
type Hook = {
memoizedState: any,
baseState: any,
baseQueue: UpdateQueue | null,
queue: UpdateQueue | null,
next: Hook | null,
}memoizedState 的语义随 Hook 类型变化:useState 存储状态值本身;useEffect 存储 { tag, create, destroy, deps, next } effect 对象;useRef 存储 { current: value }。Fiber 节点通过 memoizedState 字段指向 Hook 链表的头节点。
分派器模式
renderWithHooks 在调用函数组件前根据是首次挂载还是后续更新设置不同分派器:
function renderWithHooks(current, workInProgress, Component, props, ...) {
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate
let children = Component(props, secondArg)
ReactCurrentDispatcher.current = ContextOnlyDispatcher
...
}同一个 useState() 调用在首次渲染和后续渲染中走不同的代码路径:HooksDispatcherOnMount 包含 mountState、mountEffect 等,HooksDispatcherOnUpdate 包含 updateState、updateEffect 等。
链表构建与遍历
首次渲染时,每次 Hook 调用执行 mountWorkInProgressHook,创建新节点并追加到链表尾部。因此每次渲染的 Hook 调用序列必须完全一致。
更新阶段,updateWorkInProgressHook 从当前 Fiber 的 Hook 链表中按顺序取出对应 Hook 节点,并复制状态到 workInProgress Hook。React 不进行匹配或查找,只信任调用顺序。如果某次渲染跳过了某个 Hook,后续所有 Hook 都会错位。
useState 内部过程
mountState 创建 Hook 节点、处理惰性初始值、构建更新队列并创建 dispatch 函数。updateState 是 updateReducer 的包装,使用 basicStateReducer:(state, action) => (typeof action === 'function' ? action(state) : action),即函数式更新的底层实现。
调用 setCount(count + 1) 实际执行 dispatchSetState,流程如下:
- 创建 update 对象,包含优先级(lane)、新值或更新函数。
- eager state 优化:如果这是队列中唯一的 pending update 且不在 render 阶段,React 尝试提前计算新状态;若
Object.is(eagerState, currentState)为true,则直接返回,不触发更新。这也是setCount(0)不引起重渲染的源码依据。 - 将 update 加入
queue.pending环状单向链表。 - 从当前 Fiber 向上标记 lanes,最终调用
scheduleUpdateOnFiber触发调度。
在 render 阶段,updateReducer 遍历环状链表,依次应用 update。低优先级的 update 可能被跳过,被跳过的 update 之后的 update 也需要重新处理,因为 baseState 已经改变。dispatch 函数始终保持同一引用。
useEffect 内部
effect Hook 通过 pushEffect 将 effect 对象插入 Fiber 的 updateQueue。更新时,利用 areHookInputsEqual 对新旧依赖进行 Object.is 比较:
function areHookInputsEqual(nextDeps, prevDeps) {
if (prevDeps === null) return false
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) continue
return false
}
return true
}依赖不变则不加 HookHasEffect 标记,commit 阶段跳过执行。该比较是浅比较,对象或数组依赖每次渲染都是新引用,因此 effect 会恒定执行。
React 18 自动批量更新
React 18 将批量更新的判断从“是否在事件处理中”改为基于 lane 的统一调度,使得 setTimeout、Promise 回调中的连续 setState 也会合并为一次更新。这一变化对应用层通常影响很小,但依赖立即读取 DOM 的模式在升级后可能有不同行为。
浏览器执行时序
理解事件循环模型有助于区分 useEffect 和 useLayoutEffect 的行为差异。
以 60fps 为例,一帧大约 16.6ms,典型步骤为:事件处理 → requestAnimationFrame → Style → Layout → Paint → Composite → requestIdleCallback。
React commit 阶段的时序如下:
render phase(可中断,纯计算)
→ commit phase(不可中断):
1. before mutation
2. mutation(DOM 变更写入)
3. layout ← useLayoutEffect 在此处同步执行
→ 浏览器 painting
→ effect 执行 ← useEffect 在此处异步执行useLayoutEffect在浏览器 paint 之前同步执行。若内部触发新的setState,React 会在 paint 前同步 commit,用户不会看到中间状态,但执行时间过长会阻塞帧渲染。useEffect在 paint 之后通过scheduleCallback异步调度,不会阻塞用户看到更新,但若内部触发setState,会产生额外的 reconcile 与 commit,用户可能看到短暂的不一致状态。
tooltip 示例中,useLayoutEffect 版本:render → commit(mutation) → useLayoutEffect 执行并 setState → React 同步重新 commit → paint,用户直接看到正确位置;useEffect 版本则先 paint 初始位置,下一帧再 paint 正确位置,产生闪烁。
并发模式下 render phase 可以被中断,但 commit phase 和 effect 的执行保持原子性。useLayoutEffect 仍然同步执行并阻塞 paint。useEffect 通过 Scheduler 调度,低优先级更新的 effect 可能被推迟。被 startTransition 包裹的更新,其 useEffect 可能在新数据已渲染但 effect 尚未执行的时间窗口内延迟执行,此时如果依赖 effect 同步外部状态,可能出现外部状态与 UI 短暂不一致。
常见注意点
异步操作与清理
当 effect 包含异步操作且依赖可能变化时,若不处理清理,响应顺序可能错乱。例如列表快速切换时,后发请求先返回,先发请求后返回,旧数据覆盖新数据。使用 AbortController 可在依赖变化时取消旧请求。
定时器与过期闭包
定时器回调中使用了组件的状态或 props 时,若 effect 依赖数组为空,回调将始终读取初始值。解决方案包括使用 useRef 保存最新值,或将依赖正确声明并处理定时器重建。
状态集中与重渲染
将大量字段的状态集中在一个 context 中,且部分字段更新频繁时,会引发全局重渲染。通过按更新频率拆分 context,或采用 state 下沉、组合模式,可以降低不必要渲染。
重渲染排查线索
当组件 render 次数异常时,常见的排查方向:
- 父组件 state 频繁变化导致子组件被动渲染,可考虑将 state 下沉至最小使用范围。
- Context value 重建频繁,检查 Provider 的 value 是否每次均为新对象,必要时拆分 Provider。
- effect 依赖触发连锁 setState,检查 effect 依赖链,消除级联更新。
- memo 失效,检查传递给 memo 组件的 props 中是否存在每次渲染都变化的引用类型。
useContext 的性能退化
Provider value 变化时,所有订阅该 context 的组件都会重渲染,即使组件被 React.memo 包裹也无法阻止。订阅者数量较多或组件自身 render 耗时较大时,高频变化的 context 会导致掉帧。
useEffect 依赖数组创建成本
不仅依赖对比本身有开销,依赖数组内容每次渲染都创建新对象/数组时,会导致 effect 恒定执行:
jsx
useEffect(() => { ... }, [items.filter(i => i.active)])filter 每次创建新数组,effect 恒执行。可将过滤结果用 useMemo 包裹以稳定引用:
jsx
const activeIds = useMemo(() => items.filter(i => i.active).map(i => i.id), [items])
useEffect(() => { ... }, [activeIds])此处 useMemo 的主要收益在于稳定引用,避免 effect 恒定执行。
useTransition 与帧预算
useTransition 将长任务拆分为多个短任务,在渲染搜索结果等耗时操作时保证输入响应。不使用 transition 时,输入反馈和结果渲染合在一个同步任务中,可能接近帧预算上限;使用 transition 时,React 可在渲染超时后让出主线程,优先完成输入渲染,下一帧继续渲染结果。如果用户在结果渲染完成前再次输入,React 会中断当前 transition 渲染并丢弃中间结果。
常见问题
如果 useEffect 的依赖数组是 [obj],而 obj 每次渲染都被重新创建,会发生什么?
effect 会在每次渲染后执行。可拆分为原始值依赖、使用 useMemo 稳定引用,或在确实需要每次执行时使用 useRef 保存最新值并配合稳定 effect。
useMemo 返回的引用在什么情况下不保持稳定?
依赖数组中的值变化时,useMemo 一定会重新计算并返回新引用。如果依赖中使用了 useRef.current,修改 ref 不会触发重渲染,因此 useMemo 不会重新计算,返回的值可能不是最新的。
Context 拆分到什么程度是合适的?
按更新频率分组而非按数据类型分组。如果 user 和 theme 更新频率都很低,合并在一起没有问题。如果 route 每次导航都改变而 user 只在登录时改变,就应拆分。如果一个 context 只有一个组件消费,直接使用 props 即可。
React 18 并发模式下 useEffect 清理函数可能被调用两次吗?
在 StrictMode 下开发环境中 mount → unmount → mount 会执行两次 create 和 destroy,用于检测缺失的清理逻辑,实际使用不受影响。另外,如果渲染被中断并丢弃,对应的 effect 根本不会执行,既无 create 也无 destroy。
何时使用 useReducer 替代 useState?
当下一状态的计算依赖前一状态,且计算有多个分支时,useReducer 更合适。具体信号包括:一个事件处理中有多个 setState 调用且存在逻辑依赖;loading、error、data 等状态总是一起更新;需要测试状态转换逻辑;需要时间旅行调试。
useCallback 包裹的函数如何访问最新的 state?
可以通过将 state 加入依赖数组(函数引用会随 state 变化而重建),或通过 useRef 保存最新 state 并配合稳定引用函数。选择取决于是否需要维持传递给 React.memo 子组件的引用稳定。
为什么严格模式也应在渲染期间避免修改 useRef.current?
React 要求渲染是纯函数。在渲染中修改 ref.current 会引入隐藏的输出,且并发模式下渲染可能被中断重试,导致 ref 状态不一致。
若 useEffect 中发起的请求在返回前依赖变化,cleanup 调用 abort 后 React 如何行为?
cleanup 调用 controller.abort(),fetch 的 Promise 会 reject AbortError。React 不感知 effect 内的异步操作,不会做任何额外处理,完全依赖清理函数保证行为正确。
低优先级更新被高优先级更新打断后,低优先级更新的 effect 会如何?
render phase 被中断后,整个 work-in-progress 树可能被丢弃,被丢弃渲染中的 effect 不会执行。当低优先级更新被重新调度并完成时,新的 commit 产生新的 effect,中间状态不会产生 effect 执行。
useMemo 包裹的值什么时候会比直接计算更慢?
当依赖几乎每次渲染都变化时,useMemo 既需执行依赖对比,又需执行工厂函数,而工厂函数成本极低(如简单对象字面量),此时 useMemo 成为额外开销。
