1. 为什么要抛弃类组件:Hooks 出现之前的日子
先说个真实感受。几年前我在一个中大型后台项目里维护一段业务组件,那个组件大概是这样的:有表单校验、有接口轮询、有路由参数监听、还有好几个componentDidUpdate里的分支判断。刚开始写的时候挺痛快,逻辑都写在生命周期里,按顺序执行就行。但两个月后再打开这个文件,我盯着屏幕整整十分钟没动手——componentDidMount里拉了数据,componentDidUpdate里要根据props.id的变化重新拉数据,getDerivedStateFromProps里还有个状态同步,componentWillUnmount要清定时器……这些逻辑散落在四个生命周期里,中间还夹着this绑定和bind传参,滑倒结尾发现还有三个高阶组件(HOC)套在外面,每个都往组件里注入了一堆不知道从哪来的 props。
类组件的核心问题,其实不是“类”本身难写,而是它把本该内聚的逻辑强行拆散到了固定的生命周期切片里。用户点击按钮触发一个请求,这件事的完整链路应该是“事件处理 -> 请求 -> 更新状态 -> 清理/取消”,但在类组件里,事件处理写在方法里,请求写在componentDidMount或componentDidUpdate里,状态更新可能触发componentDidUpdate再走一遍,清理又要回到componentWillUnmount。一个完整业务逻辑被切成了四五块,分别挂在组件生命周期的不同位置。修改一个功能的时候,你得同时改好几个地方,漏掉一个就是线上 bug。
再说逻辑复用。类组件时代的复用方案是 HOC 和 Render Props。体验过的都知道,HOC 嵌套多了之后,组件层级会变得非常深。我见过一个项目里某页面组件被套了七层 HOC,打开 React DevTools,组件树里密密麻麻全是withRouter(withAuth(withI18n(withTheme(withXxx(...)))))。每一个 HOC 都会在 props 上加点东西,你根本分不清某个 props 到底是哪一层 HOC 注入的。调试的时候要在组件树里一层一层点进去看,特别痛苦。
Hooks 的出现把这些老难题一次性解决了。它把“按生命周期组织逻辑”变成了“按业务关注点组织逻辑”,想拉数据就写一个useFetch,想处理表单就写一个useForm,想监听路由参数变化就写一个小的 effect,代码天然被业务边界拆开,跟组件渲染逻辑解耦。这也是为什么 React 官方和社区都明确建议新代码全部使用 Hooks 编写。我个人觉得,对已经上手的开发者来说,真正需要转变的不是 API 记忆,而是组织代码的心智模型。这篇文章我就把这两年从类组件迁移到 Hooks 过程中的关键经验、底层机制和踩过的坑一次性讲清楚。
2. 核心 Hooks 的底层逻辑:理解机制比背 API 重要
很多人用 Hooks 停留在“照着例子写”的阶段,能跑通但不知道为什么。一旦遇到闭包陷阱、死循环、数据不同步这类经典问题,就只能靠试。想真正驾驭 Hooks,必须理解几个关键机制。
2.1 useState 不是赋值,是调度
先看最常见也最容易误解的useState。很多从类组件转过来的同学会下意识觉得:
const [count, setCount] = useState(0) count = count + 1 // 错但在函数组件里,count这个变量在每次渲染时都是一个全新的常量,它来自 React 通过 Dispatcher 从 Fiber 节点上读取的当前状态快照,然后作为本次渲染的闭合变量传进来。你永远不应该也不能直接修改它。setCount(newValue)做的事情是把新状态提交给 React 的调度器,而不是同步修改当前的count变量。
这个机制带来两个直接后果。
第一个后果是连续调用的批处理。在 React 18 之前,React 只在事件处理器内部做自动批处理,异步回调里的setState不会合并。提升到 18 之后,自动批处理已覆盖所有场景(Promise、setTimeout、原生事件),每个线程执行序列结尾只触发一次重渲染。所以你在同一个事件里连续写:
setCount(c => c + 1) setCount(c => c + 1) setCount(c => c + 1)最终count只会增加 1 还是增加 3?答案是增加 3。因为当传入的是函数参数时,React 会把更新函数放进调度队列,依次执行前一次的计算结果传给下一次。这就是为什么官方强调“如果新状态依赖旧状态,必须使用函数式更新”——你直接传count + 1的话,三次调用拿到的都是同一份渲染闭包里的旧值,最终只加了一次。
第二个后果是多次 setState 的重新渲染次数不等于 setState 次数。React 会对比新旧状态来决定是否真的需要渲染。如果是同一个值(用Object.is比较),它会直接跳过这次更新,这在高频操作场景下是个天然的优化。但要注意对象和数组:你写setObj({...obj, a: 1}),每次拿到的几乎都是新引用,React 必然触发渲染。想要在状态是引用类型时利用这个跳过机制,必须保证值的引用没变,这个特性可以在很多场景下省去不必要的渲染。
2.2 useEffect 的依赖数组:真相与陷阱
useEffect是所有 Hooks 里最容易被误用的一个。官方文档的定义是“在渲染结束后执行副作用”,但很多同学把它当成了类组件componentDidMount和componentDidUpdate的合体——这么理解没什么问题,却忽略了最核心的两个点:时机和依赖一致性。
副作用执行的时机是在浏览器完成布局和绘制之后。也就是说,useEffect中的代码默认是异步执行的,不会阻塞用户看到页面。这意味着如果你直接在 effect 里测量 DOM 尺寸然后立刻去读,可能读到的是绘制前的尺寸;如果这个测量结果还要驱动一次状态更新,页面就会闪烁一下。这种场景下应该用useLayoutEffect,它会在 DOM 变更后、浏览器绘制前同步执行,适合做 DOM 测量、样式调整这类“必须在用户看到之前完成”的操作。
依赖数组的设计初衷是让 effect 只在定义的依赖变化时重新执行。这里面的核心问题是闭包捕获。第一次渲染完成后,effect 函数会捕获本次渲染的 state 和 props,这个捕获关系在渲染之间是独立的。如果你的 effect 依赖了某个值但没写进依赖数组,React 就不会在下次渲染时替换掉旧的 effect,那么 effect 里读到的永远是上一次渲染的值——这就是“陈旧闭包(stale closure)”。
看个典型例子:
function ChatRoom({ roomId }) { const [messages, setMessages] = useState([]) useEffect(() => { const sub = createConnection(roomId) // 用了 roomId sub.onMessage(msg => { setMessages(prev => [...prev, msg]) }) return () => sub.disconnect() }, []) // 漏了 roomId // ... }这里的roomId如果从'general'切换成'private',effect 不会重新执行,订阅的还是旧房间的连接。解决方式就是把roomId写进依赖数组:
useEffect(() => { const sub = createConnection(roomId) // ... return () => sub.disconnect() }, [roomId])这里还有一层更深的含义:依赖数组是 React 用来判断“effect 是否需要重新执行”的凭据,而不是开发者的备忘提醒。所以官方提供了eslint-plugin-react-hooks,它的核心规则exhaustive-deps会强行要求你把 effect 中引用的所有响应式值都写进依赖。很多初学阶段觉得这个规则烦人,但实际上它恰恰帮你拦截了大量潜在的陈旧闭包问题。我的建议是:开启这个规则,并把它当成硬性规范,遇到“实在不知道该怎么写依赖”的情况,先停下来想清楚,而不是直接禁掉规则。
2.3 useMemo 和 useCallback:防止引用变化,而不是防止计算慢
这两个 Hooks 被大量同学理解为“性能优化工具”,用法是“把所有函数和计算结果都包上一层”。这是个很普遍也很危险的误解。
先看它们真正的用途。函数组件每次渲染都会重新执行整个函数体,这意味着组件里定义的对象、数组、函数——包括通过 props 传给子组件的——都会在每次渲染时得到新的引用。而子组件如果被React.memo包裹,它做浅比较时发现 props 引用变了,就会重新渲染。所以useMemo和useCallback真正解决的是引用稳定性问题:
const handleSubmit = useCallback((data) => { // 提交逻辑 }, [deps]) const searchResult = useMemo(() => { return heavyComputation(query) }, [query])第一段里的handleSubmit在依赖不变时始终保持同一份引用,配合React.memo的子组件可以大概率跳过不必要的重渲染。第二段里的searchResult在query不变时也保持同一份引用,适合作为 props 传入需要浅比较的组件或作为另一个useMemo的依赖。
但这里有个很关键的反直觉点:useMemo并不能保证“只计算一次”。React 官方文档明确说明,React 可能在后台丢弃某些缓存并重新计算,比如组件卸载后重新挂载,或者为了释放内存主动丢弃。所以不要用useMemo来保证某个昂贵计算只执行一次——它只是一个缓存提示,不是硬性承诺。真正保证只执行一次的场景,应该用useRef或模块级变量。
更重要的是,过早地给每个值都加上useMemo/useCallback本身就有开销。每次渲染时收集依赖、对比依赖、决定是否更新缓存,这些操作也会消耗资源和心智负担。我见过不少代码把简单函数包在useCallback里,依赖数组长达十几项,可读性急转直下,收益却趋近于零。合理的做法是:先不做优化,等真的出现重复渲染导致的性能瓶颈,借助 React DevTools 的 Profiler 定位到热点组件,再针对性地用useMemo/useCallback。优化应该由测量驱动,由数据驱动,而不是由猜测驱动。
2.4 useRef:跨越渲染周期的“活档案柜”
useRef大概是核心 Hooks 里面最“不起眼”却最实用的一个。它返回一个{ current: initialValue }这样的对象,这个对象在组件的整个生命周期里保持同一个引用,修改current不会触发重新渲染。
我自己的总结是它有三个典型用途。一是持有一个“不需要触发渲染的可变值”,比如定时器 id、滚动位置、是否已发送埋点的标志位:
const timerRef = useRef(null) const start = () => { timerRef.current = setInterval(() => { ... }, 1000) } const stop = () => { clearInterval(timerRef.current) }二是保存上一次的值,配合 effect 使用:
const [count, setCount] = useState(0) const prevCountRef = useRef(0) useEffect(() => { prevCountRef.current = count }, [count])三是获取 DOM 节点引用:
const inputRef = useRef(null) useEffect(() => { inputRef.current?.focus() // 渲染完成后聚焦 }, [])这里有个容易踩的坑:在 render 过程中直接读ref.current去驱动渲染逻辑。因为修改ref.current不会触发重新渲染,你在 render 过程中读到的可能是上一次的旧值。如果你需要基于某个可变值来渲染,请用useState而不是useRef。简单记忆方式:影响 UI 的值用 state,不影响 UI 的值用 ref。
值得一提的是,React 19 的useRef支持直接传入一个初始函数,在首次渲染时惰性初始化,这个特性在创建昂贵对象时很有用。不过当前大部分项目还是 18,写法上保持useRef(initialValue)即可。
3. 从写 Demo 到写业务:三个高频场景的 Hooks 化改造
光理解了机制还不够,Hooks 的价值要在实际业务场景里体现。下面分享三个我反复用到的场景,直接把改造前后对比写出来,方便你照着落地。
3.1 表单:用自定义 Hook 收敛状态和校验逻辑
假设你要做一个登录表单,有用户名、密码两个字段,要校验非空、长度,还要控制提交按钮的 loading 状态。类组件版本要写四五个 state 加一个校验函数,信息分散。而 Hooks 版本可以简洁很多:
import { useState, useCallback } from 'react' function useForm(initialValues, validate) { const [values, setValues] = useState(initialValues) const [errors, setErrors] = useState({}) const [isSubmitting, setSubmitting] = useState(false) const handleChange = useCallback((event) => { const { name, value } = event.target setValues(prev => ({ ...prev, [name]: value })) }, []) const handleSubmit = useCallback(async (onSubmit) => { const validationErrors = validate ? validate(values) : {} setErrors(validationErrors) if (Object.keys(validationErrors).length > 0) { return } setSubmitting(true) try { await onSubmit(values) } finally { setSubmitting(false) } }, [values, validate]) return { values, errors, isSubmitting, handleChange, handleSubmit } }在组件里这样用:
function LoginForm() { const { values, errors, isSubmitting, handleChange, handleSubmit } = useForm( { username: '', password: '' }, (values) => { const errors = {} if (!values.username) errors.username = '请输入用户名' if (values.password.length < 6) errors.password = '密码至少 6 位' return errors } ) return ( <form onSubmit={(e) => { e.preventDefault() handleSubmit(async (values) => { await api.login(values) }) }}> {/* 表单控件 */} </form> ) }这里有个关键设计点:useForm返回的handleSubmit接收一个回调参数onSubmit,也就是说这个 Hook 只负责“表单状态和校验流程”,不关心提交时具体做什么。这样表单逻辑和业务逻辑彻底解耦,整个 Hook 可以任意复用在登录、注册、修改资料等所有表单场景。useCallback在这里的用法也体现了上一节说的原则——handleChange不依赖任何状态,用空依赖数组保证引用稳定;handleSubmit依赖values和validate,依赖变了才会重新创建函数。
3.2 数据请求:封装可取消、可防抖的 useRequest
数据请求是日常开发里最频繁的场景。我在封装useRequest时参考了很多开源实现,但有一个细节经常被忽略:组件卸载后还在 setState。React 18 之前,这种操作会直接报警告;18 之后警告没了,但依然会有内存泄漏的隐患。所以封装里最好带上取消逻辑。
基础版本:
function useRequest(requestFn, deps = []) { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const abortRef = useRef(null) const run = useCallback(async (...args) => { abortRef.current?.abort() const controller = new AbortController() abortRef.current = controller setLoading(true) setError(null) try { const result = await requestFn(...args, { signal: controller.signal }) setData(result) return result } catch (err) { if (err.name !== 'AbortError') { setError(err) } } finally { setLoading(false) } }, [requestFn]) useEffect(() => { run() // 卸载时取消请求 return () => { abortRef.current?.abort() } // eslint-disable-next-line react-hooks/exhaustive-deps }, deps) return { data, loading, error, run, setData } }篇幅有限这里不展开所有边界,但有几个点值得强调。一是使用了AbortController来实现真正的请求取消,而不仅仅是“忽略结果”。二是run暴露给外部,允许在用户手动触发某个动作时重新请求,比如点击“刷新”。三是返回了setData,方便在列表页做本地乐观更新(比如删除一条数据后本地过滤),这个 API 设计能覆盖绝大多数列表场景。实际项目中你再加点防抖、缓存、错误重试逻辑,就是一个几乎能替代 axios 请求层与组件结合部的通用能力。
3.3 组件间共享状态:useContext 与 useReducer 的组合拳
如果你的项目没有引入 Redux 这类全局状态库,但又有跨层级组件共享状态的需求(比如用户信息、主题配置、购物车),Hooks 生态里最自然的方案就是useContext加useReducer。
很多同学用useContext直接存对象,然后在组件里setState更新它。这有个隐患:context 值一变化,所有消费这个 context 的组件都会重新渲染,无论它们是否真的需要用到的部分变了。优化思路有两个方向:一个是在 Provider 的value外面包useMemo,确保只有相关依赖变化时引用才变;另一个是拆分 context——把“用户信息”和“主题设置”拆成两个独立的 Context,而不是塞进一个大对象里。
useReducer适合状态转换规则比较复杂的场合,比如购物车有加、减、删除、清空、批量设置等多种操作:
function cartReducer(state, action) { switch (action.type) { case 'add': { const exist = state.items.find(item => item.id === action.payload.id) if (exist) { return { ...state, items: state.items.map(item => item.id === action.payload.id ? { ...item, count: item.count + 1 } : item ) } } return { ...state, items: [...state.items, { ...action.payload, count: 1 }] } } case 'remove': return { ...state, items: state.items.filter(item => item.id !== action.payload.id) } // 更多 case default: return state } }useReducer的设计哲学是把变化集中到一个 reducer 纯函数里,这样所有状态修改路径都在一个函数内可以审计,调试时打印 action 和 state 就能快速定位问题。搭配useContext把dispatch下发到深层子组件,子组件不直接改状态,而是发出 action,状态转换的唯一入口在 reducer,这在大型表单页、嵌套组件树里能显著降低状态失控的风险。
4. 自定义 Hooks 的设计规范:怎么写出别人愿意用的 Hook
自定义 Hook 是 Hooks 体系里最强大的能力,它是你提取复用逻辑的天然单元。但正因为自由度高,反而容易写出“只有自己能看懂”的 Hook。根据我的经验,好的自定义 Hook 有几个可对标的设计原则。
4.1 一个 Hook 只做一件事,以业务能力命名
看一个常见反例:
const { user, posts, isLoggedIn, loading, refresh } = useUserPage()这个 Hook 把用户信息、帖子列表、登录状态全塞在一起,组件的所有逻辑都靠它来“吐数据”。表面上代码是简洁了,但只要有一个状态出问题,你就要去翻这个 Hook 的实现。我建议把一个大 Hook 拆成多个小 Hook:useUserInfo负责用户信息,usePostList负责帖子列表,useAuth负责登录态。组合关系放在组件里:
const { user } = useUserInfo(userId) const { posts, loading, refresh } = usePostList(userId) const { isLoggedIn } = useAuth()这样每一段逻辑都能独立复用、独立测试。原则可以类比为组件设计里的单一职责——Hook 的命名本身应该描述它承担的能力,而不是描述页面。
4.2 入参和出参的设计约定
入参的设计有个很容易忽略的细节:如果一个 Hook 的入参可能会频繁变化(比如userId),Hook 内部需要正确处理变化带来的更新。这通常意味着你要用 effect 监听入参变化并重新执行逻辑:
function usePostList(userId) { const [posts, setPosts] = useState([]) useEffect(() => { let ignore = false fetchPosts(userId).then(data => { if (!ignore) setPosts(data) }) return () => { ignore = true } }, [userId]) return posts }这里用ignore标志位来防止竞态——用户快速切换userId时,较早的请求结果返回后不会覆盖较新的状态,这是生产环境必须处理的细节。
出参的设计我一般遵循三个约定:一是用数组或对象返回多个值,数量少(2-3 个)用数组便于自定义命名,超过 3 个用对象避免记忆顺序;二是回传操作函数(比如refresh、reset),让调用方拥有主动控制权;三是避免返回不必要的函数,每个返回函数都要有明确的使用场景。另外,如果有些值是内部使用的中间态,就不要暴露出来,保持 API 面最小。
4.3 组合优于继承,也优于包揽
Hooks 之间可以互相调用,这是它比类组件组合能力更强的地方。一个 Hook 可以依赖另一个 Hook 的输出。比如useUserProfile可以组合useUserInfo和usePostList,然后在内部按需组织数据:
function useUserProfile(userId) { const { user, loading: userLoading } = useUserInfo(userId) const { posts, loading: postsLoading } = usePostList(userId) return { user, posts, loading: userLoading || postsLoading } }这种组合式设计让每个底层 Hook 都是可独立测试、独立复用的原子能力,上层 Hook 只是这些能力的编排者。团队里沉淀的自定义 Hook 库可以像乐高积木一样被不同业务场景组装,复用率会指数级提升。
4.4 加上 TypeScript 泛型,让 Hook 更抗造
如果你团队用的是 TypeScript,我强烈建议给自定义 Hook 加上泛型。以useLocalStorage为例:
function useLocalStorage<T>(key: string, initialValue: T) { const [storedValue, setStoredValue] = useState<T>(() => { try { const item = window.localStorage.getItem(key) return item ? JSON.parse(item) as T : initialValue } catch { return initialValue } }) const setValue = useCallback((value: T | ((prev: T) => T)) => { setStoredValue(prev => { const next = value instanceof Function ? value(prev) : value window.localStorage.setItem(key, JSON.stringify(next)) return next }) }, [key]) return [storedValue, setValue] as const }泛型T让这个 Hook 的调用方可以精确获得类型提示,而不是全部 fallback 成unknown或any。好的 Hook API 设计不仅是运行时的行为正确,还包括编译期的类型安全。
5. 我在生产环境踩过的那些 Hooks 坑:完整排查链路
再熟练也有翻车的时候。下面几个坑是我和团队在真实环境里遇到的问题,我直接按排查链路复盘,方便你复现思路,而不是只看结论。
5.1 痛点:修改对象属性的 setState 不生效
某一次同事找我,说代码里改了对象里的一个字段,界面没有任何变化。他的写法大概是:
const [user, setUser] = useState({ name: '张三', age: 18 }) const handleAge = () => { user.age = 19 // 直接修改对象属性 setUser(user) // 传入同一个对象引用 }排查链路是这样的:页面上没变化,首先怀疑是不是渲染被缓存了,但检查发现没包 memo;接着怀疑是不是 setState 没调用,加断点发现真的调用了;继续往下一层看,发现setUser(user)里传入的user引用根本没有变化。React 用Object.is对比新旧状态时,发现是同一个对象,直接跳过了更新。而且问题更隐蔽的地方在于,这个对象被改掉了,因为闭包捕获的也是这个对象,后续读取到的都是改了之后的。这个 bug 在排查时很容易被忽略,因为断点显示的对象内容确实变了。
修复方式很简单,一定要创建新对象:
const handleAge = () => { setUser(prev => ({ ...prev, age: prev.age + 1 })) }这里也引出我后来一直贯彻的规范:state 中的对象和数组一律不可变更新。这个规范和 Redux 的 reducer 理念一致,但因为 useState 没有强制约束,容易在团队里被突破。我建议给 ESLint 配置里加上react/no-direct-mutation-state之类的规则,在代码层面堵住这条路。
5.2 痛点:useEffect 里的死循环
另一个高频问题:页面卡死、控制台疯狂打印请求。典型代码长这样:
const [data, setData] = useState(null) useEffect(() => { getUser().then(res => setData(res.data)) }, [data]) // 依赖了 data第一次渲染结束,effect 执行请求,请求回来 setData,data 变化,依赖数组发现 data 变了,effect 又执行,又请求,又 setData……死循环就此形成。排查时我先看依赖数组,很容易定位。但有些死循环不那么明显,比如:
const config = { page: 1, size: 10 } // 每次渲染都是新对象 useEffect(() => { fetchList(config) }, [config]) // 依赖的是一个每次渲染都新建的对象这里config是每次渲染都重新创建的字面量对象,引用每次都变,所以 effect 每次渲染后都会重新执行,产生无限循环。修复方式是把这个对象移到组件外部(如果它是常量)或用useMemo包起来保证引用稳定。
这类问题的共性根源是:effect 依赖了“每次渲染都产生新引用”的值。排查思路是三步:先看依赖数组里有没有对象/数组/函数这类引用类型;再看这些值是不是每次渲染都会重新创建;最后用useMemo/useCallback/提升到模块作用域来稳定引用。另外强调一下,useEffect里如果 setState 的数据又反过来成为依赖,一定要判断是否有“状态变化 -> effect 重新执行 -> 再 setState”这样的闭环。没有正确设置终止条件,就会死循环。
5.3 痛点:列表接口竞态导致的数据错乱
还有一个隐蔽但后果严重的问题。在一个搜索列表页,用户快速输入关键词时,请求会以不同顺序返回。如果只是“后发出的请求最后覆盖”,那一般是最后输入的关键词对应结果,偶尔会出现早先发出的慢请求覆盖了后来的结果——看到的现象是输入“前端”后,列表先短暂展示正确数据,随后被旧关键词“React”的结果覆盖了。
这个问题的本质是请求返回顺序与发起顺序不一致,本质上是并发请求的竞态条件。我修复时用到了一个常见的模式,就给每个请求加一个“当前有效”标志,只在当前请求仍然是最新时才更新状态:
useEffect(() => { let ignore = false // 关键:当前 effect 是否还有效 const controller = new AbortController() searchPosts(keyword, { signal: controller.signal }) .then(data => { if (!ignore) setPosts(data) }) .catch(err => { if (err.name !== 'AbortError') setError(err) }) return () => { ignore = true controller.abort() } }, [keyword])当keyword变化时,React 会先执行上一个 effect 的清理函数,把ignore设成 true,并中断之前的请求。这样旧请求即使返回了也不会再更新状态。这个模式我几乎用在所有请求类 Hook 里,成本极低但收益极高。顺便说一句,这个“忽略过期结果”和“真正取消请求”两个维度最好都做,AbortController负责网络层取消,ignore标志负责防止已取消或过期请求的 then 回调污染状态。
5.4 痛点:React Native 里初次进入页面白屏
搞 React Native 的兄弟应该经历过:一个页面里用了大量 Hooks,首次进入时出现短暂白屏,过一两秒内容才出来。排查后发现不是数据没回来,而是effect 里同步加载了本地的加密图片或字体资源,导致 JS 线程被阻塞,UI 渲染被推迟。
我的排查链路是:先在页面useEffect的入口加日志,发现日志确实打印了,但 UI 迟迟没渲染;接着怀疑是导航框架问题,把页面换成最简单的文本组件,白屏消失;于是二分法逐段注释业务代码,最后定位到有一段在 render 过程中同步执行了比较大的字符串解码和图片解码操作。修复方式是把这个同步操作用requestAnimationFrame或InteractionManager.runAfterInteractions推迟到渲染空闲时执行,或者把资源预加载提前到进入页面之前。这里提醒一下,在函数组件 render 过程中做耗时同步计算是白屏的常见元凶,不只是 RN 里,Web 端大量数据渲染一样会导致掉帧。Hooks 只是让你写代码更顺手,但该注意的性能约束一点没少。
6. 面试与团队落地:Hooks 时代必须掌握的核心问答
这个标题的关联热词里有不少“React 面试题”“前端面试题 hooks”的搜索,说明很多人正在准备相关面试。我把这些年面试候选人和被面试过程中反复出现的 Hooks 高频问题做一个整理,附带我的回答思路,希望能帮到正在准备的同学。
6.1 面试官最常问的五个 Hooks 问题
问题一:为什么 React 要推出 Hooks?它解决了类组件的哪些问题?
面试官期待的关键词一般有三个:状态逻辑复用难、生命周期割裂业务逻辑、this绑定和 HOC 嵌套层级深。但如果你只背这三个点,容易被认为是“背答案”。我的建议是各举一个真实的开发中例子,说清楚你写类组件时遇到了什么具体困惑,然后说明 Hooks 是怎么通过“函数式组件 + 副作用分离”解决的。有实际项目经历的描述,比单纯列优点更有说服力。
问题二:useEffect 和 useLayoutEffect 的区别?
简短版本:两者 API 完全一致,区别在执行的时机。useEffect在浏览器完成绘制后异步执行,适合请求数据、订阅监听、埋点这类不阻塞 UI 的操作;useLayoutEffect在 DOM 变更后、浏览器绘制前同步执行,适合读取 DOM 布局、修改样式、测量元素尺寸。使用上优先useEffect,只有在会闪烁或需要同步操作 DOM 时才用useLayoutEffect。此外,如果是在服务端渲染(SSR,比如 next.js)环境里,useLayoutEffect会收到警告,建议用useIsomorphicLayoutEffect这类兼容封装。
问题三:为什么 Hooks 不能放在循环、条件和嵌套函数里?
这是 Hooks 规则的底层原因:React 依赖调用顺序来把每次渲染中产生的 state 和 effect 对应到同一个 Hook 上。如果你在某次渲染中跳过了一个 Hook,后续所有 Hook 的序号都会错位,状态就全乱了。这是函数组件与类组件非常不同的地方——类组件每个成员变量的位置是固定的,而函数组件是每次重新执行、按调用顺序注册。记住这个顺序机制,就理解了 Hook 的规则。
问题四:如何理解闭包陷阱?怎么避免?
闭包陷阱的直接表现是:在 effect 或异步回调里读到的 state 不是最新的。原因是在某次渲染中,effect 函数捕获的是该次渲染的 state 快照。避免手段有三层:依赖数组写全、用函数式更新(setState(prev => ...))、用 ref 保存最新值(在需要“读最新值但不重新触发 effect”的场景)。面试时能讲清楚这三层,说明你是真的踩过坑。
问题五:React 是怎么在底层实现 Hooks 的?
这个问题现在越来越常问。面试官想听的深度大概是:每个函数组件在 Fiber 节点上有一个memoizedState链表,非常像单向链表,每个 Hook 依次挂载到这个链表上。useState的内部实现会读取当前 Fiber 的 Hook 节点,从queue里取出更新链表执行计算,得到最新状态。更新时 React 会根据 Hook 顺序遍历链表,找到对应的那个 Hook 节点,更新它的memoizedState。这也解释了为什么 Hooks 顺序不能变。如果对这个感兴趣,可以直接去看 react-reconciler 源码里ReactFiberHooks.js,非常清晰。
6.2 团队工程化落地:把规范变成本能
光面试答得好还不够,Hooks 要在团队里稳定落地,得有配套的工程化约束。说几个我实践下来特别有效的做法。
第一是强制使用 ESLint 插件。eslint-plugin-react-hooks里rules-of-hooks和exhaustive-deps两条规则必须开,并且集成到 CI 里面,不能只停留在编辑器里。这两条规则能在编码阶段拦截掉一半以上的 Hooks 常见问题。
第二是建立自定义 Hook 的统一收口机制。团队里沉淀的 Hook 统一放在src/hooks目录,按照领域分子目录(如useRequest、useForm、useAuth),命名以use开头。新代码优先从已有 Hook 组合,而不是重新造轮子。代码评审时,看到重复逻辑先问“能不能抽成 Hook”,而不是“能不能复制粘贴”。
第三是约定请求类和状态类的标准封装模式。比如统一封装useRequest这类 Hook,业务代码里禁止直接裸写axios加useEffect的组合。有了这层约束,团队在请求竞态、取消、错误处理这些横切关注点上就能保持一致的行为,而不是每个组件各写各的。
第四是关注 React 生态的演进。React 19 正式发布后,useOptimistic、useActionState、Server Components 这些新能力已经开始进入生产环境,next.js 和 vite + react 这类现代 React 工具链也对 Hooks 的支撑各不相同。我的建议是:团队核心库和组件库保持在最新稳定版本往上靠一版,新的业务代码默认使用新特性,旧代码有节奏地推进迁移,而不是一上来就彻底重写。
7. 最后再分享两个小技巧
第一个技巧是关于 Hooks 命名。自定义 Hook 必须以use开头,这是 React 官方的约定,因为 React 的 ESLint 插件正是靠这个前缀来识别 Hooks 并执行规则检查。如果你把 Hook 命名为getUserData之类的普通函数名,rules-of-hooks就不会对它进行检查,里面的 useState 用了也可能不会被规则捕获,等于白白失去了一道保险。
第二个技巧是给 Hooks 写测试。自定义 Hook 不能直接在组件外调用,需要用@testing-library/react里的renderHook来测试。我在团队里落地了一个约定:凡是涉及异步请求、本地存储、路由参数这些有副作用的自定义 Hook,必须配套一个单元测试。测试用例覆盖正常返回、请求失败、竞态三个场景,成本不高,但能把组件这个最容易出 bug 的边界给兜住。
React Hooks 发展到今天已经不是新东西了,但它挑战的“按生命周期组织代码”的惯性思维,依然是很多前端开发转型路上的最大阻碍。工具会不停换代,从 Class Component 到 Hooks,再到 React 19 的编译器和 Server Components,但贯穿始终的是同一个道理:组件的状态变化应该可预测,逻辑复用应该天然内聚,副作用应该让开发者明确感知。理解了这一点,你会发现自己写 Hooks 跟写类组件时的判断力完全不一样了。