为什么两种架构走向了不同的道路

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-bindv-ifv-for)都是在建立数据到视图的明确映射。Vue 的核心在于:这种映射关系在编译时就可以被静态分析出来。因此,Vue 选择将大部分优化工作前置到编译阶段,通过编译器生成带有优化标记的渲染函数,让运行时的更新工作变得精准而高效。Vue 3 的 Proxy 响应式系统进一步强化了这种理念——当数据变化时,框架精确知道哪些组件、哪些 DOM 节点需要更新,不需要进行全树扫描。两种框架的分歧从这一刻起就已经注定:React 押注运行时调度的通用性和灵活性,Vue 押注编译时优化的精准性和效率。

1.2 两条路径的技术DNA

React 的技术 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 的响应式系统,配合 Reflect API 实现更完整、更高效的拦截。Vue 的核心设计哲学是 让框架自动追踪数据与视图之间的依赖关系,开发者无需手动声明依赖(不像 React 的 useEffect 需要显式传递依赖数组)。当 ref 或 reactive 对象的值发生变化时,Vue 的响应式系统能够精确通知到依赖于该数据的每一个副作用(Effect),包括组件的重新渲染、computed 属性的重新计算、watch 回调的执行等。这种"自动追踪、精确触发"的机制,使得 Vue 在大多数场景下能够实现 O(1) 的更新复杂度 ——即更新成本与受影响的节点数量成正比,而非与组件树的总规模成正比。

1.3 核心差异一览

维度 React Fiber Vue 响应式系统
核心差异 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 List

Commit Phase
同步提交 DOM 变更

切换 current 指针

调度 useEffect

2.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.delete API 的原因);其次,它需要对数据对象进行 深度递归遍历,在初始化时就为每一层嵌套对象的每一个属性都设置 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) 读取属性值(Reflect API 的设计目的正是为了与 Proxy 配合使用,提供更完整和规范的元编程能力),同时调用 track() 函数进行依赖收集;set 拦截器使用 Reflect.set() 写入新值,然后调用 trigger() 函数通知所有依赖进行更新。这种 Proxy + Reflect 的组合已经成为现代 JavaScript 元编程的标准范式。

3.2 依赖收集的三剑客:TargetMap、Dep、Effect

Vue 3 的响应式系统内部维护了一个精巧的全局依赖追踪结构。其核心是三个关键数据结构:

首先是 targetMap,一个 WeakMap<object, Map<string | symbol, Set<ReactiveEffect>>> 结构。它的作用是建立"响应式对象 -> 属性键 -> 依赖集合"的三层映射。WeakMap 的选择非常重要——它允许垃圾回收器在响应式对象不再被引用时自动回收其对应的依赖信息,防止内存泄漏。当 track() 被调用时,Vue 会根据当前被访问的响应式对象和属性键,找到或创建对应的依赖集合(Dep),然后将当前正在执行的 ReactiveEffect 实例添加到这个集合中。

其次是 ReactiveEffect 类,它是 Vue 响应式系统中"副作用"的抽象表示。组件的渲染函数、computed 属性的计算函数、watch 的回调函数,本质上都是 ReactiveEffect 的不同实例。每个 ReactiveEffect 有一个 run() 方法用于执行副作用,以及一个 deps 数组用于记录它依赖于哪些 Dep 集合。这种 双向记录 的机制——Effect 记录它依赖了哪些 Dep,Dep 记录哪些 Effect 依赖了它——是实现精确更新的关键。当响应式数据变化时,trigger() 函数只需要找到对应的 Dep 集合,遍历其中的所有 Effect 并重新执行即可。

最后是调度器(Scheduler)。Vue 并不会在数据变化时立即同步执行所有副作用,而是将它们推入一个队列,通过 nextTick 机制进行 异步批量刷新。这意味着在同一个事件循环中发生的多个数据变化,只会触发一次统一的 DOM 更新——这是 Vue 性能优化的重要手段。通过 Promise.then(或降级到 setImmediate / setTimeout),Vue 确保所有同步的数据变更都完成后,才在下一个微任务中执行副作用,这种批量处理策略大幅减少了不必要的重复渲染。

读取属性

对象

属性

添加

触发

获取 Effects

nextTick

响应式对象 Proxy

track

targetMap

Map: key -> Dep

Set 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 节点会被更新。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐