Appearance
React Fiber 架构与 Vue 设计模式对比:架构哲学
理解两个框架的架构差异,重点不在 API 对照,而在于它们各自试图解决的问题、采用的机制以及对应的代价。
Fiber 要解决的问题
React 16 之前的 Stack Reconciler 做虚拟 DOM diff 是递归同步的。递归意味着一旦开始就不能打断——调用栈在那里,你想停也停不下来。
组件树较小时,同步递归调和通常足够;但当一次更新涉及上千个组件、render 中存在复杂计算,或运行在低端设备上时,JavaScript 线程可能持续占用主线程 100ms 以上,交互响应和动画帧都会受到影响。
React 团队管这个叫"帧预算超支"。一帧只有 16.6ms,一次同步调和吃掉 6 帧的时间——Fiber 要解决的就是这个:让渲染变成可分片执行的工作单元,能在帧之间暂停和恢复。
Fiber 的目标不是缩短单次渲染总耗时,而是避免阻塞交互。即使中断和恢复会增加总工作量,用户感知到的响应性仍然更稳定。
Fiber 节点:用链表替代递归
Fiber 把每个组件的渲染状态打包成一个节点,用链表连接,而不是依赖 JavaScript 调用栈:
javascript
function FiberNode(tag, pendingProps, key, mode) {
// 类型标识
this.tag = tag // FunctionComponent / ClassComponent / HostComponent...
this.type = null // 函数、class 或标签名字符串
this.stateNode = null // 对应的 DOM 节点或实例
// 这几个指针是 fiber 树的结构骨架
this.return = null // 父节点
this.child = null // 第一个子节点
this.sibling = null // 下一个兄弟节点
// 工作单元的状态
this.pendingProps = null
this.memoizedProps = null
this.memoizedState = null // hooks 链表头
this.updateQueue = null
// 副作用标记
this.flags = NoFlags // Placement / Update / Deletion...
this.subtreeFlags = NoFlags
// 优先级
this.lanes = NoLanes
this.childLanes = NoLanes
// 双缓冲
this.alternate = null // 指向另一棵树中对应的 fiber
}child → sibling → return 这三个指针构成了可遍历的树结构。配合 while 循环逐个处理 fiber 节点,任何节点处理完都可以检查是否需要让出主线程。这跟递归的本质区别是:递归的暂停需要保存整个调用栈,而链表的暂停只需要记住当前指针位置。
alternate 指针实现了双缓冲:屏幕上有一棵 current fiber 树(用户正在看),内存中在构建一棵 workInProgress fiber 树(下次要显示的)。workInProgress 构建过程中 current 不变,用户不会看到半成品 UI。构建完成后,两棵树交换身份——这就是 commit 阶段做的事。
两阶段:可中断的 render,不可中断的 commit
Fiber 的核心设计是把渲染拆成两个阶段,只有 render 阶段可以中断。
Render 阶段:
工作循环:
while (workInProgress !== null) {
performUnitOfWork(workInProgress) // 逐个处理 fiber
if (shouldYield()) break // 让出主线程的唯一出口
}
每个 fiber 的处理:
beginWork(fiber)
→ 根据 fiber.tag 分支(函数组件 / class 组件 / DOM 元素...)
→ reconcileChildren:对比新老子节点,标记 flags
→ 返回第一个子 fiber 继续处理
completeWork(fiber)
→ 回溯到父 fiber
→ 收集子树的副作用链shouldYield() 的实现在 Scheduler 包里——检查当前帧还剩多少时间,如果 < 1ms 就让出。底层用的是 MessageChannel 而非 requestIdleCallback,因为后者有 50ms 的最大超时且浏览器在后台标签页会暂停。
Commit 阶段:
commitRoot(root)
→ commitBeforeMutationEffects // getSnapshotBeforeUpdate
→ commitMutationEffects // 实际修改 DOM
→ commitLayoutEffects // useLayoutEffect / componentDidMountcommit 是同步不可中断的。这很好理解——如果你已经删除了一个 DOM 节点然后被中断,用户会看到 DOM 处于不一致的状态。
Lanes:为什么不用简单的优先级数字
React 早期用过 expiration time 模型(每个更新一个到期时间戳)。Lanes 模型用 31 位掩码替代了时间戳:
SyncLane = 0b0000000000000000000000000000001
InputContinuousLane = 0b0000000000000000000000000000100
DefaultLane = 0b0000000000000000000000000010000
TransitionLane = 0b0000000000000000000000001000000
IdleLane = 0b0000000000000000000010000000000为什么用位掩码?有几个 expiration time 做不到的事情:
- 一个 fiber 可以同时持有多个优先级的更新:
fiber.lanes = SyncLane | DefaultLane,渲染时通过位运算选择处理的 lane 子集。 - 可以批量操作:
getHighestPriorityLane(fiber.lanes)用位运算在 O(1) 时间内找到最高优先级的更新。 - 子树的优先级可以向上冒泡:
childLanes字段让父 fiber 知道子树中有待处理的更新,不需要遍历子树就能决定是否跳过某个分支。
Scheduler 包将优先级进一步映射为任务超时。同步更新立即执行,userBlockingPriority 在 250ms 内执行(用户交互),normalPriority 在 5s 内执行(transition),idlePriority 没有超时。
Concurrent Mode 不是"并行"
Concurrent Mode 准确说是"并发渲染",语义是可中断 + 优先级调度。React 只有一个主线程,不会并行执行多个渲染。它做的事是:当高优先级更新到来时,暂停当前低优先级渲染,处理后者的 commit,然后决定是恢复还是丢弃低优先级的进度。
用户输入 → 中断正在进行的列表渲染 → commit 输入更新 → 恢复列表渲染这要求一个前提条件:render 阶段必须是纯函数——同样的 props 和 state 必须产生同样的输出。如果 render 有副作用(比如直接操作 DOM),中断后再重试就会产生错误的行为。React 18 的 StrictMode 通过故意双重调用 render 来帮助开发者发现这类问题。
Vue 的另一条路
Vue 处理帧预算问题的路径与 React 不同,主要分为两层:
- 运行时:响应式系统提供精确的依赖追踪,数据变化时只通知真正使用它的组件,而不是从根开始遍历。
- 编译时:模板编译器在构建阶段分析静态结构和动态绑定,生成带有优化标记的渲染函数,让运行时直奔需要更新的 DOM 节点。
两层配合的结果是:Vue 的每次更新范围通常很小,小到不需要考虑 16.6ms 的预算。因此它不需要 Fiber 那样复杂的中断和恢复机制。
响应式不等于"自动更新 UI"
Vue 3 的响应式核心是 Proxy + effect 系统。Proxy 只负责拦截 get/set,依赖关系的建立是在 effect 执行过程中完成的:
javascript
// 极简示意——真正实现要复杂一个数量级
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
track(target, key) // 当前正在执行的 effect 订阅这个 key
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
trigger(target, key) // 通知所有订阅了这个 key 的 effect
return result
}
})
}track 在 getter 被调用时记录"谁用了我"——这里的"谁"是当前正在执行的 effect(组件的 render effect、watch 的 callback effect、computed 的 getter effect)。trigger 在 setter 被调用时通知所有订阅者。
关键点在于依赖收集的粒度。Vue 的组件 render 作为一个 effect 被包裹,render 函数执行时访问了哪些响应式数据,那些数据就把这个组件的 update 函数加入自己的 Dep。数据变化时,Dep 中只包含真正依赖它的组件——不会有"可能依赖、可能不依赖"的模糊地带。
编译器做了什么
Vue 的 SFC 编译器在构建时对模板做静态分析。三种核心优化:
静态提升:完全不变的 DOM 结构在首次渲染后被提升到 render 函数外。后续重渲染直接复用,不创建新 vnode。
javascript
// 编译前:模板
// <div><span class="title">静态标题</span><p>{{ content }}</p></div>
// 编译后(概念示意)
const _hoisted_1 = _createVNode("span", { class: "title" }, "静态标题", -1)
function render(_ctx) {
return _createBlock("div", null, [
_hoisted_1, // 直接复用
_createVNode("p", null, _ctx.content, 1 /* TEXT */) // 动态节点
])
}Patch Flags:每个动态绑定在编译时生成一个 flag,告诉运行时"这个节点只需要更新文本,不需要检查 class、style、props"。更新时直接跳到对应的 DOM 操作,跳过全量对比。
TEXT = 1 // innerText
CLASS = 2 // className
STYLE = 4 // style 对象
PROPS = 8 // 静态 key 的 props
FULL_PROPS = 16 // 动态 key 的 props
EVENTS = 32 // 事件处理器如果一个节点没有任何 flag,说明它可以按静态节点路径处理,运行时无需为该节点执行动态更新逻辑。
Block Tree:Vue 记录每个"块"中的动态后代节点。当块需要更新时,不遍历块的整个子 vnode 树,而是直接遍历已收集的动态节点列表。这相当于用 O(动态节点数) 替代了 O(总节点数)。
javascript
// patchElement 中
if (vnode.dynamicChildren) {
patchBlockChildren( // 只处理已知的动态节点
oldVNode.dynamicChildren,
newVNode.dynamicChildren
)
} else {
patchChildren(...) // 退化到全量 diff
}这三层优化叠加的效果是:在典型的后台管理页面中(表单、表格、列表),Vue 的运行时对静态内容几乎零处理,只更新真正变化的部分。
异步队列:简单但够用
Vue 的调度系统非常简单——收集需要更新的组件,去重,在一次 microtask 中同步执行:
javascript
const queue = [] // 待更新的组件
let isFlushing = false
function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job)
queueFlush()
}
}
function queueFlush() {
if (!isFlushing) {
isFlushing = true
Promise.resolve().then(flushJobs)
}
}
function flushJobs() {
queue.sort((a, b) => a.id - b.id) // 父组件先于子组件
for (let i = 0; i < queue.length; i++) {
queue[i]() // 每个组件的更新是同步执行的
}
isFlushing = false
}这里没有优先级区分、时间切片或抢占机制。Vue 能采用这种模型,前提是编译器已经将单次更新范围压缩到足够小。极端情况下,多组件同时更新仍然可能超过 16.6ms,但通常可以继续通过 v-once、v-memo 等手段缩减成本。
核心分歧点
Push vs Pull
这是两者最深层的差异。它决定了状态更新如何传播到 UI。
Vue 的 Push 模型:
data.x = 1 → Proxy setter → trigger → 遍历 Dep → 通知订阅的 effect → 组件 update更新从数据源推送到消费方。Dep 中的订阅关系精确到"哪个组件用了哪个数据的哪个属性"。响应式系统知道你用了 state.foo.bar,当且仅当 bar 变化时才通知你。
React 的 Push-Pull 混合模型:
setState → 标记 fiber.lanes → 从 root 开始调度 → render 阶段遍历 fiber 树
→ 遇到变化的子树:重新 render
→ 遇到未变化的子树:bailout(跳过)setState 是"推"——告诉 React "这里可能有变化"。但从那一刻开始 React 切换到"拉"模式——从根开始遍历,在每个 fiber 上判断是否需要重新渲染。bailout 机制可以跳过未变化的子树,但判定是否变化的逻辑(Object.is 比较 props)必须执行。
核心的权衡:Push 模型需要维护精确的依赖图,在依赖频繁变化时维护成本可能超过收益。Pull 模型不需要维护依赖图,但需要从根开始遍历。React 选择了后者,因为"从根遍历"的实现复杂度远低于"维护动态依赖图"——尤其考虑到闭包、条件渲染、hooks 调用顺序等约束。
调度策略
React 假设"最坏情况"——总有一次更新的范围大到需要拆成多个帧执行。这个假设驱动了 Scheduler + Lanes + 可中断渲染的整个基础设施。代价是:每次更新都要走完整的调度流程(计算 lanes、调度任务、检查是否需要让出),即使是只有 1 个组件需要更新。
Vue 的前提是“经过编译优化后的更新通常足够快”,因此不必再引入拆帧调度。这个前提在多数页面场景成立,但在大量动态数据或实时渲染场景下,可用的调度回旋空间更小。
这里有一个被忽略的权衡:React 的运行时优化适用于任何组件写法(JSX、createElement、自定义渲染函数),Vue 的编译优化只适用于 template 语法。如果你在 Vue 中用 JSX 写组件,patch flag 和静态提升这两层优化会大部分失效(虽然响应式依赖追踪仍然有效)。
编译器:一等公民 vs 不存在
Vue 的编译器不是工具链的可选项,而是架构的组成部分。编译器的输出直接影响运行时的行为——没有编译器就没有 block tree,没有 patch flag,Vue 的运行时只能退化为全量虚拟 DOM diff(类似 React 但缺少 Fiber)。
React 在 JSX 编译层面几乎不做优化。babel-plugin-transform-react-jsx 把 JSX 转为 jsx() 函数调用。仅此而已。React 团队反复强调:JSX 就是语法糖,不代表框架行为。你手写 createElement 和写 JSX 性能完全一样。
这造成了一个有趣的对比:
- React JSX:任何 JSX 组件在渲染时走完全相同的流程(reconcile → bailout or re-render → commit),开发者的心智模型是统一的。
- Vue template:一个
v-once的<span>和的<span>在运行时走的是完全不同的路径——前者是静态提升的常量,后者是 patch flag 标记的动态节点。开发者需要理解哪些部分会被编译器特殊处理。
从工程设计角度,这是两种价值观的碰撞:透明性 vs 默认性能。React 选择让你看到全部,代价是有时你需要手动做 memo 优化;Vue 选择在后台帮你做大部分,代价是你不看编译输出就不会知道实际跑的是什么样的渲染函数。
内存:双缓冲的代价
React 的 Fiber 双缓冲在内存中同时维护两棵完整的 Fiber 树。对于 1000 个组件的应用,意味着大约 2000 个 Fiber 节点的常驻内存——每个节点约 200-400 bytes,总共 < 1MB,实际上不是问题。真正的压力在:
- 每次渲染时为新 fiber 分配内存 + GC(旧 fiber 的回收)
- updateQueue 的分配和回收
- hooks 链表的复制(updateWorkInProgressHook 从 current 复制到 workInProgress)
Vue 的组件实例只有一个,响应式数据通过 Proxy 原地代理,更新只需要重新执行 render effect。内存压力天然更小——但差异在实际项目中的量级通常可以忽略不计,除非应用中同时挂载了数千个活跃组件。
源码追踪:两条调用链的逐级对比
React:从 setState 到 DOM 的全路径
入口是 setState(class 组件)或 dispatchSetState(函数组件/hooks)。以 hooks 路径为例,调用链分四层。
第一层 —— dispatchSetState(ReactFiberHooks.js):
javascript
function dispatchSetState(fiber, queue, action) {
const lane = requestUpdateLane(fiber) // 分配优先级
const update = { lane, action, next: null } // 创建 update 对象
// eager state 优化:如果当前没有 pending 更新且 fiber 空闲
// 尝试提前计算新 state,Object.is 相等则直接 return(bailout)
if (is(getLastStateInQueue(queue), fiber) && fiber.lanes === NoLanes) {
const eagerState = typeof action === 'function'
? action(queue.lastRenderedState)
: action
if (Object.is(eagerState, queue.lastRenderedState)) return
}
// enqueue:update 挂入 queue.pending 环状链表
const pending = queue.pending
if (pending === null) {
update.next = update
} else {
update.next = pending.next
pending.next = update
}
queue.pending = update
// 触发调度
const root = markUpdateLaneFromFiberToRoot(fiber, lane) // 向上冒泡 lanes
ensureRootIsScheduled(root) // 进入 Scheduler
}markUpdateLaneFromFiberToRoot 从当前 fiber 一路往上到 root,每一步都将 lane 合并到父 fiber 的 childLanes 中。这是 fiber 树能"自底向上冒泡优先级"的关键路径——父 fiber 不需要检查所有子 fiber 就能知道子树中有更新。
第二层 —— ensureRootIsScheduled → Scheduler(ReactFiberWorkLoop.js → Scheduler):
javascript
function ensureRootIsScheduled(root) {
const nextLanes = getNextLanes(root, NoLanes) // 计算本次要处理的 lanes 集合
const newCallbackPriority = lanesToSchedulerPriority(nextLanes)
// 与已存在的调度任务比较优先级
if (existingCallbackPriority === newCallbackPriority) return // 同优先级,复用
// 取消旧任务,调度新任务
if (existingCallback !== null) cancelCallback(existingCallback)
let newCallback
if (includesSyncLane(nextLanes)) {
newCallback = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root))
} else {
newCallback = scheduleCallback(
newCallbackPriority,
performConcurrentWorkOnRoot.bind(null, root)
)
}
root.callbackNode = newCallback
}Scheduler 的 scheduleCallback(priority, callback) 将任务按优先级插入 timer queue 或 task queue。MessageChannel 的 port.postMessage 用于触发 macrotask,确保任务在浏览器事件循环的合适时机执行。
第三层 —— reconciler 工作循环(ReactFiberWorkLoop.js):
javascript
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress)
}
}
function performUnitOfWork(unitOfWork) {
const current = unitOfWork.alternate
let next = beginWork(current, unitOfWork, renderLanes)
// beginWork:
// → 根据 fiber.tag 路由到 updateFunctionComponent / updateClassComponent / updateHostComponent
// → reconcileChildren(fiber, nextChildren, lanes)
// → 对比 current.child(旧)和 nextChildren(新)
// → 创建/复用 fiber,标记 Placement / Deletion / Update
// → 返回第一个子 fiber
// → 如果新旧子节点完全相同(bailout),返回 null,跳到 completeWork
if (next === null) {
completeUnitOfWork(unitOfWork)
// completeWork:
// → 根据 fiber.tag 处理最终化(创建 DOM 节点、收集 effect list)
// → 如果有 sibling,返回 sibling 继续 beginWork
// → 如果没有 sibling,返回 parent 继续 completeWork
// → 直到回到 root
} else {
workInProgress = next
}
}这里体现了 fiber 链表的遍历方式:深度优先的 beginWork 和 completeWork 交替执行。beginWork 往下钻(return → child),遇到叶子节点开始 completeWork 往上回溯(child → return → sibling)。
第四层 —— commitRoot(ReactFiberCommitWork.js):
javascript
function commitRoot(root) {
// 1. before mutation:getSnapshotBeforeUpdate
commitBeforeMutationEffects(root, finishedWork)
// 2. mutation:实际 DOM 操作
// 遍历 effect list,执行 Placement / Update / Deletion
commitMutationEffects(root, finishedWork)
// 3. layout:componentDidMount/Update, useLayoutEffect
commitLayoutEffects(root, finishedWork)
// 4. 交换 current 和 workInProgress(双缓冲 swap)
root.current = finishedWork
// 5. 调度 useEffect(passive effects)的异步执行
// useEffect 不在这里执行——它在 paint 之后
flushPassiveEffects()
}全部同步、不可中断。commit 阶段的执行时间决定了用户感知的交互延迟上限。
Vue:从响应式数据变更到 DOM 更新
Vue 的入口在 trigger——Proxy setter 被拦截后触发。
第一层 —— trigger → scheduler(reactivity/src/effect.ts):
javascript
function trigger(target, key, type) {
const depsMap = targetMap.get(target) // target → key → Dep 的 Map 结构
if (!depsMap) return
const deps = []
// 收集所有与此 key 相关的 Dep
deps.push(depsMap.get(key))
if (key === 'length' && isArray(target)) { ... }
// 如果 key 不存在(新增属性),触发 ITERATE_KEY 的 effect
const effects = []
for (const dep of deps) {
if (dep) effects.push(...dep) // 展开 Dep(effect 集合)
}
// 去重(因为同一个 effect 可能通过不同路径被同一个 trigger 收集)
for (const effect of new Set(effects)) {
if (effect !== activeEffect || effect.allowRecurse) {
if (effect.scheduler) {
effect.scheduler() // 组件的 scheduler = queueJob
} else {
effect.run()
}
}
}
}targetMap 是一个三层 WeakMap:target → key → Dep(Set<ReactiveEffect>)。任意响应式对象、任意属性、任意 effect 的依赖关系都记录在这里。
组件的 render effect 设置了 scheduler = queueJob,所以 trigger 最终调用 queueJob(componentUpdateFn)。
第二层 —— queueJob / scheduler(runtime-core/src/scheduler.ts):
javascript
const queue = []
let isFlushing = false
let isFlushPending = false
function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job) // 收集 job
queueFlush() // 请求冲刷
}
}
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
currentFlushPromise = Promise.resolve().then(flushJobs) // microtask
}
}
function flushJobs() {
isFlushPending = false
isFlushing = true
queue.sort((a, b) => a.id - b.id) // 父组件 id < 子组件 id
for (let i = 0; i < queue.length; i++) {
const job = queue[i]
job() // 同步执行每个组件的更新
}
queue.length = 0
isFlushing = false
}关键在于 Promise.resolve().then(flushJobs)——所有同步 trigger 收集的 job 在同一个 microtask 中被冲刷。如果在冲刷过程中又有新的 job 入队,新的 microtask 被调度。
第三层 —— componentUpdateFn / patch(runtime-core/src/renderer.ts):
javascript
const componentUpdateFn = () => {
if (!instance.isMounted) {
// mount
const subTree = (instance.subTree = renderComponentRoot(instance))
patch(null, subTree, container)
instance.isMounted = true
} else {
// update
const nextTree = renderComponentRoot(instance) // 重新执行 render
const prevTree = instance.subTree
instance.subTree = nextTree
patch(prevTree, nextTree, container) // diff + DOM 更新
}
}patch 函数是 Vue 虚拟 DOM 的入口:
javascript
function patch(oldVNode, newVNode, container) {
if (oldVNode === newVNode) return // 相同引用,跳过
// 1. 检查 patchFlag → 决定走快速路径还是全量 diff
if (newVNode.patchFlag & PatchFlags.TEXT) {
// 快速路径:只更新文本
hostSetElementText(el, newVNode.children)
return
}
// 2. 检查 dynamicChildren → 靶向 patch
if (newVNode.dynamicChildren) {
patchBlockChildren(oldVNode.dynamicChildren, newVNode.dynamicChildren)
// ... 然后只处理 static 部分的一次性逻辑
return
}
// 3. 退化路径:全量 diff
// type 相同 → patchElement(更新 props、children)
// type 不同 → unmount + mount
// key 不存在 → 逐个对比
}dynamicChildren 路径是 block tree 的核心实现——当 vnode 包含 dynamicChildren 时,patch 跳到靶向更新路径,只遍历已知的动态节点。全量 diff(第 3 步)是最后的退化,和 React 的 reconcileChildren 在算法上的行为类似。
第四层 —— effect 的重新收集:
Vue 的每个 render effect 在每次执行前会清除自己的依赖,执行过程中重新收集——这保证了依赖图始终是最新的。如果某个数据在本次渲染中没有被访问,它会被自动从 Dep 中移除。
javascript
class ReactiveEffect {
run() {
cleanupEffect(this) // 清除旧的依赖关系
activeEffect = this
shouldTrack = true
const result = this.fn() // 执行 fn → 触发 getter → track 重新记录依赖
shouldTrack = false
activeEffect = undefined
return result
}
}这与 React 的依赖管理形成对比:React 的 useEffect 依赖由开发者在数组中声明,是声明式的;Vue 的依赖追踪是自动的、基于实际执行的。声明式的好处是意图明确、易于静态分析;自动式的好处是不会遗漏、不需要手动声明。
两个调度器的复杂度对比
两者的实现行数是一个直观的指标(React 18.x / Vue 3.x):
| 模块 | React | Vue |
|---|---|---|
| 调度器核心 | ~1200 行(Scheduler) | ~80 行(scheduler.ts) |
| 优先级模型 | 31 位 Lanes + 多优先级映射 | 无——所有更新优先级相同 |
| 中断机制 | shouldYield() + MessageChannel | 无——单次 microtask 内同步完成 |
| 任务类型 | Sync / UserBlocking / Normal / Idle | 仅一种——所有 job 平级 |
| 并发支持 | 多个优先级的任务可交错执行 | 不支持——flushJobs 为原子操作 |
这不是说 React 过度设计了——React 需要处理的问题空间完全不一样。React 的调度器要解决"未知大小的更新在有限帧预算内完成",Vue 的调度器只需要"收集一批已知大小的更新然后一次执行"。Vue 的编译器承担了 React 调度器的大部分职责——在构建时确定更新范围,运行时就不需要决策。
从哲学到工程选择
回到 2017-2018 年,React 和 Vue 走到各自路径的决策点,影响至今。
React 的决策链:
"我们想让框架'知道'更少,让开发者能'控制'更多。" → JSX 保持纯 JS 身份,不在编译层面做手脚 → 失去了编译优化的机会,需要在运行时处理所有性能问题 → 需要 Scheduler、Lanes、Fiber 这样的运行时基础设施 → 额外的收益:运行时逻辑完全与平台解耦 → React Native / React Three Fiber 成为可能
Vue 的决策链:
"我们想让开发者写更少的代码,框架替他们做更多决策。" → 模板语法引入 DSL,编译器可以分析完整结构 → 编译器产出的运行时信息(静态提升、patch flag、block tree)大幅减少运行时工作 → 不需要复杂的调度器和可中断渲染 → 代价:跨平台能力受限于 DOM 渲染假设;JSX 场景下优化退化
每个框架都基于对目标用户群的假设。React 假设用户是"需要灵活性的专业团队",Vue 假设用户是"重视效率的各种规模团队"。这两个假设都成立,所以两个框架都活得很好。
React Forget(Compiler)和 Vue Vapor Mode 的演进
有趣的是,两个框架都在往对方的方向靠拢。
React 的 React Compiler(前身 React Forget)试图在编译时做 memo 优化——自动分析哪些组件需要 useMemo 和 React.memo,然后在编译时自动插入。这本质上是 React 承认运行时"默认不优化"的模型需要编译时的补充。如果这个编译器成熟,React 开发者不再需要手动写 useMemo 和 useCallback。
Vue 的 Vapor Mode 是一个不依赖虚拟 DOM 的运行模式,生成更接近 imperative DOM 操作的代码,进一步减小运行时体积。如果它成熟,Vue 可以在不牺牲编译优化能力的前提下进一步降低运行时成本。
两者的收敛方向是:编译器负责静态优化,运行时处理动态逻辑。这可能是前端框架下一阶段的主旋律。
选型真正该看什么
抛开"谁更好"的无意义争论,实际选型参考:
React 更适合:跨平台需求(Web + Native)、大量动态计算和条件渲染、复杂交互逻辑(拖拽、协同编辑)、需要框架尽量"透明"的团队(便于招聘和更替)。
Vue 更适合:快速交付需求、模板为主的 UI 密集型页面、中小团队需要低维护成本的方案、对默认性能有要求的场景。
还有一个经常被忽略的工程差异:React 的错误边界体系更成熟。组件树可以挂多个 ErrorBoundary,一个子树故障不会影响其他区域;Vue 的 onErrorCaptured 也能处理错误,但粒度和生态普及度都更弱一些。
另一面:Vue 的响应式系统天然适合表单场景。v-model + 响应式数据的组合在表单开发中的 DX 和性能都优于 React 的手动受控组件模式。如果你的应用主要是表单驱动,Vue 在这个领域有结构性的优势。
几点工程层面的观察
React 的调度器更像一项预付成本。它增加了代码体积和运行时复杂度,但在时间切片确实有价值的场景里可以直接使用。Vue 更依赖编译器默认优化;当编译器无法充分发挥作用时,例如大量 JSX 或高度动态模板,运行时侧的缓冲空间会更小。
Vue 的默认性能更容易被低水平使用达到。React 的
memo、useMemo、useCallback需要主动理解和正确使用,位置不对不但无益反而有害。Vue 的编译器在后台静默产出优化后的代码,开发者不需要理解patchFlag或dynamicChildren就能享受其收益。这是"框架替你思考"的设计哲学的直接体现。React 的 Hooks 模型更函数式、更组合性强,但也更容易写出闭包陷阱、依赖遗漏、无限循环等问题。Vue 的 Options API 和 Composition API 在函数式组合能力上略逊于 Hooks 的灵活性,但心智负担更小(不需要理解 stale closure 和
exhaustive-deps规则)。可中断渲染的真实收益:实际测量中,React 18 的 Concurrent Mode 在大多数页面上的改善并不显著——因为大部分更新的执行时间本身就远小于 16.6ms。可中断渲染的真正价值出现在更新量大且需要保持响应性的场景:搜索引擎一样的实时筛选、大数据表格的拖拽列宽、协同白板等。如果应用没有这类场景,Concurrent Mode 更多是"静默的守护"而非"明显的加速"。
追问:这些架构层面的问题值得推演
Q1:如果给 Vue 加上时间切片,最大的阻碍是什么?
不是技术上的不可能,而是架构前提会崩塌。Vue 的 push 模型依赖精确的依赖追踪来保证更新范围小,但如果引入时间切片,必须允许 render 阶段中断 → 必须像 React 一样有双缓冲 → 必须有完整的 reconcile + commit 分阶段 → 最终变成 Fiber 的复制品。等等——暂停和恢复依赖图的状态在 Proxy 体系下是个未解决的问题:如果 render 被中断时响应式数据变化了,Dep 应该更新还是不更新?中断期间新的 setter 调用怎么处理?
Q2:React 的 Lanes 模型用 31 位掩码,理论上限是 31 个不同优先级。如果需要更多怎么办?
实际应用中 31 个不同优先级远超过需求——大部分应用只有 Sync(同步事件)、InputContinuous(连续输入)、Default(常规)、Transition(transition 包裹)、Idle(空闲)五种优先级。31 位的设计不是天花板,而是提供了足够细的粒度来做优先级交错。如果真的超过,React 会用 entangledLanes 机制将互相关联的 lanes 合并处理——在 getNextLanes 中,某些 lane 如果与其他 lane 有依赖,它们会被一起处理。
Q3:Vue 的 effect 每次执行前清除依赖、执行中重新收集。如果一个组件渲染耗时很长,并且在渲染过程中有新的响应式数据被创建(getter 首次被触发),依赖图会怎样?
新的 getter 被调用时会调用 track,将当前正在执行的 effect(组件的 render effect)添加到新数据的 Dep 中——即使在渲染中途。Vue 的依赖追踪是实时的、递增的,不是"渲染前收集完"。这既是优势(动态依赖自动适配)也是风险——如果组件在渲染期间动态访问了大量之前没访问过的数据,Dep 会迅速膨胀,下次任何一个变化都触发重渲染。
Q4:React 的 Fiber 双缓冲和图形学中的双缓冲有什么相同和不同?
相同:都是在"后台缓冲区"构建新帧,构建完成后 swap 到"前台"。用户永远看到前台缓冲的内容。
不同:图形学双缓冲的 swap 是整帧替换(全量 swap),Fiber 的双缓冲在 commit 阶段只 swap 实际的 DOM 变更(增量 swap)。并且 Fiber 的 current ↔ workInProgress 交换不是深拷贝——每个 fiber 的 alternate 指针相互指向对方,swap 操作只是交换 root 的 current 引用,开销是 O(1) 而非 O(n)。
Q5:如果让你设计一个同时具有 Vue 的依赖追踪精确度和 React 的可中断调度能力的框架,核心困难在哪?
核心困难在"精确依赖 + 可中断"的矛盾。Vue 的精确依赖依赖"render 快"这个前提——如果 render 可以中断,依赖图在中断期间可能被其他更新改变。React 的 pull 模型天然适合中断(每次从根开始重算,不需要维护外部状态),但代价是失去了精确的依赖追踪。要同时做到两者,需要一个"可序列化的依赖图"——在 render 开始前快照依赖关系,中断时释放,恢复时根据快照重建。这引入了自己的复杂性(快照的一致性、并发快照的冲突解决),实际上比单独实现 Fiber 或响应式更复杂。
Q6:React Compiler(Forget)试图自动插入 memo,这和 Vue 的编译器优化本质区别在哪?
React Compiler 做的是运行时行为的等价转换——分析代码的依赖关系,在 "不改变行为" 的前提下插入 useMemo 和 useCallback。Vue 的编译器做的是结构信息的提取——分析模板结构,提取静态内容和动态绑定的信息,生成不同的更新路径。
React Compiler 的难度更大:它需要理解 JavaScript 语义(闭包、引用、副作用),判断"缓存后行为是否等价"。Vue 的编译器只需要分析模板 DSL(结构化、可预测),不需要做语义推理。React Compiler 一旦出错(错误地缓存了应该重新计算的值),结果是逻辑 bug;Vue 编译器如果误判静态内容,最多是"该更新的没更新"(性能退化而非逻辑错误)。
Q7:Vue 的 Proxy 响应式在什么场景下会产生性能问题?
大规模数组操作。对长度为 10000 的数组执行 push、splice 时,每个元素都被 Proxy 代理,访问每个元素都走 getter 做 track。虽然 Vue 内部做了一些优化(对数组的 length 访问做了特殊处理),但在大量数据 + 频繁操作的场景下,Proxy 的拦截开销仍然显著——每个 push 不只要修改数组,还要触发 length 和索引的 trigger。这也是为什么 Vue 官方建议在非响应式场景使用 shallowRef 或 markRaw 标记大数组。
Q8:React 的 Concurrent Mode 在实际生产中最容易出的 bug 是什么?
不是技术上崩溃,而是行为上的非预期。在 Concurrent Mode 下,React 可能丢弃一次渲染的中间结果并重新渲染。如果开发者在 render 函数中写了副作用(比如直接操作 ref、修改全局变量),这个副作用可能在"被丢弃的渲染"中执行了一次、在"最终成功的渲染"中又执行了一次,或者只在被丢弃的渲染中执行了而最终渲染没执行。React 18 的 StrictMode 通过故意双重调用来暴露这类问题,但生产环境的并发行为更不可预测——多了一次双重调用。排查这类问题的难度在于:问题不在代码逻辑上,而在 React 对代码的调用时机上。
