前端开发··2 阅读·预计 19 分钟

状态管理的两个世界: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 在内部建立了一张依赖图,watchcomputed 会自动追踪它们访问过的响应式数据。你不需要手动声明依赖列表,因为运行时已经帮你画好了依赖图谱

// ✅ 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 HooksVue Composables
执行模型每次渲染重新执行setup 执行一次
状态本质不可变快照可变引用
依赖追踪手动声明(静态)自动收集(运行时)
缓存策略可选(useMemo)默认(computed)
闭包行为捕获快照值始终取最新值
清理模式返回值onCleanup 回调
跨层级传递Context(重渲染风险)provide/inject(精确追踪)

理解这些差异后,你会发现:React 的设计更偏向函数式的"纯"和"可预测",代价是需要开发者更多地思考执行时机和引用稳定性;Vue 的设计更贴近直觉式的"声明即响应",代价是在某些边界场景下响应式的"魔法"会成为调试负担。

没有哪个更好——选对框架后,用对范式就好。

一个偷懒但有效的经验法则:写 React 时想象状态是照片(每一帧都是独立的),写 Vue 时想象状态是视频流(持续变化,你只需要盯着屏幕)。

0 评论

评论区

登录 后参与评论