一个设计决策如何决定一切:五大前端框架源码级横向解剖
为什么 React 需要 6 个性能优化 API 而 Solid 几乎不需要?答案不在实现细节,而在一个根源性的设计选择——"谁来承担精确性的责任"。本文沿着这条因果链,用编译产物逐行对比解剖五大框架的设计必然与权衡。
为什么"React vs Vue vs Solid"的对比方式是错的
打开 React 源码,你会看到一个叫 shouldYield 的函数——它每隔约 5ms 检查一次是否该让出主线程。打开 Solid 的源码,这个函数不存在。不是 Solid 的作者忘了写,也不是 Solid 不关心用户体验——而是它的设计根基决定了这个函数永远不需要存在。
React 需要 shouldYield,是因为它的单次更新可能要重新执行整棵组件子树的函数、生成新的虚拟 DOM、再逐节点比对差异——这套流程足够重,重到必须切成小块穿插执行以免冻结界面。Solid 不需要它,是因为一个状态变更只触发一次精确的 element.textContent = newValue——轻到根本不值得中断。
同样的需求("状态变了,更新界面"),为什么一个框架需要时间切片调度器,另一个只需要一行赋值?
这个问题的答案不在实现细节里,而在一个更根源的设计选择里——"谁来承担精确定位更新的责任"。React 说"开发者来",Vue 说"运行时帮你追踪",Solid 和 Svelte 说"编译器在构建时就知道",Qwik 说"连代码都不用立刻加载"。
这个根源选择一旦做出,后续的一切——响应式系统的追踪粒度、渲染管线的长度、调度器的复杂度、SSR 水合的成本、开发者需要学习的优化 API 数量——全部是它的必然推论,不是独立的设计决策。
本文不按框架罗列功能清单,而是沿着这个根源选择的因果链——从响应式模型到编译器角色,从渲染管线长度到 SSR 水合成本——拆解 React、Vue、Solid、Svelte、Qwik 五大框架的设计必然与权衡。1
根源分歧:谁来承担精确性的责任
状态变了,界面要更新——但精确定位"哪些 DOM 节点需要更新"这件事,由谁来做? 这个问题的回答方式,锁定了框架后续几乎所有的技术特征。
| 责任归属 | 代表框架 | 设计哲学 | 后果 |
|---|---|---|---|
| 开发者负责 | React | "我不自动追踪,你来告诉我什么可以跳过" | 需要 useMemo/React.memo 等 6+ 个优化 API,但换来了最大的灵活性——JSX 就是 JS,不限制状态管理方式 |
| 运行时追踪 | Vue 3(属性级)、Solid(表达式级) | "我在运行时自动追踪依赖关系" | 开发者不用手动声明跳过逻辑,但追踪粒度不同决定了更新单位不同——Vue 精确到组件,Solid 精确到单个 DOM 节点 |
| 编译器静态分析 | Svelte 5 | "简单绑定在编译期就确定了更新路径" | 简单场景零运行时追踪开销,但遇到动态依赖(条件分支里的响应式值)仍需 fallback 到运行时 |
| 编译器代码拆分 | Qwik | "连代码都不需要立刻加载" | 理论最优的首屏可交互时间,但整个开发模型围绕序列化约束设计 |
注意这不是三分法而是四分法——Solid 和 Svelte 5 虽然都是"表达式级追踪",但依赖图的构建时机不同:Solid 在运行时动态收集(支持条件依赖),Svelte 5 对简单绑定在编译期静态生成。这个区分在后续章节会反复出现。
框架提供的显式性能优化 API 数量,往往和它的运行时默认工作量成正比——但 API 多不意味着框架差,而是意味着框架把优化的决策权交给了开发者而非编译器。React 组件的反复重执行让 HMR Fast Refresh 能在保留状态的同时替换组件逻辑——这是 Solid 模型层面做不到的。每种选择都有对应的收益,不存在免费的优势。
接下来的章节将沿着这个根源选择的因果链逐层展开:响应式粒度 → 编译器角色 → 渲染管线长度 → 调度器复杂度 → SSR 水合成本。读者会发现,每一层都不是独立的设计决策,而是上一层选择的必然推论。
响应式系统:四种追踪粒度的代价与收益
响应式系统的本质工作只有一个:"什么变了,谁需要知道?" 各框架的分野就在于这个"谁"的粒度——追踪得越精确,后续管线要做的工作越少,但追踪本身的机制成本也越高。
React:无自动追踪 → 组件级重执行
React 的 setState 本质上是在说"这个组件的状态脏了"。框架不知道组件内部哪个值变了,所以只能从该组件开始重新执行整个函数,生成新的虚拟 DOM,再交给 diff 算法逐节点比对。响应式精度不够,只能让 diff 兜底,也只能让开发者用 useMemo、React.memo 手动补充"哪里不用重算"的信息。
Vue 3:属性级追踪 → 组件级更新
Vue 的 reactive() / ref() 基于 Proxy 做依赖收集。当渲染函数访问 state.name,响应式系统精确记录了"这个组件的渲染 effect 依赖了 state.name"——state.age 变了不会触发这个组件重渲染。通知精确到了"哪个组件因为哪个属性需要更新",但更新单位仍然是组件:重新执行渲染函数、生成新 VNode、走 diff。补丁标记(Patch Flags)在 diff 阶段再省一刀,但组件级重渲染本身逃不掉。
状态变更后的通知链路(Vue 3.5):
ref.value = newVal → Proxy set 触发 trigger()
→ 沿 dep 的 Link 链表遍历找到所有 subscriber
→ queueJob(推入微任务队列,去重 + 按组件树深度排序)
→ flushJobs → effect.run() → 执行渲染函数生成新 VNode
→ patch(带 Patch Flags 的快速 diff)→ DOM 操作
Solid:运行时表达式级追踪 → 直接 DOM 更新
Solid 的 createSignal 配合编译器生成的 insert(),追踪关系直接建立在"这个 signal → 这个 DOM 节点的 textContent"之间。中间没有组件重执行,没有 diff。signal 值变了,订阅它的 effect 直接执行一次精确的 DOM 操作。
组件函数只执行一次——后续所有更新由 signal 的 effect 订阅驱动,不经过组件函数。这意味着不存在闭包陷阱(函数只跑一次,没有"旧闭包捕获旧值"的问题),也不存在"组件更新"这个生命周期事件(Solid 没有 onUpdate,因为组件层面根本不存在"更新"的概念)。
Solid 的通知链路只有三步:
setSignal(newVal) → 遍历 signal 的 observers
→ 执行 effect 回调(如 () => el.textContent = signal())
→ DOM 操作(已经在 effect 回调里完成)
Svelte 5:编译期静态绑定 + 运行时兜底
Svelte 5 对静态可分析的简单绑定(如 {count}),编译器直接生成 signal→DOM 的绑定代码,不需要运行时的依赖收集——追踪开销为零。但遇到条件分支里的响应式值(如 {show ? name : 'default'}),Svelte 5 的 $derived 仍然需要 fallback 到运行时动态追踪,此时行为和 Solid 趋同。
这就是四分法而非三分法的原因:Solid 和 Svelte 5 虽然都达到了表达式级追踪精度,但前者完全在运行时动态构建依赖图(灵活但有追踪开销),后者对简单场景在编译期就确定了依赖关系(零开销但能力边界在"条件依赖"这道线上)。
响应式粒度的涟漪效应
| 框架 | 追踪粒度 | 通知目标 | 后果 |
|---|---|---|---|
| React | 无自动追踪 | 组件 | 需要 VDOM diff 兜底,需要开发者手动优化 |
| Vue 3 | 属性级 | 组件的渲染 effect | 精确触发但仍走 diff,Patch Flags 加速 |
| Solid | 表达式级(运行时) | 具体的 DOM 更新函数 | 无 diff,直接更新 |
| Svelte 5 | 表达式级(编译期为主) | 编译生成的更新块 | 无 diff,简单绑定零追踪开销 |
响应式原语的组合规则也直接约束了开发者封装复用逻辑的自由度——Vue 的自动解包让 composables 编写几乎无感,Solid 的统一 getter 接口让原语组合极其一致,React 的 hooks 规则(调用顺序不可变 + 手动依赖数组)是组合性最受限的。
响应式模型是框架的"基因"。 它决定了编译器的优化空间(下一章)、渲染管线的复杂度(第五章)、调度器是否必要(第六章)、以及 SSR 水合需要做多少工作(第七章)。接下来每一章的结论,都可以追溯到这里的粒度选择。
编译器的三重身份:消灭者、加速者、推迟者
上一章我们看到各框架的响应式追踪粒度从组件级到表达式级不等——这个粒度差异直接决定了编译器能生成什么样的代码。同一个计数器组件,经过不同编译器后变成了完全不同的东西。
以下编译产物经过简化以突出核心逻辑,实际编译器输出的变量名和包装层可能不同,但语义一致。
消灭者:Svelte 5 — 编译器直接生成 signal→DOM 绑定
<!-- 源码 -->
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>
// Svelte 5 编译产物
var root = template(`<button> </button>`); // 模板提取为静态 template,后续 clone 复用
export default function Counter($$anchor) {
let count = $.source(0); // $state → 内部 signal 原语
var button = root();
var text = $.child(button); // 编译器拿到文本节点的直接引用
button.__click = () =>
$.set(count, $.get(count) + 1); // 事件委托,不走 addEventListener
$.template_effect(() =>
set_text(text, $.get(count))); // signal → DOM textContent 的直接绑定,无 diff
$.append($$anchor, button);
}
没有组件重执行、没有虚拟 DOM、没有 diff。模板被提取成静态 template() 调用只创建一次,{count} 被编译成 $.template_effect(() => set_text(text, $.get(count)))——一个从 signal 到具体 DOM 节点 textContent 的直接绑定。整个更新路径就是 signal.set → effect → set_text。
Solid 的 JSX 编译产物结构几乎一样——模板克隆、insert() 建立 signal→DOM 订阅。关键差异在于:Solid 的依赖收集发生在运行时(insert 内部调用 createRenderEffect 动态收集),而 Svelte 5 对简单绑定在编译期就确定了 signal 和 DOM 节点的静态关系,不需要运行时的依赖收集过程。动态依赖(如 {show ? name : 'default'})Svelte 5 则 fallback 到运行时追踪,此时两者行为趋同。
加速者:React Compiler — 在原有模型上自动插入缓存
// 源码
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// React Compiler 编译产物
function Counter() {
const $ = _c(2); // 编译器分配 2 个缓存槽位
const [count, setCount] = useState(0);
let t0;
if ($[0] !== count) { // 自动插入的浅比较(≈ useMemo)
t0 = <button onClick={() =>
setCount(c => c + 1)}>{count}</button>; // JSX 不变,仍走 VDOM
$[0] = count;
$[1] = t0;
} else {
t0 = $[1]; // 缓存命中,跳过 JSX 创建
}
return t0;
}
区别一目了然。React Compiler 没有改变渲染模型——组件函数仍然每次状态变更都重新执行,虚拟 DOM diff 仍然在运行时发生。编译器做的事情是自动插入 $[0] !== count 这类缓存检查,等价于开发者手写 useMemo(() => <button>...</button>, [count])。
Svelte/Solid 的编译器是翻译官——把声明式语言翻译成命令式 DOM 操作;React Compiler 是校对员——在原文上标注"这段不用重读"。前者输出了一套全新的执行模型,后者在原有模型上加了缓存跳过逻辑。
推迟者:Qwik — 按交互边界拆分代码
Qwik 的编译器核心工作既不是"生成高效更新代码"也不是"自动插入缓存",而是按交互边界做代码拆分:
:::details Qwik 编译产物:事件处理函数被拆到独立懒加载 chunk
// 源码
export const Counter = component$(() => {
const count = useSignal(0);
return <button onClick$={() => count.value++}>{count.value}</button>;
});
// main.js — 初始加载
export const Counter = component(() => {
const count = useSignal(0); // signal 声明保留,值会被序列化到 HTML
return h("button", {
"on:click": "chunk-abc.js#handler_0", // 事件处理函数被拆到独立 chunk,这里只留字符串引用
children: count.value
});
});
// chunk-abc.js — 用户点击时才下载执行
export const handler_0 = (ctx) => {
// ⚠️ 闭包变量必须为 JSON 可序列化类型(不支持 Map/Set/WeakRef/class 实例)
const count = useLexicalScope(ctx); // 从序列化状态恢复闭包
count.value++;
};
:::
编译器看到 onClick$ 的 $ 后缀时,把事件处理函数抽取到独立的懒加载 chunk。useLexicalScope 从序列化状态恢复闭包捕获的变量——这是可恢复性(Resumability)的关键,也带来了"所有闭包上下文必须可序列化"这个开发约束。
三种编译哲学的本质区别
| 编译器 | 核心输出 | 优化目标 |
|---|---|---|
| Svelte / Solid | signal → DOM 的直接绑定代码 | 消灭运行时 diff |
| React Compiler | 自动插入的缓存检查代码 | 减少不必要的重渲染 |
| Qwik Optimizer | 按交互边界拆分的懒加载 chunk | 消灭初始加载的 JS 执行量 |
消灭、加速、推迟——三种完全不同的优化哲学,但都发生在编译阶段。 它们的差异不在编译器的"聪明程度",而在各自服务的响应式模型给了多大的优化空间:表达式级追踪让编译器有条件生成 signal→DOM 直接绑定,组件级重执行则只允许编译器做缓存跳过。
:::details Vue 3 模板编译:Patch Flags 与静态提升
Vue 的编译优化不是"源码结构变成了不同的代码结构",而是"生成的代码里多了精确的数字标记":
<!-- 源码 -->
<template>
<div :class="cls">
<p>static text</p>
<span>{{ count }}</span>
</div>
</template>
// 编译产物
const _hoisted_1 = createElementVNode(
'p', null, 'static text', -1 /* HOISTED */ // -1 = HOISTED,diff 时完全跳过此节点
)
function render(_ctx) {
return (openBlock(), createElementBlock('div', {
class: _ctx.cls // 动态 class
}, [
_hoisted_1, // 静态节点引用被提升到渲染函数外,不重复创建
createElementVNode('span', null,
toDisplayString(_ctx.count), 1 /* TEXT */) // 1 = TEXT,diff 时只检查文本内容
], 2 /* CLASS */)) // 2 = CLASS,diff 时只检查 class 属性
}
Vue 的模板编译器做了两件事:静态提升把不变的节点提到渲染函数外(_hoisted_1),补丁标记用位掩码告诉运行时 diff "这个节点只有哪些属性可能变化"。patchFlag & PatchFlags.CLASS 为真就只检查 class,跳过其他所有属性。这本质上是编译器在给渲染管线"划重点"——把 O(template) 的 diff 压缩到 O(dynamic bindings)。
Vue 的编译策略介于"消灭 diff"和"加速 diff"之间:它没有消除 diff 过程,但大幅缩减了 diff 需要检查的范围。
:::
渲染管线与浏览器的跷跷板
前两章揭示了一条因果链:响应式粒度决定了编译器的优化空间,编译器的优化深度决定了渲染管线的长度。直觉上,管线越短性能越好——Solid 的三步更新路径显然比 React 的七步快。但这个直觉忽略了一个关键的反作用力:框架管线的长度和浏览器渲染管线的压力存在跷跷板关系。
React:长管线换来了读写分离的架构空间
React 的 commit 阶段不只是批量执行 DOM 写操作——更关键的是它通过 beforeMutation(读 DOM)→ mutation(写 DOM)→ layout(useLayoutEffect,受控的读写混合区)三个子阶段的架构设计,强制隔离了 DOM 读和 DOM 写。
为什么这很重要?浏览器合并连续 DOM 写操作的前提是:写操作之间没有触发 layout 的读操作(offsetHeight、getBoundingClientRect() 等)。一旦在两次写之间读了任何一个布局属性,浏览器就不得不立即执行挂起的 layout 计算来返回准确值——这就是 forced synchronous layout,性能杀手。
React 的 mutation 阶段是纯写、不可插入读操作的,从架构上消除了 forced layout 的可能性。开发者在 React 里写不出交替读写 DOM 的 commit 代码——框架不给你这个机会。
Solid:短管线的隐性成本
Solid 的 effect 回调里,开发者拥有完全的 DOM 访问权限。框架不做读写分离的强制隔离。看这段代码:
createEffect(() => {
el.style.width = width() + 'px'; // 写
const h = el.offsetHeight; // 读 → 触发 forced layout!
el.style.height = h * 2 + 'px'; // 写
});
这段代码在 React 里写不出来——不是因为 React 不允许你读 DOM,而是因为 commit 架构把 DOM 读和 DOM 写隔离到了不同阶段。 Solid 的 effect 里没有这个隔离,读写交替触发 forced layout 的风险完全由开发者承担。
batch() 能帮忙吗?只能帮一半。batch() 合并的是 effect 间的通知——多个 signal 变更延迟到 batch 结束时一次性 flush,避免 effect 之间的 DOM 操作被其他代码的读操作打断。但 batch() 无法阻止单个 effect 内部的读写交替触发 forced layout。React 在两个层面都做了隔离(effect 间的批量 commit + effect 内的三阶段读写分离),Solid 的 batch() 只覆盖了第一层。
Vue:微任务批处理提供了一层缓冲
Vue 的 queueJob + 微任务 flush 机制把所有 DOM 操作集中在同一个微任务回调里执行。patch 函数递归执行一棵组件子树的所有 DOM 变更,行为类似 React 的 commit 阶段——集中写、不插入读。nextTick 提供了"正确的读 DOM 时机",但这是建议而非强制——在 watchEffect 里直接读布局属性一样会触发 forced layout。Vue 组件实例的存在也带来了 React 和 Solid 都没有的能力:KeepAlive 可以把组件的 DOM 子树搬到离屏容器缓存,恢复时跳过整个 mount 管线——有实例才有东西可以"缓存"。
跷跷板的本质
管线长度不只是性能开销,也是架构空间。 管线长的框架(React),有空间在管线内部做精细的读写分离,消除 forced layout 风险;管线短的框架(Solid),没有这个架构空间,读写分离的责任转嫁给开发者。Solid 的短管线在开发者遵守读写纪律的前提下,每次更新只触发一次精确的 DOM 操作——绝对性能仍然是最高的。但管线越短,框架能提供的"安全网"就越少。
编译器优化的跷跷板还有另一面:编译器介入越深,运行时越高效,但开发者在 DevTools 里看到的代码离自己写的代码越远——调试体验是编译深度的隐性成本。
这些跷跷板不是独立的——选择了编译器深度介入的框架,在每一对跷跷板上都偏向同一侧。不存在混搭的可能,因为它们共享同一个根源选择。
调度器:为响应式粒度不足买单
调度器解决的问题和上一章的渲染管线读写分离完全正交——读写分离解决的是"DOM 操作怎么做才不触发 forced layout"(commit 阶段),调度器解决的是"render 阶段的长任务怎么拆才不卡主线程"(render 阶段)。两个独立的优势,来自两个独立的架构设计。
React:Fiber + Lanes + 时间切片
React 的调度模型是目前最复杂的,三个核心机制:
Lanes(车道模型):每个更新被分配一个 lane(位掩码),SyncLane 优先级最高(离散事件如 click),TransitionLane 最低(startTransition 包裹的更新)。调度器每次挑当前最高优先级的 lanes 子集来处理。
时间切片:render 阶段的工作被切成约 5ms 的 chunk,每个 chunk 结束后检查是否该让出主线程。注意:时间切片让 React 的长 render 阶段不阻塞用户交互,但不让 React "更快"——同样的工作量,有时间切片和没有的总耗时相当,甚至有切片的略慢(切换开销)。时间切片解决的是响应性问题,不是速度问题。
双缓冲:current 树和 workInProgress 树交替,commit 阶段原子性切换,保证用户看到的 DOM 始终一致。
调度器和渲染管线的分界线在 workLoopSync / workLoopConcurrent 的入口——这是调度器的最后一个决策(选同步循环还是并发循环)。过了这个点,beginWork → completeWork 的递归就是纯渲染逻辑了。 了解更多
React: 事件 → setState → enqueueUpdate → scheduleCallback →
[调度器选 lane] → [选同步/并发] ──┃── workLoop → beginWork → ...→ commitRoot
┃
分界线
Vue:微任务队列 + 树深度排序
Vue 的调度简单得多——queueJob 把组件更新推入队列,在微任务结束时统一 flush。同一个组件在一个 tick 内多次触发只入队一次(去重),队列按组件树深度排序(父先于子,避免子组件做无用功)。Vue 没有优先级调度——所有 job 等价,唯一的排序维度是树深度,不是紧急程度。
Vue: 事件 → ref.value= → trigger → queueJob →
[微任务等待] → flushJobs ──┃── job() → effect.run() → render → patch
┃
分界线
Solid:几乎没有调度
Solid 的 setSignal 触发后,更新在当前 batch 内同步传播,effect 回调直接执行 DOM 操作。调度和渲染是"内联"的,没有明确的分界线。
Solid: 事件 → setSignal → [batch 内同步] → effect() → DOM 操作
(几乎无分界线,调度和渲染内联)
为什么 Solid 不需要时间切片
导读中那个"不需要存在"的 shouldYield,原因就在这里:Solid 的每次更新只改一个 DOM 节点,根本产生不了需要时间切片来拆分的长任务。React 必须有时间切片,是因为 setState 一调用就要重跑组件函数 + diff 子树——单次更新工作量大到可能冻结界面。调度器的复杂度是框架为响应式粒度不足买的单。
React 默认对大多数场景下的状态更新做自动批处理。Solid 的 batch() 和 React 的批处理解决的是同一个问题(合并多个原子更新),但发生在管线的不同位置:React 在调度层合并,Vue 在微任务层合并,Solid 在 signal 传播层合并。不管框架从哪个起点出发,最终都会在"合并多个原子更新"这个需求上相遇——但只有 React 在第二层(commit 阶段的读写分离)也做了架构级保障。
SSR 水合:所有设计决策的终极考验
响应式模型决定了编译器能否生成 DOM 精确路径——有路径则水合只需"接线",无路径则必须重建虚拟 DOM 再比对。SSR 水合是本文因果链的终端验证:前面每一个设计选择的代价和收益,在"服务端做了的工作客户端要不要重做"这个问题面前全部兑现。
四种水合策略
React 的全量水合:hydrateRoot 遍历整棵组件树,重新执行每个组件函数,生成虚拟 DOM,和服务端输出的 DOM 做 reconciliation。组件函数完整重新执行——所有 useState 初始值重新求值,所有 useEffect 重新注册。React 18 的选择性水合(Selective Hydration)通过 <Suspense> 边界让用户交互的区域优先水合,改变了水合的执行顺序,但不改变总工作量——每个组件最终仍要完整重执行一次。React Server Components 从根本上减少了需要水合的组件集合——Server Components 永远只在服务端运行,客户端只需水合带 'use client' 标记的组件。
Vue 的水合:组件的 setup() 重新执行,响应式系统重新建立依赖关系,然后和服务端 DOM 做 patch 比对。Patch Flags 在这里发挥的作用比 update 路径上更大——编译器标注的位掩码让水合过程跳过已知的静态属性,只检查动态绑定是否匹配。
Solid 的轻量水合:编译产物里的 $.child(button) 这类调用,在水合时可以用相同的路径直接定位到服务端渲染的 DOM 节点——不重新创建 DOM,不比对属性,只把 signal 的订阅接上已有的节点。在非流式 SSR(renderToString)下,Solid 信任服务端 DOM 直接接管,不做属性级比对——出现不一致时静默接受而非报错。流式模式(renderToStream)下会校验水合标记位置的一致性。
Qwik 的可恢复性(Resumability):接近零水合。编译器把每个事件处理函数拆成独立的懒加载 chunk,服务端渲染时把组件状态和事件绑定信息序列化到 HTML 属性里(q: 前缀)。客户端加载时不执行任何 JS——页面直接可交互。用户点击按钮时,才下载对应的事件处理函数 chunk 并执行。代价是首次交互有 chunk 下载延迟,且所有闭包变量必须为 JSON 可序列化类型。
编译产物如何决定水合成本
为什么 Svelte/Solid 的水合成本低而 React 的高?答案不在编译器的"聪明程度",而在响应式模型给编译器的可优化空间:
Svelte 的编译产物里有 $.child(button) 这种精确的 DOM 路径——编译器知道 count 这个 signal 只影响 button 的文本子节点,所以在编译期就确定了 DOM 定位路径。水合时沿着同样的路径找到节点、接上订阅就完事了。
React 的编译产物里只有 JSX(虚拟 DOM 描述)——useState 返回的值通过组件函数的执行逻辑流向 JSX,中间可能经过任意的 JavaScript 计算、条件分支、函数调用,这条路径在编译期不可分析。只有在运行时执行完组件函数、生成虚拟 DOM 之后,才能通过 diff 知道哪些 DOM 节点需要更新。所以 React 的水合必须重新执行组件函数——这是响应式模型的必然推论。
React Compiler 对水合路径几乎没有收益——缓存槽在首次客户端执行时是空的,必然走 miss 分支,JSX 照常创建,虚拟 DOM 照常生成。编译器的优化目标和 SSR 水合优化的目标可能完全正交:React Compiler 优化 update 路径,Svelte/Solid 编译器优化的 mount 路径天然也优化了 hydrate,Qwik 编译器直接消灭了 hydrate。
| 框架 | 水合策略 | 客户端组件重执行 | TTI 代价 |
|---|---|---|---|
| React(传统) | 全量水合 | 全部重执行 | 最高 |
| React 18 | 选择性水合 | 全部重执行,但分批 | 高 |
| React + RSC | 部分水合 | 只重执行 Client Components | 中 |
| Vue / Nuxt | 全量水合 + 可选懒水合 | setup() 全部重执行 | 中高 |
| Solid | 轻量水合 | 执行一次,接管订阅 | 低 |
| Qwik | 可恢复性 | 不执行,按需加载 | 接近零 |
| Astro(Islands) | 岛屿水合 | 只水合交互岛屿 | 低 |
收敛趋势:所有框架都在向中间靠拢
本文第二章用四分法定位了各框架的当前位置,但这些位置不是固定的——三个正在发生的事实表明,所有主流框架都在向同一个区间收敛。
React 正在向编译辅助迁移。 React Compiler 的出现不是一次功能迭代,而是一次范式承认——承认"让开发者手写 useMemo / useCallback"这条路走到了尽头。编译器自动插入的缓存检查,本质上是用工具链来弥补运行时模型的精确性不足。React 没有改变虚拟 DOM diff 的基本架构,但它正在把"谁来做优化"的答案从"开发者"转向"编译器"——这恰好是 Vue 模板编译器十年前就选定的位置。但这个收敛不是免费的——React Compiler 插入的缓存检查代码让 DevTools 断点停在开发者没写过的代码上,React 曾经最好的调试体验正在为编译化趋势支付代价。
Angular 正在向细粒度响应式迁移。 Angular Signals 的引入是对 Zone.js 全量脏检查模式的根本否定。用 Signal 替代 Zone.js,本质上是从"我不知道什么变了所以全部检查"转向"我精确知道什么变了所以只更新相关部分"——这是 Vue 的 Proxy 追踪和 Solid 的 Signal 早已占据的领地。
Svelte 从编译器魔法转向显式原语。 Svelte 5 的 Runes($state、$derived、$effect)替代了 v3/v4 时代的隐式 $: 标签和编译器赋值拦截。表面上是语法变化,深层是 Svelte 承认了纯编译期魔法的可维护性天花板2——$state 在语义上更接近 Solid 的 createSignal,Svelte 正在从"极致编译"向"显式响应式原语 + 编译辅助"的中间位置靠拢。
三个方向,同一个终点。React 从"纯运行时"向编译辅助走,Angular 从"全量检查"向细粒度响应式走,Svelte 从"极致编译魔法"向显式原语走。所有主流框架都在向"运行时追踪 + 编译辅助"这个区间收敛,差异正在从"模型之争"缩小为"刻度之争"。 Solid 和 Qwik 占据的极端位置——纯细粒度响应式和接近零水合——可能始终是少数派选择,但它们定义了这个光谱的边界,推动着所有框架向更高效的中间地带移动。
跨框架因果传导全景图
下图是本文因果链的完整全景——选中一个框架,看它的每一个设计选择如何连锁决定了其余一切。