首页 / 资讯中心 / 文章详情

React Hooks与Vue Hooks深度对比:从状态管理到自定义Hook

React Hooks与Vue Hooks深度对比:从状态管理到自定义Hook ★ FEATURED ARTICLE
如果现在让你说说 React Hooks 和 Vue Hooks 的差别你能讲出几条我最早接触 Hooks 时只是照着文档把 Class 组件改成函数组件以为useState就是换个状态的写法。直到后来被面试题、被线上 bug、被同事的代码反复教育才意识到 Hooks 不是语法糖而是一套需要重新理解的心智模型。这篇文章想把这些年踩过的坑和想明白的东西一次性说清楚从 Class 组件的痛点出发拆useState、useEffect的原理再带你把自定义 Hook 从 0 到 1 写出来最后用 Vue 3 的 Composition API 做一次横向对比。不管你是准备 React 面经、正在重构老项目还是从 Vue 转 React都应该能用得上。1. React Hooks 到底解决了什么从 Class 组件的痛点说起1.1 状态逻辑复用难HOC 与 Render Props 的困境在 Hooks 之前React 想复用一段带状态的逻辑主流方案是 HOC高阶组件和 Render Props。HOC 的思路是把公共逻辑放进一个包装组件再通过 props 把状态和回调传递给被包装组件。听起来很干净但一旦复用两三个 HOC就会形成嵌套地狱const EnhancedComponent withRouter( withUser( withTheme(MyComponent) ) );每一层 HOC 都会在 React DevTools 里多渲染一层组件排查问题就像拆洋葱。更麻烦的是多个 HOC 如果都往子组件传同名的 props后包裹的会直接覆盖前面的你很难追查到是哪个 HOC 动了手脚。Render Props 虽然避免了 HOC 的 props 命名冲突但回调嵌套同样会带来可读性问题render函数里再套一个render代码很快就变得像回形针一样扭在一起。Hooks 出现后这种复用逻辑直接变成普通函数调用。你不再需要创建一个额外的组件层级只需要在一个自定义 Hook 内部维护状态和副作用然后在组件里调用它。从函数式编程的角度看Hooks 把“逻辑”从“UI”里彻底解放了出来复用成本从“组件树的层级嵌套”降低到了“一次函数调用”。1.2 副作用逻辑分散生命周期函数的混乱Class 组件的另一个大坑是副作用逻辑被生命周期函数切碎了。一个典型的例子是监听窗口尺寸变化。在 Class 组件里你需要同时在componentDidMount和componentDidUpdate里注册监听在componentWillUnmount里移除监听。如果同一个组件里还要监听键盘事件、处理网络请求、操作定时器那这几个生命周期函数就会被不相关的逻辑塞满维护起来非常痛苦。更反直觉的是很多逻辑其实需要“同一段代码在挂载和更新时都执行一次”但 Class 组件强制你把它拆成两份。比如用户 ID 变化后重新拉取数据你必须在componentDidMount里拉一次再在componentDidUpdate里比较prevProps.id和this.props.id不一样再拉一次。这套比较逻辑写多了人就容易麻木漏掉某一次更新是常有的事。useEffect解决了这个结构性问题。它把“副作用”作为一个独立的、声明式的概念提了出来你只需要描述这段代码依赖了什么React 负责在合适的时间执行和清理。同一个副作用可以同时覆盖挂载、更新和卸载三个阶段不再需要人为拆分到三个生命周期里。1.3 Hooks 设计目标函数组件的“状态能力补全”从设计目标来看Hooks 不是一拍脑袋想出来的 API而是要解决三个具体问题第一让函数组件具备和 Class 组件一样的状态管理能力第二让逻辑复用的方式从组件嵌套变成函数组合第三让副作用相关的代码从生命周期里解放出来按照“关注点”而不是“生命周期”来组织。这也是为什么 Hooks 被设计成“函数”而不是“类”的原因。函数天然适合组合可以塞进自定义 Hook 里也可以被多个组件共享。而函数组件本身是纯函数接收 props、返回 JSXHooks 在函数执行的中间注入状态和副作用让纯函数拥有了内部记忆。理解了这一点后面看useState的实现就会顺畅很多。2. 核心 Hooks 原理解读从 useState 到 useEffect2.1 useState 的实现原理与状态更新机制useState返回的是一个数组[state, setState]不是对象。这是因为数组的解构可以让你自由命名const [count, setCount] useState(0)想叫什么都行。但它的底层远没有看起来这么简单。React 在渲染一个组件时会维护一个与组件对应的 Fiber 节点Hooks 的数据就挂在这个 Fiber 节点上。每次调用useStateReact 不是凭空创建一个变量而是按调用顺序从一条 Hooks 链表上取出对应的 Hook 对象。这个 Hook 对象里存了当前状态值、更新的队列以及一些内部标记。第一次渲染时useState用初始值创建链表节点后续渲染时直接复用已有节点并处理队列里积压的更新。这种“按顺序匹配”的设计决定了 Hooks 绝对不能出现在条件分支、循环或者提前 return 之后。一旦某一次渲染时少调用了一个 HookReact 拿到的链表顺序就错位了状态会被张冠李戴而且报错信息往往很隐晦。这就是为什么官方反复强调要遵守 Hooks 规则背后是这条链表实现直接决定的。再看状态更新机制。setCount(count 1)和setCount((c) c 1)是有区别的。前者把count 1作为一个值直接丢进更新队列后者传入一个函数React 会在下一次渲染时按队列顺序用前一个状态计算出新状态。如果你在事件处理函数里连续调用三次setCount(c c 1)最终状态会加 3如果你三次都写setCount(count 1)由于闭包里的count是同一个快照最终只会加 1。这个坑在面试题里出现的频率极高几乎可以算必考。2.2 useEffect 的依赖追踪与执行时机useEffect是另一个高频考点。很多人只知道“在 effect 里做副作用”但不知道它的执行时机和依赖判断逻辑。useEffect的回调函数会在浏览器完成绘制之后异步执行注意不是渲染过程中而是渲染提交之后。这意味着你在这里读取 DOM 的几何信息、执行网络请求都不会阻塞用户看到页面。如果你需要在页面绘制前同步操作 DOM应该用useLayoutEffect。依赖数组的作用是让 React 比较前后两次渲染时依赖项是否发生变化。比较算法是Object.is不是深比较。所以如果依赖项里放了一个每次渲染都新建的对象或函数React 会认为依赖变了effect 就会重新执行甚至可能造成无限循环。解决方式通常是配合useMemo或useCallback稳定引用或者干脆把对象内部字段拆出来作为依赖。effect 的清理函数也很有意思。React 会在下一次 effect 执行前清理上一次的 effect也会在组件卸载时清理。这个机制天然适合取消订阅、清除定时器、中止请求。如果忘了写清理函数最常见的后果就是组件卸载后还在setStateReact 会提醒你对已卸载组件执行状态更新是无效的。虽然 React 18 之后这个告警已经被移除但不做清理仍然可能造成内存泄漏。2.3 useMemo / useCallback / useRef 的底层逻辑useMemo和useCallback本质是缓存。useMemo缓存一个计算值useCallback缓存一个函数。它们都依赖一个“记忆化”的表在 Hook 节点上保存上一次的依赖数组和对应的值下次渲染时比对依赖如果没变就返回缓存变了就重新计算。这里有个容易误解的点useMemo(() expensive(a, b), [a, b])第一个参数是一个函数React 会调用它来得到缓存值而不是直接缓存函数本身。useRef表面上像一个“可变盒子”但它背后的实现更接近一个稳定的引用。useRef(initialValue)创建的对象在整个组件生命周期内保持不变.current属性可以随意修改修改它不会触发重新渲染。这个特性让useRef成为保存闭包变量、访问 DOM 节点、存储定时器 ID 的首选工具。需要特别强调的是useRef的值变化不会通知 React所以不要试图用它来驱动 UI那只应该由useState负责。useMemo和useCallback的依赖数组都要求你在职责上诚实。如果你在回调里访问了一个外部的变量却没有把它写进依赖React 不会自动帮你追踪。eslint-plugin-react-hooks 的exhaustive-deps规则就是强制你把这些依赖补全省得因为闭包问题半夜被线上 bug 叫醒。3. 自定义 Hook 设计实战从需求分析到落地3.1 自定义 Hook 的本质与命名规范自定义 Hook 没有任何魔法它就是一个普通函数只不过函数内部调用了其他 Hooks。比如你写一个useDocumentTitle本质上是封装了useEffect来修改document.title。它的价值在于把“给文档设置标题”这个逻辑从组件中抽离出来多个组件都能复用。命名规范是use开头这一点不是形式主义而是为了让 eslint 插件能识别出这是一个 Hook从而正确检查内部 Hooks 的调用规则。如果命名不以use开头eslint 会默认它是一个普通函数内部的 Hooks 调用就可能逃过检查这是非常危险的。我自己见过同事把自定义 Hook 命名为getWindowSize结果内部用了useState和useEffecteslint 直接报错。规范这种东西关键时刻能救命。自定义 Hook 还可以返回任意值数组、对象、函数都行。我建议返回对象字段名清晰调用方可读性更好如果你只需要两个值返回数组也挺好解构时还能自定义名字。但无论返回什么都要保证函数的职责单一不要一个 Hook 里既管网络请求又管 localStorage。3.2 实战封装一个 useRequest 数据请求 Hook网络请求是前端绕不开的需求。原生useEffect里写请求最大的痛点是手动管理 loading、data、error 三个状态组件卸载时请求还在跑依赖变化后重新请求时上一次的结果还可能短暂闪现。我们可以用自定义 Hook 把这些问题收拢起来。import { useState, useEffect, useCallback, useRef } from react; function useRequest(fn, options {}) { const { manual false, defaultParams [], onSuccess, onError } options; const [data, setData] useState(null); const [loading, setLoading] useState(!manual); const [error, setError] useState(null); const fnRef useRef(fn); const paramsRef useRef(defaultParams); useEffect(() { fnRef.current fn; }, [fn]); const run useCallback(async (...args) { const params args.length 0 ? args : paramsRef.current; setLoading(true); setError(null); try { const result await fnRef.current(...params); setData(result); onSuccess?.(result); return result; } catch (e) { setError(e); onError?.(e); throw e; } finally { setLoading(false); } }, [onSuccess, onError]); useEffect(() { if (!manual) { run(...paramsRef.current); } }, [manual, run]); return { data, loading, error, run }; }这段代码里有几个值得注意的点。fnRef保证了run始终调用最新传入的请求函数不会因为闭包捕获了旧的fn而拿不到新的参数。run本身用useCallback包了一层依赖只有onSuccess和onError这样在非手动模式下useEffect不会因为run引用不稳定而重复发起请求。finally里统一把loading置为 false不管成功失败都能恢复正常状态。使用的时候也很简单const { data, loading, error, run } useRequest(fetchUser, { manual: true, }); useEffect(() { if (id) run(id); }, [id, run]);这个 Hook 虽然只是一个简化版本但已经能覆盖不少业务场景。你可以继续扩展加入请求竞态处理、缓存最近一次结果、支持请求防抖等等。设计自定义 Hook 时优先把“状态”和“副作用”封装好再逐步增加复杂度。3.3 实战封装一个 useEventListener 事件监听 Hook事件监听是另一个特别适合自定义 Hook 的场景。如果在每个组件里都写一遍addEventListener和removeEventListener很容易忘记清理。我把监听逻辑抽成一个useEventListener效果立竿见影。import { useEffect, useRef } from react; function useEventListener(eventName, handler, options {}) { const handlerRef useRef(handler); const targetRef useRef(options.target ?? window); useEffect(() { handlerRef.current handler; }, [handler]); useEffect(() { const target targetRef.current; if (!target?.addEventListener) return; const eventHandler (event) handlerRef.current(event); target.addEventListener(eventName, eventHandler, options); return () { target.removeEventListener(eventName, eventHandler); }; }, [eventName, options]); return null; }这里有几个设计细节。handlerRef避免了因为handler每次渲染都变化而重新绑定事件同时保证事件回调里用的是最新的handler。targetRef则让你可以灵活地传入任意 DOM 元素不局限于 window。options直接透传给原生addEventListener支持capture、once、passive等配置。使用示例useEventListener(mousemove, (e) { setPosition({ x: e.clientX, y: e.clientY }); }); useEventListener(click, onClick, { target: buttonRef.current });这个 Hook 的通用性很强窗口 resize、键盘事件、鼠标移动、滚动监听都可以复用。在类组件时代你还得写一个工具类或者混入现在一个 Hook 就搞定了。3.4 自定义 Hook 的常见设计误区写自定义 Hook 时间长了我总结出三个最容易踩的坑。第一个坑是在 Hook 内部返回了一个每次都新创建的对象导致组件依赖这个对象时反复渲染。比如return { loading, data }如果loading和data是基础值其实没问题但如果你返回{ data, run }而run没有用useCallback稳定每次调用 Hook 都会生成新的函数下游组件用useEffect依赖这个对象时就会死循环。解决办法是把函数都包上useCallback或者对返回对象使用useMemo。第二个坑是过度依赖useState把能从props或已有状态计算出来的值也单独存一份。比如const [isAdult, setIsAdult] useState(age 18)age变化时isAdult不会自动更新必须手动同步。这种“派生状态”应该直接计算const isAdult age 18;只有那种无法通过计算得到、需要异步获取或者用户输入产生的值才该放进 state。第三个坑是忽略了清理函数。如果你在 Hook 里创建了定时器、订阅了事件、发起了请求一定要在 effect 的清理函数里收尾。否则组件卸载后定时器还在跑事件还在触发轻则报警告重则内存泄漏。4. 规则与调试Hooks 的硬性约束和排查技巧4.1 Hooks 调用规则为什么不能放在条件里前面提到 Fiber 节点上的 Hooks 链表按顺序匹配所以 Hooks 的调用顺序必须在多次渲染之间保持一致。这意味着// 错误示例 if (visible) { const [count, setCount] useState(0); } // 错误示例 for (let i 0; i n; i) { useEffect(() {}); } // 正确示例 const [count, setCount] useState(0);React 官方提供了一条简单准则只在 React 函数组件或自定义 Hook 的顶层调用 Hooks。不要在普通 JavaScript 函数里调用不要在条件、循环、嵌套函数里调用。eslint-plugin-react-hooks 的rules-of-hooks规则会自动检查这些强烈建议接入。在实际项目中最常见的违规场景是在回调里调用useState比如在点击事件里写setVisible是没问题的但你不能在点击事件里写useState。逻辑一定要保持在组件正文的最顶层。4.2 依赖数组的坑闭包陷阱与过期状态依赖数组是 Hooks 最容易翻车的地方。闭包陷阱的典型场景是const [count, setCount] useState(0); useEffect(() { const timer setInterval(() { setCount(count 1); // 这里的 count 永远是最初的 0 }, 1000); return () clearInterval(timer); }, []);这段代码的本意是每秒让 count 加 1但因为 effect 只执行一次闭包捕获的count是首次渲染时的快照 0后面 set 多少次都是 1。解决办法有两个要么把count加进依赖数组让 effect 重新执行要么改成setCount((c) c 1)这种函数式更新。函数式更新不依赖外部变量能彻底绕开闭包陷阱。依赖数组还有一个容易忽略的点如果依赖项里包含对象或者数组哪怕内容一样只要引用不同React 也会认为依赖变了。比如const params { id: 1 }; useEffect(() { fetchData(params); }, [params]); // params 每次渲染都是新对象这会导致 effect 反复执行甚至无限循环。正确的做法是把id单独作为依赖或者在组件外用useMemo缓存 params。排查这类问题的时候我一般先看依赖数组里有没有对象字面量、数组字面量、匿名函数把它们都换成稳定的引用或基础类型问题通常能消除一大半。4.3 用 React DevTools 定位 Hooks 问题调试 Hooks 不能只靠 console.log。React DevTools 的 Components 面板里选中一个组件后可以看到 Hooks 列表每个 Hook 当前的值都一目了然。比如useState会显示当前 stateuseEffect会显示依赖数组和 effect 状态useMemo会显示缓存值。这比打印一堆 console 日志要直观得多。自定义 Hook 还可以配合useDebugValue在 DevTools 里展示额外的调试信息useDebugValue(visible ? visible : hidden);这样一来在 Hooks 列表里就能看到你的自定义 Hook 当前的标签团队协作时排查问题会轻松很多。另外DevTools 还会提示不必要的重复渲染如果你看到某个组件因useCallback或useMemo失效而不停重渲染优先检查依赖数组里是否混入了不稳定的引用。5. Vue Hooks 对比Composition API 与 React Hooks 的异同5.1 设计哲学对比函数式不可变 vs 响应式可变聊到 Vue Hooks其实指的是 Vue 3 的 Composition API。虽然日常用语里 Vue 开发者也会说useXxx组合式函数但它的底层思路和 React Hooks 完全不同。React 的哲学是“不可变数据 显式更新”。每次渲染都是一次全新的函数执行state是一个快照你不能修改它只能调用setState让 React 重新渲染。这种模式的好处是数据流可预测组件像纯函数一样稳定。代价是你得时刻注意闭包、依赖数组、引用稳定性心智负担不轻。Vue 3 的哲学是“响应式代理 自动追踪”。ref和reactive创建的数据是真真切切可以变的你改count.value界面上用到count的地方会自动更新。Vue 的响应式系统会在运行期收集依赖而不是在渲染期声明依赖。所以 Vue 里没有“闭包陷阱”这个说法你在watchEffect里读到了哪个响应式变量它就会自动追踪哪个变量。用一个类比来说React Hooks 像是你每次做饭都要把所有食材列个清单谁变了就重新做哪道菜Vue 的响应式系统则像是一个传感器食材一变厨房就自动知道哪些菜需要重做。5.2 依赖追踪差异React 依赖数组 vs Vue 自动追踪这个差异直接影响了开发体验。在 React 里useEffect的依赖数组必须手动维护漏一个就出 bugconst [a, setA] useState(0); const [b, setB] useState(0); useEffect(() { console.log(a b); }, [a]); // 只依赖 ab 变化时不会执行这是 bug在 Vue 的watchEffect里你根本不需要写依赖const a ref(0); const b ref(0); watchEffect(() { console.log(a.value b.value); });watchEffect会在回调执行期间自动读取了哪些响应式变量就自动把这些变量记录下来任何一个变化都会重新执行回调。这种“隐式追踪”让很多 React 开发者刚切换到 Vue 时非常爽不用再天天纠结依赖数组。但自动追踪也不是万能药。如果你在watchEffect里读取了不想被追踪的变量需要额外使用watchPostEffect之类的 API 去控制执行时机。相比之下React 的依赖数组更像“显式契约”调试时你能一眼看出这个 effect 依赖哪些值Vue 则需要通过运行时的日志才能确认追踪关系。两种各有取舍。5.3 自定义逻辑复用方式对比自定义 Hook vs 组合式函数在代码形态上React 自定义 Hook 和 Vue 组合式函数非常相似都是把逻辑抽成函数返回状态和操作。但它们内部机制不同。React 自定义 Hookfunction useCounter() { const [count, setCount] useState(0); const increment useCallback(() setCount((c) c 1), []); return { count, increment }; }Vue 组合式函数function useCounter() { const count ref(0); const increment () { count.value; }; return { count, increment }; }看起来几乎一模一样。差别在于Vue 组合式函数可以自由使用if、循环或提前返回因为这些函数里的响应式变量是真正的对象不依赖调用顺序React 则因为 Hooks 链表的顺序机制必须在函数顶层无条件下调用。所以在 Vue 里写组合式函数心态上比写自定义 Hook 要松弛一些。不过 React 的不可变模型也带来了一个红利Hooks 的输入输出更容易被单元测试。你可以在renderHook里直接调用自定义 Hook断言返回值和回调行为不需要启动一个 Vue 应用实例。5.4 迁移与选型建议如果你都在用一个团队维护 React 和 Vue 两个项目我的建议是先想清楚团队更习惯哪种心智模型。团队里如果早有 React 函数式思维写自定义 Hook 会更顺手如果大家更熟悉 Vue 的响应式直觉Composition API 的上手曲线会更平滑。从面试角度讲两边经常会互相问对方的核心概念。React 面试官喜欢问 Hooks 原理、闭包陷阱、依赖数组Vue 面试官喜欢问ref和reactive的区别、响应式依赖收集、watch和watchEffect的差异。本质上考的都是同一个问题你到底理解这套机制背后的运行时行为还是只会调 API。如果你需要同时维护两个项目可以先在 React 里把自定义 Hook 封装成纯逻辑函数不依赖 React API 的部分可以直接复用但跟渲染和生命周期相关的逻辑就要各写各的了。用 TypeScript 泛型把两边的接口统一起来迁移成本会明显降低。6. 实操总结与避坑清单6.1 我总结的 Hooks 使用准则这几年写了大量 Hooks 代码后我给自己定了几条硬规矩分享出来供你参考。第一条能用useState解决的绝不用useRef。useRef改值不触发渲染容易造成 UI 不更新却不报错的怪异现象。第二条派生数据不要存 state直接用计算表达式或useMemo。第三条useEffect里凡是有订阅、定时器、请求必须写清理函数不允许裸奔。第四条回调函数一旦被传给子组件或者放进依赖数组就用useCallback包一层复杂计算值用useMemo缓存。第五条依赖数组要写全但绝不能盲目依赖。这些规则不一定适用于所有场景但能在绝大多数情况下帮你避免低级 bug。遇到具体问题时先问自己这个状态是必须在 React 渲染流里存在的吗这个副作用应该关心哪个变量的变化如果答案不清晰多半是设计出了偏差。6.2 最后再分享两个调试小技巧第一个小技巧是给自定义 Hook 加上useDebugValue并且配合format函数显示结构化信息。这样在 DevTools 里你的 Hook 状态会非常清晰不亚于打印日志。第二个小技巧是善用 eslint 的exhaustive-deps规则。很多人嫌它烦但我现在反而推荐把这个规则开到 warn 级别每次报错都意味着你的依赖数组不完整。先按它的建议把依赖补全再运行一遍如果出现死循环再去考虑怎么稳定引用。这比自己一根根猜依赖靠谱得多。说到底React Hooks 和 Vue Hooks 没有谁绝对比谁高级它们只是用不同方式解决了同一个问题如何在组件里优雅地管理状态和副作用。我个人在实操中的体会是理解 Hooks 的价值不在于背住几条规则而是掌握它背后的运行时模型——一旦想通了为什么不能条件调用、为什么依赖数组需要手动、为什么闭包会捕捉旧值你写任何框架的代码都会更稳。希望这篇文章能帮你把这块拼图拼上早点告别“看着文档会写、离开文档就懵”的状态。
阅读完成 · 觉得有帮助?
咨询建站