Skip to content

React 的灵活性到底在哪

先记结论

React 的灵活性不来自于"JSX 就是 JavaScript"这种表层语法。它来自三个架构层的根本选择:

  1. 运行时模型:React 对数据依赖一无所知——每次渲染都是一次全新执行,框架只负责 diff 结果。这给了你完全的渲染控制权,代价是手动优化。
  2. 组件一等公民性:React 组件是普通函数。你可以传递、包装、柯里化、条件调用一个组件,就像操作任何 JavaScript 值。Vue SFC 组件是被编译系统理解的对象——它们不能像函数一样被随意传递。
  3. 调度控制权: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()

关键区别:

ReactVue
谁管依赖图开发者(依赖数组)框架(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.memouseMemouseCallback 不是在"优化"——它们是在修补"默认行为就过度渲染"这个问题。依赖数组是手工维护的依赖图,忘了一项就是 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' 的状态 → bug

Vue 的 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+ 引入的编译器自动分析组件内部的依赖关系,自动注入等价于 useMemouseCallback 的优化:

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 的默认性能更好、代码更少。

理解这两个框架,不是在找一个赢家——是理解它们各自做了什么交易,以及这个交易在你的场景中是否划算。