状态管理的两个世界:React Hooks 与 Vue Composables 的设计哲学深度对比
引言
React 的 Hooks 和 Vue 的 Composables 都宣称解决了"逻辑复用"问题,但当你真正在两个框架间穿梭时,会发现同样的状态管理思路,在一个框架里行云流水,在另一个里却写出一身 Bug。这不是你水平的问题——是底层响应式模型的差异决定了"正确"的写法长什么样。
本文不会教你"如何用 useState"或"如何写 composable",而是聚焦于:当你已经会用两者之后,如何从设计哲学层面理解它们的差异,从而在各自的生态里写出"对味儿"的代码。
一、闭包陷阱:谁才是真正的"当前值"?
这是 React 开发者最容易踩的坑,也是理解两个框架差异的钥匙。
React:每次渲染都是一个新的闭包
// ❌ 反例:定时器里的 count 永远是 0
function BadCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // count 被闭包捕获,始终是 0
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
// ✅ 正例:用函数式更新打破闭包
function GoodCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // prev 始终是最新值
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
React 的每次渲染都会重新执行组件函数,产生全新的闭包。useEffect 里的回调捕获的是声明那一次渲染的 props 和 state。这是 React "不可变更新"哲学的副产品——状态是快照,不是可变的盒子。
Vue:setup 只执行一次,引用始终指向当前值
// ✅ Vue 的直觉写法就能工作
const count = ref(0);
onMounted(() => {
setInterval(() => {
count.value++; // 每次访问都能拿到最新值
}, 1000);
});
Vue 的 setup() 只在组件实例化时执行一次,ref 返回的是一个响应式引用对象。无论你在哪个闭包里访问 .value,拿到的永远是当前值。这让 Vue 的代码看起来更"直觉",但也带来了另一个问题——当你不想要响应式的时候。
// ⚠️ Vue 的"反直觉"时刻:解构会丢失响应性
function useMouse() {
const x = ref(0);
const y = ref(0);
// ... 事件监听更新 x, y
return { x, y }; // 直接返回 ref 对象
}
// ❌ 这样解构后 x 只是一个普通 number
const { x, y } = useMouse(); // x 是 0,不会再更新
// ✅ 需要用 toRefs 包裹
const { x, y } = toRefs(useMouse()); // x 仍是响应式引用
核心差异:React 以"渲染帧"为边界管理状态快照;Vue 以"引用"为媒介建立持续追踪。一个重"时机",一个重"通道"。
二、依赖追踪:手动声明 vs 自动收集
这可能是两者在日常开发中体感差异最大的地方。
React:你必须说出你的依赖
function useSearch(keyword: string) {
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
fetch(`/api/search?q=${keyword}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setResults(data);
});
return () => { cancelled = true; };
}, [keyword]); // 必须显式声明依赖
return results;
}
React 的理念是"信任开发者"——你告诉我依赖什么,我就按你的规则来。这给了开发者完全的控制权,但代价是:
// ❌ React 常见的"撒谎"式依赖声明
useEffect(() => {
fetchData(options);
}, []); // 骗 React 说没有依赖,但实际依赖了 options
// 结果:options 变化时不会重新请求,而且 ESLint 还会警告你
// ❌ 另一个极端:过度依赖
useEffect(() => {
fetchData(options);
}, [options, options.page, options.size, options.sort]);
// 深层引用变化导致无限重请求
Vue:你只需要用就行了
function useSearch(keyword: Ref<string>) {
const results = ref<string[]>([]);
watch(keyword, async (kw) => {
const res = await fetch(`/api/search?q=${kw}`);
results.value = await res.json();
});
return results;
}
// 或者用 computed 更优雅
const searchResults = computedAsync(() =>
fetch(`/api/search?q=${keyword.value}`).then(r => r.json())
);
Vue 在内部建立了一张依赖图,watch 和 computed 会自动追踪它们访问过的响应式数据。你不需要手动声明依赖列表,因为运行时已经帮你画好了依赖图谱。
// ✅ Vue 版本:自动追踪,不怕遗漏
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`;
// 自动订阅 firstName 和 lastName
});
const displayInfo = computed(() => {
if (showTitle.value) {
return `${title.value} ${fullName.value}`;
// 自动订阅 title, showTitle, fullName
}
return fullName.value;
});
核心差异:React 的依赖追踪是编译期 + Lint 辅助的静态策略;Vue 是运行时的动态追踪。前者让你能精确控制,后者让你少写样板代码。
三、缓存策略:useMemo 的焦虑 vs computed 的从容
React 开发者常常陷入"要不要包 useMemo"的内耗。
// React:到底要不要 memo?
function ProductList({ items, filter }: Props) {
// ❓ 这个 filterItems 要不要 useMemo?
// 如果 items 是后端返回的引用每次都变 —— 要
// 如果 items 是 useState 的稳定引用 —— 不要
// 决策成本全部在开发者身上
const filtered = useMemo(
() => items.filter(i => i.category === filter),
[items, filter]
);
return filtered.map(item => <ProductCard key={item.id} {...item} />);
}
// ❌ React 过度缓存反模式
function OverMemoized({ a, b }: Props) {
const sum = useMemo(() => a + b, [a, b]); // 多余
const doubled = useMemo(() => sum * 2, [sum]); // 多余
const text = useMemo(() => `${doubled}`, [doubled]); // 多余
return <div>{text}</div>;
// 这些计算比 useMemo 本身还便宜,纯属负优化
}
// ✅ 只在计算昂贵或引用稳定需要时 useMemo
function SmartMemo({ items }: Props) {
const sorted = useMemo(
() => [...items].sort(complexComparator), // 实际昂贵操作
[items]
);
return <List data={sorted} />;
}
Vue 的 computed 设计就少了很多心理负担:
// Vue:computed 默认就是惰性求值 + 自动缓存
const filtered = computed(() =>
items.value.filter(i => i.category === filter.value)
);
// 自动做:
// 1. 只在依赖变化时重新计算
// 2. 依赖没变时返回缓存值
// 3. 不需要手动声明依赖
但 Vue 也不是银弹——当你的计算真的不需要缓存时(比如只是做一下格式化),computed 的缓存反而是负担。
// Vue:当缓存反而碍事时
const formatted = computed(() => formatDate(timestamp.value));
// 每次 timestamp 变都会触发 computed 重新计算并缓存
// 但 formatDate 本身极快,缓存反而浪费内存
// 这种场景应该用普通函数
const formatted = () => formatDate(timestamp.value);
四、副作用清理:不同形式的"打扫战场"
两只框架都将清理逻辑提升到了一等公民,但形态截然不同。
// React:useEffect 的返回值就是清理函数
useEffect(() => {
const socket = new WebSocket(url);
socket.addEventListener('message', handler);
return () => {
socket.removeEventListener('message', handler);
socket.close();
};
}, [url]);
// Vue:onCleanup / watch 的 onCleanup
watch(url, (newUrl, _, onCleanup) => {
const socket = new WebSocket(newUrl);
socket.addEventListener('message', handler);
onCleanup(() => {
socket.removeEventListener('message', handler);
socket.close();
});
});
// 或者用 watchEffect,语义更接近 useEffect
watchEffect((onCleanup) => {
const socket = new WebSocket(url.value);
socket.addEventListener('message', handler);
onCleanup(() => {
socket.removeEventListener('message', handler);
socket.close();
});
});
React 的清理模式是"返回值即清理",函数式的表达,优雅但有时会让 useEffect 变得冗长。Vue 的 onCleanup 回调式更灵活,可以在 watch / watchEffect 中内联声明,且不像 React 那样严格的执行时序。
五、共享状态:Context 的代价 vs provide/inject 的轻量
这是一个经常被忽视的差异——跨层级状态传递的机制设计。
// React Context:Provider value 变化会导致所有消费者重渲染
const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });
function App() {
const [theme, setTheme] = useState('light');
// ⚠️ 每次渲染都创建新对象 → 所有消费者无条件重渲染
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<SlowTree />
</ThemeContext.Provider>
);
}
// ✅ 用 useMemo 稳定引用
function App() {
const [theme, setTheme] = useState('light');
const contextValue = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={contextValue}>
<SlowTree />
</ThemeContext.Provider>
);
}
// Vue provide/inject:默认不触发重渲染,只有你消费的 ref 变化才更新
const theme = ref('light');
provide('theme', readonly(theme));
provide('setTheme', (t: string) => { theme.value = t; });
// 子组件
const theme = inject('theme'); // 自动追踪
const setTheme = inject('setTheme'); // 普通函数,不触发响应
// 只有 theme.value 变化时此组件才更新
Vue 的 provide/inject 在响应式追踪上更精细化——它追踪的是数据本身,而不是 Provider 这个"容器"。React Context 的粒度是"整个 value 对象",这迫使开发者需要手动拆分 Context 或用状态管理库来收敛重渲染。
总结:不是你做错了,是模型不同
| 维度 | React Hooks | Vue Composables |
|---|---|---|
| 执行模型 | 每次渲染重新执行 | setup 执行一次 |
| 状态本质 | 不可变快照 | 可变引用 |
| 依赖追踪 | 手动声明(静态) | 自动收集(运行时) |
| 缓存策略 | 可选(useMemo) | 默认(computed) |
| 闭包行为 | 捕获快照值 | 始终取最新值 |
| 清理模式 | 返回值 | onCleanup 回调 |
| 跨层级传递 | Context(重渲染风险) | provide/inject(精确追踪) |
理解这些差异后,你会发现:React 的设计更偏向函数式的"纯"和"可预测",代价是需要开发者更多地思考执行时机和引用稳定性;Vue 的设计更贴近直觉式的"声明即响应",代价是在某些边界场景下响应式的"魔法"会成为调试负担。
没有哪个更好——选对框架后,用对范式就好。
一个偷懒但有效的经验法则:写 React 时想象状态是照片(每一帧都是独立的),写 Vue 时想象状态是视频流(持续变化,你只需要盯着屏幕)。
评论区
登录 后参与评论