Skip to content
Vue 与 React 设计思想与心智模型对比:渐进式框架与单向数据流
Vue 与 React 都能做组件化开发,都提倡单向数据流。差异的源头不在 API 层面,而在两条设计路线:Vue 选择了“渐进式框架”,React 选择了“视图库”。这两种定位会一路渗透到组件通信、状态管理的心智模型以及工程化决策里。
两条设计路线:渐进式框架与视图库
渐进式的 Vue:核心库与可插拔生态
Vue 官方定义自己是“用于构建用户界面的 JavaScript 框架”,并强调它是一个渐进式框架[1][2]。拆开来看:Vue 的核心只关注视图层,路由、状态管理、服务端渲染、静态站点生成等能力全都通过可插拔的官方生态逐步集成。
这个设计让 Vue 出现在功能跨度很大的场景里。你可以把它当作一行 <script> 引入的 CDN 工具,在已有 HTML 页面上加几行代码就实现某个模块的响应式交互,完全不需要构建步骤[4]。随着项目增长,你可以逐步加入 Vue Router 做页面路由、Pinia 做全局状态管理,再配合 Vite 和 SFC(单文件组件)组织复杂 UI[6][5],一路演进到全栈 SSR 应用。
一个典型 Vue 项目中,Vue Router 处理 URL 映射、Pinia 管理跨组件状态是常见搭配[5]。它们不是“选”出来的第三方库,而是官方维护、与核心配合良好的生态成员。这相当于给开发者一份菜单:今天只需要视图层,就只用核心库;明天需要路由,加载 Vue Router;后天需要全局状态,挂上 Pinia。每步都保持核心稳定,架构不会因为能力叠加而崩塌。
专注视图的 React:库式思维与第三方拼装
React 官方文档的定位是“用于构建用户界面的 JavaScript 库”[10]。它没有“框架”这个头衔,关注点非常收敛:组件、props、state,以及“UI 是状态的函数”这套声明式编程模型。
给定相同的 props 和 state,组件返回的 UI 就应该是相同的。React 不关心状态从哪里来,只关心当状态和更新函数传入组件后如何产生正确的视图。所以在 React 中,组件就是接收数据并返回 JSX 的 JavaScript 函数——没有额外的模板语言,没有自动追踪状态变化的魔法。
当 React 自己不带路由、状态管理、数据获取等能力时,这些能力就必须由第三方或上层框架提供。React 官方建议新应用从一个框架起步,比如 Next.js、Remix[17],也允许不用框架、只用原生 React 加上社区库。但这就把选择权转嫁到了开发者身上:路由用哪个?状态管理用 Context + useReducer 还是 Redux、Zustand?数据获取用 fetch 还是 React Query?这些不是 React 本身的职责,却在每个新项目中变成必须先做的决策。React 的开放性在这里成了双刃剑。
单向数据流:组件树中的数据流动
Vue 和 React 在组件间数据流动方向上高度一致:数据从父组件流向子组件,子组件不能直接修改来自父组件的数据。
React 的状态提升与回调
在 React 中,父组件通过 props 把状态和状态更新函数一起传给子组件[11]。子组件需要发起修改时,调用父组件传入的回调函数,回调执行 setState,触发新的一次渲染[13][14]。当多个组件需要共享某个状态时,状态会被提升到它们最近的公共祖先组件里,这称为“状态提升”[12]。
下面是精简实现。父组件管理 count,把值和一个 onChange 回调传给子组件;子组件触发回调来更新父组件状态。
jsx
// Parent.jsx
import { useState } from 'react';
import Display from './Display';
import Button from './Button';
export default function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<Display value={count} />
<Button label="Increment" onChange={() => setCount(count + 1)} />
</div>
);
}
// Display.jsx
export default function Display({ value }) {
return <div>{value}</div>;
}
// Button.jsx
export default function Button({ label, onChange }) {
return <button onClick={onChange}>{label}</button>;
}每一步状态变更的路径都清晰:点击按钮 → 触发 onChange → setCount 生成新状态快照 → 重新渲染父组件 → 新 props 传入 Display 和 Button。这里没有隐式的双向绑定,数据的“反向流”被明确包裹在一个函数调用里。
这种模式的心智负担在于:当组件层级变深,状态提升会让 props 链条越来越长,回调函数需要一层层传递。React 提供了 Context 和第三方状态库来缓解这个问题,但这些方案本质上仍然是“通过某个管道把 state 和更新函数向下注入”,不改变单向数据流的规则。
Vue 的 props 下传与事件上抛
Vue 同样遵循 props 只向下流、子组件不直接修改 props 的原则[7]。区别在于,Vue 把“向上通知”这件事设计成了自定义事件:子组件用 defineEmits 声明可以发出的事件,父组件用 v-on 或 @ 监听这些事件[8]。
用相同场景重写:
vue
<!-- Parent.vue -->
<template>
<div>
<Display :value="count" />
<Button label="Increment" @increment="count++" />
</div>
</template>
<script setup>
import { ref } from 'vue';
import Display from './Display.vue';
import Button from './Button.vue';
const count = ref(0);
</script>
<!-- Display.vue -->
<template>
<div>{{ value }}</div>
</template>
<script setup>
defineProps(['value']);
</script>
<!-- Button.vue -->
<template>
<button @click="$emit('increment')">{{ label }}</button>
</template>
<script setup>
defineProps(['label']);
defineEmits(['increment']);
</script>Vue 同样表现为 props 下传、事件上抛。但父组件监听 @increment 后直接执行 count++,而不是调用 setCount。count 本身是 ref 包装的响应式值,Vue 会自动追踪这次修改,并更新依赖它的 Display 组件视图[9]。这处差异引出了两套框架最核心的心智模型分歧。
心智模型对比:自动追踪与状态快照
两套框架都把“状态 → 界面”这个过程自动化了,但自动化实现的方式完全不同。这个差异不是某个 API 的差别,而是你面对状态时建立的一种思维习惯。
Vue:修改即更新
Vue3 的响应式系统基于 Proxy,能拦截对数据的读写操作,并在数据被读取时收集依赖、数据被写入时触发更新[3][5]。开发者基本不需要感知“更新”这个动作本身:修改数据,和这个数据有关的地方就跟着刷新。一个计数器的全部逻辑可以浓缩成三行:
vue
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>你不用调用任何额外函数来“通知”框架,count++ 就是通知。Vue 内部把依赖追踪和 DOM 更新的时机全部托管了。
这套模型在开发时感觉很自然,尤其当你从直接操作 DOM 的经验转向框架时,更像是在操作普通变量,只不过带了响应式能力。但同时也意味着更新粒度不总是肉眼可见的——在一个组合式函数中直接修改共享的响应式状态,可能触发一连串更新,需要你对依赖关系有一定感知,否则可能过度触发重渲染。
React:显式 setState 与不可变快照
React 的模型更像一台照相机:每次调用 setState,你提交一份新的状态快照,React 根据这份快照和上一次的快照进行 diff,然后更新 DOM。当前渲染周期里的 state 是“冻结”的——不管外部怎么变化,这份 state 的值不会改变[15]。这也是为什么 React 强调把 state 当作不可变数据来对待,更新对象或数组时必须创建新副本而非原地修改[16]。
jsx
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>{count}</button>
);
}与 Vue 最直观的区别就在这里:你不能写 count = count + 1 然后期望界面更新,你必须通过 setCount 来“申请”下一次渲染,且每次传入的是一个新值(不可变方式)。这个约束让每一次状态变更都有明确的触发点,调试时顺着 setState 调用链就能定位问题。代价也很明显:更新嵌套较深的对象时,代码量会比直接修改属性多出很多。
同一场景的两种实现:搜索过滤
下面用一个搜索框的例子展示两种心智模型对同一交互的实现差异。用户输入文字后,列表过滤出匹配条目。
Vue 实现(修改即更新):
vue
<script setup>
import { ref, computed } from 'vue';
const query = ref('');
const items = ['Apple', 'Banana', 'Orange', 'Mango'];
const filtered = computed(() =>
items.filter(item => item.toLowerCase().includes(query.value.toLowerCase()))
);
</script>
<template>
<input v-model="query" placeholder="Search..." />
<ul>
<li v-for="item in filtered" :key="item">{{ item }}</li>
</ul>
</template>query 通过 v-model 绑定输入框,任何输入都直接修改 query.value。filtered 是一个计算属性,内部访问了 query.value,Vue 会追踪这个依赖。当 query 改变时,filtered 自动重新计算,模板中的列表自动更新。全过程没有显式的更新调用。
React 实现(显式快照):
jsx
import { useState, useMemo } from 'react';
const items = ['Apple', 'Banana', 'Orange', 'Mango'];
export default function Search() {
const [query, setQuery] = useState('');
const filtered = useMemo(
() => items.filter(item => item.toLowerCase().includes(query.toLowerCase())),
[query]
);
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<ul>
{filtered.map(item => <li key={item}>{item}</li>)}
</ul>
</>
);
}React 这边,query 的更新必须经过 setQuery。过滤列表用 useMemo 包裹并声明 [query] 依赖——这等于告诉 React:“如果 query 变了,就重新计算 filtered”。这种声明显式化让依赖关系一目了然,但也需要开发者自己维护依赖数组的准确性,漏写依赖会导致显示过期的数据。
设计取舍与工程化考量
Vue 的响应式系统允许组件内部以非常直接的方式共享状态。组合式函数可以返回一个可变的 ref,多个组件通过调用同一个组合式函数拿到同一个引用,一处修改,处处响应。这种隐式共享在快速开发时很灵活,但当项目膨胀后,状态的变更路径可能变得难以追踪——你无法快速知道哪个组件在什么时候改了这份数据。Vue 社区提供的 Pinia 本质上就是为跨组件共享提供一个更结构化的容器,同时保留响应式特性。
React 则从根源上杜绝了这种隐式共享的可能。组件函数的执行是“纯渲染”——输出 JSX,不产生副作用。状态必须显式通过 props、Context 或状态管理库传递。这迫使开发者在架构阶段就思考数据的归属和流向,增加了初始决策负担,但也让数据流更可预测。React 里一个组件可以写成纯粹的展示组件,不依赖任何外部可变状态;Vue 虽然也能做到,但因为响应式顺手,开发者经常不自觉地让组件直接绑定外层可变状态。
在编译优化上,Vue 的模板语法可以在编译阶段识别静态内容,做大量的静态提升,减少运行时虚拟 DOM 对比开销;React 的 JSX 更灵活,但编译器能做的优化相对有限。对大多数应用来说,这些性能差异并不显著,但反映出的设计哲学差异很明显:Vue 倾向于用编译器把更多事情在 build 阶段处理掉,React 则倾向于把控制权留给开发者。
生态上,Vue 的官方方案让团队在选择路由、状态管理时几乎不需要额外调研,降低了决策成本,但也让生态的多样性受限——你大概率会用 Pinia 而不是其他方案。React 的第三方生态极度活跃,几乎每个问题领域都有多个竞争方案,这带来了极高的定制空间,但也伴随着持续的比较、选型和切换成本。
参考链接
- [1] https://cn.vuejs.org/guide/introduction
- [4] https://cn.vuejs.org/guide/extras/ways-of-using-vue.html
- [5] https://router.vuejs.org/zh/
- [7] https://cn.vuejs.org/guide/components/props.html#one-way-data-flow
- [8] https://cn.vuejs.org/guide/components/events.html
- [9] https://cn.vuejs.org/guide/components/props.html
- [10] https://react.dev/learn/your-first-component
- [11] https://react.dev/learn/passing-props-to-a-component
- [12] https://react.dev/learn/sharing-state-between-components
- [14] https://react.dev/learn/state-a-components-memory
- [15] https://react.dev/learn/state-as-a-snapshot
- [16] https://react.dev/learn/updating-objects-in-state
- [17] https://react.dev/learn/start-a-new-react-project
