React 内置 Hook 与 Vue3 对照

一、Hook 到底是个什么东西

组件函数每渲染一次,就是一次全新的函数调用。函数调用结束后,里面的局部变量全部销毁。

那你写的 count 呢?下次渲染它怎么还在?

React 的答案:在组件外面准备一排"储物柜",编号 0、1、2……每次函数渲染时,useState 这类函数不再新建数据,而是按调用顺序去对应编号的柜子里取。

就像你去健身房,柜子是场馆提供的,你每次来都拿同一个号码牌的柜子。你本人(函数)每次都是空手进场的,但你的东西(状态)存在柜子里。

理解了柜子,你就理解了两条 Hook 规则:

  • 只在顶层调用——不能放进 if 里。因为柜子是按调用顺序认领的,这次渲染进了 if 少开一个柜子,下次没进,后面的全部错位。
  • 只在组件/自定义 Hook 里调用——普通函数没有场馆,没有柜子可领。

重建一次:20 行代码造一个 useState

不看 React 源码,自己造一个能跑的,你就真正知道它在干什么:

// mini-react.js —— 重建 useState
let hooks = [];   // 那一排储物柜
let index = 0;    // 当前取到第几号柜子

function useState(initialValue) {
  const currentIndex = index; // 先记住自己的柜子号

  // 首次:柜子是空的,放进初始值
  if (hooks[currentIndex] === undefined) {
    hooks[currentIndex] =
      typeof initialValue === "function" ? initialValue() : initialValue;
  }

  // 修改状态的唯一方式:换柜子里的东西 + 触发重新渲染
  function setState(newValue) {
    hooks[currentIndex] =
      typeof newValue === "function" ? newValue(hooks[currentIndex]) : newValue;
    render(); // 重新执行组件函数
  }

  index++; // 指针后移,下一个 Hook 用下一个柜子
  return [hooks[currentIndex], setState];
}

function render() {
  index = 0;            // 每次渲染前,指针归零
  App();                // 重新执行组件函数
}

render() 里那行 index = 0 就是全部秘密:状态本身没有魔法,只是"数组 + 顺序指针"。

二、React 全部内置 Hook 一览

Hook 干什么的 引入版本
useState 存状态,改了会重渲染 16.8
useReducer 存状态,但修改走"派发 action"的路子,适合复杂逻辑 16.8
useEffect 处理副作用:请求、定时器、订阅,DOM 之外的世界 16.8
useLayoutEffect 同上,但同步执行,在浏览器画页面之前跑 16.8
useInsertionEffect 给 CSS-in-JS 库插样式用的,日常几乎碰不到 17
useContext 读跨层传下来的数据,免得一层层传 props 16.8
useRef 存一个"改了也不重渲染"的值,常用来抓 DOM 16.8
useMemo 缓存计算结果,依赖不变就不重算 16.8
useCallback 缓存函数本身,依赖不变就返回同一个函数 16.8
useImperativeHandle 定制暴露给父组件的 ref 方法 16.8
useTransition 把某次更新标记为"低优先级",不被大更新卡住 18
useDeferredValue 延迟拿一个值的"滞后版本",让输入先响应 18
useId 生成跨服务端/客户端一致的唯一 id 18
useSyncExternalStore 订阅 React 之外的数据源(如 Redux store) 18
useDebugValue 在 React DevTools 里给自定义 Hook 打标签 16.8
useActionState 表单提交中管理 pending / 结果(基于 action) 19
useOptimistic 提交时先显示"乐观值",失败再回滚 19
useFormStatus 读所在表单的提交状态(react-dom 提供) 19

一个概念先分清:Hook ≠ 生命周期钩子。Vue 的 mounted、unmounted 是"在某个时刻自动叫你";React 的 Hook 是"函数组件渲染时按顺序领取的能力"。useEffect 长得像生命周期,但它本质是"声明一次同步任务",React 自己决定什么时候执行和清理。

三、逐组对照 Vue3

Vue3 的函数叫"组合式 API"(Composition API),名字不叫 Hook,但解决的问题一模一样。下面按功能一组一组对。

1. 状态:useState / useReducer ↔ ref / reactive

React Vue3 关键差异
useState ref / reactive 不可变 vs 可变
// React:换新值,不碰旧值
const [count, setCount] = useState(0);
setCount(count + 1);        // 传入新值
setCount(c => c + 1);       // 函数式更新
<script setup>
import { ref } from "vue";
// Vue:直接改,Proxy 拦截到修改,安排更新
const count = ref(0);
count.value++;              // 直接改
</script>

差异的根源在于"谁发现你改了数据":

  • React 是推模式——你只能通过 setState 这个唯一入口通知它。
  • Vue 是拉模式——ref 的 .value 是个 Proxy,你一赋值它就知道了,所以允许直接改。

useReducer 对应的是把复杂修改逻辑集中到一个 reducer 函数。Vue 没有官方对应物,但社区习惯用 Pinia 的 action 承担同样角色——把"怎么改"集中起来。

// useReducer:改状态的规则收拢成一张"菜单"
const [count, dispatch] = useReducer(
  (state, action) => {
    switch (action.type) {
      case "add":   return state + action.payload; // 加多少,由 action 决定
      case "reset": return 0;
      default:      return state;
    }
  },
  0 // 初始值
);
dispatch({ type: "add", payload: 2 }); // count: 2
dispatch({ type: "reset" });           // count: 0

2. 副作用:useEffect / useLayoutEffect ↔ watch / watchEffect

这是两个框架差异最大的一组。先看代码再解释。

// React:手动申报依赖
useEffect(() => {
  const timer = setInterval(() => console.log(userId), 1000);
  return () => clearInterval(timer); // 清理上一次
}, [userId]); // ← 忘写 userId,就是经典 bug
<script setup>
import { watch, watchEffect } from "vue";

// watchEffect:自动追踪,用到谁就盯谁
watchEffect(() => {
  console.log(userId.value); // 内部读了 userId,自动建立依赖
});

// watch:明确指定盯谁,且能拿到新旧值
watch(userId, (newVal, oldVal) => {
  console.log(newVal, oldVal);
}, { immediate: true });
</script>

用一个生活类比讲清这个差异:

  • React 像老式报账——你进货了,得自己填单子(依赖数组)告诉会计盯哪些科目。填漏了,账就对不上(旧数据 bug);填多了,白干活(多余的重新执行)。
  • Vue 像超市扫码枪——watchEffect 执行时读到哪个变量,就自动给哪个变量挂上记录仪。你不用申报,它扫到了就记。

先厘清一个容易绕晕的点:useLayoutEffect 更像 watchEffect 还是更像 watch?功能族上确实是 watchEffect——两者都是"声明一段副作用,依赖一变自动重跑",都没有 watch 那种显式 source 和新旧值。但时机上要补一刀:watchEffect 默认是 flush: 'pre',在组件更新(DOM patch)之前执行,回调里读 DOM 拿到的是旧 DOM;useLayoutEffect 在 DOM 更新之后、浏览器 paint 之前同步执行,能拿到新 DOM。所以精确对应的是 watchEffect 的 post 变体——watchPostEffect(或 watch 加 { flush: 'post' }),不是默认形态。

// useLayoutEffect:改完 DOM 立刻同步量尺寸,页面画出前就定好位
function Tooltip({ show }) {
  const tipRef = useRef(null);
  const [top, setTop] = useState(0);

  useLayoutEffect(() => {
    if (show) setTop(tipRef.current.getBoundingClientRect().height);
  }, [show]); // 同步执行:不等浏览器画完,避免位置闪一下

  return show ? <div ref={tipRef} style={{ top }}>提示</div> : null;
}
<script setup>
import { ref, watchPostEffect } from "vue";
const tip = ref(null);
const top = ref(0);
// 同样在 DOM 更新后同步量尺寸,读得到新 DOM
watchPostEffect(() => {
  top.value = tip.value?.getBoundingClientRect().height ?? 0;
});
</script>
React Vue3 时机
useLayoutEffect watchPostEffect / watch(…, {flush:'post'}) DOM 更新后、画之前(同步,能拿到新 DOM)
useEffect 无完全对应,近似 watchPostEffect 浏览器画完之后(异步,更晚)
— watchEffect / watch 默认(flush:'pre') DOM 更新前(读旧 DOM),React 无对应 API
— watch(显式 source) 能拿到新旧值;React 的 effect 拿不到旧值

3. 缓存:useMemo / useCallback ↔ computed

// React:缓存计算结果
const total = useMemo(
  () => items.reduce((s, i) => s + i.price, 0),
  [items]
);
// 缓存函数本身,防止传给子组件时引用变了
const handleClick = useCallback(() => save(id), [id]);
<script setup>
import { computed } from "vue";
// Vue:依赖自动追踪,只写"怎么算",不写"依赖是什么"
const total = computed(() =>
  items.value.reduce((s, i) => s + i.price, 0)
);
</script>

注意一个不对称:Vue 没有 useCallback 的对应物,因为它不需要。

React 重渲染是整棵函数重跑,里面新定义的函数每次都是新引用,子组件就算没变也觉得 props 变了,所以要用 useCallback 保住引用。

Vue 的组件更新是细粒度的——模板里 @click="handleClick" 建立的绑定不会因为别处状态变化而重建,函数引用天然稳定。这个 Hook 在 Vue 侧没有对应物,本身就是两种更新模型差异的证据。

computed 还比 useMemo 多一层:它有真正的惰性求值和缓存语义(依赖没动就读缓存),useMemo 只是"可能缓存"(React 不承诺一定保留)。

4. 跨层传值:useContext ↔ provide / inject

React Vue3
父侧:<UserContext.Provider value={user}> 父侧:provide('user', user)
子侧:const user = useContext(UserContext) 子侧:const user = inject('user')
// context.js —— 单独建一个文件导出
export const UserContext = createContext(null);

// 父组件:套一层 Provider,value 传下去
<UserContext.Provider value={user}>
  <Profile />
</UserContext.Provider>;

// 任何子孙组件:直接读,不用一层层传 props
const user = useContext(UserContext);
<script setup>
import { inject } from "vue";
const user = inject("user");        // 字符串 key
const user2 = inject(SymbolKey);    // 也支持 Symbol,防重名
</script>

差别不大。React 用 Context 对象当钥匙,Vue 用字符串或 Symbol 当钥匙。真正的分水岭在上面一层——React 里 Context 变了会触发所有消费组件重渲染,这也是它不适合存高频变化数据的原因;Vue 的 provide 配合 ref,消费者是精确订阅,天然就是响应式的。

5. 引用:useRef / useImperativeHandle ↔ 模板 ref / defineExpose

// React:抓 DOM 或存"不触发渲染的值"
const inputRef = useRef(null);
<input ref={inputRef} />;
inputRef.current.focus();

// 给父组件暴露方法(父用 ref 调)
useImperativeHandle(ref, () => ({
  focus: () => inputRef.current.focus(),
}));
<script setup>
import { ref } from "vue";
const input = ref(null); // 变量名和模板 ref 同名,自动绑定
const open = () => input.value?.focus();
defineExpose({ open }); // 对应 useImperativeHandle
</script>

<template>
  <input ref="input" />
</template>

Vue 里"存不触发渲染的值"用什么?也是 ref——只是约定上用 shallowRef 存大对象,或者干脆存进非响应式变量。ref 的通用性来自 Proxy 本身:你不去读 .value 参与渲染,它就不影响渲染。

6. 并发特性:useTransition / useDeferredValue ↔ 没有直接对应

这两个是 React 18 独有的思路:把一次更新拆成"高优先级"和"低优先级",让打字这类小更新不被大列表渲染卡住。

// useTransition:把耗时更新标成"低优先级"
const [isPending, startTransition] = useTransition();
const [keyword, setKeyword] = useState("");

<input
  value={keyword}
  onChange={(e) => {
    setKeyword(e.target.value);            // 打字:高优先级,立刻响应
    startTransition(() => {
      setList(filterBigList(keyword));     // 过滤 10 万条:低优先级,不卡输入
    });
  }}
/>
// isPending 为 true 时,可以显示"筛选中…"

// useDeferredValue:同样效果,但连 startTransition 都不用写
const deferred = useDeferredValue(keyword);
const filtered = useMemo(
  () => bigList.filter(i => i.includes(deferred)), // 用滞后版本过滤
  [deferred]
);
// keyword 先更新输入框,deferred 慢半拍更新列表

Vue 的回应是靠更新模型本身解决:Vue 的更新是组件级的细粒度调度,本就不存在"整个函数重跑、所有重新计算"的问题。实践中对应物是:

React Vue3 的近似做法
useTransition(低优先级更新) 无直接 API;大更新场景用 shallowRef 减少追踪范围
useDeferredValue(滞后值) 无直接 API;可用 v-memo 缓存大列表子树
useSyncExternalStore 不需要——watchEffect 读外部数据源时,Proxy 依赖追踪自动完成订阅/退订

最后一行值得多说一句。useSyncExternalStore 存在的原因是 React 不知道你什么时候读了外部数据;Vue 的响应式系统读的时候就知道。一个框架需要 7 个参数的 API,另一个框架需要 0 个,这不是 API 设计水平差异,是底层模型差异。

// useSyncExternalStore:订阅 React 世界之外的数据源
const isOnline = useSyncExternalStore(subscribe, getSnapshot);

function subscribe(cb) {
  window.addEventListener("online", cb);
  window.addEventListener("offline", cb);
  return () => {                              // 返回退订函数
    window.removeEventListener("online", cb);
    window.removeEventListener("offline", cb);
  };
}
const getSnapshot = () => navigator.onLine;   // 每次拿"当前快照"
<script setup>
import { ref } from "vue";
// Vue:外部事件源没有 Proxy,需要手动接线,但只需接一次
const isOnline = ref(navigator.onLine);
window.addEventListener("online", () => (isOnline.value = true));
window.addEventListener("offline", () => (isOnline.value = false));
</script>

7. React 19 表单三件套 ↔ Vue 无对应

useActionState、useOptimistic、useFormStatus 是配合 React 的 Server Actions 体系出现的。Vue 生态里对应的事由 UI 库(如 Element Plus 的表单校验、Nuxt 的 server functions)承担,核心层没有对等物。乐观更新在 Vue 里通常就是手动先改 ref 再回滚:

// useActionState:表单提交,自动管 pending 和错误
const [error, submitAction, isPending] = useActionState(
  async (prev, formData) => await saveUser(formData),
  null // 初始错误值
);
<form action={submitAction}>
  <input name="name" />
  <button disabled={isPending}>{isPending ? "提交中…" : "保存"}</button>
</form>;

// useFormStatus:子组件里读"我所在的表单"是否在提交
function SubmitButton() {
  const { pending } = useFormStatus(); // 不用传 props,自动感知父表单
  return <button disabled={pending}>{pending ? "提交中…" : "保存"}</button>;
}

// useOptimistic:提交瞬间先显示新值,失败自动回滚
const [optimisticLikes, addLikes] = useOptimistic(
  likes,                // 真实值
  (state, delta) => state + delta // 怎么算乐观值
);
async function onLike() {
  addLikes(1);                            // 界面立刻 +1(乐观值)
  await api.like();
  // 官方语义:action 结束后自动以真实值为准覆盖乐观值;
}
<script setup>
import { ref } from "vue";
const liked = ref(false);
async function toggleLike() {
  liked.value = !liked.value;      // 乐观更新:先改
  const ok = await api.like();
  if (!ok) liked.value = !liked.value; // 失败回滚
}
</script>

8. 杂项

React Vue3
useId useId(Vue 3.5 加入,同名同用途)
useDebugValue 无对应,Vue DevTools 自动展示
useInsertionEffect 无对应,Vue 单文件组件天然按需插样式
自定义 Hook(useXxx,前缀强制) 组合式函数(useXxx 前缀只是约定,不带也能用)
// useId:生成稳定的唯一 id,给 label 和 input 配对
const id = useId();
<label htmlFor={id}>用户名</label>
<input id={id} />;

// useDebugValue:在 React DevTools 里给自定义 Hook 显示标注
function useFriendStatus(id) {
  const [status, setStatus] = useState("offline");
  useDebugValue(status === "online" ? "在线" : "离线");
  return status; // DevTools 里展开这个 Hook 就能看到标注
}

// useInsertionEffect:在渲染前同步插样式,CSS-in-JS 库内部用
useInsertionEffect(() => {
  injectGlobalStyles(); // 你的应用代码一般用不到它
}, []);

自定义这层两边几乎是一回事——都是"把可复用逻辑抽成一个函数,内部调用底层 API"。区别只有一个:React 自定义 Hook 调用方必须遵守 Hook 顺序规则;Vue 组合式函数内部用 ref/watch,没有调用顺序约束。

四、总对照表

功能 React Vue3 相似度
基础状态 useState ref / reactive ★★★
复杂状态 useReducer Pinia action(约定俗成) ★★
副作用 useEffect watchEffect / watch ★★★
时机控制 useLayoutEffect watchPostEffect(flush:'post') ★★★
计算缓存 useMemo computed ★★★
函数缓存 useCallback 不需要 —
跨层传值 useContext provide / inject ★★★
抓 DOM useRef 模板 ref(同名绑定) ★★★
暴露方法 useImperativeHandle defineExpose ★★★
低优先更新 useTransition / useDeferredValue 无(模型层面解决) ★
外部数据源 useSyncExternalStore 不需要(响应式自动订阅) ★
唯一 id useId useId(3.5+) ★★★
表单状态 useActionState / useOptimistic / useFormStatus 无核心 API ★
调试标签 useDebugValue DevTools 自动 ★
复用逻辑 自定义 Hook 组合式函数 ★★★

五、为什么对照表长这样

把这张表从头扫一遍,你会发现规律:两边对得上的,都是"状态、副作用、计算"这类每个框架都必须解决的问题;对不上的,全是 React 为弥补自身模型缺陷而造的补丁。

  • useCallback 存在,因为函数重跑导致引用不稳定;
  • useSyncExternalStore 存在,因为框架感知不到你读外部数据;
  • useTransition 存在,因为一次更新会把整个函数体拖下水。

Vue 那一列的空格不是功能缺失,是它的响应式模型让这些问题不存在。反过来说,Vue3 的 ref 必须写 .value、心智上要区分 ref/reactive/shallowRef,也是它的模型付出的代价。

最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容