Skip to content
概述
Vue 3 的响应式系统通过 ref 和 reactive 两个 API 声明组件状态。React 函数组件则使用 useState 与 useReducer 这两个 Hook。二者解决的是同一类问题:在组件内部声明可变数据,数据变化后界面自动反映最新值。
状态声明
ref
ref 接受任意类型的值,返回一个带有 .value 属性的响应式引用对象。在 <script> 中读取和修改都需要通过 .value;在模板中会自动解包,直接用变量名即可。
ts
import { ref } from 'vue'
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
const user = ref({ name: 'Alice' })
user.value.name = 'Bob' // 触发更新在 <script setup> 中定义的顶层变量会自动暴露给模板:
vue
<template>
<p>{{ user.name }}</p>
<span>{{ count }}</span>
</template>ref 包装对象时,内部会对值调用 reactive 进行深层转换,因此对象内的所有嵌套属性同样是响应式的。
reactive
reactive 只接受对象或数组,返回一个用 Proxy 包裹的响应式代理。读取和写入直接通过属性,不需要 .value。
ts
import { reactive } from 'vue'
const state = reactive({ count: 0, items: ['a'] })
state.count++ // 触发更新reactive 的使用限制会在后文单独说明。
useState
React 函数组件中,useState 接收初始值,返回一个包含当前状态和更新函数的数组。
tsx
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
return <p onClick={() => setCount(prev => prev + 1)}>{count}</p>
}状态本身是不可变的。改变 count 必须通过 setCount 传入新值,不能直接修改。对象和数组的更新同样需要返回新的引用:
tsx
const [user, setUser] = useState({ name: 'Alice' })
// 直接修改不会触发重渲染
// user.name = 'Bob'
setUser({ ...user, name: 'Bob' })useReducer
当状态逻辑变复杂,或者下一个状态依赖上一个状态时,useReducer 比多次 setState 调用更清晰。它接收一个 reducer 函数和初始状态,返回 state 和 dispatch。
tsx
import { useReducer } from 'react'
type Action = { type: 'increment' } | { type: 'decrement' }
function reducer(state: number, action: Action) {
switch (action.type) {
case 'increment': return state + 1
case 'decrement': return state - 1
default: return state
}
}
function Counter() {
const [count, dispatch] = useReducer(reducer, 0)
return (
<div>
<p>{count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</div>
)
}核心模式是把“要做什么”通过 action 描述出来,状态转移逻辑集中写在 reducer 里。Vue 没有与 useReducer 直接对应的内置 API,但可以用 ref 配合更新函数模拟出类似的效果(见下文“在 Vue 中模拟 useReducer”)。
读取与更新状态的语法差异
Vue 的模板编译过程和 React 的 JSX 执行过程决定了状态变量如何被读取。
Vue 模板中的 ref 自动解包
Vue 模板在编译阶段会识别 ref 对象,在生成的渲染函数里自动补上 .value。所以模板里直接用变量名即可,而在 <script> 中必须手动写 .value。这种“模板内自动、脚本内手动”的不一致是上手时容易卡住的地方。
深层嵌套对象的 ref 在模板中也会自动解包。但如果 reactive 对象的某个属性是 ref,同样存在自动解包的行为,具体表现取决于嵌套层级和容器类型,需要在具体场景下验证。
React JSX 中直接使用状态变量
React 的 JSX 本质上是普通 JavaScript 函数调用,没有编译到不同运行行为的步骤。状态变量本身就是值,直接使用:
tsx
return <span>{count}</span> // count 就是数字对象同理,直接访问属性:
tsx
return <span>{user.name}</span>React 中没有 .value 这个中间层,状态就是普通的 JavaScript 值。
更新方式:可变修改与不可变替换
Vue 的响应式系统在 ref.value 或 reactive 属性被修改时自动追踪,可以直接用赋值修改任意属性:
ts
// ref
count.value = 5
user.value.age = 30
// reactive
state.items.push('new') // 原地修改数组
state.nested.flag = trueReact 强制不可变更新。直接修改不会触发重渲染,且会导致前后状态比较(基于 Object.is)失效。常见做法是用扩展运算符创建新对象或数组:
tsx
setItems(prev => [...prev, 'new'])
setUser(prev => ({ ...prev, age: 30 }))数组的变异方法(push、pop、splice 等)在 React 中必须避免,除非先通过 slice() 拷贝一份再操作。
在 Vue 中模拟 useReducer
Vue 没有 useReducer,但可以把更新逻辑封装成一个函数,接收 state 和 action,返回新状态,然后赋给 ref.value。这样就从组件逻辑中抽出了状态转移。
ts
import { ref } from 'vue'
type Action = { type: 'increment' } | { type: 'decrement' }
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'increment': return state + 1
case 'decrement': return state - 1
default: return state
}
}
const count = ref(0)
function dispatch(action: Action) {
count.value = reducer(count.value, action)
}在模板中直接使用 count,事件处理中调用 dispatch。
可以将这段逻辑抽象为可组合的函数:
ts
import { ref, readonly } from 'vue'
export function useReducer<R extends (...args: any[]) => any>(
reducer: R,
initial: Parameters<R>[0]
) {
const state = ref(initial)
const dispatch = (action: Parameters<R>[1]) => {
state.value = reducer(state.value as any, action)
}
return [readonly(state), dispatch] as const
}readonly 确保外部不能直接修改 state.value,只能通过 dispatch 改动。这种封装让 Vue 组件里的复杂状态逻辑也能聚合在 reducer 中。
响应式限制与不可变约束
reactive 解构丢失响应
reactive 返回的对象是 Proxy。解构时拿到的是普通值,不再是响应式引用,以下写法会失效:
ts
const state = reactive({ count: 0 })
const { count } = state // count 是普通数字 0,不再响应需要使用 toRefs 将每个属性转为 ref:
ts
import { toRefs } from 'vue'
const { count } = toRefs(state) // count 现在是 Ref<number>这也是 ref 在多数场景下比 reactive 更灵活的一个原因:ref 本身是一个引用容器,解构行为由使用方控制;而 reactive 对象的属性解构就直接脱离了代理。
ref 自动解包规则
ref 作为 reactive 对象的属性时会自动解包:
ts
const count = ref(0)
const state = reactive({ count })
console.log(state.count) // 0,不需要 .value但在 Map、Set 或数组等原生集合类型中不会自动解包,依然需要 .value。此外,模板中的自动解包仅对顶层 ref 生效;嵌套在普通对象里的 ref 仍需要 .value,除非该对象自身是响应式的。处理复杂数据结构时需要留意这些边界情况。
React 状态更新的不可变性要求
React 通过 Object.is 进行浅比较来判断状态是否变化。如果修改了对象内部属性但引用没变,React 检测不到更新,不会触发重渲染。因此不可变替换是硬性要求。
常见的错误写法:
tsx
const [list, setList] = useState([1, 2, 3])
// list.push(4)
// setList(list) // 引用未变,不触发重渲染
setList([...list, 4]) // 正确:创建新数组useReducer 的 reducer 同样必须返回新状态,不能修改传入的 state 参数。这一点与 Vue 中可以随意修改状态对象的心智模型完全不同。
Hooks 规则与组合式 API 的执行差异
React 要求在函数组件的最顶层调用 Hooks,不能在循环、条件或嵌套函数中使用。原因是 React 依赖 Hook 的调用顺序来保持每次渲染时状态与 Hook 的对应关系。
tsx
// 错误:条件分支内调用 Hook
if (someCondition) {
const [data, setData] = useState(null)
}Vue 3 的组合式 API 在 setup 或 <script setup> 中仅执行一次,因此可以放在条件分支里:
vue
<script setup>
import { ref } from 'vue'
const showExtra = ref(false)
if (showExtra.value) {
const extra = ref('...')
}
</script>需要注意的是,setup 只在组件初始化时执行一次。如果在条件分支里定义 ref,只有初次执行时会被注册;后续条件变化不会重新创建新的 ref——这未必是期望的行为。
这种执行模型让组合逻辑更容易提取到独立的可组合函数(useXxx)中,不用担心调用顺序。对于模拟 useReducer 的模式,由于 setup 只执行一次,可以直接在可组合函数里返回 state 和 dispatch,不必担心重渲染后乱序。
状态变更后的重渲染触发时机
Vue 的响应式系统精确知道哪个组件依赖了哪部分数据。修改响应式状态后,依赖当前组件的那部分视图会被标记为脏,在微任务队列中异步批量更新。默认情况下不依赖当前数据的组件不会重渲染。
React 中调用 setState 或 dispatch 都会触发当前组件及其所有子组件的重新渲染(从调用组件开始的整个子树)。之后在协调阶段计算出需要更新的 DOM 节点。React.memo 或 useMemo 等手段可以阻止部分子组件重渲染,但默认行为是全面重跑函数组件。
同样的更新操作,Vue 的默认渲染影响范围更窄,而 React 需要通过不可变数据和比较机制来手动优化。这一差异源自二者的底层设计:Vue 基于依赖追踪的推送式更新,React 基于自顶向下的拉取式对比。
不同状态形态的 API 选择
- 基本类型值:Vue 用
ref,React 用useState。 - 对象或数组:Vue 用
reactive或ref,React 用useState但必须不可变更新。 - 多个关联状态,且转换逻辑复杂:React 用
useReducer;Vue 用ref配合自定义dispatch/reducer,或者直接使用多个独立ref,没有硬性约束。 - 需要在模板外解构对象:Vue 中避免直接解构
reactive,改用toRefs或使用ref包装对象;React 中解构就是普通值,不存在丢失响应的问题,但需要注意更新时重建整个对象引用。
React 的不可变模式让时间旅行调试和状态快照变得自然;Vue 的可变写入更贴近直接操作数据的直觉,但调试时需要依赖 DevTools 来追踪变化路径。两种模型是框架在状态治理上做出的不同设计取舍。
