写在前面前端框架之争吵了快十年。但坦白说大多数争论卡在React 好用还是 Vue 好用的层面很少有人真正追问这两个框架为什么从根上就是两套东西它们的差异不是 API 设计喜好不同而是对UI 的本质应该怎么抽象这个问题给出了完全不同的回答。这篇文章从架构层面拆这两套方案React 为什么选择在运行时重新实现一个操作系统调度器Vue 为什么选择让编译器替你做大部分苦力。如果你只想快速上手哪个框架这篇文章可能帮不上忙。但如果你想理解自己每天用的工具到底在做什么它可能值得读。一、分歧的起点——UI 的本质是什么一切分歧从一个问题开始用户界面到底是什么React 的回答UI 是状态的函数React 团队的公式很简洁UI f(state)意思很直白给定相同的状态你应该得到完全相同的 UI。这是一个纯粹的数学映射。它的好处在于通用——框架不需要知道你的数据怎么变、变哪里只需要在状态变化时重新执行整个函数然后找出前后两次输出的差异。但这个等式藏了一个代价你不知道状态从哪里变、变多少所以每次更新都得从头算一遍。React 的解法是虚拟 DOM diff——在全量计算和最小 DOM 操作之间找平衡。Fiber 架构是这个思路的极端体现既然我不知道哪些数据变了我就把重新计算做到足够快快到用户感知不到。Vue 的回答UI 是数据到 DOM 的绑定尤雨溪选了另一条路。在他看来{{ message }}这个模板插值已经定义了数据message→ 这个文本节点的映射关系。编译器在构建时就能静态分析出这些绑定运行时要做的事情不是算出差异而是精准推送。message → 追踪依赖 → 精确更新 DOM 节点这里的优势是你不需要全量 diff因为框架从一开始就知道谁依赖谁。代价是你得接受一套声明式的模板语法或者遵守 Composition API 的使用规则让编译器有迹可循。这条分歧的后果这两个回答直接决定了后续所有的架构决策React 押注运行时的通用调度——既然不知道哪里变了就把找变化这个过程做得足够快、可中断、可分优先级Vue 押注编译时的静态分析——既然我能提前知道绑定关系那运行时只需要做最精准的更新这不是技术能力的差异而是对同一个问题的不同取舍。理解了这个前提后面的技术细节就都能串起来了。二、React Fiber——运行时的调度野心2.1 Stack Reconciler 是怎么把自己逼到墙角的React 16 之前Reconciliation 的过程是递归调用的function renderComponent(component) { const vnode component.render() vnode.children.forEach(child renderComponent(child)) patchDOM(vnode) }你调用setState它从根节点开始深度优先遍历整棵树递归调用一次性返回。调用栈像这样App.render → Header.render → Nav.render → NavItem.render → ... → ... 层层返回 → patch DOM问题在于JavaScript 的调用栈是后进先出的一旦进入递归没有任何机制可以中断它。如果你的组件树有 2000 个节点一次setState可能要阻塞主线程 50~100ms。在 60fps 下每一帧只有约 16ms 的处理时间。一旦 reconciliation 超了这个预算卡顿就来了。更要命的是所有更新的优先级是平等的。用户在输入框中打字需要即时反馈和后台数据更新可以延迟触发的是同一套流程前者会被后者阻塞。这个问题在 Stack Reconciler 下无解——你没有机制说这个更新先执行那个等一下。2.2 Fiber——自己管调用栈React 团队的核心洞察是问题的根源不是虚拟 DOM而是递归调用不可中断。他们的方案也很大胆如果浏览器不给我中断递归的能力那我自己实现一个调用栈。Fiber 的数据结构长这样高度简化interfaceFiberNode{return:FiberNode|null// 父节点child:FiberNode|null// 第一个子节点sibling:FiberNode|null// 下一个兄弟节点pendingProps:anymemoizedProps:anymemoizedState:anyflags:Flags nextEffect:FiberNode|nullalternate:FiberNode|null// 双缓冲}你可以把 Fiber 树理解成一个可遍历的链表。React 用while循环替代了递归functionworkLoop(deadline:IdleDeadline){letshouldYieldfalsewhile(nextUnitOfWork!null!shouldYield){nextUnitOfWorkperformUnitOfWork(nextUnitOfWork)shouldYielddeadline.timeRemaining()1}if(nextUnitOfWork!null){requestIdleCallback(workLoop)}}每次处理一个 Fiber 节点后检查当前帧剩余时间。不够了保存nextUnitOfWork指针把控制权还给浏览器。下一帧回调中从这个指针处继续。关键点是暂停和恢复不需要保存额外的上下文因为所有的状态都在 Fiber 节点的属性中pendingProps、memoizedState等。这和操作系统线程切换保存寄存器是一个逻辑只不过 React 的线程在用户空间实现。Andrew Clark 的原话是Fiber 是一个专门为 React 组件设计的虚拟栈帧。区别在于这些栈帧存在堆里开发者可以控制它们的执行顺序。2.3 双缓冲永远不让用户看到半成品Fiber 同时维护了两棵树current当前屏幕上显示的内容对应的 Fiber 树workInProgress正在计算中的草稿树更新触发时React 从current克隆出一棵workInProgress。所有的 reconciliation 都在workInProgress上操作用户看到的界面不受任何影响——即使渲染到一半被中断或者发现这次更新不需要了直接丢弃workInProgress即可。workInProgress完成后React 进入Commit 阶段把副作用DOM 增删改、生命周期、effect 调度一次性应用到真实 DOM原子性地把current指针切换到workInProgressCommit 阶段必须是同步且不可中断的。原因很简单你在修改用户看到的东西任何不一致都会导致视觉问题。这个两阶段模型是 Fiber 的基石Render Phase可中断 状态更新 → 构建 workInProgress → 收集 Effect List Commit Phase不可中断 应用 DOM 变更 → 切换 current 指针 → 调度 useEffect2.4 Lane优先级怎么管React 18 的 Lane 模型用 31 位二进制数表示优先级每一位是一条通道constInputContinuousLane0b0000000000000000000000000000010constDefaultLane0b0000000000000000000000000000100constTransitionLane10b0000000000000000000000000010000用户输入、点击事件分配高 Lane数据加载、过渡动画分配低 Lane。高 Lane 更新进来后React 可以抢占当前正在进行的低 Lane 工作。更巧妙的是Lane 纠缠机制如果高优先级更新依赖了低优先级更新的数据比如一个过渡动画中的状态被用户输入读取React 会自动合并到一次渲染中避免视觉不一致。这套系统的复杂度也很高。我见过不少 React 项目因为并发模式下的更新时序问题出现先来的数据被后来的覆盖之类诡异的 bug。Lane 模型的强大是以运行时复杂度为代价的——这是 React 的取舍。三、Vue 响应式系统——编译器才是真正的王牌3.1 从 defineProperty 到 ProxyVue 2 的响应式系统通过Object.defineProperty实现functiondefineReactive(obj,key,val){constdepnewDep()Object.defineProperty(obj,key,{get(){dep.depend()returnval},set(newVal){valnewVal dep.notify()}})}这个方案有三个硬伤无法检测新增属性——这就是为什么 Vue 2 需要Vue.set()和Vue.delete()无法检测数组索引赋值和 length 修改——这就是为什么 Vue 2 要 hack 数组的push、pop等方法必须深度递归遍历——初始化时就把每一层的每个属性都加上 getter/setter不管你用不用Vue 3 切换到 Proxy 后三个问题一次性解决functionreactive(target){returnnewProxy(target,{get(target,key,receiver){track(target,key)returnReflect.get(target,key,receiver)},set(target,key,value,receiver){constresultReflect.set(target,key,value,receiver)trigger(target,key)returnresult}})}Proxy 的代理是懒的——只有当你访问到某个嵌套对象时才递归创建代理。这不仅解决了 Vue 2 的各种边界问题还让 Vue 3 天然支持 Map、Set、WeakMap。但 Proxy 也带来了一个小代价响应式对象的引用变了。reactive(obj) ! obj因为 Proxy 返回的是代理对象。在某些边缘场景下会有问题——比如你传了一个响应式对象给不接受 Proxy 的第三方库。3.2 依赖追踪的三层结构Vue 3 内部维护了一个三层映射结构targetMap (WeakMap) └─ key → depsMap (Map) └─ key → dep (SetReactiveEffect)targetMapWeakMap 关联响应式对象和它的依赖映射。对象被 GC 时依赖信息自动释放不会内存泄漏depsMapMap以属性名为 key映射到依赖集合depSet收集所有依赖某个属性的ReactiveEffectReactiveEffect是副作用的抽象。组件的渲染函数、computed的计算函数、watch的回调本质上都是ReactiveEffect的实例classReactiveEffect{deps:Dep[][]active:booleantruerun(){activeEffectthisreturnthis.fn()// 触发 getter → track()}}关键设计是双向记录访问属性时track()把当前activeEffect添加到dep集合同时 effect 自己也记录它属于哪些dep。这样在清理或 effect 失效时可以精确地从依赖集合中把自己移除。这种设计的核心优势在于更新复杂度是 O(m)其中 m 是实际变化影响的节点数与组件树的总规模 n 无关。一个 1000 个组件的页面中如果只有一个文本节点需要更新Vue 只需要做一次textContent赋值。而在没有 Compiler 优化的 React 中可能需要走整棵组件树的 reconciliation。3.3 编译时优化的三板斧Vue 3 的编译器做了一系列运行时难以复现的优化。这里说三个最关键的。第一板斧静态提升template div span静态文字/span span{{ dynamic }}/span /div /template编译后// 静态节点提到外部只创建一次const_hoisted_1createVNode(span,null,静态文字)functionrender(_ctx){returncreateVNode(div,null,[_hoisted_1,createVNode(span,null,_ctx.dynamic)])}静态节点只创建一次后续更新完全跳过。这是最直观的优化——但能这么做的前提是编译器能静态判断哪些节点是纯静态的。第二板斧Patch Flagstemplate div :classactive :idfixedId {{ text }} /div /template编译后每个动态节点会被打上一个优化标记createVNode(div,{class:_ctx.active,id:fixedId},_ctx.text,PatchFlags.CLASS|PatchFlags.TEXT)运行时看到PatchFlags.CLASS | PatchFlags.TEXT就知道只需要比较class和textContent不需要检查id因为它是静态的也不需要检查style、onClick等属性。在大型列表更新时这种精确跳过可以省掉大量的属性遍历开销。第三板斧树扁平化传统的虚拟 DOM diff 是递归的——父节点 diff 完递归 diff 子节点。Vue 3 的编译器会把所有动态节点收集到一个扁平数组中constdynamicNodes[node1,node2,node3,...]functionpatchChildren(prev,next){for(leti0;idynamicNodes.length;i){patch(dynamicNodes[i].el,dynamicNodes[i].newVNode)}}diff 时直接遍历这个扁平数组不需要递归也不需要跳过静态节点。这个优化在组件树很深的时候效果尤其明显。Vue 的虚拟 DOM 和 React 的虚拟 DOM 虽然名字一样但实际差异很大——Vue 的虚拟 DOM 是被编译器优化过的高度特化版本它比 React 的通用 diff 更偏科但在大多数场景下更快。3.4 Vapor Mode扔掉虚拟 DOMVue 3.6 的 Vapor Mode 更进一步——在编译时直接生成 DOM 操作代码// Vapor Mode 编译产物示意functionrender(_ctx){constdivdocument.createElement(div)constspandocument.createElement(span)effect((){span.textContent_ctx.text})div.appendChild(span)returndiv}没有虚拟 DOM没有 createVNode直接在 setup 中创建真实 DOM通过effect把数据变化精确绑定到 DOM 操作。和 SolidJS 的思路非常接近了。Vapor Mode 的意义不只在性能提升而在于Vue 的响应式系统本身就不依赖虚拟 DOM。虚拟 DOM 只是 Vue 用来兼容模板灵活性的一个实现细节不是架构的必要组成部分。四、架构光谱其他框架的位置把主流前端框架放一根轴上的话大概这样编译时 ← — — — — — — — — — — — — — — → 运行时 Svelte Solid Vue Vapor Vue 3 React Angular(Zones) | | | | | | 编译掉 编译优化 编译为 编译优化 通用运行时 运行时脏检查 运行时 运行时 DOM操作 运行时追踪 调度系统 Signal迁移中 信号系统Svelte最左端编译器即框架。$state声明状态编译器分析模板引用生成精确的 DOM 更新代码。运行时包只有 2~3KB gzip。代价是编译器必须处理所有边界情况调试时看到的代码和写的代码差异很大。SolidJS中左保留 JSX但编译器把它转为细粒度的 DOM 更新指令运行时用 SignalscreateSignal做依赖追踪。组件函数只执行一次后续更新通过信号直达对应 DOM 节点。在 js-framework-benchmark 上长期和 vanilla JS 竞争第一。Vue中偏左编译时优化 运行时 Proxy 追踪。Vapor Mode 会让它向左移动但正式发布的 Vue 3 仍是编译优化 虚拟 DOM 的组合路线。React中右最纯粹的运行时调度方案。Fiber 的核心是在运行时管理更新优先级和中断恢复。React Compiler原 React Forget正在向编译时方向移动。Angular最右端Zone.js 在运行时 monkey-patch 所有异步 API任何异步操作后触发全局脏检查。正在向 Signals 迁移16 引入signal()、computed()、effect()逐步从全局检查走向细粒度追踪。这个光谱说明一件事没有哪个位置是正确的每个框架的位置反映的是它对编译时和运行时这两股力量的权衡。五、趋同谁在向谁学习过去两年框架们在互相学习。React 在学编译时React Compiler 做的事情本质上就是 Vue 3 编译器一直在做的——在构建时分析组件依赖关系自动插入 memoization。以前需要手动写的useMemo、useCallback、React.memo编译器自动帮你完成// 编译前functionMyComponent({items}){constsorteditems.sort((a,b)a.name.localeCompare(b.name))returnList items{sorted}/}// 编译后React Compiler 自动插入 memoizationfunctionMyComponent($){constsorted$.memo(itemsitems.sort((a,b)a.name.localeCompare(b.name)))($[0])returnList items{sorted}/}加上 React Server Components代表了 React 向减少运行时工作方向的双重努力。但和 Vue 不同React 的 JSX 天然比 Vue 模板更难做静态分析——JSX 就是 JavaScript你可以写任意逻辑编译器很难确定哪些是静态的。Vue 在学细粒度Vapor Mode 是对 Svelte/Solid 路线的回应。Vue 的响应式系统Proxy effect 追踪本身就不依赖虚拟 DOMVapor Mode 只是把这个事实兑现了。Vue 的路线图和 SolidJS 越来越像——但有一个关键区别Vue 保留了可选的虚拟 DOM 路径Vapor Mode 只是一个编译选项不是替代方案。为什么趋同我个人觉得趋同是因为行业对运行时万能的信念在减弱。虚拟 DOM 很棒但它不是免费的。当终端设备从旗舰手机到 IoT 设备越来越多样运行时体积和执行效率依然是硬约束。编译时优化虽然硬编码、不灵活但它可以给出最差情况也有保障的性能。反过来运行时调度能力依然不可替代。Vue 3 的异步队列、nextTick、suspense本质上也是在运行时做调度。我理想中的框架应该是 React Compiler 和 Vue Vapor 的组合编译时做能做的优化运行时处理那些不能预测的部分。六、对日常开发的实际影响说这些底层差异在写代码时会带来什么。更新心智模型看两个效果相似的代码// Reactconst[count,setCount]useState(0)useEffect((){document.titleCount:${count}},[count])// Vueconstcountref(0)watchEffect((){document.titleCount:${count.value}})表面一样底层差很多React 的useEffect在提交阶段之后异步调用——它看到的是快照值依赖数组告诉 React “当 count 变化时重新执行”Vue 的watchEffect同步执行——它在执行中自动追踪count.value的读取建立依赖无需声明数组这就是显式声明和自动追踪的差异。Vue 写起来更少但一旦脱离响应式上下文比如在setTimeout回调里读ref.valuetrack 跟不上就会出现值变了但视图没更新的问题。React 的方式更啰嗦但行为更可预测——你明确知道自己声明了什么。性能调优思路React 性能瓶颈通常出在不必要的重渲染。优化方向是减少渲染范围React.memo、useMemo、useCallback或者在架构层面做组件拆分Vue 性能瓶颈通常出在过大的响应式对象。优化方向是把大的reactive拆成小的ref或用shallowReactive/shallowRef避免深层追踪没有优劣——但它让你排查性能问题时往完全不同的方向看。Debug 体验React 的 Fiber 调度有个现实问题console.log在渲染函数中可能输出多次因为 React 可能多次构建 workInProgress 树后才提交。你不太确定当前看到的是草稿阶段的日志还是正式阶段的日志。第一次遇到这个现象时我困惑了好一会儿。Vue 的更新路径更直接数据变化 → track → trigger → effect。大多数情况下console.log输出次数和 DOM 更新次数是一一对应的。七、总结React Fiber 和 Vue 响应式系统的差异归根结底是 UI 抽象模型的分歧React 选择了一条靠近操作系统底层的路——重新实现调用栈、用户空间调度器、优先级抢占——以运行时复杂度换取对不可预测更新的掌控力Vue 选择了一条靠近应用层面的路——让编译器做尽可能多的静态分析让数据自己追踪自己的依赖——以编译时约束换取运行时效率这两个选择没有高下之分。Fiber 证明了在 JavaScript 单线程中实现复杂调度的可行性Vue 的编译器证明了静态分析在现代 UI 框架中的巨大价值。Svelte 证明了没有运行时的可行性SolidJS 证明了细粒度响应式能跑多快。这些探索的价值不在谁取代谁而在它们共同扩展了前端工程的知识边界。每一次跨框架的相互启发——Fiber 启发 Vue 的异步队列重构Vue 的编译器启发 React Compiler 的方向——都在让整个前端技术栈变得更好。作为开发者搞清楚这些底层的取舍比站队吵哪个框架更好有用得多。因为最终你选的不是框架是你对UI 应该怎么组织这个问题的回答。原创技术博客 · 开源项目分享 · AI全栈创作社区 idao.fun
React Fiber vs Vue 响应式:从调用栈到依赖图,前端两大架构的底层对决
写在前面前端框架之争吵了快十年。但坦白说大多数争论卡在React 好用还是 Vue 好用的层面很少有人真正追问这两个框架为什么从根上就是两套东西它们的差异不是 API 设计喜好不同而是对UI 的本质应该怎么抽象这个问题给出了完全不同的回答。这篇文章从架构层面拆这两套方案React 为什么选择在运行时重新实现一个操作系统调度器Vue 为什么选择让编译器替你做大部分苦力。如果你只想快速上手哪个框架这篇文章可能帮不上忙。但如果你想理解自己每天用的工具到底在做什么它可能值得读。一、分歧的起点——UI 的本质是什么一切分歧从一个问题开始用户界面到底是什么React 的回答UI 是状态的函数React 团队的公式很简洁UI f(state)意思很直白给定相同的状态你应该得到完全相同的 UI。这是一个纯粹的数学映射。它的好处在于通用——框架不需要知道你的数据怎么变、变哪里只需要在状态变化时重新执行整个函数然后找出前后两次输出的差异。但这个等式藏了一个代价你不知道状态从哪里变、变多少所以每次更新都得从头算一遍。React 的解法是虚拟 DOM diff——在全量计算和最小 DOM 操作之间找平衡。Fiber 架构是这个思路的极端体现既然我不知道哪些数据变了我就把重新计算做到足够快快到用户感知不到。Vue 的回答UI 是数据到 DOM 的绑定尤雨溪选了另一条路。在他看来{{ message }}这个模板插值已经定义了数据message→ 这个文本节点的映射关系。编译器在构建时就能静态分析出这些绑定运行时要做的事情不是算出差异而是精准推送。message → 追踪依赖 → 精确更新 DOM 节点这里的优势是你不需要全量 diff因为框架从一开始就知道谁依赖谁。代价是你得接受一套声明式的模板语法或者遵守 Composition API 的使用规则让编译器有迹可循。这条分歧的后果这两个回答直接决定了后续所有的架构决策React 押注运行时的通用调度——既然不知道哪里变了就把找变化这个过程做得足够快、可中断、可分优先级Vue 押注编译时的静态分析——既然我能提前知道绑定关系那运行时只需要做最精准的更新这不是技术能力的差异而是对同一个问题的不同取舍。理解了这个前提后面的技术细节就都能串起来了。二、React Fiber——运行时的调度野心2.1 Stack Reconciler 是怎么把自己逼到墙角的React 16 之前Reconciliation 的过程是递归调用的function renderComponent(component) { const vnode component.render() vnode.children.forEach(child renderComponent(child)) patchDOM(vnode) }你调用setState它从根节点开始深度优先遍历整棵树递归调用一次性返回。调用栈像这样App.render → Header.render → Nav.render → NavItem.render → ... → ... 层层返回 → patch DOM问题在于JavaScript 的调用栈是后进先出的一旦进入递归没有任何机制可以中断它。如果你的组件树有 2000 个节点一次setState可能要阻塞主线程 50~100ms。在 60fps 下每一帧只有约 16ms 的处理时间。一旦 reconciliation 超了这个预算卡顿就来了。更要命的是所有更新的优先级是平等的。用户在输入框中打字需要即时反馈和后台数据更新可以延迟触发的是同一套流程前者会被后者阻塞。这个问题在 Stack Reconciler 下无解——你没有机制说这个更新先执行那个等一下。2.2 Fiber——自己管调用栈React 团队的核心洞察是问题的根源不是虚拟 DOM而是递归调用不可中断。他们的方案也很大胆如果浏览器不给我中断递归的能力那我自己实现一个调用栈。Fiber 的数据结构长这样高度简化interfaceFiberNode{return:FiberNode|null// 父节点child:FiberNode|null// 第一个子节点sibling:FiberNode|null// 下一个兄弟节点pendingProps:anymemoizedProps:anymemoizedState:anyflags:Flags nextEffect:FiberNode|nullalternate:FiberNode|null// 双缓冲}你可以把 Fiber 树理解成一个可遍历的链表。React 用while循环替代了递归functionworkLoop(deadline:IdleDeadline){letshouldYieldfalsewhile(nextUnitOfWork!null!shouldYield){nextUnitOfWorkperformUnitOfWork(nextUnitOfWork)shouldYielddeadline.timeRemaining()1}if(nextUnitOfWork!null){requestIdleCallback(workLoop)}}每次处理一个 Fiber 节点后检查当前帧剩余时间。不够了保存nextUnitOfWork指针把控制权还给浏览器。下一帧回调中从这个指针处继续。关键点是暂停和恢复不需要保存额外的上下文因为所有的状态都在 Fiber 节点的属性中pendingProps、memoizedState等。这和操作系统线程切换保存寄存器是一个逻辑只不过 React 的线程在用户空间实现。Andrew Clark 的原话是Fiber 是一个专门为 React 组件设计的虚拟栈帧。区别在于这些栈帧存在堆里开发者可以控制它们的执行顺序。2.3 双缓冲永远不让用户看到半成品Fiber 同时维护了两棵树current当前屏幕上显示的内容对应的 Fiber 树workInProgress正在计算中的草稿树更新触发时React 从current克隆出一棵workInProgress。所有的 reconciliation 都在workInProgress上操作用户看到的界面不受任何影响——即使渲染到一半被中断或者发现这次更新不需要了直接丢弃workInProgress即可。workInProgress完成后React 进入Commit 阶段把副作用DOM 增删改、生命周期、effect 调度一次性应用到真实 DOM原子性地把current指针切换到workInProgressCommit 阶段必须是同步且不可中断的。原因很简单你在修改用户看到的东西任何不一致都会导致视觉问题。这个两阶段模型是 Fiber 的基石Render Phase可中断 状态更新 → 构建 workInProgress → 收集 Effect List Commit Phase不可中断 应用 DOM 变更 → 切换 current 指针 → 调度 useEffect2.4 Lane优先级怎么管React 18 的 Lane 模型用 31 位二进制数表示优先级每一位是一条通道constInputContinuousLane0b0000000000000000000000000000010constDefaultLane0b0000000000000000000000000000100constTransitionLane10b0000000000000000000000000010000用户输入、点击事件分配高 Lane数据加载、过渡动画分配低 Lane。高 Lane 更新进来后React 可以抢占当前正在进行的低 Lane 工作。更巧妙的是Lane 纠缠机制如果高优先级更新依赖了低优先级更新的数据比如一个过渡动画中的状态被用户输入读取React 会自动合并到一次渲染中避免视觉不一致。这套系统的复杂度也很高。我见过不少 React 项目因为并发模式下的更新时序问题出现先来的数据被后来的覆盖之类诡异的 bug。Lane 模型的强大是以运行时复杂度为代价的——这是 React 的取舍。三、Vue 响应式系统——编译器才是真正的王牌3.1 从 defineProperty 到 ProxyVue 2 的响应式系统通过Object.defineProperty实现functiondefineReactive(obj,key,val){constdepnewDep()Object.defineProperty(obj,key,{get(){dep.depend()returnval},set(newVal){valnewVal dep.notify()}})}这个方案有三个硬伤无法检测新增属性——这就是为什么 Vue 2 需要Vue.set()和Vue.delete()无法检测数组索引赋值和 length 修改——这就是为什么 Vue 2 要 hack 数组的push、pop等方法必须深度递归遍历——初始化时就把每一层的每个属性都加上 getter/setter不管你用不用Vue 3 切换到 Proxy 后三个问题一次性解决functionreactive(target){returnnewProxy(target,{get(target,key,receiver){track(target,key)returnReflect.get(target,key,receiver)},set(target,key,value,receiver){constresultReflect.set(target,key,value,receiver)trigger(target,key)returnresult}})}Proxy 的代理是懒的——只有当你访问到某个嵌套对象时才递归创建代理。这不仅解决了 Vue 2 的各种边界问题还让 Vue 3 天然支持 Map、Set、WeakMap。但 Proxy 也带来了一个小代价响应式对象的引用变了。reactive(obj) ! obj因为 Proxy 返回的是代理对象。在某些边缘场景下会有问题——比如你传了一个响应式对象给不接受 Proxy 的第三方库。3.2 依赖追踪的三层结构Vue 3 内部维护了一个三层映射结构targetMap (WeakMap) └─ key → depsMap (Map) └─ key → dep (SetReactiveEffect)targetMapWeakMap 关联响应式对象和它的依赖映射。对象被 GC 时依赖信息自动释放不会内存泄漏depsMapMap以属性名为 key映射到依赖集合depSet收集所有依赖某个属性的ReactiveEffectReactiveEffect是副作用的抽象。组件的渲染函数、computed的计算函数、watch的回调本质上都是ReactiveEffect的实例classReactiveEffect{deps:Dep[][]active:booleantruerun(){activeEffectthisreturnthis.fn()// 触发 getter → track()}}关键设计是双向记录访问属性时track()把当前activeEffect添加到dep集合同时 effect 自己也记录它属于哪些dep。这样在清理或 effect 失效时可以精确地从依赖集合中把自己移除。这种设计的核心优势在于更新复杂度是 O(m)其中 m 是实际变化影响的节点数与组件树的总规模 n 无关。一个 1000 个组件的页面中如果只有一个文本节点需要更新Vue 只需要做一次textContent赋值。而在没有 Compiler 优化的 React 中可能需要走整棵组件树的 reconciliation。3.3 编译时优化的三板斧Vue 3 的编译器做了一系列运行时难以复现的优化。这里说三个最关键的。第一板斧静态提升template div span静态文字/span span{{ dynamic }}/span /div /template编译后// 静态节点提到外部只创建一次const_hoisted_1createVNode(span,null,静态文字)functionrender(_ctx){returncreateVNode(div,null,[_hoisted_1,createVNode(span,null,_ctx.dynamic)])}静态节点只创建一次后续更新完全跳过。这是最直观的优化——但能这么做的前提是编译器能静态判断哪些节点是纯静态的。第二板斧Patch Flagstemplate div :classactive :idfixedId {{ text }} /div /template编译后每个动态节点会被打上一个优化标记createVNode(div,{class:_ctx.active,id:fixedId},_ctx.text,PatchFlags.CLASS|PatchFlags.TEXT)运行时看到PatchFlags.CLASS | PatchFlags.TEXT就知道只需要比较class和textContent不需要检查id因为它是静态的也不需要检查style、onClick等属性。在大型列表更新时这种精确跳过可以省掉大量的属性遍历开销。第三板斧树扁平化传统的虚拟 DOM diff 是递归的——父节点 diff 完递归 diff 子节点。Vue 3 的编译器会把所有动态节点收集到一个扁平数组中constdynamicNodes[node1,node2,node3,...]functionpatchChildren(prev,next){for(leti0;idynamicNodes.length;i){patch(dynamicNodes[i].el,dynamicNodes[i].newVNode)}}diff 时直接遍历这个扁平数组不需要递归也不需要跳过静态节点。这个优化在组件树很深的时候效果尤其明显。Vue 的虚拟 DOM 和 React 的虚拟 DOM 虽然名字一样但实际差异很大——Vue 的虚拟 DOM 是被编译器优化过的高度特化版本它比 React 的通用 diff 更偏科但在大多数场景下更快。3.4 Vapor Mode扔掉虚拟 DOMVue 3.6 的 Vapor Mode 更进一步——在编译时直接生成 DOM 操作代码// Vapor Mode 编译产物示意functionrender(_ctx){constdivdocument.createElement(div)constspandocument.createElement(span)effect((){span.textContent_ctx.text})div.appendChild(span)returndiv}没有虚拟 DOM没有 createVNode直接在 setup 中创建真实 DOM通过effect把数据变化精确绑定到 DOM 操作。和 SolidJS 的思路非常接近了。Vapor Mode 的意义不只在性能提升而在于Vue 的响应式系统本身就不依赖虚拟 DOM。虚拟 DOM 只是 Vue 用来兼容模板灵活性的一个实现细节不是架构的必要组成部分。四、架构光谱其他框架的位置把主流前端框架放一根轴上的话大概这样编译时 ← — — — — — — — — — — — — — — → 运行时 Svelte Solid Vue Vapor Vue 3 React Angular(Zones) | | | | | | 编译掉 编译优化 编译为 编译优化 通用运行时 运行时脏检查 运行时 运行时 DOM操作 运行时追踪 调度系统 Signal迁移中 信号系统Svelte最左端编译器即框架。$state声明状态编译器分析模板引用生成精确的 DOM 更新代码。运行时包只有 2~3KB gzip。代价是编译器必须处理所有边界情况调试时看到的代码和写的代码差异很大。SolidJS中左保留 JSX但编译器把它转为细粒度的 DOM 更新指令运行时用 SignalscreateSignal做依赖追踪。组件函数只执行一次后续更新通过信号直达对应 DOM 节点。在 js-framework-benchmark 上长期和 vanilla JS 竞争第一。Vue中偏左编译时优化 运行时 Proxy 追踪。Vapor Mode 会让它向左移动但正式发布的 Vue 3 仍是编译优化 虚拟 DOM 的组合路线。React中右最纯粹的运行时调度方案。Fiber 的核心是在运行时管理更新优先级和中断恢复。React Compiler原 React Forget正在向编译时方向移动。Angular最右端Zone.js 在运行时 monkey-patch 所有异步 API任何异步操作后触发全局脏检查。正在向 Signals 迁移16 引入signal()、computed()、effect()逐步从全局检查走向细粒度追踪。这个光谱说明一件事没有哪个位置是正确的每个框架的位置反映的是它对编译时和运行时这两股力量的权衡。五、趋同谁在向谁学习过去两年框架们在互相学习。React 在学编译时React Compiler 做的事情本质上就是 Vue 3 编译器一直在做的——在构建时分析组件依赖关系自动插入 memoization。以前需要手动写的useMemo、useCallback、React.memo编译器自动帮你完成// 编译前functionMyComponent({items}){constsorteditems.sort((a,b)a.name.localeCompare(b.name))returnList items{sorted}/}// 编译后React Compiler 自动插入 memoizationfunctionMyComponent($){constsorted$.memo(itemsitems.sort((a,b)a.name.localeCompare(b.name)))($[0])returnList items{sorted}/}加上 React Server Components代表了 React 向减少运行时工作方向的双重努力。但和 Vue 不同React 的 JSX 天然比 Vue 模板更难做静态分析——JSX 就是 JavaScript你可以写任意逻辑编译器很难确定哪些是静态的。Vue 在学细粒度Vapor Mode 是对 Svelte/Solid 路线的回应。Vue 的响应式系统Proxy effect 追踪本身就不依赖虚拟 DOMVapor Mode 只是把这个事实兑现了。Vue 的路线图和 SolidJS 越来越像——但有一个关键区别Vue 保留了可选的虚拟 DOM 路径Vapor Mode 只是一个编译选项不是替代方案。为什么趋同我个人觉得趋同是因为行业对运行时万能的信念在减弱。虚拟 DOM 很棒但它不是免费的。当终端设备从旗舰手机到 IoT 设备越来越多样运行时体积和执行效率依然是硬约束。编译时优化虽然硬编码、不灵活但它可以给出最差情况也有保障的性能。反过来运行时调度能力依然不可替代。Vue 3 的异步队列、nextTick、suspense本质上也是在运行时做调度。我理想中的框架应该是 React Compiler 和 Vue Vapor 的组合编译时做能做的优化运行时处理那些不能预测的部分。六、对日常开发的实际影响说这些底层差异在写代码时会带来什么。更新心智模型看两个效果相似的代码// Reactconst[count,setCount]useState(0)useEffect((){document.titleCount:${count}},[count])// Vueconstcountref(0)watchEffect((){document.titleCount:${count.value}})表面一样底层差很多React 的useEffect在提交阶段之后异步调用——它看到的是快照值依赖数组告诉 React “当 count 变化时重新执行”Vue 的watchEffect同步执行——它在执行中自动追踪count.value的读取建立依赖无需声明数组这就是显式声明和自动追踪的差异。Vue 写起来更少但一旦脱离响应式上下文比如在setTimeout回调里读ref.valuetrack 跟不上就会出现值变了但视图没更新的问题。React 的方式更啰嗦但行为更可预测——你明确知道自己声明了什么。性能调优思路React 性能瓶颈通常出在不必要的重渲染。优化方向是减少渲染范围React.memo、useMemo、useCallback或者在架构层面做组件拆分Vue 性能瓶颈通常出在过大的响应式对象。优化方向是把大的reactive拆成小的ref或用shallowReactive/shallowRef避免深层追踪没有优劣——但它让你排查性能问题时往完全不同的方向看。Debug 体验React 的 Fiber 调度有个现实问题console.log在渲染函数中可能输出多次因为 React 可能多次构建 workInProgress 树后才提交。你不太确定当前看到的是草稿阶段的日志还是正式阶段的日志。第一次遇到这个现象时我困惑了好一会儿。Vue 的更新路径更直接数据变化 → track → trigger → effect。大多数情况下console.log输出次数和 DOM 更新次数是一一对应的。七、总结React Fiber 和 Vue 响应式系统的差异归根结底是 UI 抽象模型的分歧React 选择了一条靠近操作系统底层的路——重新实现调用栈、用户空间调度器、优先级抢占——以运行时复杂度换取对不可预测更新的掌控力Vue 选择了一条靠近应用层面的路——让编译器做尽可能多的静态分析让数据自己追踪自己的依赖——以编译时约束换取运行时效率这两个选择没有高下之分。Fiber 证明了在 JavaScript 单线程中实现复杂调度的可行性Vue 的编译器证明了静态分析在现代 UI 框架中的巨大价值。Svelte 证明了没有运行时的可行性SolidJS 证明了细粒度响应式能跑多快。这些探索的价值不在谁取代谁而在它们共同扩展了前端工程的知识边界。每一次跨框架的相互启发——Fiber 启发 Vue 的异步队列重构Vue 的编译器启发 React Compiler 的方向——都在让整个前端技术栈变得更好。作为开发者搞清楚这些底层的取舍比站队吵哪个框架更好有用得多。因为最终你选的不是框架是你对UI 应该怎么组织这个问题的回答。原创技术博客 · 开源项目分享 · AI全栈创作社区 idao.fun