Skip to content
Vue 与 React 核心概念:响应式数据与不可变状态
概述
上一篇提到,前端框架需要解决的集中问题是数据变化后界面自动跟随,不再靠开发者手动操作 DOM。往这一步走,不同的框架出现了两条路径。
一条是让数据自身变得“可感知”——对数据的读写都被框架拦截,拦截后自动通知用到它的地方。这是 Vue 的响应式路线。
另一条是把每次状态更新当成一次“整体声明”——开发者告诉框架“现在状态是这个新对象”,框架比较新旧两个状态,计算出视图需要修改的最小动作再执行。这是 React 的不可变状态路线。
两条路线都能完成从数据到视图的驱动,但编写代码的方式、开发者的思维负担、容易出错的地方完全不同。
Vue:将数据变成响应式
Proxy 拦截
Vue 3 的响应式引擎建立在 Proxy 之上。拿一个普通对象 { count: 0 } 调用 reactive(),返回的对象在外观上与原始对象相同,但读取和写入都会被拦截。
ts
import { reactive } from 'vue'
const raw = { count: 0 }
const state = reactive(raw)
// 读取进入 Proxy 的 get 陷阱
console.log(state.count) // 0
// 写入进入 set 陷阱
state.count = 1Proxy 拦截不只是“知道数据变了”,它支撑着一整套依赖追踪体系。框架内部维护一个结构类似下面的表:
WeakMap<target, Map<key, Set<effect>>>组件渲染时,渲染函数被当作一个 effect 执行。执行期间读到 state.count,get 陷阱把这个 effect 记录下来:当前组件依赖了 count 字段。之后 count 被再次赋值时,set 陷阱找出所有依赖它的 effect 并安排它们重新执行——也就是重新渲染组件。Vue 文档里将这两个步骤称为 track(收集依赖)和 trigger(触发更新)。
ref:原始值的包装
reactive() 有一个限制:它只能处理对象类型(对象、数组、Map、Set)。对于 0 或 "hello" 这样的原始值,Proxy 无能为力,所以提供了 ref()。
ts
import { ref } from 'vue'
const count = ref(0)
// 在 <script> 中读写需要 .value
console.log(count.value) // 0
count.value = 1ref() 内部通过一个对象的 getter/setter 实现同样的 track/trigger 逻辑。模板里会自动解包 .value,框架在处理模板时碰到 ref 就直接取它的 .value,不需要手写。
reactive 的解构问题
reactive 的代理对象在解构时会丢失响应性,因为解构出来的是当前字段的值,而不是 ref。
ts
const state = reactive({ count: 0, name: 'vue' })
const { count } = state // count 此时为 0,与响应系统无关
count++ // 不会触发任何更新因此,实践中习惯用 ref 管理单个状态,用 reactive 管理表单或配置这类一次性的对象聚合。
React:状态是不可变快照
React 的函数组件没有“响应式数据”的概念。useState 返回当前状态值(如 count)和一个用于更新它的函数(setCount)。组件重新渲染后,count 会变为上次通过 setCount 指定的值。
tsx
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
function increment() {
// 直接修改 count 不会触发重渲染
// count = count + 1 ❌
// 必须通过 setCount 传入新值
setCount(count + 1)
}
}count 本身是只读常量,无法被重新赋值。即便拿到了一个对象,也不能按可变方式修改:
tsx
const [list, setList] = useState([1, 2, 3])
list.push(4)
setList(list) // React 发现引用相同,跳过渲染React 内部用 Object.is 做浅比较:新旧状态如果是同一个引用,就不触发重新渲染。这是设计选择,并非缺陷——它强制开发者把每一次状态变更都视为“生成新快照”。
要让视图更新,必须创建新的数组或对象:
tsx
setList([...list, 4]) // 新数组,新引用
setList(list.concat(4)) // 同理
setUser({ ...user, age: 30 }) // 新对象,新引用与 Vue 中 user.age = 30 的直接修改形成直接对比。
代码对比:状态的读取与更新
下面用一个相同的场景来对比两种写法:一个列表,可以增删条目,并保存一个深层嵌套的对象。
Vue:直接修改
ts
import { reactive } from 'vue'
const store = reactive({
items: [
{ id: 1, text: 'Learn Vue', done: false }
],
meta: { author: 'Alice' }
})
// 添加条目
store.items.push({ id: 2, text: 'Build something', done: false })
// 修改深层属性
store.items[0].done = true
// 删除条目
const idx = store.items.findIndex(i => i.id === 2)
store.items.splice(idx, 1)
// 替换整个数组也可以,但不是必须所有操作都是原地修改(push、直接赋值、splice),视图自动更新。Vue 给人的体会是“修改即更新”。
注意:将响应式对象的引用赋值给另一个变量后再修改,依然可能处于代理范围内:
ts
const rawItems = store.items // rawItems 指向代理数组原型的引用
rawItems.push({ id: 3 }) // 仍然操作代理,会触发更新
// 解构出来的对象仍然是代理对象
const firstItem = store.items[0] // firstItem 是代理对象
firstItem.done = true // 视图更新Vue 的 reactive 会深度转换:嵌套对象都会被递归变成代理,所以多深层的属性直接赋值即可。
React:每次都是新快照
tsx
import { useState } from 'react'
function TodoList() {
const [items, setItems] = useState([
{ id: 1, text: 'Learn React', done: false }
])
const [meta, setMeta] = useState({ author: 'Bob' })
function addItem() {
const newItem = { id: Date.now(), text: 'New', done: false }
setItems([...items, newItem]) // 新数组
}
function toggleItem(id: number) {
setItems(items.map(item =>
item.id === id ? { ...item, done: !item.done } : item
))
}
function removeItem(id: number) {
setItems(items.filter(item => item.id !== id))
}
function updateMeta() {
setMeta({ ...meta, author: 'Charlie' })
}
}每次操作都创建新对象或新数组,将新引用传给 setItems。深层嵌套的对象更新会让代码变得啰嗦,这时社区通常会引入 Immer 来模拟“可变”写法:
ts
import { produce } from 'immer'
setItems(produce(draft => {
draft[0].done = true
}))即便如此,最终交给 React 的仍然是不可变的新对象。
易错点
- React 中如果先
push再setState,引用不变,渲染被跳过。 - React 中直接修改对象属性后传入同一个引用,同样不会触发渲染。
- Vue 中如果直接替换整个
reactive对象,响应性连接会中断:
ts
let state = reactive({ count: 0 })
state = { count: 1 } // state 现在是普通对象,不再是代理要整体替换一个 reactive 包裹的对象,需要用 Object.assign 或逐属性赋值。所以官方更推荐用 ref 包裹对象:
ts
const state = ref({ count: 0 })
state.value = { count: 1 } // 替换 .value 触发更新,安全重渲染触发机制
触发机制的核心差异会影响开发者的日常决策。
Vue 的组件渲染函数在执行过程中收集依赖,框架精确知道“这个组件用到了 state.a 和 state.b”。当 state.a 变化时,只有依赖了 state.a 的组件会重新渲染。每个组件自带“是否需要更新”的判断,开发者不需要手写任何优化逻辑。
React 没有自动依赖收集。父组件重渲染时,默认所有子组件都会重新渲染(除非手动用 React.memo 或 shouldComponentUpdate 做浅比较跳过)。React 的 Fiber 架构会在 reconciler 阶段做 diff,但“要不要 re-render”的决定在更早的 render 阶段就已经做出——父组件一渲染,子组件函数体就会重新执行,即便最终 DOM 没有变化。
这一差异直接塑造了开发习惯:
- 在 Vue 中,很少需要担心“改了某个状态会不会导致不相关组件重渲染”,依赖图已经反映了组件与数据的关系。
- 在 React 中,需要关注组件树的渲染成本,并在合适时用
React.memo减少不必要的子树执行。而React.memo要生效,依赖的正是不可变数据:前后 props 的浅比较如果引用没变,就可以直接跳过。
React 选择不可变不仅是一种代码风格,更是它的性能优化路径与不可变数据绑定在一起的体现。
心智模型对比
长期在 Vue 中编写代码,开发者的心智模型是:“有一个数据对象,我改了它,界面就变了。”这类似于在电子表格里修改单元格:B1 里写了 =A1*2,改动 A1,B1 立即刷新。开发者感知到的是“数据变了”,而不是“我创建了一份数据副本”。
React 给人的感觉不同。每一次 setState 像拍一张新的状态照片交给 React,React 将新照片与上一张比较,计算出 DOM 需要改动的地方并执行。开发者需要时刻记得:“当前操作的是一张快照,要更新就必须生成新快照”。
因此,两种框架训练不同的思维方式:
- Vue 训练关注数据流和数据修改的直接后果。
- React 训练关注状态转换:从旧状态映射到新状态。
这不意味着 Vue 里没有不可变场景。对于撤销/重做、时间旅行调试这类功能,在可变的响应式系统下实现很痛苦,因为每次修改都会改变原数据,无法轻松获取前一个状态。Vue 官方文档也建议在这种情况下引入不可变数据,或者使用 shallowRef 与外部不可变状态集成。
反过来,React 虽然 UI 层要求不可变,但可以把可变数据源(比如 Map)放在 useRef 里,完全由开发者自己控制更新时机。只是这种做法会脱离 React 常规的“数据驱动视图”范式。
单向数据流
虽然状态更新的方式不同,Vue 和 React 在组件通信上都遵循单向数据流。父组件通过 props 将数据传给子组件;子组件不直接修改 props,而是通过 emit(Vue)或回调函数(React)通知父组件做改动。
tsx
// React
function Child({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return <button onClick={() => onChange(value + 1)}>+</button>
}vue
<!-- Vue -->
<Child :value="count" @update:value="count = $event" />这种一致性意味着,无论选择哪种状态范式,组件树的顶层数据流向和“谁拥有状态”的处理思路是相通的。
小结
框架如何将数据变化反映到 UI 上?
Vue 的答案是把数据变成可观察的,自动建立“谁用了这个数据”的关系网,修改数据时沿着网通知对应组件。
React 的答案是每次主动声明一个新状态对象,框架通过比对前后对象来决定 DOM 更新的范围。
两种答案各自配备了一套紧密咬合的工具链和心智模型。
