声明:本文涉及的"源码"部分均为大幅简化的伪代码 ,目的是帮助理解核心机制,并非真实的 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]
}
几个关键点:
- 为什么
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
}
- 为什么初始值只用一次 :因为首次渲染后,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,不要盲目添加。
有什么问题或者补充欢迎评论区交流。