想象你要开一家属于自己的餐厅。厨房里需要储物柜(存放食材)、需要自动化的设备(比如洗碗机)、需要提前备菜(缩短出餐时间)、还需要固定的工具摆放位(不用每次找)。React 里的 Hooks 就像厨房里的这些“功能模块”——useState 是储物柜,useEffect 是自动化设备,useMemo 是备菜区,useCallback 是固定工具位。没有它们,你的 React “厨房” 就会杂乱无章、反复炒同一道菜、甚至把厨房点着。 组件每次渲染就像厨师重新进一次厨房——如果每次都要重新切菜、重新找工具、重新洗碗,那效率极低。Hooks 就是帮你把厨房管理得井井有条的“厨房管理系统”。本篇文章用最生活化的方式,把这四大 Hooks 逐个讲透,让你真正理解它们的工作原理、适用场景和常见坑,而不是死记 API。

岗位关联

对 AI 从业者而言,React Hooks 是构建 AI 应用前端界面(如聊天机器人、模型可视化平台)的核心工具。掌握它,你就能自己搭建高效、流畅的 AI 产品界面,而不是只会调 API。

目录
  1. 可视化演示
  2. Hook心智模型
  3. useState状态管理
  4. useEffect副作用
  5. useMemo缓存计算
  6. useCallback缓存函数
  7. 闭包陷阱
  8. 性能优化实践
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示useState状态更新与重渲染、useEffect依赖追踪与生命周期、useMemo/useCallback缓存机制的交互演示。

本节配有交互动效(进入视口后自动加载,约 3KB)。

useState状态更新机制 setState触发组件重渲染,新状态替代旧状态 批量更新合并多个setState为一次渲染 setN(v=>v+1) 函数式更新避免闭包陷阱

Hook心智模型

走进一间井然有序的厨房:储物柜里食材分门别类,料理台上刀具悬挂整齐,墙上的计时器滴答作响,冰箱里备着洗好切好的半成品。每一间餐厅的后厨都是这样——每个工位都有明确的功能分区。React 的组件就像这间厨房,而 Hooks 就是厨房里的功能区。理解了这个心智模型,你就掌握了 React Hooks 的底层思路。

React 16.8 之前,函数组件是“无状态的展示组件”,就像一间只有一张桌子的临时摊位,没法存食材、没法用设备。Hooks 的出现相当于给这个摊位升级成了完整厨房——函数组件也能拥有状态和副作用了。其中四个最常用的 useState、useEffect、useMemo、useCallback,分别对应厨房里的储物柜、自动化设备、备菜区、固定工具位。

组件每次渲染,就像厨师重新走进厨房开始做菜。渲染过程中,React 会按顺序执行每一个 Hook——这就是为什么 Hooks 不能放在条件语句或循环里。Hooks 的调用顺序必须每次渲染都完全一致,就像厨房里储物柜的位置不能每天换一样。React 靠调用顺序来匹配每次渲染对应的状态,如果顺序变了,状态就会错乱。

回到厨房的比喻:为什么每次渲染都要重新“进厨房”?因为 React 需要检查食材是否变了、客人有没有新要求。Hooks 的价值在于让厨房每次都能保持同样的布局——厨师闭着眼都能拿到想要的东西。这就是 Hooks 心智模型的核心:稳定的厨房布局 + 按序取用。

渲染结果=f(状态,Props,上下文)\text{渲染结果} = f(\text{状态}, \text{Props}, \text{上下文})
组件渲染是状态的函数:状态变了(像食材换了),输出自然会变。Hooks 就是让这个函数能记住过去的“厨房状态”。
  1. 渲染函数渲染结果是输入状态和属性的函数,像“菜品 = 食材 + 菜谱 + 厨具”。
    渲染结果\displaystyle \text{渲染结果}
  2. 状态参数组件内部通过 useState 管理的数据,像储物柜里的食材。
    状态=useState(初始值)\displaystyle \text{状态} = \text{useState}(\text{初始值})
  3. 属性参数父组件传入的数据,像客人下单时指定的口味偏好。
    Props=父组件传入\displaystyle \text{Props} = \text{父组件传入}
  4. 上下文全局共享的数据,像厨房里公用的调料架。
    上下文=全局共享数据\displaystyle \text{上下文} = \text{全局共享数据}

useState状态管理

厨房的储物柜是最基础也最重要的设施。不管什么食材,先得有地方放。useState 就是你厨房里的储物柜——它让组件记住自己的状态,并且在状态变化时触发重新渲染(相当于食材补齐后通知厨师开工)。

useState 的基本用法是传入初始值,返回一个数组:当前状态和一个更新函数。这个更新函数就像储物柜的钥匙——只有通过它才能修改柜子里的东西。但这里有个细节:如果你直接写 setCount(count + 1),React 可能不会按你想象的方式执行,因为 count 可能是个“过期”的值。这就是为什么 React 提供了函数式更新:

另一个重要机制是批量更新。React 18 自动批处理状态更新:在一个事件处理函数里连续多次 setState,React 会把它们合并为一次渲染——就像厨师在同一时间把多种食材一起放进储物柜,而不是每放一种就停下来检查一遍。这在大多数时候是好事(提升了性能),但也意味着你不能依赖在同一个函数里“立即读取”更新后的状态。

State 与渲染的关系:每次状态变化都会触发一次新的渲染(重新进厨房做菜)。但有些人会误把状态当普通变量,直接在组件里改它的值——这就像你把食材从储物柜拿出来放在台面上,然后又放回去,但没告诉管理员食材变了,厨房自然不知道需要重新做菜。永远不要直接修改 state 变量的值,必须通过 setState 函数来更新。

回到厨房比喻:useState 的储物柜设计体现了一个关键原则——单一数据源。所有食材都从同一个柜子里取,更新也必须通过管理员。这避免了两处记录同一件事但不一样(数据不一致)的问题。理解了这个,你就理解了 React 数据流的核心。

setState(prev→next)\text{setState}(\text{prev} \rightarrow \text{next})
函数式更新就像告诉储物柜管理员“把当前数量加一”,而不是自己先拿钥匙看一眼再放回去——避免因为“看的时间差”导致放错数量。
  1. 更新函数setState 是更新状态的唯一入口,相当于储物柜管理员。
    setState\displaystyle \text{setState}
  2. 回调参数传入一个函数,接收上一次的状态值作为参数。
    prev→next\displaystyle \text{prev} \rightarrow \text{next}
  3. 计算结果React 确保 prev 一定是最新值,你只需返回新状态。
    next=prev+1\displaystyle \text{next} = \text{prev} + 1

useEffect副作用

厨房里有些设备不需要厨师主动操作:洗碗机洗完碗自动停止、烤箱烤好自动响铃、排风扇到点自动打开。useEffect 就是 React 厨房里的“自动化设备”——它让组件渲染完成后自动执行一些操作,比如发请求、操作 DOM、设置定时器。

但自动化设备有个特点:配置不当会反复触发或泄漏。useEffect 的第二个参数——依赖数组——就是设备的“触发条件”。依赖数组里放什么,决定副作用什么时候重新执行:

依赖数组有一个关键行为:如果不传第二个参数,副作用在每次渲染后都执行——像排风扇每次进厨房都自动打开。传了空数组 [],则只在组件挂载时执行一次。传了具体依赖,则在依赖变化时执行。依赖数组应该包含所有你在副作用中用到的响应式值——如果漏掉了,就会出现“闭包陷阱”(后面会详细讲)。

cleanup 清理函数:useEffect 的另一个核心概念是清理函数。比如你设置了定时器,组件卸载时应该清除它,否则定时器还在运行——就像洗碗机洗完碗后还在空转,浪费水电。在 useEffect 中返回一个函数,React 会在组件卸载时或下一次执行副作用前调用它:

执行时机:useEffect 里的代码不是在浏览器绘制前执行的,而是在浏览器绘制完成之后,类似放在了 componentDidMount 或 componentDidUpdate 的位置。这意味着如果副作用里需要读取 DOM 或发起请求,这些操作不会阻塞页面渲染——但如果你需要更早的执行时机,可以使用 useLayoutEffect(不过大多数场景不需要)。

回到厨房比喻:useEffect 最需要警惕的是“副作用外溢”。洗碗机(定时器)如果没关好,水会漏到整个厨房(内存泄漏)。每次你启动一个自动化设备,都需要想清楚:它什么时候该停?什么时候该重新配置?这就是依赖数组和 cleanup 存在的意义。

useEffect(setup,depends)\text{useEffect}(\text{setup}, \text{depends})
依赖数组像自动化设备的“感应器”:感应到指定食材变化才启动设备。空数组 [] 表示只在进厨房(组件挂载)时运行一次;不传数组则每次都运行。
  1. 副作用函数第一个参数 setup 函数,包含要在渲染后执行的逻辑。
    setup=渲染后执行的逻辑\displaystyle \text{setup} = \text{渲染后执行的逻辑}
  2. 依赖数组第二个参数 controls,列出影响副作用执行的状态变量。
    depends=[var1,var2,…]\displaystyle \text{depends} = [\text{var}_1, \text{var}_2, \ldots]
  3. 依赖比较React 用 Object.is 对每个依赖做对比,只有变化时才重新执行副作用。
    return cleanup(组件卸载时执行)\text{return cleanup} \quad \text{(组件卸载时执行)}

useMemo缓存计算

餐厅生意红火时,厨师每天都要切几十斤土豆丝。聪明的厨师会每天开工前一次性切好大量半成品放在冰箱里,客人点单时直接取用——这就是 useMemo 的思维。它用于缓存计算量较大的操作的返回值,避免每次渲染都重复计算。

useMemo 接受一个纯计算函数和依赖数组,只有依赖变化时才重新执行计算函数,否则直接返回上一次缓存的结果:

刚才提到 useMemo 缓存的是计算结果。但在函数组件中,每次渲染时其中的所有代码都会重新执行——包括函数声明、对象字面量、数组创建。当这些“对象”作为 Props 传给子组件时,即使内容没变,引用也变了(就像你每次都拿个新袋子装同样的菜)。useMemo 不仅缓存计算结果,还能让对象引用在依赖不变时保持稳定。

适用场景:主要适用于计算量大的场景,比如复杂列表过滤、数据转换、大量数据的统计计算。对于简单计算(如 1 + 2),用 useMemo 反而是浪费——就像为了切一根葱也专门开一次备菜流程,反而增加了复杂度。

不能用来干什么:useMemo 不要用于副作用操作(如发请求、修改 DOM),因为它的计算函数应该是纯的。它也不能替代 ref——如果你要在渲染之间共享可变引用,应该用 useRef,就像备菜区的东西不能放冰箱以外的区域,每种设施各司其职。

回到厨房比喻:备菜区(useMemo)的存在是为了减少重复劳动,但备菜本身也需要时间。如果一道菜 10 秒就能做完,专门备菜(比如切好放冰箱)反而多花 5 秒——这就是为什么 React 官方强调不要过度使用 useMemo,先测量性能瓶颈再优化。

constmemo=useMemo(computeFn,[dep1,dep2])\text{const} \quad \text{memo} = \text{useMemo}(\text{computeFn}, [\text{dep}_1, \text{dep}_2])
这就像菜品“土豆丝”——只要土豆(依赖)没变,就直接从冰箱取之前切好的半成品;土豆换了新品种,才需要重新切。
  1. 计算函数第一个参数是纯函数,只根据输入计算输出,不修改外部状态。
    computeFn=(inputs)⇒result\displaystyle \text{computeFn} = (\text{inputs}) \Rightarrow \text{result}
  2. 依赖参数第二个参数列出影响计算结果的状态变量。
    deps=[dep1,dep2,…]\displaystyle \text{deps} = [\text{dep}_1, \text{dep}_2, \ldots]
  3. 缓存比较React 存储上一次的 result 和 deps,渲染时对比 deps 是否变化。
    cached=(deps 未变化) ? 上一次的 result:computeFn(deps)\displaystyle \text{cached} = (\text{deps} \text{ 未变化}) \, ? \, \text{上一次的 result} : \text{computeFn}(\text{deps})

useCallback缓存函数

厨房里的刀具如果每天都换位置,厨师每次做饭都要找半天。优秀的厨房一定会给每把刀固定一个位置。useCallback 就是 React 厨房里的“固定工具位”——它缓存一个函数实例,在依赖不变的情况下返回同一个引用。

为什么函数引用稳定如此重要?因为在 React 中,每次渲染都会重新创建函数。如果你把函数作为 Prop 传给经过 React.memo 优化的子组件,父组件每次渲染时新的函数引用会导致子组件不必要的重新渲染——就像你每次都递给孩子一把新刀,孩子每次都得重新适应手感。

useCallback 和 useMemo 的区别:很多人会混淆这两个 Hook。useCallback 缓存的是函数本身(固定工具位),useMemo 缓存的是计算的结果值(备菜区)。useCallback(fn, deps) 实际上等价于 useMemo(() => fn, deps)——两者底层机制相同,只是语义不同:一个是“我要复用这个函数”,一个是“我要复用这个计算结果”。

依赖数组的一致性:useCallback 的依赖数组必须包含函数体内所有使用到的响应式值。如果函数内部访问了 state 或 props 但没写进依赖数组,这个函数就会一直引用旧值——这就是“闭包陷阱”的经典形态:厨师拿到的菜单是昨天的,菜谱上写的是昨天的食材清单。

回到厨房比喻:useCallback 的固定工具位思维提醒我们——函数也是对象。每次渲染创建新函数,就像每天换一把新刀,不仅浪费,还会让依赖这个工具的人(子组件)无所适从。保持引用的稳定,才能让整个厨房(组件树)高效协作。

consthandler=useCallback(event⇒[...],[dep])\text{const} \quad \text{handler} = \text{useCallback}(\text{event} \Rightarrow \text{[...]}, [\text{dep}])
固定工具位让每次拿到的都是同一把刀。函数引用稳定了,配合 React.memo 才能有效拦截子组件的无意义重渲染。
  1. 函数声明第一个参数是要缓存的函数,每次渲染传入新函数,但只有依赖变化时返回的才是新函数。
    handler=useCallback(fn,[deps])\displaystyle \text{handler} = \text{useCallback}(\text{fn}, [\text{deps}])
  2. 依赖比较React 对比依赖数组。依赖没变,直接返回上次缓存的函数。
    返回=deps 没变 ? 上次的 fn:新的 fn\displaystyle \text{返回} = \text{deps} \text{ 没变} \, ? \, \text{上次的 fn} : \text{新的 fn}
  3. 配合 memo子组件用 React.memo 包裹,父组件函数引用不变,子组件跳过重新渲染。
    子组件重渲染=(props 变化或者父组件重渲染) 且无 memo 拦截\displaystyle \text{子组件重渲染} = \text{(props 变化或者父组件重渲染) 且无 memo 拦截}

闭包陷阱

假设厨师周一看了菜单,记住了“明天要做宫保鸡丁”的配方。周三真正做菜时,他用的还是周一的记忆——但食材早就换了。这就是闭包陷阱(Stale Closure):函数记住了创建它的那个时刻的值,即使外面的状态已经变了。

React 中的闭包陷阱最常见的出现场景是 useEffect 或 useCallback。比如你在 useEffect 中使用了 count 但依赖数组只写了 [],那么这个 useEffect 里的函数体捕获的是组件首次渲染时的 count = 0。即使 count 之后变成 5,这个 effect 里读到的仍然是 0——就像厨师一直拿着一本过期的菜单在做菜。

怎么解决闭包陷阱?最常用的方法是函数式更新:

函数式更新让 React 替你管理“当前值”,你不直接使用可能过期的 count 变量,而是告诉 React“基于最新值计算”。这就像你告诉储物柜管理员“把食材数量加一”,而不是自己先看一眼当前数量(可能已经过期)再加一。

另一种解决方法是使用 useRef 保存最新值:ref.current 永远是最近一次渲染的值,可以绕过闭包陷阱。这在事件监听器(比如 addEventListener)中特别有用——监听器只创建一次,但其内部需要访问最新的状态。

依赖数组遗漏也是闭包陷阱的高发区。React 官方有一个 ESLint 插件 eslint-plugin-react-hooks 专门检查依赖数组是否完整——一定要使用它。把依赖写全,React 就能确保每次 effect 运行时的闭包都是最新的。回到厨房比喻:闭包陷阱就是“记忆中的菜单”。食材(state)已经更新换代,但厨师手里的菜单还是三天前的。要么换成最新菜单(加依赖),要么让管理员为他提供实时食材清单(函数式更新)。

闭包陷阱=函数捕获了创建时的旧值(stale closure)\text{闭包陷阱} = \text{函数捕获了创建时的旧值} (\text{stale closure})
闭包是 JavaScript 的特性:函数会“记住”创建它的作用域中的变量。问题不在闭包本身,而在于 React 组件的渲染模型让这些变量在不同的渲染中有不同的值。
  1. 闭包定义函数内部访问了外部作用域的变量,这个函数就是闭包。
    closure=fn+创建时的词法环境\displaystyle \text{closure} = \text{fn} + \text{创建时的词法环境}
  2. 过期捕获当函数创建后外部变量更新,函数内的引用仍然指向旧值。
    stale=捕获的值≠当前的值\displaystyle \text{stale} = \text{捕获的值} \ne \text{当前的值}
  3. React 场景每次渲染都会创建新的函数,新函数才会捕获新的 state 值。
    每次渲染⇒新闭包⇒新值\displaystyle \text{每次渲染} \Rightarrow \text{新闭包} \Rightarrow \text{新值}
javascript5 行
// 错误:捕获了旧值setCount(count + 1); // 正确:总是基于最新值更新setCount(prev => prev + 1);

性能优化实践

当厨房运转了一段时间后,你会发现有些流程可以优化:哪些材料可以提前备好?哪些厨具使用频率低可以收起来?React 应用的性能优化也是这个思路。前面讲的 useMemo 和 useCallback 是主要的优化工具,但它们不是万能的。

第一条原则:缓存不是免费午餐。useMemo 和 useCallback 本身也有开销——React 需要存储依赖数组、进行对比。对于计算量小的场景,使用缓存反而更慢。React 官方文档建议:先测量再优化,找到真正的性能瓶颈再动手。

第二条原则:依赖数组越精简越好。观察以下代码:

1. 最小化依赖:依赖数组里变量越多,缓存的失效频率越高。

命中率∝1依赖数量\displaystyle \text{命中率} \propto \frac{1}{\text{依赖数量}}

2. 拆分状态:将大型状态拆成多个小状态,减少依赖的粒度。

拆分={state1}∪{state2}∪…\displaystyle \text{拆分} = \{ \text{state}_1 \} \cup \{ \text{state}_2 \} \cup \ldots

3. 组件拆分:将复杂的组件拆分为多个子组件,配合 memo 隔离重渲染范围。

重渲染范围=子树的尺寸\displaystyle \text{重渲染范围} = \text{子树的尺寸}

第三条原则:状态提升与下放。如果一个状态只被某个子组件使用,它就应该放在那个子组件内部——就像盐和糖的调料瓶应该放在灶台旁边,而不是放在餐厅门口的储物间。反过来,多个子组件共享的状态才需要提升到父组件(放在公共区域)。这是 React 社区称为“状态就近原则”的核心思想。

第四条原则:使用 Profiler 测量。React 提供了 <Profiler> 组件和 React DevTools 的 Profiler 标签页,可以记录组件的渲染耗时。找出渲染耗时最长的组件,再决定是否使用 useMemo/useCallback/React.memo。盲目添加缓存就像在厨房里为每个动作都装一台自动化设备——大部分设备都闲置,还占了宝贵的空间。

回到厨房比喻:性能优化的最终目标是减少不必要的工作。React 默认每次渲染会重跑所有代码(每个厨师都从零开始做菜),优化手段就是让 React 知道“这些食材没变、这道菜不用重做”。每个 Hook、每个缓存都有成本,只有当你清楚知道“工作量”在哪里时,优化才有效。

text1 行
$$\text{useMemo}(() \Rightarrow \text{expensive}(\text{a}, \text{b}), [\text{a}, \text{b}])$$

AI 应用场景

构建高效 AI 聊天前端

阶段:前端界面开发

解决的问题:AI 聊天应用需要实时显示响应流、处理用户输入、维护对话历史。传统方式每次输入都触发整个组件树重新渲染,导致页面卡顿。用 useMemo 缓存对话列表的格式化结果、useCallback 稳定发送消息的函数引用,配上 React.memo 隔离消息项的渲染,能显著降低交互延迟。

具体案例:一个 LLM 聊天页面,对话消息有几百条,每条包含文本、代码块、Markdown 渲染。如果每条消息都做 Markdown 解析(重度计算),每次输入新消息都会卡顿。用 useMemo 对每条消息的解析结果做缓存,依赖是消息文本本身——文本不变就直接复用解析结果,让打字输入保持流畅。

流式响应中的闭包陷阱防护

阶段:AI 模型输出解析与状态管理

解决的问题:流式响应中,WebSocket 或 SSE 回调里如果使用了过期的 state,会导致状态更新丢失——例如读取了旧的对话 ID。用函数式更新和 useRef 保存最新值的方案,确保回调总是操作最新的对话状态。

具体案例:在流式输出过程中,用户在界面上切换了对话。如果回调闭包捕获了旧对话 ID,AI 的回复会被追加到错误的历史记录中。通过 useRef 保存最新对话 ID,回调里总是读取 ref.current,问题迎刃而解。

模型推理参数面板优化

阶段:推理参数配置 UI

解决的问题:大模型配置面板(temperature、top_p、max_tokens 等参数)通常需要多个滑块实时联动。每次拖动滑块都触发频繁重渲染,导致交互不跟手。用 useCallback 稳定滑块变化处理函数,结合拆分独立的参数子组件,让每个滑块的更新只重渲染自身。

具体案例:一个包含温度、top_p、top_k、max_tokens 四个参数的推理面板,四个滑块独立组件化 + useCallback 稳定回调 + React.memo 拦截无关传参,拖动一个滑块时其他滑块不再闪烁重排。

Python 演示

python83 行
# 模拟 React Hooks 核心机制的最小 Python 实现class Component:    def __init__(self, name):        self.name = name        self._states = {}    # 状态槽位        self._effects = []   # 副作用列表        self._memos = {}     # 缓存槽位        self._call_index = 0  # Hook 调用顺序索引        self._count = 0       # 状态计数器     def useState(self, initial):        idx = self._count  # 当前 Hook 序号        self._count += 1        # 槽位自增计数        slot = "state_" + str(idx)        if slot not in self._states:            self._states[slot] = initial        value = self._states[slot]         def setState(new_val):            # 支持函数式更新            if callable(new_val):                self._states[slot] = new_val(self._states[slot])            else:                self._states[slot] = new_val         return value, setState     def useEffect(self, fn, deps):        slot = "effect_" + str(len(self._effects))        entry = {            "fn": fn,            "deps": deps,            "cleanup": None,            "prev_deps": None        }        self._effects.append({"slot": slot, "entry": entry})     def useMemo(self, compute, deps):        slot = "memo_" + str(len(self._memos))        key = str(deps)  # 用依赖列表的字符串表示做缓存键        if key in self._memos:            return self._memos[key]        result = compute()        self._memos[key] = result        return result     def render(self):        # 每次渲染重置调用索引        self._count = 0        view = "Render of " + self.name        # 模拟组件挂载后执行副作用        for e in self._effects:            entry = e["entry"]            if entry["prev_deps"] != entry["deps"]:                # 先清理上一次的副作用                if entry["cleanup"]:                    entry["cleanup"]()                entry["cleanup"] = entry["fn"]()                entry["prev_deps"] = entry["deps"]        return view  def demo():    comp = Component("Counter")    count, setCount = comp.useState(0)     def tick():        print("Timer set, count now:", comp._states["state_0"])     comp.useEffect(tick, [])    # 第一次渲染    comp.render()    # 模拟点击 +3    setCount(lambda prev: prev + 1)    setCount(lambda prev: prev + 3)    print("After updates, value:", comp._states["state_0"])    # 第二次渲染    comp.render()  if __name__ == "__main__":    demo()
目的:用 Python 模拟 React Hooks 的核心机制,帮助你理解 useState(含函数式更新)、useEffect(含 cleanup 清理)和 useMemo 的缓存逻辑。
  1. if slot not in self._states首次渲染才初始化状态,模拟 useState 的惰性初始化
  2. setState` 内部判断可调用对象 — 对应 React 的函数式更新 `setState(prev => prev + 1)self._count 递增模拟 Hook 调用顺序 — 这就是为什么不能条件调用 Hook
  3. "effect_" + str(len(self._effects))用列表长度做槽位,避免属性与方法同名问题
  4. entry["cleanup"] = entry["fn"]()保存清理函数,下次执行副作用前先调用
  5. key = str(deps)简化版缓存键,使用依赖数组的字符串表示

常见误区

误区“useState 传一个函数作为初始值,这个函数会在每次渲染时都执行一遍,所以要用 useMemo 去包一下。”

正解useState(() => expensive()) 中的初始化函数只在组件首次挂载时执行一次,React 内部会记住这个初始值,后续渲染直接忽略。这就像储物柜只在开业时放一次食材,之后都是直接开柜取用——不是每次做菜都重新进货。

为何常见:因为 JSX 中的代码每次渲染都会执行,初学者看到 useState(表达式) 会以为函数也被每次调用,其实 React 对初始值做了特殊处理。

误区“依赖数组应该把所有变量都写进去,不管用不用得到,这样最安全。”

正解依赖数组写太多无关变量,会导致 effect 频繁执行。正确的标准是:只包含 effect 内部实际使用到的响应式值。用 ESLint 插件自动检查即可,不用手工全写。就像洗碗机的感应器只应该检测“是否有脏碗”,如果连“客人走了没”“灯关了没”都监控,就会乱启动。

为何常见:因为闭包陷阱的教训让大家记住了“依赖要写好”,但走到了另一个极端。

误区“useMemo 和 useCallback 是性能优化神器,只要用了它们应用就会变快,所以所有函数和计算都要包一层。”

正解缓存本身有开销——存储依赖、比较依赖、垃圾回收压力。对于简单计算,加了 useMemo 反而更慢。React 官方的建议是:先测量,发现问题再用优化。就像给每个动作都配一台自动化设备,大部分时候设备在待机,还占厨房空间。

为何常见:性能优化的话题容易让人产生“多多益善”的直觉,但缓存的成本在多次渲染后才会被收益覆盖,需要权衡。

误区“闭包陷阱只存在于 useCallback 中,只要不用 useCallback 就不会有闭包问题。”

正解闭包陷阱可能出现在任何创建函数的场景——useEffect、事件监听器、setTimeout 回调等。只要函数捕获的变量在当前渲染之外被读取,就可能读到旧值。本质是 JavaScript 闭包特性与 React 渲染模型的互动问题,而非某个 Hook 特有。

为何常见:因为 useCallback 的依赖数组写法容易踩坑,很多人就以为问题出在 useCallback 本身。

误区“state 就是一个变量,我直接 count = 5 然后调用 setState,或者直接改 state 对象的属性,就能更新界面。”

正解直接修改对象属性的方式(如 obj.a = 1)不会触发重新渲染,因为 React 通过 Object.is 比较新旧状态引用,修改同一个对象引用时不会检测到变化。正确做法是创建一个新对象(展开或结构拷贝),然后 setState 传入新对象。就像你不能只把储物柜里的食材换掉而不通知管理员——管理员(React)只认“换了一整个柜子”这个信号。

为何常见:从普通 JavaScript 转向 React 时,容易保留“直接赋值”的直觉,没意识到状态更新需要走触达机制。

面试题

Q1. 在 React 中,函数组件使用哪个 Hook 来管理本地状态?

单选题 · easy

  • A.useState← 正确答案
  • B.useEffect
  • C.useMemo
  • D.useRef

答案:A。useState 是用于声明组件内部状态的 Hook,返回当前状态和更新函数。useEffect 处理副作用,useMemo 缓存计算结果,useRef 管理可变引用。就像储物柜(useState)是存放食材的地方,自动化设备(useEffect)是另一回事。

Q2. 关于 useState 的函数式更新,下列说法正确的是?

单选题 · mid

  • A.比普通更新更节省内存
  • B.可以避免闭包中捕获过期状态← 正确答案
  • C.只能在 useEffect 中使用
  • D.函数参数代表初始值

答案:B。函数式更新 setState(prev => prev + 1) 不直接依赖外部变量,React 会传入最新状态值,因此避免闭包陷阱。它没有内存优势,也不限使用场景。选项 D 描述的是 useState 初始化时的惰性初始化函数,二者作用完全不同。

Q3. useEffect 第二个参数是空数组 [] 时,副作用函数会?

单选题 · mid

  • A.每次渲染后都执行
  • B.在组件卸载时执行
  • C.只在组件挂载时执行一次← 正确答案
  • D.永远不执行

答案:C。空数组表示没有任何依赖项,React 只在组件首次挂载后执行一次副作用。如果完全省略第二个参数,则每次渲染后都会执行。cleanup 函数仍然会在组件卸载时执行。

Q4. 以下哪些做法能有效避免闭包陷阱?

多选题 · mid

  • A.在 useEffect 中使用函数式更新← 正确答案
  • B.把所有用到的 state 都放进依赖数组← 正确答案
  • C.使用 useRef 保存最新值← 正确答案
  • D.使用 useState 直接读取状态

答案:ABC。函数式更新让 React 管理最新值,完整依赖数组确保 effect 内部闭包是最新的,useRef 提供了一个跨渲染的稳定可变对象。选项 D 中直接读取状态变量、不通过 setState 回调,在异步回调中往往读到的是过期引用。

Q5. useMemo 和 useCallback 的核心区别是什么?

单选题 · easy

  • A.useMemo 缓存计算结果,useCallback 缓存函数本身← 正确答案
  • B.useMemo 用于异步操作,useCallback 用于同步操作
  • C.useMemo 用于状态,useCallback 用于 Props
  • D.它们没有任何区别

答案:A。useMemo(() => value, deps) 缓存的是计算的结果值,useCallback(fn, deps) 缓存的是函数引用。二者底层实现相通(useCallback 可看作 useMemo 的语法糖),但语义不同。就像备菜区(useMemo)存的是切好的菜,固定工具位(useCallback)放的是刀本身。

Q6. 下列代码存在什么问题?

单选题 · hard

  • A.定时器不会运行
  • B.count 永远停留在初始值,且定时器没有清理← 正确答案
  • C.每次渲染都会重新创建定时器
  • D.setCount 不是合法的更新函数

答案:B。空依赖数组导致 effect 只执行一次,但内部 setInterval 回调捕获的是首次渲染的 count=0,之后每次执行都是 setCount(0+1),状态不会持续增长。同时没有返回 cleanup 函数,组件卸载后定时器仍然存在,造成内存泄漏。应改为函数式更新 setCount(prev => prev + 1) 并实现 cleanup 清理定时器。

Q7. 关于 React.memo 与 useCallback 的配合,正确的说法有?

多选题 · hard

  • A.React.memo 只对函数组件生效,对普通函数无效← 正确答案
  • B.父组件传给子组件的函数经过 useCallback 缓存后,配合 React.memo 才能避免子组件重渲染← 正确答案
  • C.React.memo 会阻止所有子组件重渲染
  • D.不传函数给子组件时,React.memo 仍然有效← 正确答案

答案:ABD。React.memo 是浅比较 Props 的高阶组件,只对函数组件生效。每次渲染时父组件创建的新函数引用会让 memo 的比较失败(因为引用不同),useCallback 能让函数引用稳定,memo 才有意义。但 React.memo 不代表绝对不渲染,它只拦截 Props 变化导致的渲染,组件内部状态变化仍会渲染。

Q8. 当一个组件卸载时,useEffect 返回的清理函数会被调用,它主要用于?

单选题 · mid

  • A.清除组件内部的所有状态
  • B.清理定时器、取消订阅、移除事件监听等← 正确答案
  • C.强制组件重新渲染
  • D.保存组件当前状态

答案:B。清理函数用于释放副作用资源——清除定时器、取消网络请求、移除事件监听器、断开 WebSocket 等,防止内存泄漏。就像洗碗机洗完碗后自动关水关电。它不会清除状态、不会触发渲染。

Q9. 以下哪个 Hook 最适合缓存一个复杂的计算结果?

单选题 · easy

  • A.useRef
  • B.useCallback
  • C.useMemo← 正确答案
  • D.useEffect

答案:C。useMemo 专门用于缓存计算结果,在依赖不变时返回旧值,避免重复执行昂贵计算。useCallback 缓存函数引用,useRef 管理可变引用,useEffect 处理副作用。

Q10. React 18 中关于 useState 批量更新的说法,正确的是?

多选题 · hard

  • A.同一个事件处理函数中的多次 setState 会被合并为一次渲染← 正确答案
  • B.在 Promise 回调中的 setState 也会被批量处理← 正确答案
  • C.setState 是同步更新状态的
  • D.批量更新让状态“立即生效”

答案:AB。React 18 的自动批处理涵盖 Promise、setTimeout 和原生事件处理器。批量更新意味着点击后多个 setState 合并成一次渲染,因此状态不会立即更新到 UI。选项 C、D 都是对更新时机的误解——读取此时的状态仍然可能是旧值(除非用函数式更新确保基于最新值)。