为什么 React 和 Vue 不一样?

📅 2026/6/25 13:52:04
为什么 React 和 Vue 不一样?
为什么两种架构走向了不同的道路1.1 UI 的本质是什么要理解 React Fiber 和 Vue 响应式系统为何走向截然不同的架构路径我们必须回到一个更根本的问题用户界面的本质是什么。React 团队给出的答案是——UI 是状态的函数UI f(state)。这个看似简单的等式蕴含着深刻的架构决策如果 UI 只是状态的纯粹映射那么每次状态变化时整个 UI 都应该被重新计算框架的职责是通过 diff 算法来最小化实际的 DOM 操作。React 的虚拟 DOM 和 Fiber 架构都是这一观点的工程实现它们假设状态变化是不可预测的、细粒度的因此需要一个通用的运行时调度系统来处理任意复杂度的更新。这种设计赋予了 React 极强的灵活性和表达能力但也带来了不可避免的运行时开销——每一次更新都需要走过渲染 - 虚拟 DOM 树构建 - Diff - Patch的完整链路。Vue 的创始人尤雨溪对这个问题给出了不同的回答。在他看来UI 的本质是响应式数据与 DOM 之间的绑定关系。当开发者声明了一个模板Template其中的每一个插值表达式{{ }}、每一个指令v-bind、v-if、v-for都是在建立数据到视图的明确映射。Vue 的核心在于这种映射关系在编译时就可以被静态分析出来。因此Vue 选择将大部分优化工作前置到编译阶段通过编译器生成带有优化标记的渲染函数让运行时的更新工作变得精准而高效。Vue 3 的 Proxy 响应式系统进一步强化了这种理念——当数据变化时框架精确知道哪些组件、哪些 DOM 节点需要更新不需要进行全树扫描。两种框架的分歧从这一刻起就已经注定React 押注运行时调度的通用性和灵活性Vue 押注编译时优化的精准性和效率。1.2 两条路径的技术DNAReact 的技术 DNA 可以追溯到底层系统编程的启发。Fiber 架构的设计者 Andrew Clark 曾明确表示Fiber 是对操作系统线程调度模型的借鉴。在操作系统中进程调度器需要在多个任务之间分配 CPU 时间片确保高优先级任务如用户输入能够及时响应同时不让低优先级任务如后台计算饿死。React Fiber 将同样的思想引入了 JavaScript 的单线程环境通过将渲染工作拆分为可中断的工作单元并利用浏览器的requestIdleCallback机制React 可以在每一帧的空闲时间内执行一小部分渲染工作高优先级更新则可以随时 抢占 当前工作。这种架构赋予了 React时间切片和并发渲染的能力使得 React 能够在不阻塞主线程的前提下处理大规模组件树的更新。Vue 的技术 DNA 则源于数据绑定和依赖追踪。Vue 2 使用Object.defineProperty对数据对象进行递归劫持在 getter 中收集依赖在 setter 中触发更新。Vue 3 则将这一机制升级为基于 ES6 Proxy 的响应式系统配合ReflectAPI 实现更完整、更高效的拦截。Vue 的核心设计哲学是让框架自动追踪数据与视图之间的依赖关系开发者无需手动声明依赖不像 React 的useEffect需要显式传递依赖数组。当ref或reactive对象的值发生变化时Vue 的响应式系统能够精确通知到依赖于该数据的每一个副作用Effect包括组件的重新渲染、computed属性的重新计算、watch回调的执行等。这种自动追踪、精确触发的机制使得 Vue 在大多数场景下能够实现O(1) 的更新复杂度——即更新成本与受影响的节点数量成正比而非与组件树的总规模成正比。1.3 核心差异一览维度React FiberVue 响应式系统核心差异UI f(state)通用运行时调度数据驱动视图编译时优化 响应式追踪更新粒度组件级别需要 diff 确定实际变更属性级别精确追踪依赖调度模型协作式多任务Cooperative Scheduling依赖触发式Dependency-driven可中断性原生支持Time Slicing需配合nextTick批量处理编译角色次要JSX 转译核心模板编译 优化标记生成内存模型双缓冲Current / WorkInProgress 两棵树代理对象 Effect 依赖图学习曲线中等需理解 hooks 规则、闭包陷阱平缓模板语法直观回到顶部二、React Fiber在单线程世界里的调度器2.1 Stack Reconciler 的困局在 React 16 之前的 Stack Reconciler 时代React 的更新过程可以简单概括为一撸到底。当组件状态发生变化时React 会从根节点开始递归遍历整棵组件树计算新的虚拟 DOM 树与旧的树进行 Diff最后一次性将所有变更提交到真实 DOM。这个过程完全同步且不可中断——一旦开始就必须等到全部完成才能将控制权交还给浏览器。对于小型应用这种方式工作得很好因为整个更新过程可能只需要几毫秒。但随着应用规模的增长组件树可能包含数千个节点一次完整的 reconciliation 可能消耗数十甚至上百毫秒直接阻塞浏览器的主线程。这种阻塞带来的用户体验问题是灾难性的。我们想象一下用户在搜索框中输入文字同时后台正在接收实时数据更新。在 Stack Reconciler 中数据更新触发的重渲染可能会完全占用主线程 100ms在这段时间内用户的键盘输入事件被挂在事件队列中无法得到响应——用户会感觉卡顿。更严重的是动画在这一期间完全停滞因为浏览器没有机会执行requestAnimationFrame回调。React 团队意识到问题的根源不在于虚拟 DOM 本身而在于 JavaScript 的执行模型——调用栈是 后进先出的、不可抢占的数据结构一旦进入深层递归就没有优雅的方式来暂停当前工作去处理更紧急的任务。2.2 Fiber 的创新重新实现调用栈React Fiber 的创新点在于它对这一底层问题的回应如果浏览器的调用栈不够灵活那就自己实现一个。Fiber 架构的本质是一种用户空间调度器它将原本由 JS 引擎管理的调用栈转换为显式维护的链表数据结构。每一个 React 组件实例不再只是一个函数调用而是一个持久化的 Fiber 节点对象其中包含了child第一个子节点、sibling下一个兄弟节点和return父节点三个指针构成了一棵可任意遍历、暂停和恢复的树形链表。这种数据结构的选择绝非偶然。链表结构使得 React 可以彻底放弃递归recursion改用循环loop来遍历组件树。在循环的每一次迭代中React 处理一个 Fiber 节点然后检查当前帧的剩余时间。如果剩余时间不足React 默认设置了一个约5ms 的帧预算或者检测到有更高优先级的更新到来React 可以立即保存当前的工作进度记录下一个待处理的 Fiber 节点引用将控制权交还给浏览器然后在下一帧的requestIdleCallback回调中无缝恢复工作。Andrew Clark 将 Fiber 描述为一个专门用于 React 组件的虚拟栈帧——它的核心优势在于这些栈帧存储在堆内存中React 可以完全控制它们的执行顺序和时机这是操作系统调用栈所不具备的能力。2.3 双缓冲架构与两阶段提交Fiber 架构引入了双缓冲的内存模型这是另一个深刻影响 React 更新行为的创新。React 在内存中同时维护两棵 Fiber 树一棵是current树代表了当前屏幕上真实 UI 的状态另一棵是workInProgress树用于进行正在进行的渲染计算。当更新触发时React 并不会直接修改current树而是基于它克隆出一棵workInProgress树所有的 reconciliation 工作都在这棵草稿树上进行。这个设计的精妙之处在于渲染过程完全不会影响用户看到的界面——即使渲染过程中途被中断或完全丢弃用户看到的依然是current树对应的一致 UI。当workInProgress树的所有工作完成后React 进入提交阶段Commit Phase。这是一个同步、不可中断的阶段React 将workInProgress树的所有副作用DOM 插入、更新、删除以及生命周期函数和useEffect回调的调度一次性应用到真实 DOM 上然后原子性地将workInProgress树切换为新的current树。两阶段架构的严格分离是 React 并发特性的基石渲染阶段Render Phase可以被打断和重启因为它只操作内存中的workInProgress树提交阶段Commit Phase必须是原子的因为此时正在修改用户可见的界面任何不一致都会导致视觉闪烁。是否状态更新触发是否有更高优先级任务?保存当前进度yield 控制权处理高优先级任务恢复之前工作Render Phase构建 workInProgress 树生成 Effect ListCommit Phase同步提交 DOM 变更切换 current 指针调度 useEffect2.4 优先级调度与 Lane 模型React 18 进一步演化出了Lane 优先级模型用 31 位的二进制数来表示不同类型的更新优先级。每一位代表一个通道Lane不同的交互类型用户输入、点击、数据加载、过渡动画等被分配到不同的 Lane 上。React 可以精确判断哪些更新更紧急并支持 Lane 的纠缠entanglement机制——当高优先级更新和低优先级更新之间存在数据依赖时React 会自动将它们合并渲染防止出现视觉不一致。这种精细的优先级控制系统使得 React 能够在极端复杂的并发场景中依然保持用户交互的流畅性但也显著增加了框架的运行时复杂度和学习成本。回到顶部三、Vue 响应式系统让数据自己告诉你它变了3.1 从 defineProperty 到 Proxy响应式技术的进化Vue 的响应式系统经历了两代重大演进。Vue 2 使用Object.defineProperty为对象的每一个属性定义 getter 和 setter在属性被读取时收集依赖在被修改时触发更新。这个方案在当时是创新的但它有几个根本性缺陷首先Object.defineProperty只能拦截已经存在的属性无法检测对象的新增属性和数组索引的变化这也是 Vue 2 需要Vue.set和Vue.deleteAPI 的原因其次它需要对数据对象进行深度递归遍历在初始化时就为每一层嵌套对象的每一个属性都设置 getter/setter这在处理大型数据对象时会产生大的性能开销。Vue 3 的响应式系统基于 ES6 的Proxy对象进行了彻底重写。与Object.defineProperty不同Proxy可以拦截对目标对象的任何操作——包括属性读取、赋值、删除、枚举、函数调用、in运算符甚至new操作。这意味着 Vue 3 不再需要深度递归初始化代理是懒的只有当访问到某个嵌套对象时才会递归地为该对象创建代理。更重要的是Proxy让 Vue 3天然支持 Map、Set、WeakMap、WeakSet等 ES6 数据结构以及数组的所有操作包括直接通过索引赋值和修改length无需任何特殊处理。在 Vue 3 的源码中reactive()函数通过new Proxy(target, mutableHandlers)创建响应式对象其中mutableHandlers包含了get和set拦截器。get拦截器使用Reflect.get(target, key, receiver)读取属性值ReflectAPI 的设计目的正是为了与Proxy配合使用提供更完整和规范的元编程能力同时调用track()函数进行依赖收集set拦截器使用Reflect.set()写入新值然后调用trigger()函数通知所有依赖进行更新。这种 Proxy Reflect 的组合已经成为现代 JavaScript 元编程的标准范式。3.2 依赖收集的三剑客TargetMap、Dep、EffectVue 3 的响应式系统内部维护了一个精巧的全局依赖追踪结构。其核心是三个关键数据结构首先是targetMap一个WeakMapobject, Mapstring | symbol, SetReactiveEffect结构。它的作用是建立响应式对象 - 属性键 - 依赖集合的三层映射。WeakMap的选择非常重要——它允许垃圾回收器在响应式对象不再被引用时自动回收其对应的依赖信息防止内存泄漏。当track()被调用时Vue 会根据当前被访问的响应式对象和属性键找到或创建对应的依赖集合Dep然后将当前正在执行的ReactiveEffect实例添加到这个集合中。其次是ReactiveEffect类它是 Vue 响应式系统中副作用的抽象表示。组件的渲染函数、computed属性的计算函数、watch的回调函数本质上都是ReactiveEffect的不同实例。每个ReactiveEffect有一个run()方法用于执行副作用以及一个deps数组用于记录它依赖于哪些Dep集合。这种双向记录的机制——Effect 记录它依赖了哪些 DepDep 记录哪些 Effect 依赖了它——是实现精确更新的关键。当响应式数据变化时trigger()函数只需要找到对应的Dep集合遍历其中的所有 Effect 并重新执行即可。最后是调度器Scheduler。Vue 并不会在数据变化时立即同步执行所有副作用而是将它们推入一个队列通过nextTick机制进行异步批量刷新。这意味着在同一个事件循环中发生的多个数据变化只会触发一次统一的 DOM 更新——这是 Vue 性能优化的重要手段。通过Promise.then或降级到setImmediate/setTimeoutVue 确保所有同步的数据变更都完成后才在下一个微任务中执行副作用这种批量处理策略大幅减少了不必要的重复渲染。读取属性对象属性添加触发获取 EffectsnextTick响应式对象 ProxytracktargetMapMap: key - DepSet of Effects当前 Effect修改属性trigger批量调度更新执行 Effect/DOM更新3.3 编译器的智慧从模板到优化标记Vue 的响应式系统之所以高效很大程度上归功于其编译器的静态分析能力。与 React 的 JSX 不同Vue 使用基于 HTML 的模板语法。这种看似限制性的设计实际上为编译器优化打开了巨大的空间。当 Vue 编译器分析一个模板时它能够识别出哪些部分是静态的不会随数据变化哪些是动态的绑定响应式数据。Vue 3 的编译器引入了多项革命性的优化技术静态提升Static Hoisting将静态节点从渲染函数中提取出来只在首次渲染时创建一次后续更新完全跳过这些节点Patch Flags为每一个动态节点打上一个优化标记精确指示该节点的哪个部分可能变化文本内容、类名、样式、属性等这样运行时的 diff 算法可以跳过完整的 props 比较只检查可能发生变化的特定部分树扁平化打破了传统的递归 diff 模式将所有动态节点收集到一个扁平数组中diff 时只需要遍历这个数组而非整棵树。这些编译时优化的综合效果使得 Vue 3 的虚拟 DOM 更新效率远超传统的全树 diff 实现——虽然 Vue 仍然使用虚拟 DOM但它已经是一个被编译器武装到牙齿的高度优化版虚拟 DOM。回到顶部四、业内其他框架百花齐放的方案4.1 Svelte编译器即框架如果说 React 代表了运行时最大化的极端那么 Svelte 则代表了编译时最大化的另一个极端。Svelte 的创造者 Rich Harris 提出了一个激进的问题如果框架在构建时就知道你的组件会如何变化那为什么还要在运行时做这些工作。Svelte 的核心架构决策是将框架本身编译掉——最终运行在浏览器中的代码几乎是纯粹的手写 JavaScript DOM 操作没有虚拟 DOM没有响应式运行时库没有 diff 算法。Svelte 5 进一步引入Runes如$state、$derived、$effect将响应式模型从隐式的编译器魔法转变为显式的信号Signals机制。编译器分析组件模板中的每一个响应式绑定生成精确的 DOM 更新代码。当$state的值变化时编译生成的代码直接调用textNode.data newValue或element.setAttribute(class, newClass)没有任何中间抽象层。这种架构的代价是 Svelte 需要一个功能强大的编译器来处理各种边界情况但它的回报也是巨大的Svelte 应用的运行时体积极其微小约 2-3 KB gzip更新性能接近原生 JavaScript内存占用也远低于虚拟 DOM 方案。4.2 SolidJS Signals 驱动的细粒度响应式SolidJS 的创造者 Ryan Carniato 将细粒度响应式Fine-grained Reactivity推向了一个极致。Solid 同样不使用虚拟 DOM但它与 Svelte 的编译器驱动方式有所不同Solid 保留了 JSX 语法其编译器将 JSX 转换为高效的 DOM 创建和更新指令而响应式追踪则在运行时通过Signals完成。Solid 的createSignal返回一个 getter/setter 对当在 JSX 或其他响应式上下文中读取 signal 时依赖关系被自动建立当 signal 值变化时只有直接依赖于该值的 DOM 节点会被更新。