Skip to content

D09 · Composables vs React Hooks ​

对应主课: L37 Composition API 设计哲学 最后核对: 2026-09-23


1. 表面相似,底层不同 ​

下面是两个不同框架的模块,分别在各自项目使用:

typescript
// Vue:useCounter.ts
import { ref } from 'vue'
export function useCounter() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment }
}
typescript
// React:useCounter.ts
import { useState } from 'react'
export function useCounter() {
  const [count, setCount] = useState(0)
  const increment = () => setCount(c => c + 1)
  return { count, increment }
}

关键差异在于:这些函数何时执行,状态变化后又由谁继续读取和更新。


2. 核心差异 ​

维度Vue ComposableReact Hook
执行频率在 setup 中调用时,每个实例初始化一次;函数本身仍可被显式多次调用普通自定义 Hook 随调用它的组件 render 执行
依赖追踪computed/watchEffect 跟踪响应式读取;watch 显式声明来源useEffect/useMemo 等通过依赖数组描述重新同步或重算条件
条件调用不依赖固定调用顺序,但要满足活动实例/作用域要求useState/useEffect 等普通 Hook 必须在顶层;React 19 use 是例外
回调读取读 ref.value 得到当时值;提前复制普通值也会形成快照每次 render 的闭包保留当次 props/state 快照
返回值可返回 ref、函数、普通值或其他接口可返回状态、ref、函数或其他接口,不只有快照值

3. 执行模型 ​


4. 回调读取与闭包 ​

React:Effect 的依赖要与读取一致 ​

如果定时器回调读取 count,却把 useEffect 依赖写成空数组,定时器会留住首次 render 的 count。下面用 [count] 表达“计数变化后重新同步定时器”,每次先清理旧 interval,再建立新的;这是明确的生命周期选择:

jsx
// ReactTimer.jsx(React 19 项目)
import { useEffect, useState } from 'react'
export function ReactTimer() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const id = setInterval(() => console.log(count), 1000)
    return () => clearInterval(id)
  }, [count])
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

如果需要保持同一个 interval,同时读取最新已提交的计数,可以用 ref 保存该值;在 Effect 中同步它,而不是在每次 render 中随意写 ref.current:

jsx
// ReactStableTimer.jsx
import { useEffect, useRef, useState } from 'react'
export function ReactStableTimer() {
  const [count, setCount] = useState(0)
  const latest = useRef(count)
  useEffect(() => { latest.current = count }, [count])
  useEffect(() => {
    const id = setInterval(() => console.log(latest.current), 1000)
    return () => clearInterval(id)
  }, [])
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

ref 的同步发生在 commit 后的 Effect 阶段,不是任意时刻都与刚调用 setter 的值一致。React useEffect 本身也在 commit 后执行;开发 StrictMode 可能额外执行 setup/cleanup,空数组不保证开发环境绝对只绑定一次。useEffect、useRef 的读写限制

Vue:保留 ref 与复制数字不同 ​

vue
<!-- VueTimer.vue(Vue 3.5 项目) -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const count = ref(0)
const snapshot = count.value
let timer: ReturnType<typeof setInterval> | undefined
onMounted(() => {
  timer = setInterval(() => console.log(count.value, snapshot), 1000)
})
onUnmounted(() => clearInterval(timer))
</script>
<template><button @click="count++">{{ count }}</button></template>

点击后,回调读取 count.value 可获得当时值,snapshot 仍为 0。setup 每实例执行一次并不会消除 JavaScript 的快照与闭包问题;定时器也仍需明确清理。


5. 依赖声明 ​

用日志观察选择条件变化,先不混入网络请求:

typescript
// React:useSelectionLog.ts
import { useEffect } from 'react'
export function useSelectionLog(userId: string, page: number) {
  useEffect(() => { console.log(userId, page) }, [userId, page])
}
typescript
// Vue:useSelectionLog.ts
import { watchEffect, toValue, type MaybeRefOrGetter } from 'vue'
export function useSelectionLog(userId: MaybeRefOrGetter<string>, page: MaybeRefOrGetter<number>) {
  watchEffect(() => { console.log(toValue(userId), toValue(page)) })
}

Vue 传入 ref 或 getter 才能持续读取变化;把 someRef.value 提前作为普通值传进去,就是一次快照。watchEffect 只追踪同步执行期间的读取,异步函数在第一个 await 后读取的属性不会因此自动订阅。若要明确来源,用 watch 更合适。

computed 也按实际读取的依赖失效并惰性求值;React useMemo 依据声明的依赖复用计算结果,但它是性能优化接口,不应承载必须持久保存的业务状态。两者的 getter/calculation 都应保持纯净。网络请求无论用哪个框架,都另需处理失败、取消与迟到结果,依赖声明正确不能自动解决这些问题。Vue watcher 依赖、React useMemo


6. 条件调用 ​

React 的 useState/useEffect 等普通 Hook 要在组件或自定义 Hook 顶层调用,不能放在条件分支、循环、事件回调中。需要按开关启停逻辑时,调用 Hook 本身保持稳定,把开关传进去,在内部 Effect 决定是否订阅。

Vue 在 setup 的同步执行范围内可以根据条件调用 composable,不靠调用次序匹配状态。但 if (props.enabled) useMousePosition() 只判断实例初始化时的值;以后 enabled 变化不会重新执行 setup。需要动态开关时,同样可以传入 getter,由内部 watcher 管理订阅,§7 给出两种完整实现。

含生命周期、inject 或需要自动绑定组件的 watcher 的 Vue composable 依赖活动上下文,不能据此说“任意位置都能调用”。只有 script setup 顶层 await 有编译器恢复上下文的特殊支持。Vue composable 使用限制

React 19 的资源读取 API use 是特别规则:允许在条件和循环内调用,但仍限组件或 Hook 中,不能放进 try/catch。它不是普通 useState/useEffect 调用次序规则的全面取消。Rules of Hooks、use

7. 实战对比:useMousePosition ​

下面模块分别用于 Vue 3.5 与 React 19 项目,二者都支持动态启停并在卸载时移除监听器。框架相同的文件放在同一目录:

typescript
// Vue:useMousePosition.ts
import { ref, watch, toValue, onMounted, onUnmounted, type MaybeRefOrGetter } from 'vue'
export function useMousePosition(enabled: MaybeRefOrGetter<boolean> = true) {
  const x = ref(0)
  const y = ref(0)
  function update(event: MouseEvent) { x.value = event.clientX; y.value = event.clientY }
  onMounted(() => {
    watch(() => toValue(enabled), active => {
      if (active) window.addEventListener('mousemove', update)
      else window.removeEventListener('mousemove', update)
    }, { immediate: true })
  })
  onUnmounted(() => window.removeEventListener('mousemove', update))
  return { x, y }
}
vue
<!-- Vue:MousePanel.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useMousePosition } from './useMousePosition'
const enabled = ref(true)
const { x, y } = useMousePosition(enabled)
</script>
<template>
  <button @click="enabled = !enabled">监听:{{ enabled }}</button>
  <p>{{ x }}, {{ y }}</p>
</template>

同步 mounted 回调中的 watcher 归属当前组件,会随卸载停止。onMounted 内才访问 window,服务端渲染不建立 DOM 监听器。

typescript
// React:useMousePosition.ts
import { useEffect, useState } from 'react'
export function useMousePosition(enabled = true) {
  const [pos, setPos] = useState({ x: 0, y: 0 })
  useEffect(() => {
    if (!enabled) return
    const update = (event: MouseEvent) => setPos({ x: event.clientX, y: event.clientY })
    window.addEventListener('mousemove', update)
    return () => window.removeEventListener('mousemove', update)
  }, [enabled])
  return pos
}
tsx
// React:MousePanel.tsx
import { useState } from 'react'
import { useMousePosition } from './useMousePosition'
export function MousePanel() {
  const [enabled, setEnabled] = useState(true)
  const { x, y } = useMousePosition(enabled)
  return <section>
    <button onClick={() => setEnabled(value => !value)}>监听:{String(enabled)}</button>
    <p>{x}, {y}</p>
  </section>
}

React 监听器不读取渲染快照中的坐标,而是把事件坐标传给稳定的 setPos,因此依赖 enabled 就足够,不是“碰巧没问题”。父组件导致的额外 render 也不会凭空新建 pos 状态对象;这个示例是每次 mousemove 的 setPos 明确传入了新对象。

Vue 模板读了顶层 x、y ref,变化会通知该组件更新;模板自动解包,不要求写 x.value。两种实现都可能产生频繁事件与页面更新,必要时仍需节流或减少无效工作。


8. 性能影响 ​

要观察的工作Vue ComposableReact Hook
初始化与更新通常在每实例 setup 建立逻辑,后续相关计算/渲染继续执行自定义 Hook 随 render 调用,Effect 的 setup 按依赖重新同步
引用身份setup 中创建的函数通常保持稳定;render 中仍可能分配对象render 可能产生新函数/对象;state/ref 并不因此每次重建
优化方法检查依赖、组件边界、事件频率与 DOM 工作同样检查这些工作;需要时才考虑 memoization

不能仅用函数执行次数推出整体速度或 GC 压力。React 不要求每个函数都套 useCallback/useMemo,React Compiler 也可提供自动 memoization;Vue 同样可能需要优化大列表、深层追踪或高频事件。React Compiler


9. 总结 ​

  • Vue composable 组织响应式容器与副作用;React 自定义 Hook 参与每次 render 的状态快照与 Effect 同步。
  • Vue 不靠固定调用次序,但仍有实例与作用域限制;React 普通 Hook 与 React 19 use 要分开理解。
  • 两者都需要说明输入是快照还是持续来源、状态归谁所有、订阅何时建立与清理。
  • 是否需要优化由真实工作量决定,不由“使用了 Hook 或 composable”决定。