ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

React Hooks 进阶指南:从类组件迁移到自定义 Hook 的实战经验

React Hooks 进阶指南:从类组件迁移到自定义 Hook 的实战经验 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切换成privateeffect 不会重新执行订阅的还是旧房间的连接。解决方式就是把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 拆成多个小 HookuseUserInfo负责用户信息usePostList负责帖子列表useAuth负责登录态。组合关系放在组件里const { user } useUserInfo(userId) const { posts, loading, refresh } usePostList(userId) const { isLoggedIn } useAuth()这样每一段逻辑都能独立复用、独立测试。原则可以类比为组件设计里的单一职责——Hook 的命名本身应该描述它承担的能力而不是描述页面。4.2 入参和出参的设计约定入参的设计有个很容易忽略的细节如果一个 Hook 的入参可能会频繁变化比如userIdHook 内部需要正确处理变化带来的更新。这通常意味着你要用 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 useLocalStorageT(key: string, initialValue: T) { const [storedValue, setStoredValue] useStateT(() { 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 执行请求请求回来 setDatadata 变化依赖数组发现 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 跟写类组件时的判断力完全不一样了。
返回列表