想象一下你家楼下那家老字号的厨房:在 React 16.8 之前,class 组件就像一口巨大的灶台,所有食材、火候、碗碟都要堆在同一个灶面上,煮个面要写一堆生命周期代码;而函数组件呢,更像一个一次性纸碗,只能接收客人递来的口味单(props),连“刚才加了几勺盐”这种状态都记不住。React Hooks 的诞生,就是把这间老厨房改造成了模块化操作台:useState 是贴好标签的食材罐,useEffect 是自动感应水管,useRef 是墙上的记号笔,useContext 是公共调料架……每一件工具各司其职,函数组件一下子从纸碗成长为能独立掌勺的大厨。 本篇文章会沿着这条“厨房改造”的进化路线,带你逐一认识这些工具,理解它们为什么存在、怎么用、以及最常见的翻车现场。读完你不仅会写 Hooks,更会明白 React 团队当年为什么要打破 class 这口大锅。
无论你做 AI 应用前端、模型工具链界面还是数据可视化产品,组件状态与副作用管理都是绕不开的基建;吃透 Hooks,相当于拿到现代前端厨房的标准厨具使用证,能少写几千行面条式代码。
目录
可视化演示
展示React Hooks体系架构、useState驱动的状态更新与重渲染循环,以及useMemo/useCallback的性能优化效果对比。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Hooks体系架构 useState管理状态,useEffect处理副作用 useMemo/useCallback优化性能,useRef管理引用 St + Fx + Mo + Rf + Cx 五大核心Hook覆盖组件状态、副作用、性能、引用和跨组件通信
1. 诞生背景:从大灶台到模块化厨房
在 Hooks 出现之前,React 的 class 组件就像一间传统的大灶台:你要把数据请求、事件绑定、定时器、表单校验全部堆在同一块灶面上,不同团队写出的代码层层叠叠,最后连打开锅盖都要小心翼翼。更尴尬的是函数组件,它只是一只一次性纸碗,只能接收 props 并直接返回界面,哪怕只是想记住“我加过几勺盐”,也无能为力。
这种割裂让 React 社区发明了 render props、高阶组件、mixin 等各种补丁,但这些方案像用胶带贴漏水的水龙头,贴着贴着反而把灶台缠成了毛线团。终于,React 16.8 决定彻底改造厨房:与其逼着函数组件模仿 class,不如造一套可以自由插拔的模块化工具,让函数组件也能优雅地管理状态和副作用。这套工具,就是 Hooks。
Hooks 的进化,本质上是把“组件=纯函数”的理想真正落地:厨房还是那间厨房,但每个能力都被装进了可以自由组合的工具抽屉。接下来我们一件一件打开它们。
- UI(端上桌的面)组件最终输出给用户的界面,也就是 DOM 树或组件树。
- Render(一次烹饪过程)组件函数执行的完整过程,class 版靠实例方法,Hooks 版直接调用函数。
- state 与 props(食材与订单)state 是组件自己保存的可变数据,props 是父组件传入的外部参数。
2. useState:给灶台贴上食材罐
模块化厨房的第一件工具,是贴好标签的食材罐。你只需要写一行 const [count, setCount] = useState(0),就把 count 放进了带标签的罐子,setCount 则是一把换料勺。以后想更新数量,就抓起换料勺操作一次,下一轮烹饪自然会用上新食材。
不过这里有一个新手最容易踩的坑:换料是异步的。你刚把 setCount(count + 1) 递出去,当前这锅面的 count 并不会立刻变成新值,而是要等这一轮 render 结束后,React 才把更新队列里的任务统一执行,然后开启新一轮 render。这个机制就像厨房里所有订单先贴到墙上,厨师不可能一边颠锅一边瞬间换掉手里的食材。
还有个重要的哲学:更新时要保持不可变。比如往数组里加一项,不要用 push 改原数组,而要 setList([...list, newItem]) 返回一个新数组。食材罐的规矩是:换料时就换一张新标签,绝对不允许在旧标签上涂改,否则 React 可能根本发现不了变化。
useState 的核心是“旧状态喂给 updater,产生新状态”:你是直接给新值,还是给一个基于旧值计算的函数,React 都会用结果去替换下一次渲染读取到的 count。- S\_next(下一锅的食材量)下一次渲染时组件读到的状态值,也就是更新完成后展示给用户的数据。
- updater(换料勺)
setCount接收的新值或函数;如果传函数,React 会按队列顺序依次执行。 - S\_prev(当前锅里的旧食材)本轮渲染时的状态快照,同一轮里多次更新都基于它计算。
3. useEffect:自动感应水管
如果说 useState 是食材罐,那么 useEffect 就是厨房里的自动感应水管。它专门处理那些“不是纯渲染”的脏活:拉取数据、订阅事件、手动改 DOM、启动定时器。你把这根水管接到组件上,React 会在页面渲染完成后自动打开水龙头,执行你传入的副作用函数。
这根水管的精妙之处在于“先关旧阀,再开新阀”。假设你订阅了某个聊天房间,房间号从 A 变成 B,那么 useEffect 会先执行上一次返回的清理函数(退订 A),再执行新的副作用(订阅 B)。当组件卸载时,它还会执行最后一次清理,相当于关掉总水阀,避免内存泄漏和无效更新。
- Effect\_n(第 n 次放水)第 n 次需要执行的副作用操作,比如请求数据、订阅事件、操作 DOM。
- run(deps\_n)(按开关执行)只有当依赖数组里的值与上次不同时才执行新的副作用;没有依赖数组时每次渲染都执行。
- cleanup\_{n-1} → Effect\_n(先关旧阀)下一次副作用开始前,先执行上一次返回的清理函数;组件卸载时也会执行最后一次清理。
4. 性能优化:保鲜盒与预制菜
当厨房越来越忙,你需要保鲜盒和预制菜来减少重复劳动。useMemo 是保鲜盒,用来缓存“昂贵的计算值”:比如一个几千条数据的过滤结果,如果依赖没有变,就没必要每次渲染都重新算一遍。useCallback 则是预制菜包,用来缓存“函数的引用”:父组件每次渲染都会生成新函数,子组件如果用 memo 做浅比较,拿到新引用就会被迫重新渲染,而缓存后的函数能帮我们拦住这种无效更新。
这两个工具本身并不神奇,神奇的是它们的依赖控制:你告诉 React 这道菜依赖哪些食材,只要食材没换,就直接端上一模一样的盒饭,省去重新下锅的时间。
useMemo 和 useCallback 的核心都是“依赖不变,结果就复用”。前者缓存值,后者缓存函数,但判断逻辑完全一致。- m(保鲜盒里的成品菜)
useMemo返回的缓存结果,可能是一个数组、对象或字符串。 - calc(deps)(重新炒菜)只有当依赖项变化时才重新执行昂贵计算。
- old(昨天留下的盒饭)依赖不变时直接复用上一次的缓存,避免重复计算和多余渲染。
5. 引用与共享:冰箱贴与公共调料架
厨房里还有两种特殊的“读工具”:useRef 和 useContext。useRef 像冰箱贴,你可以随时在 .current 上写一句话、改一个定时器 ID,但改它不会触发组件重新渲染。它最适合保存那些“变了也不想让 UI 重画”的临时值,比如 setInterval 的句柄、上一次的输入值。
useContext 则是公共调料架。如果每个楼层之间都要靠服务员一层层传调料,那会很累;直接在公共调料架上放一瓶共享酱料,任何层级的组件伸手就能取。在 React 里,最上面的 <Context.Provider value={...}> 负责“上架”,子孙组件用 useContext(MyContext) 直接“取货”。
read 是组件取到的东西:useRef 读自己的冰箱贴,useContext 读公共调料架;区别在于前者不触发渲染,后者会在 Provider 值变化时自动通知所有消费者重新渲染。- read(你取到的东西)组件最终拿到的数据,可能是 DOM 节点、定时器 ID,也可能是主题色或用户信息。
- ref.current(墙上的冰箱贴)
useRef返回对象上的可变引用,改它不会通知 React 重画页面。 - provider.value(公共调料架上的当前调料)
Provider上绑定的值,它更新时所有消费者组件都会收到通知并重新渲染。
6. 自定义 Hook:菜谱封装
当你发现“取数据”这个动作在十个页面里重复出现,正确的做法不是继续复制粘贴,而是把它写成一张菜谱——也就是自定义 Hook。自定义 Hook 本质上还是一个函数,但它内部调用了 useState、useEffect 等官方 Hook,对外暴露统一入口,比如 useChatMessages()、useLocalStorage(key)。
自定义 Hook 的命名必须以 use 开头,这既是社区约定,也是让 ESLint 插件能自动检查 Hook 规则的关键。它就像把食材、火候、调料全部写进一张卡片:主厨只要按菜谱操作,不必关心内部细节。更棒的是,不同 Hook 可以互相组合,一个 useUser 可以内部调用 useState 和 useContext,像乐高一样拼出复杂逻辑。
- useCustom(x)(菜谱的封面与原料)自定义 Hook 的名称和参数,通常暴露一个或多个状态与操作方法。
- combine(……)(菜谱内部步骤)自由组合官方 Hooks,如
useState、useEffect、useMemo,它们共同完成一段完整逻辑。 - 返回值(端出来的成品)通常返回
[value, action]数组或对象,让调用方按需取用。
7. 规则与陷阱:厨房安全条例
厨房越模块化,安全条例越重要。Hooks 的底层实现依赖“每次渲染时按完全相同的顺序调用”这一假设:React 会在组件上挂一连串 Hook 节点,用数组下标记录每个 Hook 的状态。如果你把 useState 放进 if 或提前 return,下标就对不上,状态就会错乱,这也是为什么 Hooks 必须在顶层调用。
另一个高频陷阱是闭包旧值:effect 或回调函数里读到的变量,可能停留在某次渲染的快照里,而不是“最新值”。解决办法很简单——把用到的一切外部变量都写进依赖数组,或者用 useRef 保存一个始终最新的引用。依赖数组不是摆设,它是水管和保鲜盒的“保鲜期标签”。
- order\_i^{mount}(首次挂载的位置)第 i 个 Hook 第一次挂载时在组件里的调用顺序。
- order\_i^{every render}(每次渲染的位置)第 i 个 Hook 在后续渲染中的调用顺序,必须和挂载时一致。
- i = 1, ……, n(全部 Hook 的编号)一个组件里所有 Hook 的集合,数量一旦发生跳变,等式立刻失效。
AI 应用场景
大模型对话页的流式消息管理
阶段:前端交互层
解决的问题:AI 对话产品需要接收流式 token,传统方式是用 class 组件把连接、累积消息、停止生成逻辑散落在多个生命周期函数里,极难维护。用 useState 累积消息内容,用 useEffect 管理 WebSocket 或 SSE 订阅,用 useRef 保存中止控制器,再封装成 useChatStream,页面代码会非常干净。
具体案例:在 ChatGPT 风格页面中,用户点击发送后,前端建立 SSE 连接;每收到一个 token,调用 setMessages(prev => [...prev, token]) 追加内容;组件卸载或用户停止生成时,清理函数负责断开连接。整个逻辑统一收敛到自定义 Hook 中,任何一个新页面都能直接复用。
AI 训练任务实时看板
阶段:数据可视化与监控
解决的问题:训练过程中的 loss、acc 每秒钟刷新一次,如果直接用 setState 更新并重新过滤上千条指标曲线,页面会频繁重渲染甚至卡顿。用 useEffect 开启轮询,用 useMemo 聚合/降采样指标数据,用 useRef 保存轮询定时器,是标准解法。
具体案例:一个分布式训练监控面板,前端每 5 秒拉取一次指标快照,useEffect 负责更新最新数据,useMemo 只对需要的窗口区间做平滑计算;切到其他页面时,定时器随组件卸载自动清除。
多租户主题与模型配置共享
阶段:全局状态与用户偏好
解决的问题:同一个 AI 平台可能有多个大模型供应商、不同主题色、不同语言环境。用 useContext 包装全局配置,任何层级的组件都能直接读取,不需要从顶层一路 props 透传。
具体案例:管理后台的“设置中心”修改主题后,顶栏 Provider 的值更新,所有消费 useContext(ThemeContext) 的页面自动切换配色;修改模型服务地址后,所有请求组件立即拿到新的 endpoint。
Python 演示
from typing import Any, Callable, List, Tuple class MiniHooks: def __init__(self, render_fn): self.render_fn = render_fn self.hook_slots: List[Any] = [] self.effect_queue: List[Tuple[Callable[[], None], Any]] = [] self.cursor = 0 def useState(self, initial): idx = self.cursor self.cursor += 1 if len(self.hook_slots) == idx: self.hook_slots.append(initial) value = self.hook_slots[idx] def set_value(next_value): if callable(next_value): self.hook_slots[idx] = next_value(self.hook_slots[idx]) else: self.hook_slots[idx] = next_value self.render() return value, set_value def useEffect(self, fn, deps=None): self.effect_queue.append((fn, deps)) def render(self): self.cursor = 0 self.effect_queue = [] print("---- render ----") ui = self.render_fn() print("UI:", ui) for fn, deps in self.effect_queue: fn() state = {} def App(): count, set_count = app.useState(0) label = "count=" + str(count) app.useEffect(lambda: print("effect after render:", label), [count]) state["set_count"] = set_count return label app = MiniHooks(App)app.render()state["set_count"](2)state["set_count"](lambda v: v + 1)
useState 的排队更新与 useEffect 的执行时机。self.hook_slots: List[Any] = []用列表保存每个 Hook 节点的状态,相当于 React 内部的状态数组。idx = self.cursor每次调用useState都取当前光标位置,保证多次渲染时的调用顺序一致。if len(self.hook_slots) == idx:首次挂载时才初始化状态,后续渲染直接读取已保存的值。def set_value(next_value):模拟setCount,负责修改 Hook 槽位并触发下一次渲染。if callable(next_value):支持函数式更新,如setCount(v => v + 1)。app.useEffect(lambda: print(...), [count])模拟副作用注册,渲染结束后再统一执行。state["set_count"] = set_count把更新函数暴露到外部,模拟按钮点击等事件触发状态更新。
常见误区
误区我总是把依赖数组写成 `[]`,让 effect 只执行一次,这样性能肯定最好。
正解空依赖数组意味着 effect 里捕获到的所有外部变量都永远停留在首次渲染,之后的数据变化它根本看不见。这就像把水管焊死,只放一次水,后面厨房再需要水也毫无反应。依赖数组应当完整列出 effect 中使用的外部变量,必要时可以把一个 effect 拆成多个。
为何常见:很多教程用 [] 控制请求只发一次,新手就误以为“依赖越少越好”,忽略了闭包过期问题。
误区给每个函数和每个计算都包一层缓存,页面一定会更快。
正解缓存本身需要存储和比较依赖,等于给每道菜都套一层保鲜膜;如果计算非常简单,或者依赖频繁变化,缓存反而成为负担。useMemo 和 useCallback 应该只用在“计算昂贵”或“函数参与子组件重渲染优化”的场景。
为何常见:看到“缓存”二字就联想到性能提升,忽略了 React 内部每次渲染都要做依赖比较的隐形成本。
误区我把 `ref.current` 改了,为什么页面上的数字没变?
正解useRef 就是冰箱贴,写上去完全不会通知 React 重画 UI。它适合保存定时器、DOM 引用、上一次的值等“不需要驱动视图”的数据;真想触发渲染,必须搭配 useState 或强制更新。
为何常见:因为 useRef 和 useState 都返回“可以持有数据的容器”,初学者很容易把两者混为一谈。
误区`use` 开头只是一个约定,我把函数写成 `fetchData` 也一样能跑。
正解运行时确实不会因为名字报错,但 React 官方的 ESLint 插件必须靠 use 前缀来识别哪些函数是自定义 Hook,进而检查 useState、useEffect 的调用位置是否合法。一旦名字不符合规范,规则检查就会失效,很多隐藏问题会漏掉。
为何常见:因为“能跑”不等于“正确”,新手往往只关心运行结果,忽略了工具链的静态检查能力。
误区封装一个普通工具函数,在里面调用 `useState` 也可以在组件外使用。
正解Hooks 只能从函数组件或自定义 Hook 的顶层调用。普通函数没有 React 渲染流程中的“挂载时机”,也没有稳定的状态槽位,调用 useState 会直接报错。这就像在厨房里装一根没有接进总水管的开关,按钮再漂亮也出不了水。
为何常见:自定义 Hook 本质也是函数,容易让人误以为“函数里就能用 Hook”,忽略了它必须参与组件渲染链路这个前提。
面试题
Q1. React Hooks 正式随哪个版本发布?
- A.React 15.0
- B.React 16.8← 正确答案
- C.React 17.0
- D.React 18.0
答案:B。React 16.8 是 Hooks 的正式发布版本,它让函数组件拥有了状态和副作用能力。选项 B 正确;A、C、D 都不是 Hooks 引入的版本。
Q2. useState 的返回值结构是?
- A.当前状态与更新函数← 正确答案
- B.初始状态与默认值
- C.组件状态与外部属性
- D.渲染结果与虚拟节点
答案:A。useState 返回一个数组,第一项是当前状态,第二项是更新状态的函数,就像食材罐和换料勺。B、C、D 描述的都是其他概念,不是 useState 的返回值。
Q3. 调用 setCount(count + 1) 后,count 会在什么时候变化?
- A.调用后立刻同步变化
- B.在下次渲染时生效← 正确答案
- C.在组件卸载时生效
- D.在事件循环结束时生效
答案:B。setState 的更新是异步排队的,当前渲染里的 count 不会立刻变,要等 React 完成更新调度并触发下一次渲染后,组件才能读到新值。B 最准确;A 是常见误解,D 描述的是批处理现象而不是状态生效时机。
Q4. 以下哪些 Hooks 常用于性能优化?
- A.useMemo 缓存结果← 正确答案
- B.useCallback 缓存函数← 正确答案
- C.useReducer 转换状态
- D.useContext 共享数据
答案:AB。useMemo 缓存计算结果,useCallback 缓存函数引用,两者都用来减少重复计算和子组件重渲染。useReducer 是状态管理工具,useContext 是共享数据工具,它们的主要目的不是性能优化。
Q5. useEffect(fn, []) 中的空依赖数组表示什么?
- A.每次渲染后都执行
- B.仅挂载后执行一次← 正确答案
- C.仅卸载时执行一次
- D.依赖为 undefined 时执行
答案:B。空依赖数组告诉 React“没有任何外部食材需要监控”,因此 effect 只在组件首次挂载后执行一次,相当于只放一次水。A 是没有依赖数组时的行为,C 只描述了清理阶段,D 不存在这种语义。
Q6. 修改 useRef 返回对象的 .current 值会怎样?
- A.立即触发组件重新渲染
- B.不触发重新渲染← 正确答案
- C.触发父子组件重新渲染
- D.使 effect 重新执行
答案:B。useRef 是可变引用容器,修改 .current 不会进入 React 的更新流程,因此不会触发重渲染,也不会自动让 effect 重新执行。它适合保存那些不需要驱动视图的临时数据。
Q7. 以下哪些属于 React Hooks 的强制规则?
- A.只能在函数组件顶层调用← 正确答案
- B.只能从组件或自定义 Hook 调用← 正确答案
- C.允许在条件语句中调用
- D.必须配合 class 组件使用
答案:AB。Hooks 必须在渲染顶层调用,且只能从函数组件或自定义 Hook 中调用,否则 React 无法保证调用顺序和状态槽位。C 会破坏 Hook 顺序,D 与 Hooks 设计目的完全相反。
Q8. 自定义 Hook 必须以 use 开头的主要原因是什么?
- A.运行时强制校验名称
- B.便于 ESLint 插件识别← 正确答案
- C.能自动获得缓存能力
- D.必须配合 useMemo 使用
答案:B。use 前缀不是运行时硬性要求,而是为了帮助 ESLint 插件识别自定义 Hook,从而检查 Hooks 的使用规则。它不是性能开关,也不强制依赖 useMemo。
Q9. useContext 主要解决的问题是?
- A.跨层级组件共享数据← 正确答案
- B.替换所有 useState
- C.提高组件渲染速度
- D.绑定原生 DOM 事件
答案:A。useContext 让组件直接读取最近的 Provider 提供的值,避免逐层传递 props,是典型的“公共调料架”方案。它不负责状态存储、性能优化或事件绑定。
Q10. 关于 useEffect 清理函数,下列说法正确的是?
- A.下次 effect 执行前触发← 正确答案
- B.组件卸载时触发← 正确答案
- C.首次渲染后必定触发
- D.依赖未变时不触发← 正确答案
答案:ABD。清理函数会在下一个 effect 执行前以及组件卸载时执行;如果依赖没有变化,上一次的清理函数就不会运行。首次渲染后不会有“上一次 effect”需要清理,所以 C 错误。