Appearance
React 的灵活性到底在哪
先记结论
React 的灵活性不来自于"JSX 就是 JavaScript"这种表层语法。它来自三个架构层的根本选择:
- 运行时模型:React 对数据依赖一无所知——每次渲染都是一次全新执行,框架只负责 diff 结果。这给了你完全的渲染控制权,代价是手动优化。
- 组件一等公民性:React 组件是普通函数。你可以传递、包装、柯里化、条件调用一个组件,就像操作任何 JavaScript 值。Vue SFC 组件是被编译系统理解的对象——它们不能像函数一样被随意传递。
- 调度控制权:React 的 Lane 模型让你显式标记更新的优先级。
useTransition/useDeferredValue允许你告诉框架"这个更新可以被中断"。Vue 的调度器是内部的——你享受自动批处理,但无法干预优先级。
这三个选择不是 React 比 Vue "更好"——它们是同一笔交易的不同方向。React 用开发者的负担换灵活性;Vue 用灵活性约束换自动化。
第一条分歧:运行时模型
React:框架对你的数据一无所知
jsx
function Counter() {
const [count, setCount] = useState(0)
// 每次 setCount 调用 → 整个 Counter 函数重新执行
// React 不知道 count 影响了哪些 DOM
// 它只是重新执行函数 → 得到新 VNode 树 → diff → 更新 DOM
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}React 的模型是无状态的框架 + 有状态的开发者。框架层面不维护任何依赖图。组件函数每次执行都是一次完整的重新计算。这个模型的核心特征:
- 没有依赖追踪。
useState不记录"谁在读取 count"。useEffect的依赖数组是开发者手动声明的——不是框架推导的。 - 重新执行 = 重新闭包。每次 render 创建一个新的闭包,捕获当时的 props 和 state。这是"快照式"语义的来源——UI 是某次 state 的快照。
- 框架只负责 diff 和 commit。Render 阶段产出 VNode 树(纯计算,可以被中断),Commit 阶段操作 DOM(不可中断)。
这个模型给开发者带来的控制权:
jsx
// 你可以决定是否跳过渲染
const MemoChild = React.memo(function Child({ data }) {
return <div>{data}</div>
})
// 你可以缓存计算结果
const expensive = useMemo(() => compute(a, b), [a, b])
// 你可以缓存函数引用
const handleClick = useCallback(() => { /* ... */ }, [dep])
// 你可以直接访问上一次的值
function usePrevious(value) {
const ref = useRef()
useEffect(() => { ref.current = value })
return ref.current
}这些 API 的存在本身就是一个信号:React 把优化决策权交给了开发者。React.memo 的存在是因为 React 默认不做浅比较。useMemo 的存在是因为 React 默认不缓存计算。Vue 不需要这些——编译器已经帮你做了。
Vue:框架知道你的数据去过哪里
js
<script setup>
const count = ref(0)
// 模板中读取 count.value → track() 被调用
// 依赖图:count → 当前组件的 render effect
// 后续 count.value = 1 → trigger() → 只重新执行这个组件的 render
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>Vue 的响应式系统在运行时维护一张完整的依赖图。ref() 创建的值被 Proxy 包装(或 getter/setter),每次 .value 读取触发 track(),每次 .value 赋值触发 trigger()。
关键区别:
| React | Vue | |
|---|---|---|
| 谁管依赖图 | 开发者(依赖数组) | 框架(Proxy 自动收集) |
| 渲染触发 | 调用 setState 的函数 | 修改 ref.value |
| 渲染粒度 | 组件树(除非手动优化) | 组件级(自动,Vapor Mode 可达元素级) |
| 跳过渲染 | React.memo(手动) | 编译器自动(patchFlags) |
| 计算缓存 | useMemo(手动) | computed()(自动,惰性求值) |
Vue 的代价:依赖追踪让你无法做一些 React 中很简单的事。比如你不能在 setup() 外部修改响应式数据而不触发渲染——Proxy 会捕获它。你不能把一个 ref 当作普通值在函数间传递——必须保持 .value 的包装。你不能随意用解构——const { count } = useCounter() 在 Vue 中会丢失响应性(需要 toRefs)。
React 的代价:没有依赖追踪意味着默认情况下每次 setState 都会重新渲染整个子树。React.memo、useMemo、useCallback 不是在"优化"——它们是在修补"默认行为就过度渲染"这个问题。依赖数组是手工维护的依赖图,忘了一项就是 bug。
第二条分歧:组件的一等公民性
这条分歧比运行时模型更根本。它决定了"你能用组件做什么",而不仅仅是"组件怎么更新"。
React 组件是函数
jsx
// 组件可以当参数传递
function Layout({ header: HeaderComp, children }) {
return (
<div>
<HeaderComp />
<main>{children}</main>
</div>
)
}
// 组件可以当返回值
function withAuth(Component) {
return function Authenticated(props) {
if (!useAuth()) return <Login />
return <Component {...props} />
}
}
// 组件可以条件执行——不是在模板里用 v-if,而是直接不调用
function MaybeRender({ condition, component: Comp }) {
return condition ? <Comp /> : null
}
// children 可以是一个函数
function DataFetcher({ url, children }) {
const { data, loading } = useFetch(url)
return children({ data, loading })
}
// 使用
<DataFetcher url="/api/user">
{({ data, loading }) => loading ? <Spinner /> : <UserInfo user={data} />}
</DataFetcher>React 组件是普通 JavaScript 函数,返回 VNode。这意味着:
- 组件可以赋值给变量、作为参数传递、作为函数返回值
- 高阶组件(HOC)只是函数组合
- render props 只是回调函数
- children-as-function 只是把
props.children当作函数调用 - 所有这些都是原生 JavaScript 模式,没有框架特有的 API
这不是设计上的巧合。React 的组件模型故意不引入任何框架特有的抽象层。一个 React 组件就是 (props) => VNode。
Vue 组件是被编译系统理解的对象
vue
<!-- UserCard.vue -->
<script setup>
defineProps(['user'])
</script>
<template>
<div>{{ user.name }}</div>
</template>这个 .vue 文件不只是一个函数。它是一个被编译系统处理的结构化对象,包含:
<template>→ 编译器解析为 render 函数(带静态提升、patchFlag 标记)<script setup>→ 编译器转换为 Options API 等价代码<style scoped>→ 编译器生成 scoped CSS
Vue 组件不是一等公民。你不能在 .ts 文件中写 <MyComponent />——JSX/TSX 在 Vue 中是二等支持。你不能把 SFC 当作函数随意传递——它本质上是 defineComponent 返回的选项对象。Vue 的组件抽象是对象 + 编译器,不是函数。
Vue 如何弥补这个差距:
- scoped slots 提供了类似 render props 的能力——但它是模板 DSL,不是 JavaScript
h()函数提供了 JSX/TSX 支持——但失去了编译优化(没有 patchFlag、没有静态提升)- Composables 提供了类似 custom hooks 的逻辑复用——这是在逻辑层,不在组件层
这导致了什么实际差异
React 做过这些事:
- Headless UI 模式:组件只管理状态和行为,渲染完全交给 children/render prop。Radix UI、TanStack Table 都基于这个模式。
- A/B 测试不侵入代码:根据 flag 返回不同的组件函数,不需要在每个模板里加
v-if="flag"。 - 完整的组件原语可以写在不返回 JSX 的函数里:
const Link = variant === 'button' ? ButtonLink : TextLink——这是正常的 JavaScript,没有框架学习成本。
Vue 做这些需要绕过框架限制。Headless UI 在 Vue 中通过 renderless components + scoped slots 实现——但这利用了 Vue 的一个边缘特性(<template> 可以没有 DOM 输出),不是语言层面的表达能力。
第三条分歧:调度控制权
React:开发者决定优先级
jsx
function SearchPage() {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
function handleInput(e) {
// 高优先级:输入框的值立即更新
setQuery(e.target.value)
// 低优先级:搜索结果可以在后台计算,可以被中断
startTransition(() => {
setSearchResults(expensiveSearch(data, e.target.value))
})
}
return (
<div>
<input value={query} onChange={handleInput} />
{isPending && <Spinner />}
<SearchResults results={searchResults} />
</div>
)
}React 的 Scheduler + Lane 模型允许显式的优先级控制:
useTransition:标记某些状态更新为低优先级,可被用户交互中断useDeferredValue:延迟某个值的消费,旧值保持可见直到新值计算完成- Suspense:声明式加载边界,未就绪的子树不阻塞已就绪的部分
Fiber 架构的实现使得 React 可以在 render 阶段 yield 给浏览器(通过 shouldYield()),处理更高优先级的用户输入后再恢复。这是 requestIdleCallback 的思想,用 MessageChannel 实现。
这不是语法糖——它改变了渲染可以被中断和恢复这个根本假设。React 15 的递归渲染在遇到大组件树时一定会掉帧,React 18+ 的并发渲染可以在 5ms 内 yield 回到浏览器处理用户点击。
Vue:框架决定批处理策略
Vue 的调度策略更简单但足够高效:
js
// Vue 内部(简化)
const queue = []
let isFlushing = false
function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job)
}
if (!isFlushing) {
isFlushing = true
// nextTick → Promise.resolve() → 微任务
nextTick(flushJobs)
}
}
function flushJobs() {
// 按 id 排序:父组件先于子组件
queue.sort((a, b) => a.id - b.id)
for (const job of queue) {
job.run()
}
queue.length = 0
isFlushing = false
}Vue 的调度器的核心特点:
- 自动去重:同一 effect 在一次 flush 中只执行一次
- 自动排序:父组件先于子组件更新
- 微任务批处理:所有同步修改收集到一个队列,下一个微任务统一执行
- 开发者不可控:没有 API 可以指定某个更新的优先级
Vue 的选择是合理的:因为依赖追踪已经精确定位了哪些组件需要更新,所以不需要调度层来避免过度渲染。调度器只需要"批处理"这一个职责。React 的调度器承担了双重职责:批处理 + 防止过度渲染(通过可中断性放弃低优先级渲染)。
React 的选择同样是合理的:因为框架不知道数据依赖,默认行为是"全量渲染 + diff"。调度器必须提供可中断性和优先级,否则大组件树的 setState 一定会卡顿。
灵活性的代价
React 三个架构选择带来的代价,对应着每个 React 开发者都踩过的坑:
代价 1:手动优化地狱
jsx
// 这段代码有多少问题?
function UserList({ users, onSelect }) {
const [filter, setFilter] = useState('')
const filtered = users.filter(u => u.name.includes(filter))
// ① 每次 render 都重新 filter → 如果 users 是 10000 条会怎样?
// 解决:useMemo(() => users.filter(...), [users, filter])
const handleClick = (id) => onSelect(id)
// ② 每次 render 都创建新函数 → 传给 PureComponent 的 child 必触发 re-render
// 解决:useCallback((id) => onSelect(id), [onSelect])
// 但 onSelect 如果是 inline 函数,useCallback 也救不了
return filtered.map(u => (
<UserItem key={u.id} user={u} onClick={handleClick} />
// ③ 如果 UserItem 不是 React.memo,每次都会 re-render
))
}这段代码的功能等价于 Vue 中:
vue
<script setup>
const props = defineProps(['users'])
const emit = defineEmits(['select'])
const filter = ref('')
const filtered = computed(() =>
props.users.filter(u => u.name.includes(filter.value))
)
// ① computed 自动缓存,依赖不变不重新计算
// ② 模板中不需要传递稳定函数引用——编译器生成的 render 函数自动做 diff
// ③ 编译器生成的 patchFlag 标记了动态内容,非动态部分不参与 diff
</script>
<template>
<UserItem v-for="u in filtered" :key="u.id" :user="u" @click="emit('select', u.id)" />
</template>React 的三层手动优化(useMemo + useCallback + React.memo)在 Vue 中是编译器的默认行为。不是因为 Vue 的编译器更聪明——是因为模板的约束让编译器能分析静态/动态边界。
代价 2:闭包陷阱
jsx
function Timer() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
console.log(count) // 永远是 0
setCount(count + 1) // 永远是 0 + 1 = 1
}, 1000)
return () => clearInterval(id)
}, []) // 空依赖数组 → effect 只在 mount 执行一次
// 闭包捕获了 mount 时的 count = 0
return <div>{count}</div>
}这个问题的根因是 React 的"每次渲染 = 一次全新闭包"模型。useEffect 的回调在第一次渲染时创建,捕获了当时的 count 值(0)。后续渲染创建了新的闭包(count = 1, 2, 3...),但 setInterval 里跑的还是第一个闭包。
Vue 中不存在这个问题:
js
const count = ref(0)
setInterval(() => {
console.log(count.value) // 总是最新的值
count.value++
}, 1000)ref 是一个对象,.value 是属性访问——不是闭包捕获。任何地方读取 ref.value 都拿到当前值。
React 的解决方案暴露了问题的本质:
jsx
// 方案 A:依赖数组 → effect 每次重建,setInterval 每次清除重建
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000)
return () => clearInterval(id)
}, []) // 用函数式更新避免依赖 count
// 方案 B:useRef → 把值放进可变容器,绕开闭包
const countRef = useRef(count)
countRef.current = count
useEffect(() => {
const id = setInterval(() => console.log(countRef.current), 1000)
return () => clearInterval(id)
}, [])方案 B 本质上是在 React 的不可变模型中凿了一个可变后门。这和 Vue 的 ref 做的事情一模一样。useRef 就是 React 开发者承认:有时候可变引用比不可变快照更合理。
代价 3:Rules of Hooks
Hooks 的调用顺序必须是稳定的——因为 React 内部用链表存储 hook 状态,通过调用顺序定位:
text
组件首次渲染:
useState('a') → 创建 hook 节点 0,存入 fiber.memoizedState 链表
useState('b') → 创建 hook 节点 1
组件第二次渲染:
useState('a') → 找到 hook 节点 0 → 返回当前状态
if (condition) {
useState('c') → ❌ 破坏了调用顺序
}
useState('b') → 期望找到 hook 节点 1 → 实际拿到的是 'c' 的状态 → bugVue 的 Composition API 没有这个限制。setup() 只执行一次——ref() 在那一次调用中创建,后续更新不会重新执行 setup():
js
// Vue:这在任何位置都安全
const count = ref(0)
if (condition) {
const extra = ref('extra') // 没问题,setup 只执行一次
}这是"函数即组件"模型必须付出的代价。React 需要 Rules of Hooks 的 ESLint 插件来防止开发者写出 bug——Vue 不需要,因为 setup() 只执行一次。
Vue 用约束换来的东西
约束 1:模板的语法限制
Vue 模板中你不能:
- 写
map/filter→ 必须用v-for - 写
if-else→ 必须用v-if/v-else - 直接调用函数 → 必须用事件处理器
@click="handler"或计算属性
但这些限制换来了编译器的完整静态分析:
js
// 编译器编译模板后的输出(简化)
// 模板:<div><p>{{ msg }}</p></div>
// 编译结果:
const _hoisted_1 = createElementVNode("p", null, null, -1 /* HOISTED */)
function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", null, [
_hoisted_1, // 复用静态节点
_createTextVNode(_toDisplayString(_ctx.msg), 1 /* TEXT */)
// ↑ patchFlag: 1 = 只有文本是动态的
]))
}编译器知道 <p> 是静态的(没有动态属性、没有动态子节点),所以提升为常量,每次渲染复用同一对象。只有 动态文本参与 diff。React 的 JSX 做不到这一点——<div><p>hello</p></div> 在 JSX 中每次 render 都创建新的 VNode 对象。
patchFlag 是 Vue 真正的性能优势来源。它不是"比 React 的 diff 更快"——它是"比 React 少了需要 diff 的东西"。
约束 2:SFC 不是纯 JavaScript
Vue 组件必须在 .vue 文件中、通过编译管线处理。不能像 React 一样:
jsx
// React:在任何 .js/.ts 文件中随时定义和使用组件
const InlineIcon = ({ name }) => <i className={`icon-${name}`} />
// 这是合法的 JavaScript,React 在运行时处理Vue 需要 defineComponent + h() 来达到同等效果——但失去编译优化。
约束 3:响应式包装
js
// Vue:必须记住用 .value
const count = ref(0)
count.value++ // ✅
count++ // ❌ 不工作
// 解构需要 toRefs
const { count } = useCounter() // ❌ 失去响应性
const { count } = toRefs(useCounter()) // ✅
// React:解构是正常的 JavaScript
const { count, increment } = useCounter() // 直接可用Vue 3 的 ref() 引入 .value 包装是为了跨原始类型和对象的统一响应式 API。React 没有这个负担——它不需要包装值,因为它不追踪值的读写。
趋同:从两端向中间走
React Compiler(React Forget)
React 19+ 引入的编译器自动分析组件内部的依赖关系,自动注入等价于 useMemo 和 useCallback 的优化:
jsx
// 源码:什么都不写
function UserList({ users }) {
const filtered = users.filter(u => u.active)
return filtered.map(u => <UserItem key={u.id} user={u} />)
}
// React Compiler 编译后(概念上等价于):
function UserList({ users }) {
const filtered = useMemo(() => users.filter(u => u.active), [users])
return filtered.map(u => <MemoizedUserItem key={u.id} user={u} />)
}React Compiler 在做的事:分析任意 JavaScript 中的依赖关系,推导哪些值需要缓存。这和 Vue 编译器做的事在本质上是同一类问题——只不过 Vue 的编译器分析模板(有结构),React 的编译器分析 JavaScript(无结构)。
React Compiler 的局限性:它分析的是组件函数内部的依赖,不是跨组件的依赖。Vue 的响应式系统追踪的是跨组件的数据流——count.value 的修改会让所有依赖它的组件重新渲染,不需要通过 props 逐层传递。
Vue Vapor Mode
Vue 正在开发的 Vapor Mode 去掉了 Virtual DOM,模板直接编译为 DOM 操作指令:
js
// 模板:<div :class="{ active }">{{ msg }}</div>
// Vapor 编译结果(概念):
const div = document.createElement('div')
renderEffect(() => {
setClass(div, { active: active.value })
setText(div, msg.value)
})这是 Vue 在向 Solid.js 的方向走。去掉了 VDOM 中间层后,响应式系统直接操作 DOM——这和 React 的"component = state → VNode → DOM"三层模型有了根本区别。
它们不会完全趋同
因为各自的优化方向受制于各自的核心约束:
- React 不能轻易引入编译时模板分析:JSX 的灵活性是 React 的招牌,引入模板约束等于否定自己。
- Vue 不能轻易取消响应式包装:
.value/ref()/reactive()是依赖追踪的根基,去掉它等于重写整个响应式系统。 - React 可以加编译优化(React Compiler),但分析对象是 JavaScript:这比分析模板难一个数量级。
- Vue 可以加 JSX 支持,但会失去编译优化:这是一个取舍,不是技术能力问题。
实际选型
选 React 当你需要
- 跨平台渲染目标(React Native、React Three Fiber、Ink)。React 的 reconciler 是可插拔的——任何东西只要能实现 host config 就能被 React 渲染。Vue 的渲染器与 DOM/web 耦合更深。
- 组件层面的深度抽象。Headless UI、render props、children-as-function 这些模式需要把组件当函数传。Vue 的 scoped slots 能覆盖多数场景但不是全部。
- 团队有较强的 JavaScript 功底且愿意维护优化规则。React 不会阻止你写出性能差的代码——ESLint 插件和 React Compiler 在帮忙,但最终责任在开发者。
- 显式的渲染控制。如果你需要
startTransition标记低优先级更新、需要 Suspense 控制加载边界的粒度,React 目前的选择更多。
选 Vue 当你需要
- 快速交付的中后台项目。模板 + 指令的开发速度比手写 JSX + hooks 快——不是"思维模式"的差异,是代码量的差异。一个
v-model省掉一个value={x}+onChange={setX}。 - 团队有不同经验水平的成员。模板的约束性让代码审查更快(不用看每个人的 JSX 习惯),编译器优化让性能问题更少出现在 CR 层面。
- 不想要复杂的构建配置。Vite + Vue SFC 的开箱体验减少了 webpack / babel 插件的折腾,
<style scoped>省掉 CSS-in-JS 或 CSS Modules 的配置。 - 对渐进式引入有需求。Vue 可以 CDN 引入、可以嵌入现有多页应用、可以在不迁移整个技术栈的情况下逐步使用。
不存在"哪个更好"
React 的灵活性是真实的——它是架构选择的结果,不是营销说辞。但这份灵活性在 80% 的业务场景中是用不到的,而在用到的 20% 场景中是决定性的。
Vue 的约束也是真实的——它限制了你能写的代码,但换来了更少的底层决策。对于不需要组件一等公民性的场景,Vue 的默认性能更好、代码更少。
理解这两个框架,不是在找一个赢家——是理解它们各自做了什么交易,以及这个交易在你的场景中是否划算。
