React vs Vue 3 架构全景:从浏览器渲染管线到框架设计哲学
两个框架面对同一个浏览器,给出了完全不同的答案——搞清楚'为什么'比'是什么'重要
React 和 Vue 3 都要把组件树变成浏览器能渲染的像素。管线是一样的,但路径完全不同。
这篇文章从浏览器底层出发,逐层拆解两个框架的核心架构,讲清楚每个设计决策背后的“为什么”。
架构图基于我的学习笔记绘制,不是官方文档截图。图里的每一层都对应下文的一个章节。
浏览器的 Event Loop:比你想的复杂得多
大多数人对 Event Loop 的理解停留在“宏任务 → 微任务 → 渲染”。但真实的浏览器 Event Loop 是多队列、多优先级的。
任务不是一类,而是分源的
浏览器里有不同类型的 task source,优先级不同:
- User interaction(click、keydown、pointermove)—— 最高优先级,用户输入必须立即响应
- Timer(setTimeout、setInterval)—— 由浏览器 timer 线程管理,精度不保证
- Network(XMLHttpRequest 回调、fetch)—— I/O 完成后入队
- MessageChannel —— 纯 JS 驱动的宏任务,可以精确控制执行时机
关键点:不同 source 的 task 优先级不同。浏览器可以选择先执行 user interaction task 再执行 timer task。这就是 React 选择 MessageChannel 的原因——它需要一个可以被精确调度的 task source,而不是和用户输入抢优先级的 rAF。
rAF 不是 task,是渲染帧钩子
requestAnimationFrame 不在 task queue 里。它在每帧渲染之前、微任务之后执行。这意味着:
- rAF 里的 JS 执行会延迟渲染,因为浏览器要等你执行完才开始 Style → Layout → Paint
- rAF 每帧最多执行一次(60fps 下约 16.6ms),不能像 setTimeout 那样高频调用
- rAF 不适合做调度——它在渲染前触发,无法利用渲染后的空隙
requestIdleCallback:best-effort 的空闲检测
requestIdleCallback 是浏览器提供的“空闲时回调”机制——当前帧没有 task 和渲染任务时才执行,有 deadline 参数告诉你剩余时间。
但它是 best-effort——浏览器忙的时候可能根本不调用你。Vue 3 不用它,因为 Vue 的更新量通常很小,不需要等到空闲。React 的 Scheduler 也不直接用它(它用 MessageChannel 模拟了自己的时间切片),因为 rIC 的调度粒度不够细。
微任务的执行时机
微任务(Promise.then、queueMicrotask)在当前 task 结束后、下一个 task 或渲染之前执行。这是 Vue 3 选择微任务刷新的依据——同一轮事件循环里的多次响应式修改,可以通过微任务合并成一次渲染。
但微任务有一个陷阱:如果微任务队列里有无限循环的微任务,浏览器会卡死——因为微任务执行完才会渲染,永远不会到渲染步骤。这就是为什么 queueMicrotask 要谨慎使用。
React 三大架构
React 面对的核心问题是:声明式 UI 不知道谁变了。你返回新的 JSX 树,React 得自己 diff。diff 可能很慢,但又不能阻塞渲染。所以 React 构建了三层架构来解决这个问题。
① Fiber 架构:为什么需要可中断的链表
传统的 tree diff 是递归的——从根节点一路递归到叶子,中间无法暂停。如果组件树很大(比如一个 1000 行的表格),递归 diff 可能占用主线程 50ms 以上,浏览器就会掉帧。
React 的解法:把递归变成链表遍历。每个 Fiber node 是一个工作单元,代表一个组件。链表遍历意味着:
- 可以暂停——执行到一半可以让出主线程,让浏览器先渲染一帧
- 可以恢复——下一帧从暂停的位置继续
- 可以丢弃——如果用户又点了一下,之前的 diff 可以直接废弃
双缓冲(current tree ↔ workInProgress tree)确保了原子性。diff 过程中不会污染已提交到 DOM 的 current tree。只有 diff 完成后,一次原子切换才生效。用户永远看不到半成品 UI。
② Scheduler:为什么用 MessageChannel 做时间切片
React 需要一个机制来执行“工作一段、让出主线程、再继续工作”。这个机制需要:
- 可以被高优先级任务打断——用户点击时能立即抢占
- 可以在渲染后执行——利用帧间空隙,不和渲染抢时间
- 可以精确控制执行时长——每个 slice 不超过 5ms
rAF 不满足条件 2(在渲染前触发)。setTimeout 精度太差(嵌套 setTimeout 会被浏览器节流到 4ms)。requestIdleCallback 不满足条件 1(无法被打断)。
MessageChannel 是唯一满足所有条件的:它是宏任务,在渲染后执行,不会和渲染竞争,且可以被更高优先级的 task 打断。
5 级优先级(Immediate → UserBlocking → Normal → Low → Idle)让 Scheduler 可以:
- 用户点击(Immediate)→ 立即中断 Normal 级别的 diff
- 后台数据加载(Low)→ 在空闲时慢慢处理
- Transition 更新(UserBlocking)→ 可以被打断,但优先级高于普通更新
这就是 Suspense 和 Transition 的底层基础——没有 Scheduler 的优先级调度,这些 Concurrent features 无从谈起。
③ Reconciler + Renderer:从 Fiber tree 到真实 DOM
Reconciler 负责“算出差异”,Renderer 负责“应用差异”。
Reconciler 的工作流:beginWork(递归向下,标记需要更新的节点)→ completeWork(递归向上,收集 effect list)→ 得到一个需要提交的副作用列表。
Renderer(ReactDOM)的提交分四个阶段,每个阶段和浏览器渲染管线有精确对应:
| React 阶段 | DOM 操作 | 浏览器管线影响 |
|---|---|---|
| beforeMutation | 读取 layout(getBoundingClientRect) | 只读,不触发 reflow |
| mutation | 写 DOM(insert、delete、update) | 触发 Layout / Reflow |
| layout | useLayoutEffect 回调 | 几何计算完成,Paint 之前 |
| passive | useEffect 回调 | Paint 之后,微任务执行 |
这就是为什么 useLayoutEffect 能同步读取 DOM 尺寸——它在 mutation(写 DOM)之后、Paint 之前执行,此时浏览器已经计算完几何信息但还没绘制。而 useEffect 在 Paint 之后执行,所以它读到的 DOM 尺寸可能和用户看到的不一致(如果 Paint 还没完成)。
⚠️ 性能陷阱:
flushSync和useLayoutEffect都会触发同步 reflow。在循环里使用它们会导致 Layout thrashing——同步读 → 强制 reflow → 同步读 → 强制 reflow,每次 reflow 都是 O(n) 的。
Suspense:Fiber + Microtask 的协作
Suspense 是 React 最复杂的 feature 之一,它的实现涉及 Fiber 和 Microtask 的精密协作:
- 组件 suspend → 抛出 Promise → Fiber 标记 pending 状态
- Scheduler 暂停该 Fiber 分支的渲染,先渲染 fallback UI
- Promise resolve → microtask 触发
flushPassiveEffects - Scheduler 恢复 Fiber 工作,切换回真实内容
关键在第 3 步:为什么用 microtask 而不是宏任务?因为 Promise.then 是微任务,它在当前 task 结束后立即执行,不会被其他 task 打断。这保证了 Suspense 的恢复是即时的——Promise 一 resolve,UI 就更新,不会有延迟。
Vue 3 四大核心
Vue 3 走了一条和 React 完全相反的路。React 是“运行时重、编译时轻”,Vue 3 是“编译时重、运行时轻”。
① 响应式系统:Proxy 精确追踪
Vue 3 的响应式基于 Proxy 拦截对象的 get/set:
reactive(obj) → new Proxy(obj, {
get(target, key, receiver) {
track(target, key) // 收集依赖:当前 effect 在读这个属性
return Reflect.get(...)
},
set(target, key, value, receiver) {
const result = Reflect.set(...)
trigger(target, key) // 通知依赖:这个属性变了
return result
}
})
为什么用 Proxy 而不是 defineProperty?
Vue 2 的 Object.defineProperty 只能拦截已有的属性。新增属性、删除属性、数组索引修改都需要额外的 hack(Vue.set、$set)。Proxy 拦截的是操作本身,不管属性存不存在。
为什么需要 track/trigger 的 dep 集合?
这是响应式的核心——精确依赖追踪。每个 effect(组件渲染、watch 回调等)在执行时会被 push 到一个栈里。当 effect 读取 reactive(obj).foo 时,track 把当前 effect 注册到 foo 的 dep 集合里。当 foo 被修改时,trigger 从 dep 集合里取出所有 effect,交给调度器。
这意味着 Vue 3 天然知道谁依赖了谁。不需要 diff 整棵树——只有依赖了变化属性的 effect 才会重新执行。
② 编译器:template → render + 四层优化
Vue 3 的 SFC 编译器(@vue/compiler-sfc)把 template 编译成 render 函数,过程中做四层优化:
静态提升(Static Hoisting):模板中不变的节点(比如纯文本 <h1>)被提到 render 函数外面,只创建一次 VNode,后续渲染直接复用。减少 GC 压力。
PatchFlag:每个动态节点被标记一个 flag——TEXT(文本变了)、CLASS(class 变了)、STYLE(style 变了)、PROPS(props 变了)等。运行时只检查标记的部分,不需要全量 diff。
Block Tree:动态节点被收集到一个扁平数组 dynamicChildren 里。diff 时直接遍历这个数组,跳过所有静态节点。
Tree Flattening:v-if、v-for 内的 block 也被收集进来,确保条件渲染的动态节点不会被遗漏。
这就是 Vue 3 diff 复杂度是 O(动态节点数) 而非 O(整棵树) 的原因。编译器在构建时就告诉了运行时“哪些节点需要 diff”。
③ 调度器:queueJob + 微任务刷新
trigger → queueJob(job) → 按 id 排序插入队列(去重)
→ queueFlush() → Promise.resolve().then(flushJobs)
→ pre flush → 组件更新 → post flush(生命周期/watch)
为什么用微任务而不是 MessageChannel?
因为 Vue 3 的更新量通常很小——PatchFlag 精确标记了需要更新的部分,不需要像 React 那样做复杂的优先级调度。一个 Promise.resolve().then(flushJobs) 就够了:同一轮事件循环里的多次响应式修改合并成一次渲染,简单高效。
为什么需要 pre flush 和 post flush?
组件更新前需要执行 beforeUpdate 钩子(pre flush),更新后需要执行 updated 钩子和 watch 回调(post flush)。这个顺序保证了生命周期钩子的语义正确。
④ 渲染器:patchFlag 驱动的精确 diff
patch(n1, n2, container) → 判断 VNode 类型
→ blockTree.dynamicChildren → 只 diff 动态节点
→ hostInsert / hostSetElementText → 操作真实 DOM
传统 diff 是“比较两棵树,找出差异”。Vue 3 的 diff 是“编译器已经标记了差异,运行时直接应用”。
这不是偷懒——这是把运行时的工作前移到编译时。编译器分析 template 的时间成本是固定的(只在构建时发生一次),但运行时 diff 的时间成本是每次渲染都发生的。用一次编译时间换无数次运行时间,是值得的。
React vs Vue 3:哲学分叉
| 维度 | React | Vue 3 |
|---|---|---|
| 核心问题 | 不知道谁变了 | 编译时已知谁变了 |
| 解法 | Fiber 可中断 diff + Scheduler 优先级调度 | PatchFlag 精确标记 + 微任务刷新 |
| Diff 策略 | 全树 diff,但可中断、可优先级 | Block Tree,只 diff 动态节点 |
| 调度 | 5 级优先级 + MessageChannel 时间切片 | 单队列 + Promise.resolve 微任务 |
| 编译时 | 几乎没有(JSX 只是语法糖) | 重编译(静态提升、PatchFlag、Block Tree) |
| 运行时 | 重(Fiber + Scheduler + Reconciler) | 轻(Proxy + 调度器 + patchFlag 驱动 diff) |
| 并发特性 | ✅ Suspense、Transition、RSC streaming | ❌ 同步更新(但量小所以快) |
这不是“谁更好”的问题,而是设计哲学的分叉:
React 走“运行时重”的路线,获得了 Suspense、Transition、RSC 这些并发特性。代价是需要理解 Fiber、Scheduler、优先级这些底层概念。优势是复杂场景下的灵活性——你可以中断更新、可以做流式渲染、可以让低优先级任务在后台慢慢跑。
Vue 3 走“编译时重”的路线,获得了开箱即用的性能。SFC 天然享受编译优化,不需要手写 memo 或 useCallback。代价是对框架内部的控制力更少——你不能暂停渲染、不能做优先级调度。
React 说:“我不知道谁变了,但我可以慢慢找,找到优先级最高的先更新。” Vue 3 说:“我编译时就知道谁变了,运行时直接更新就行。”
理解了这条分叉,就理解了两个框架为什么长成现在这样。
架构图基于学习笔记绘制。参考:React 源码、Vue 3 源码、Dan Abramov 博客、Jake Archibald 的 Event Loop 讲解。