一、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,也是它的模型付出的代价。