前段时间有个后辈去面前端,回来跟我复盘的时候说:面试官问了一句“React Hooks 你理解吗?常用的有哪些”,他直接背出了八九个 Hook 的名字,列举了 useState、useEffect、useMemo,面试官点了点头,接着追问了一句“那为什么 Hooks 不能写在条件判断里”,他当场卡住了。这个问题其实特别典型——“常用的有哪些”几乎人人都能列出一串,但“理解”两个字,考的是你有没有真正踩过它的运行逻辑。
我在团队里带人也有几年了,面试候选人时也爱问这个问题。我关心的从来不只是那个 API 清单,而是候选人对机制的理解、对状态的思考方式,以及对踩坑场景的敏感度。这篇我就把 React Hooks 的来龙去脉、底层逻辑、常用 API 的实战用法和常见坑完整梳理一遍。内容既适合准备面试的人,也适合已经用 Hooks 写了一阵子、但总在依赖数组和闭包问题上犯迷糊的开发者。
1. 从 Class 时代走到 Hooks:先搞清楚它真正解决的是什么
1.1 Class 组件时代的四个结构性痛点
在 Hooks 出现之前,React 组件的状态逻辑有两个主要载体:Class 组件和高阶组件(HOC)。我在 2018 年前后写过不少 Class 组件,说实话,Class 不是不能用,但它的别扭之处会在项目大了以后集中爆发。
第一是this撞墙。Class 组件里到处都是事件处理函数的绑定问题,.bind(this)、箭头函数、构造器里预绑定,写法五花八门,新人进来看到一堆this.handleClick = this.handleClick.bind(this)直接懵掉。一旦函数被作为回调传下去,this丢了,就是线上 bug。Hooks 把状态和函数都变成普通变量,彻底绕开了这堵墙。
第二是生命周期把逻辑撕碎了。一个常见场景:组件里既要在componentDidMount里发请求和监听事件,又要在componentDidUpdate里根据 props 变化重新拉数据,最后还要在componentWillUnmount里清理监听。一个完整的功能逻辑被拆成三段,散落在三个生命周期函数里;如果页面里有五六个这样的功能,你就只能在生命周期函数里来回跳着找代码。Hooks 的做法是用useEffect把“同一个副作用”的启动、更新、清理放在同一处,从“按时间切片”变成“按功能聚合”。
第三是逻辑复用困难。Class 时代复用有状态逻辑,主流方案是 HOC 和 Render Props。HOC 层层嵌套之后,调试 call stack 深不见底,还会出现 props 命名冲突;Render Props 的嵌套回调也难看得要命。而且这两种方案都会给组件树额外包一层节点,项目大了开发工具里能看到一堆<WithHover><WithRouter><WithAuth>之类的“包裹地狱”。自定义 Hook 则可以让两个组件直接共享逻辑,不增加任何多余的嵌套层级。
第四是 Class 组件本身对人和编译器都不友好。constructor里忘记调super(props)、方法绑定错误这类基础问题反复出现;代码压缩和 tree shaking 对 Class 的处理也不如函数灵活。函数组件配合 Hooks 之后,状态逻辑、副作用、派生数据全部以普通函数的形式组织,心智负担明显小一截。
1.2 Hooks 的本质:把 React 特性“钩”进函数组件
官方文档的说法是:Hooks 让你在不编写 Class 的情况下使用状态和其他 React 特性。这个定义准确,但对很多人来说太抽象了。
我的理解是:函数组件本身只是一个“纯函数”——输入 props,输出 JSX。它天然是确定性的,同一次渲染,同样的 props,产出一模一样的 UI。这种方式简单、可靠,可它没有状态、没有副作用、没有生命周期,一套纯函数模型在真实业务里撑不住。Hooks 就是一组“接口”,把这些原本只有 Class 才有的能力,一个一个再装回到函数组件身上。
useState装上的是内部状态能力,useEffect装上的是副作用管理能力,useContext装上的是跨层级取数据的能力,useMemo/useCallback装上的是性能优化的能力。函数组件相当于一台没有外设的裸机,Hooks 就是外设接口,你需要什么,就插什么。这也是“Hooks”这个名字的由来:它是一组钩子,把 React 的运行时能力挂到函数上。我建议所有学 Hooks 的人都在脑子里建立这个模型,而不是只把它当成“在函数组件里写状态的语法糖”。
2. 为什么函数组件能“记住”状态:Fiber 链表和调用顺序
2.1 状态到底存在哪里
很多人第一次看useState的代码都会好奇:函数组件每次渲染都重新执行一遍,执行完毕局部变量全部销毁,那下一次渲染时useState是怎么把上一次的值取出来的?它总得有个地方“存”吧。
答案是:状态不是存在函数作用域里,而是存在 React 内部的 Fiber 节点上。Fiber 是 React 16 开始引入的架构,可以把它理解成每个组件实例对应的一个“内存对象”,里面保存了组件类型、props、state、副作用标记、以及一个非常关键的东西——Hooks 链表。
每次调用useState、useEffect、useRef这些 Hook 时,React 并不是凭空变出一个状态来,而是沿着当前 Fiber 节点上维护的这个 Hooks 链表,顺序往下取节点。第一次渲染时链表是空的,React 一个个创建 Hook 节点,填进去;后续渲染时,React 按同样的顺序去链表里把对应的节点捞出来,把之前保存的 state 返回给你。
所以有一句话特别重要:Hooks 是“附着”在 Fiber 节点上的,而它的身份由调用顺序决定,不是由名字决定。这也是为什么两个useState调用之间不能插入条件判断——React 根本不在乎你给变量起什么名字,它只认第几个调用。
2.2 顺序即契约:条件调用为什么是灾难
假设你写了这样的代码:
function Component({ showName }) { const [count, setCount] = useState(0); if (showName) { const [name, setName] = useState('张三'); } const [age, setAge] = useState(20); }第一次渲染时showName是true,链表结构是:节点0(count)、节点1(name)、节点2(age)。第二次渲染showName变成false,条件不成立,name那个 Hook 没执行,此时setAge对应的调用顺序落到了链表里第1个节点上——而第1个节点里存的是name的值。于是age拿到的是'张三',链表错位,状态全部乱套。
React 为了保证不出现这种错乱,干脆设了一条硬性规则:Hooks 只能在组件顶层调用。所谓“顶层”,就是不能在条件、循环、嵌套函数里调用。这很大程度上不是道德要求,而是技术约束——只要调用顺序稳定,React 就能用位置匹配状态;一旦顺序不稳定,整个匹配机制就崩了。你可以把 Hooks 链表想象成一排固定编号的储物柜,你每次都得从第一个柜子开始按顺序打开,跳过一个柜子再开后面的,拿到的肯定不是自己放进去的东西。
这个机制也解释了为什么自定义 Hook 必须以use开头命名。它不只是一个约定,因为 React 有专门的 lint 规则(eslint-plugin-react-hooks)去检查一个函数内部有没有调用其他 Hooks;而它判断一个函数是不是 Hook 的方式,就是看函数名是否以use开头。名字不对,检查规则就不生效,条件调用就可能在项目里悄悄溜达进去。
3. 高频三件套:useState / useEffect / useRef 的正确姿势
3.1 useState 不只是 setState 的替代品
useState是最基础的状态 Hook,但它的用法细节比大多数人认识的要多。
第一,初始值可以是惰性初始化。如果初始值需要经过复杂计算(比如从 localStorage 读取、遍历数组聚合),不要直接写成useState(expensiveFn()),这样每次渲染都会执行一次expensiveFn()然后把结果丢掉——纯白算。要写useState(() => expensiveFn()),把计算放进函数里,React 只在首次渲染时执行一次,之后完全跳过。
第二,更新函数有两种形态:
const [count, setCount] = useState(0); // 直接传值 setCount(count + 1); // 函数式更新 setCount(prev => prev + 1);在事件处理函数里两种写法大多数时候等价,但如果在异步队列里连续更新,或者依赖的是“旧状态”,函数式更新就是唯一的正确选择。典型例子是 setInterval 回调里想基于最新状态更新计数。闭包会捕获旧值,直接setCount(count + 1)永远在旧值基础上加,函数式更新传进去的prev永远是 React 当前最新的值,不受闭包影响。
第三,不要贪多。多个互不相关的 state 建议拆成多个useState调用,而不是塞进一个useState({ count, name, list })对象里。拆分之后每个状态独立更新,可读性好,调试图也好定位。但如果是多个状态存在必然联动(比如互斥选中项、一组表单字段),那不如用一个对象,或者直接上useReducer。
3.2 useEffect 的时机、清理与依赖
useEffect是面试官最喜欢深挖的一个 Hook。很多人把它当成“componentDidMount 和 componentDidUpdate 的结合体”,这个理解不算错,但太粗了,如果只停留在这一层,很容易写出有问题的代码。
关键在于useEffect的声明式心智模型:它不是“在某个生命周期时刻执行副作用”,而是“在本次渲染完成之后,如果依赖变了,就同步这一份副作用”。每次渲染,React 都会记录当次渲染的 effect 函数和依赖数组。渲染提交到浏览器以后,React 再回头检查这次的依赖和上次的依赖是否一样,如果不一样,就执行这次的 effect;如果有清理函数,则在上一次执行后、下一次执行前调用它。
依赖数组常见的三种形态:
| 依赖写法 | 执行时机 | 典型场景 |
|---|---|---|
| 不写依赖 | 每次渲染后都执行 | 极少需要,大概率是设计有问题 |
[]空数组 | 仅挂载后执行一次 | 初始化请求、埋点上报、注册一次性监听 |
[prop, state] | 依赖变化时执行 | 数据请求、联动计算、防抖 |
空数组对应的清理函数会在组件卸载时执行,这替代了componentWillUnmount,但又不完全等价:清理函数在“依赖变化、下一次 effect 执行前”也会先执行。这个行为经常被忽略,比如你用[]注册了一个window.addEventListener,清理函数写了,卸载时正常移除;但如果依赖数组里加了某个 props,那么每次该 props 变化时,都会先清理旧监听、再注册新监听,这样反而能保证监听器里永远用的是最新 props 值。
还有一个容易出错的地方:useEffect里发请求时,竞态问题。假设用户在快速切换筛选条件,请求 A 发出去了还没回来,请求 B 又发出去,如果 A 比 B 晚返回,页面就可能显示旧数据。解决办法是在 effect 里维护一个“当前请求是否过期”的标志:
useEffect(() => { let cancelled = false; fetchData(filter).then(res => { if (!cancelled) setData(res); }); return () => { cancelled = true; }; }, [filter]);清理函数在依赖变化时执行,把上一次的cancelled置为true,旧请求的响应即使回来了也不会更新状态。这套写法在 React Native 里同样适用,尤其是处理启动阶段的长列表加载,能明显减少“页面数据被覆盖”的怪异现象。
3.3 useRef 的两种正经用途
useRef经常被误解为“可以在组件里随便改的变量”。它的正确定义是:返回一个在组件整个生命周期内保持不变的 ref 对象,其.current属性可以写入任何值,写入不会触发重新渲染。
用途一:操作 DOM。把 ref 传给 JSX 的ref属性,挂载后.current就是对应的 DOM 节点,可以在useEffect里读取尺寸、绑定事件、聚焦输入框。这个用法在画布类应用里特别常见,比如初始化一个 2D 画布实例,就先把 canvas 的 ref 拿到,再在useEffect里初始化 drawing 实例。
用途二:跨渲染保存可变值。比如定时器 id、上一次的 props、最新的事件回调。经典场景:useEffect里开了setInterval,回调里需要读取最新的 state,如果把state写进依赖数组,定时器会被反复重启;如果不写,闭包捕获的是旧值。解法就是用 ref 保存最新状态:
const countRef = useRef(0); countRef.current = count; // 每次渲染重新赋值 useEffect(() => { const timer = setInterval(() => { console.log(countRef.current); }, 1000); return () => clearInterval(timer); }, []);这里countRef.current每次渲染都会更新,而定时器闭包里读取的是 ref 对象本身。ref 对象是同一个引用,所以定时器每次读到的是最新值。要注意的是:不要在渲染过程中“读取”可变 ref 来参与 UI 渲染,ref 变化不会触发重新渲染,读出来也只会拿到旧画面。ref 更适合用在“渲染完成之后”的逻辑里,比如事件回调、定时器、副作用内部。
useRef的初始值也做了惰性处理,useRef(null)和useRef(initialValue)里的initialValue只会参与首次初始化,后续渲染都会被忽略,不会像useState那样每次渲染都去走一遍初始化逻辑,这一点面试时经常被拿出来考。
4. 复杂状态与跨组件数据:useReducer / useContext / 自定义 Hook
4.1 useReducer 什么时候真的比 useState 合适
useReducer是一个暗藏实力的 Hook。它的 API 是 React 原生的,不依赖 Redux,用法也简单:
const [state, dispatch] = useReducer(reducer, initialState);reducer是一个纯函数,接收当前 state 和 action,返回新 state。什么时候该用它?我个人的判断标准是:当状态更新逻辑复杂到“写完 setState 后解释不清状态为什么变成这样”的时候。
举个例子,购物车状态有三个变量:商品列表、选中项、优惠券。加购、删购、切换选中、应用优惠券,每种操作都要根据当前状态算出一系列新值,如果用useState,每个操作都得写一长串setXxx,还容易漏。改用useReducer后,每个 action 类型对应一个分支,更新逻辑集中且可测试:
function cartReducer(state, action) { switch (action.type) { case 'ADD_ITEM': return { ...state, items: [...state.items, action.payload] }; case 'TOGGLE_SELECT': return { ...state, selected: state.selected.includes(action.payload) ? state.selected.filter(id => id !== action.payload) : [...state.selected, action.payload], }; default: return state; } }面试时谈到useReducer,有经验的面试官会追问:reducer 里为什么要返回新对象而不是直接改原对象?这个问题考的是 React 的更新机制——React 靠Object.is比较新旧状态来决定是否重新渲染,只有返回新引用,React 才能知道状态变了。直接改原对象返回同一个引用,React 会认为什么都没发生,页面不刷新。这也是所有状态 Hook 的共同底线:状态不可变,更新要产生新引用。
4.2 useContext 的便利和它带来的重渲染代价
useContext解决了 props 层层传递的问题。在典型的多层嵌套场景里,登录用户信息、主题配置、国际化的 locale,如果一路用 props 传下去,中间层组件会被迫接收并转发大量自己根本用不到的数据,组件接口臃肿不堪。用 Context 后,中间层可以完全不知道这些数据的存在,只有真正消费数据的组件才调用useContext。
但useContext有一个必须在实战里正视的性能问题:一旦 Context 的值变化,所有消费了这个 Context 的组件都会重新渲染。注意,是“所有”,不管那个组件是否只用到了其中某一段数据。
举个常见例子,Context 里放了一个大对象{ user, theme, cartCount },某个组件只读theme,但只要cartCount变了,它依然跟着重新渲染。优化手段是把 Context 拆细,让不同层面的数据走不同的 Provider;或者把 Provider 的 value 用useMemo缓存,防止父组件无关渲染导致 value 变成新引用、引发下游无意义重渲染:
const value = useMemo(() => ({ user, theme }), [user, theme]); return <AppContext.Provider value={value}>{children}</AppContext.Provider>;这里要用useMemo而不是每个渲染都直接传对象字面量,因为每次渲染都新建一个引用,即使数据没变,所有消费者也都会被强行刷新一遍。这个点和后面要讲的useMemo正好衔接上,很多人第一次意识到 Context 的性能代价,都是从这个写法开始的。
4.3 自定义 Hook:项目里真正的复用单元
自定义 Hook 是 Hooks 体系里最体现工程能力的一层。它的本质就是把一组有内在关联的 state、effect、回调封装成一个函数,函数内部可以随意使用其他 Hooks,所有约束和组件内一样。
我举一个封装“防抖搜索”的例子。这是业务里最常见的需求,搜索框输入后延迟 300ms 请求接口:
function useDebouncedValue(value, delay = 300) { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debounced; } // 组件里 const [keyword, setKeyword] = useState(''); const debouncedKeyword = useDebouncedValue(keyword); useEffect(() => { if (debouncedKeyword) search(debouncedKeyword); }, [debouncedKeyword]);这里有几个工程细节值得说:清理函数clearTimeout必不可少,否则上一次输入的回调会继续执行,产生过期结果;依赖数组里同时放value和delay,保证两者变化都会重置定时器;useDebouncedValue返回的是“稳定延迟后的值”,组件可以把它当普通状态用,下游的useEffect只管依赖它。
自定义 Hook 的命名必须以use开头,这不只是约定,也决定了 lint 规则能否生效。我见过的项目里,很多烂代码就是把一坨逻辑塞进组件内部几十行useEffect,一旦遇到第二个页面需要相同逻辑,就 Ctrl+C、Ctrl+V,然后噩梦开始。正确的做法是,只要发现“两个组件里用了差不多的状态逻辑”,第一反应就应该是抽成自定义 Hook。它比 HOC 轻量,比工具函数多管状态,是 React 官方钦定的组合式复用方案。
另外提一句,抽象的程度要有度。如果一个 Hook 只有一个useState、一个useEffect,使用它的地方也只有一处,那它就是在制造间接层,反而增加阅读负担。自定义 Hook 的第一原则是“为复用而生”,不是为了显得代码高级。
5. 性能 Hooks 别乱用:useMemo / useCallback 的适用边界
5.1 它们到底防的是什么
先看useMemo和useCallback的定义:
useMemo缓存计算结果,依赖不变则返回同一个值,避免重复执行昂贵计算。useCallback缓存函数引用,依赖不变则返回同一个函数对象。
useCallback(fn, deps)其实等价于useMemo(() => fn, deps),两者底层是同一套机制。它们的价值不是孤立的“优化了自己”,而是稳定了引用,从而让下游优化生效。最典型的下游是React.memo包裹的子组件。
React.memo的作用是对比 props 的引用,没变化就跳过子组件重新渲染。父组件每次渲染都会重新创建内联函数,如果把这个函数传给React.memo子组件,即使数据没变,子组件也会因为 props 引用变化被迫重渲染。用useCallback稳定函数引用,配合React.memo,才能做到真正跳过子组件渲染。
useMemo与useCallback对比如下:
| 对比项 | useMemo | useCallback |
|---|---|---|
| 返回内容 | 任意值(计算结果) | 函数本身 |
| 主要用途 | 避免昂贵计算重复执行 | 稳定函数引用传给子组件 |
| 典型场景 | 大数组排序/过滤、复杂聚合计算 | 子组件回调、Context value |
| 等价写法 | 无 | useMemo(() => fn, deps) |
5.2 什么时候真的需要它
我见过的最大误用,是“为了优化而优化”,把整个组件里的函数和计算全部包上useMemo/useCallback,最后代码看起来每个函数都带依赖数组,阅读成本翻倍,性能却没提升多少。原因很简单:这两个 Hook 本身也有成本——每次渲染要对比依赖数组,内部要维护缓存结构,处理不当反而占内存。
我的使用口径相当克制:
第一,计算本身真的昂贵才用useMemo。比如在长列表上做多条件筛选、按复杂权重排序、把数组对象转换成图表坐标数据。判断标准很简单:一次渲染里这个计算需要多少毫秒,如果 1ms 以内,别缓存;超过几十毫秒,考虑缓存。
第二,函数引用的稳定性真的影响下游才用useCallback。检查标准是:这个函数会传给React.memo子组件吗?会作为useEffect的依赖吗?会传给useRef监听的 DOM 事件吗?三个问题全是“否”,那不用。
第三,在自定义 Hook 里,导出的函数建议一律useCallback包裹。因为自定义 Hook 的使用者无法控制调用方组件的重渲染,而你导出的函数引用如果不稳定,使用者在useEffect里依赖它时就会产生“每次渲染都触发 effect”的连锁问题。这个建议是“接口方”的自觉,不是全局建议。
5.3 常见的误区和坑
一个高频面试题:useMemo能不能代替useEffect做数据请求?答案是最好不要。useMemo在渲染过程中执行,useEffect在渲染完成后执行。在渲染过程中触发请求,React 会警告你“不能在渲染期间执行副作用”,而且在 React 18 并发特性下,渲染可能被中断、回滚,useMemo的计算结果可能被丢弃再重算,在里面发请求极不靠谱。请求这种副作用永远放useEffect。
还有依赖数组里写对象或数组的坑。如果useMemo的依赖是一个每次渲染都新创建的对象字面量,那么它的缓存永远失效,等于没缓存。解决方式是把依赖拆成原语类型(id、name这种),或者先对那个对象本身做useMemo稳定引用,再把它作为依赖。这个“依赖数组里的引用是否稳定”问题,是整个 Hooks 性能优化里最隐蔽、也最容易让优化白做的一环。
最后提一下:不要迷信“把 useCallback 加上就一定快”。React 官方的态度一直很明确:useCallback和useMemo是性能优化手段,不是语义保证。正确性永远是第一位,等性能问题真的出现了,用 profiler 定位到具体是哪个组件在浪费渲染,再回来看要不要加缓存。我之前在一个项目中就干过“全线加 useCallback”的事,结果代码难读、依赖容易写漏,性能提升几乎可以忽略,后来花了半天把这些多余的包裹全部拆干净,组件反而清晰多了。
6. 踩坑实录:闭包陷阱、依赖数组和 StrictMode 的意外行为
6.1 闭包陷阱:定时器永远拿到旧值
这是 Hooks 使用中被问得最多的“怪现象”。写一个倒计时组件:
function Timer() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(timer); }, []); return <div>{count}</div>; }运行之后你会发现count永远停在 1。原因在于useEffect的空依赖数组只在挂载时执行了一次,定时器回调闭包捕获的是首次渲染的count,也就是 0。之后每次setInterval触发的都是setCount(0 + 1),结果一直是 1。
正确解法前面已经写过,用函数式更新或者useRef配合。这个例子的价值在于:它逼着你理解“每次渲染都有自己的 props 和 state 快照”。函数组件每次渲染都会执行一次,每次执行都生成一个全新的闭包环境,useEffect里注册的回调捕获的是“触发 effect 那一次渲染”的快照。只要依赖数组没变,这个快照就一直停留在那一次。想突破闭包,要么让依赖数组重新变化,要么借助 ref。
6.2 依赖数组写不全的连锁问题
eslint 的exhaustive-deps规则会警告“effect 里用了某个变量但依赖数组里没写”。很多新人图省事,直接忽略警告,或者更粗暴——给 eslint 关掉。这种“清爽”的代价是,大部分情况下代码碰巧还能工作,因为 effect 里读的是旧渲染的闭包值,只有两处能发现不对劲:一是调试时发现数据永远滞后一步,二是并发渲染下出现极其诡异的状态错位。
我的建议是:除非有明确理由(比如那变量确实只需要首渲染时的初始值,且你清楚知道自己为什么这么做),否则exhaustive-deps的警告都该当错误处理。当 lint 报“你缺了xxx”,先停下来想,而不是直接补一个变量进去——因为补了依赖导致 effect 频繁重跑的问题同样常见。更合理的方向往往是调整 effect 内部的写法,比如把读取值的工作交给 ref 或函数式更新,让依赖数组保持简洁。
6.3 StrictMode 下 effect 执行两次:不是 bug
React 18 在开发模式下,如果组件被<StrictMode>包裹,挂载时useEffect、useState初始化函数、useReducer的 reducer 都会被故意执行两次。不少人第一次遇到这个问题,第一反应是抓狂,甚至误以为是自己代码问题。
这是 React 有意为之的行为。它模拟“组件被卸载再重新挂载”的过程,用来暴露那些没有正确清理副作用的代码。如果你在一个useEffect里注册了全局监听但忘记清理,StrictMode 的双执行就会立刻暴露“重复注册”。如果清理函数写对了,双执行反而什么都看不见。所以遇到 StrictMode 双执行,正确反应应该是庆幸它在开发阶段帮你排查清理逻辑,而不是去把 StrictMode 摘掉。生产环境下这种行为不会出现,可以放心。
还有一个和 StrictMode 关联的排查方向:如果刚升级到 React 18,发现某些接口在开发模式下请求了两次,先检查是不是 StrictMode 导致的,再检查是不是 effect 依赖里写了不稳定的引用(比如每次渲染都新建的对象),最后检查是不是真的没有写清理函数。
6.4 一次完整的排查链路:setInterval 读取最新 state
我举一个自己实际遇过的排查过程,帮你建立“按链路走”的感觉。当时一个页面有实时轮询,每隔 5 秒要基于当前筛选条件重新请求数据。代码长这样:
const [query, setQuery] = useState(''); useEffect(() => { const timer = setInterval(() => { fetchData(query); // query 永远是旧值 }, 5000); return () => clearInterval(timer); }, []);现象是:筛选条件改了,轮询请求里带的还是旧关键词。排查顺序是:
第一步,先确认 effect 是否重新执行。我在 effect 里加了一行 console,发现只打印了一次,说明 effect 本身没有再跑。问题定位到定时器回调闭包。
第二步,验证闭包捕获的是哪个值。我在回调里把query和queryRef.current同时打出来,前者一直是空字符串,后者是当前最新值。确认就是闭包问题。
第三步,修复。选择在定时器回调里从 ref 读取最新 query,同时保证 ref 每次渲染同步。这个方案比“把 query 加进依赖数组”更合理,因为后者会导致定时器每改一次条件就销毁重建,浪费资源且逻辑上“轮询器”不是稳定的。
第四步,回归验证。改完之后观察一组行为:初始空 query 请求一次;修改 query 后再等 5 秒,发出新 query 的请求;清理页面后定时器被清除。三步都通过,修复完成。
这套链路看起来简单,但整个排查过程里最值钱的不是最后那行修复代码,而是“先确认 effect 有没有执行,再确认闭包捕获值,最后选择不会被重渲染打乱位置的解法”这个思路。面试时能把排查链路完整讲出来,远远比背出十个 Hook 名称有用。
Hooks 这个东西,说到底没有太多玄机,核心就三点:函数组件每次渲染都有独立的闭包快照,Hooks 通过 Fiber 节点上的链表按顺序保存状态,effect 和依赖数组共同管理副作用的生命周期。把这三句话吃透,再用useState、useEffect、useRef这几个高频 Hook 把肌肉记忆练出来,剩下的useReducer、useContext、useMemo、useCallback都只是在不同场景下的组合变体。我在带人时总说,Hook 叫什么名字不重要,重要的是你手上的场景需要哪一种能力,以及你知不知道每一次调用背后 React 正在做什么。