未设置发布日期约 5222 字17 分钟阅读

编译器的三重身份:五大前端框架编译管线深度拆解

同一个 TodoMVC 组件经过五种编译器后面目全非。本篇沿着 Parse→Transform→Codegen 三阶段管线,逐行拆解 Svelte、Solid、React Compiler、Qwik、Vue 的编译策略差异——消灭 diff、加速 diff、推迟加载,三种哲学的完整技术链路。

前端框架深度解剖

第 2 篇 / 共 4 篇

从计数器到 TodoMVC:编译器差异的真正战场

总览篇用一个计数器组件揭示了编译器的三重身份——消灭 diff(Svelte/Solid)、加速 diff(React Compiler)、推迟加载(Qwik)。但计数器太简单了:一个状态、一个绑定、一个事件。真正暴露编译器设计差异的是复杂场景——列表渲染、条件分支、派生状态、事件委托同时出现时,编译器的三阶段管线(Parse → Transform → Codegen)各自做了什么、省了什么、付出了什么代价。

本文不再重复"编译器做了什么"的结论(总览篇已经讲过),而是追问**"编译器怎么做到的"**——拆开每个编译器的内部管线,看源码从文本变成 AST、AST 被注入优化标记、最终生成目标代码的完整过程。

以下编译产物经过简化以突出核心逻辑,实际编译器输出的变量名和包装层可能不同,但语义一致。

Parse:从源码到 AST

所有编译器的第一步都是 Parse——把开发者写的文本变成结构化的抽象语法树(AST)。但各框架的解析器面对的"源码"形态完全不同,这决定了 AST 的结构差异和后续 Transform 的起点。

Svelte:单文件组件的自定义解析器

Svelte 的 .svelte 文件不是标准的 HTML、CSS 或 JavaScript——它是一种自定义语法,<script> 块里可以用 $state$derived 等 Runes 语法,模板里可以用 {#each}{#if} 等控制流。Svelte 用自己的解析器(不是 Babel 也不是 Acorn)把 .svelte 文件解析成一棵包含模板节点、脚本节点和样式节点的 AST。

关键特征:Svelte 的 AST 在 Parse 阶段就区分了"静态 HTML"和"动态绑定"。 {count} 在 AST 里不是一个文本节点,而是一个 ExpressionTag 节点,明确标记了"这里有一个运行时依赖"。这个早期标记让后续 Transform 阶段可以直接识别哪些节点需要生成更新代码,哪些是纯静态的。

Solid:标准 JSX 解析 + Babel 插件

Solid 用的是标准的 JSX 解析——Babel 的 @babel/parser 把 JSX 解析成标准的 JSX AST 节点(JSXElementJSXExpressionContainer 等)。Solid 的编译不是自定义解析器,而是一个 Babel 插件babel-plugin-jsx-dom-expressions),在 Babel 的 Transform 阶段介入。

关键特征:Parse 阶段 Solid 和 React 完全一样——都是标准 JSX AST。 差异完全发生在 Transform 阶段。这意味着 Solid 的 JSX 可以用任何标准的 JSX 工具链解析,不需要自定义 parser。

React Compiler:标准 JSX + Flow/TypeScript 扩展

React Compiler 同样基于标准 JSX 解析,但它面对的 AST 更复杂——需要处理 TypeScript 类型标注、hooks 调用的模式识别、use 前缀的约定检测。Parse 阶段用的是 Hermes parser(Meta 自研的 JavaScript 引擎的解析器),输出标准的 ESTree AST。1

关键特征:React Compiler 在 Parse 阶段没有做任何额外工作——所有的编译优化都在 Transform 阶段。 这和 Svelte 形成鲜明对比:Svelte 在 Parse 阶段就开始收集优化信息,React Compiler 在 Parse 阶段只做语法解析。

Vue:模板编译器 + SFC 解析器

Vue 有两层解析:SFC 解析器.vue 文件拆成 <template><script setup><style> 三个块,然后模板编译器compiler-core)把 <template> 块解析成模板 AST。模板 AST 的节点类型包括 ElementInterpolation{{ count }})、Directive:classv-ifv-for)等。

关键特征:Vue 的 <script setup> 代码几乎不经过编译变换。 编译器主要处理的是 <template> 部分——这就是为什么 Vue 的调试体验比 Svelte 好:开发者调试的 setup 代码和源码几乎一一对应,编译差异只发生在模板侧。

Qwik:标准 JSX + $ 后缀标记识别

Qwik 用标准 JSX 解析,但它的 Optimizer(编译器)会在 Parse 阶段识别 $ 后缀——component$onClick$useTask$ 等。这些 $ 标记告诉编译器"这个函数需要被拆成独立的懒加载 chunk"。

关键特征:$ 后缀是 Qwik 编译器和开发者之间的"协议"——开发者用 $ 标记代码拆分边界,编译器据此决定如何拆分 chunk。 这在 Parse 阶段就被识别,但实际的代码拆分逻辑在 Transform 阶段执行。

Parse 阶段的核心差异

框架解析器AST 特征Parse 阶段是否收集优化信息
Svelte自定义解析器区分静态/动态节点是(ExpressionTag 标记)
SolidBabel parser标准 JSX AST否(纯语法解析)
React CompilerHermes parser标准 ESTree AST否(纯语法解析)
Vue自定义模板解析器区分元素/插值/指令是(指令和插值标记)
Qwik标准 JSX + Optimizer标准 JSX + $ 标记部分($ 后缀识别)

有自定义解析器的框架(Svelte、Vue)在 Parse 阶段就开始为编译优化做准备;用标准解析器的框架(Solid、React Compiler)把所有优化推迟到 Transform 阶段。 这不是技术实力的差异,而是编译策略的选择——自定义解析器的代价是工具链兼容性降低(不能直接用标准 ESLint/Prettier 插件),换来的是更早的优化时机。

Transform:AST 变换与优化标记注入

Transform 是编译器差异最大的阶段——同一个"Todo 列表组件"的 AST,经过五个编译器的 Transform 后变成了完全不同的东西。Parse 阶段生成的 AST 只描述"开发者写了什么",Transform 阶段决定"运行时需要做什么"。

Svelte 的 Transform:从声明式模板到命令式 DOM 指令

Svelte 的 Transform 做了几件关键的事:

1. 模板静态分析:遍历模板 AST,把每个节点标记为"完全静态"或"含动态绑定"。{#each todos as todo} 被标记为"列表块",内部的 {todo.text} 被标记为"依赖 todo.text 的表达式绑定"。

2. 响应式绑定生成:对每个动态绑定,生成一个 template_effect 调用,直接把 signal 的 getter 和 DOM 更新函数绑在一起。{todo.completed ? 'done' : ''} 这种条件表达式会生成一个 effect,内部包含条件判断和 DOM 属性赋值——不是生成一个新的虚拟节点再 diff,而是直接生成 if (completed) el.classList.add('done') else el.classList.remove('done') 这样的命令式代码

3. 列表处理{#each} 块被编译成一个专用的列表 reconciliation 函数——不走通用 VDOM diff,而是针对数组变更(增/删/移/改)的优化 diff 逻辑。带 key 和不带 key 的 each 块生成的 reconciliation 策略不同。

Solid 的 Transform:JSX 到 DOM 操作的 Babel 变换

Solid 的 Babel 插件在 Transform 阶段做的核心工作是把 JSX 表达式拆成"静态模板"和"动态插入点"

1. 模板提取:JSX 里的静态 HTML 结构被提取成 template() 字符串,运行时通过 cloneNode(true) 克隆。这和 Svelte 的模板提取策略几乎一样。

2. 动态绑定点替换:JSX 里的 {expression} 被替换成 insert(el, () => expression) 调用。insert 内部用 createRenderEffect 建立 signal→DOM 的订阅关系——依赖收集完全在运行时发生,Transform 阶段只负责把 JSX 语法转成 insert 调用。

3. 条件和列表的特殊处理{show() && <Component />} 被转成 Show 组件包裹,{items.map(item => ...)} 被转成 For 组件包裹——这些组件内部实现了针对条件/列表变更的优化 diff 逻辑。

React Compiler 的 Transform:自动 Memoization 推导

React Compiler 的 Transform 和其他编译器方向完全不同——它不转换代码结构,而是分析数据流并自动插入缓存

1. HIR 构建(High-level Intermediate Representation):把 ESTree AST 转成 React Compiler 自己的中间表示——HIR 比 AST 更适合做数据流分析,它把组件函数的执行流展平成基本块(Basic Block)序列,每个基本块有明确的输入/输出/副作用标注。

2. 响应式值推导:分析每个变量是否是"响应式的"——props、state、context 是响应式的源头,依赖它们的表达式也是响应式的,不依赖任何响应式值的表达式是"非响应式的"(可以安全缓存)。

3. 作用域划分:把组件函数切成多个"响应式作用域"——同一个作用域内的代码依赖相同的响应式值集合。每个作用域分配一个缓存槽位。

4. 缓存代码生成:在每个作用域的入口插入依赖比较,依赖没变就跳过整个作用域直接返回缓存值。

jsx
// Transform 前
function TodoList({ todos }) {
  const remaining = todos.filter(t => !t.completed).length;
  return <div>
    <span>{remaining} items left</span>
    <ul>{todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
  </div>;
}

// Transform 后(简化)
function TodoList({ todos }) {
  const $ = _c(4);
  let t0;
  if ($[0] !== todos) {
    const remaining = todos.filter(t => !t.completed).length;
    t0 = <span>{remaining} items left</span>;
    $[0] = todos;
    $[1] = t0;
  } else { t0 = $[1]; }

  let t1;
  if ($[2] !== todos) {
    t1 = <ul>{todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
    $[2] = todos;
    $[3] = t1;
  } else { t1 = $[3]; }

  return <div>{t0}{t1}</div>;
}

注意 remaining 的计算被包进了第一个缓存区域——因为它只依赖 todos,和 <span> 的渲染属于同一个响应式作用域。如果 todos 引用没变(浅比较),filterlength 的计算以及 <span> 的 JSX 创建全部跳过。

注意:React Compiler 不改变组件函数"每次 render 都完整执行"的基本模型。 函数体的每一行代码都会被执行到(至少执行到缓存检查的比较语句),只是比较通过时跳过后续的计算和 JSX 创建。这和 Svelte/Solid 的"组件函数只执行一次"是本质不同的。

Vue 的 Transform:Patch Flags 注入 + 静态提升

Vue 的模板编译器在 Transform 阶段做了两类优化:

1. 静态提升(Static Hoisting:遍历模板 AST,把完全静态的子树标记为 HOISTED,在 Codegen 阶段提到渲染函数外面——每次组件重渲染时这些节点不重新创建,直接引用提升后的常量。

2. 补丁标记注入(Patch Flags:对每个含动态绑定的节点,分析它有哪些动态属性(文本内容?class?style?props?),生成对应的位掩码。:class="cls" 的节点拿到 PatchFlags.CLASS = 2{{ count }} 的节点拿到 PatchFlags.TEXT = 1。这些标记在 Codegen 阶段被写入 createElementVNode 的参数里,运行时 diff 通过位运算跳过不可能变化的属性。

3. Block Tree 构建v-if / v-for 等结构指令会创建"块"(Block),块内的动态节点被扁平化收集到一个数组里。diff 时不需要递归遍历整棵子树,只需要比对这个扁平数组里的动态节点——从 O(template size) 降到 O(dynamic nodes)。

Qwik Optimizer 的 Transform:代码拆分与闭包序列化

Qwik 的 Transform 做的事情和其他四个编译器都不同——不是优化更新路径,而是按交互边界拆分代码

1. $ 标记函数提取:遇到 component$onClick$useTask$ 等带 $ 后缀的函数,把函数体提取到独立的文件/chunk 里,原位留一个字符串引用("chunk-xxx.js#handler_0")。

2. 闭包变量分析:被提取的函数体如果引用了外部作用域的变量(闭包捕获),编译器分析这些变量是否可序列化。可序列化的变量生成 useLexicalScope 的序列化/反序列化代码;不可序列化的(Map、Set、class 实例等)在构建时报错或降级。

3. 事件代理映射onClick$ 被编译成 HTML 属性 on:click="chunk-xxx.js#handler_0",运行时全局事件代理解析这个属性值,用户交互时动态 import() 对应的 chunk。

Transform 阶段的核心差异

框架Transform 做了什么优化目标运行时模型变化
Svelte模板→命令式 DOM 指令消灭 diff彻底改变(无 VDOM)
SolidJSX→insert/cloneNode 调用消灭 diff彻底改变(无 VDOM)
React Compiler自动插入缓存检查减少不必要的 JSX 创建不变(仍走 VDOM diff)
Vue注入 Patch Flags + 静态提升加速 diff不变(仍走 VDOM diff,但跳过静态节点)
Qwik代码拆分 + 闭包序列化消灭初始 JS 执行彻底改变(按需加载)

Transform 阶段是编译器"三重身份"的核心分界线。 Svelte/Solid 在这里把声明式模板翻译成了命令式 DOM 操作,从根本上消灭了运行时 diff;React Compiler 在这里分析数据流并插入缓存,但保留了 VDOM diff 的完整链路;Vue 在这里注入优化标记加速了 diff 但没有消灭它;Qwik 在这里做代码拆分,解决的不是更新效率而是加载效率。

Codegen:从 AST 到目标代码

Codegen 是编译管线的最后一步——把 Transform 处理过的 AST 序列化成浏览器可执行的 JavaScript 代码。如果说 Transform 决定了"运行时需要做什么",Codegen 决定了"这些事情用什么代码表达"。

Svelte 的 Codegen:组件函数 + 命令式更新块

Svelte 的 Codegen 输出一个完整的组件函数,内部包含模板克隆、DOM 引用获取、响应式绑定、列表块。Codegen 的输出就是最终运行时执行的代码——没有中间层、没有虚拟 DOM 工厂、没有 diff 函数调用。

Solid 的 Codegen:模板字符串 + insert 调用

Solid 的 Codegen 结构和 Svelte 非常相似——模板提取、节点克隆、insert() 建立绑定。核心区别在于 Solid 的 insert 内部用 createRenderEffect 做运行时依赖收集,而 Svelte 的 template_effect 的依赖在编译期就确定了(简单绑定场景)。

React Compiler 的 Codegen:原始代码 + 缓存包装

React Compiler 的 Codegen 最简单——原始代码几乎不变,只是被缓存检查包裹了。开发者写的组件函数体、JSX 表达式、hooks 调用全部原样保留——Codegen 只在外面套了一层缓存判断。这也是 React Compiler 的 Source Map 精度高于 Svelte 的原因:代码结构没变,只是插入了额外的行。

Vue 的 Codegen:渲染函数 + Patch Flags 参数

Vue 的 Codegen 把模板 AST 转成渲染函数——createElementVNodecreateElementBlockopenBlock 等函数调用的嵌套结构。静态节点被提到渲染函数外面,动态节点带 Patch Flags 参数,Block 容器用 openBlock() + createElementBlock() 包裹。

Vue 的 Codegen 输出看起来和开发者写的 <template> 差异很大(声明式模板变成了函数调用链),但逻辑上是等价的——每个模板节点对应一个 createElementVNode 调用,每个动态绑定对应一个 Patch Flag。

Qwik 的 Codegen:多文件输出 + 序列化胶水

Qwik 的 Codegen 是唯一一个输出多个文件的——主 bundle 包含组件结构和 signal 声明,每个 $ 标记的函数被输出到独立的 chunk 文件。Qwik 的 Codegen 复杂度是五个编译器里最高的——它不只是"把 AST 序列化成代码",还要做跨文件的引用管理和序列化边界检查。

Codegen 阶段的核心差异

框架输出形态与源码的差距输出文件数
Svelte命令式 DOM 操作函数大(模板→命令式代码)1
Solidinsert/cloneNode 调用链中(JSX 结构保留,绑定方式改变)1
React Compiler原始代码 + 缓存包装小(只插入缓存检查)1
Vue渲染函数调用链中偏大(模板→函数调用,setup 不变)1
Qwik主 bundle + 多个懒加载 chunk大(代码拆到多文件 + 序列化胶水)多个

Codegen 的输出形态直接决定了两件事:调试体验和水合成本。 输出和源码差距越大,DevTools 里断点越难对应回源码;输出里包含 DOM 精确路径的(Svelte 的 $.child()),水合时可以直接定位节点不用重建 VDOM。

消灭者的完整链路:Svelte 5 编译管线

前三章分阶段横向对比了五个编译器,接下来沿着每个编译器的完整链路纵向走一遍。先看"消灭者"——Svelte 5 如何从一个 .svelte 文件走到最终的命令式 DOM 操作代码。

Svelte 5 的编译管线分为四个阶段:Parse → Analyze → Transform → Codegen。和通用三阶段模型相比,Svelte 多了一个 Analyze 阶段——专门做响应式依赖分析和作用域解析。Analyze 遍历 <script> 和模板的 AST,建立依赖图:哪些变量是 $state、哪些是 $derived、每个模板绑定依赖了哪些响应式值。对于简单绑定,Analyze 阶段就能确定"这个绑定只依赖一个 signal"——这是编译期静态绑定的基础。 对于条件依赖则标记为"动态",fallback 到运行时追踪。

Transform 基于依赖图为每个动态绑定生成更新指令(template_effect),列表渲染生成专用的 each_block reconciliation。Codegen 输出最终的组件函数模块。

核心权衡:运行时极轻、更新路径最短、简单绑定零追踪开销。代价是编译产物和源码差距大(调试难)、体积随组件数线性增长、自定义解析器降低工具链兼容性。

加速者的完整链路:React Compiler 编译管线

React Compiler 的管线:Parse(Hermes parser)→ HIR 构建 → 响应式值推导 → 作用域划分 → 缓存代码注入 → Codegen(HIR 回退到 JavaScript)。

HIR 是 React Compiler 独有的中间表示——比 AST 更适合做数据流分析,把组件函数展平成基本块序列。响应式值推导分析每个变量是否依赖 props/state/context,作用域划分把依赖相同响应式值的代码归入同一缓存区域,最后注入 $[n] !== dep 的缓存检查。

核心权衡:消除了手动 useMemo/useCallback 的负担,减少不必要的 JSX 创建。代价是缓存基础设施的固定运行时开销、极小组件可能缓存检查比直接重渲染更贵、调试体验下降(断点停在开发者没写过的代码上)、水合路径零收益(首次执行缓存槽为空必然 miss)。

推迟者的完整链路:Qwik Optimizer 编译管线

Qwik Optimizer 的管线:Parse(标准 JSX + $ 识别)→ Transform($ 函数体提取 + 闭包捕获分析 + Manifest 生成)→ Codegen(多文件输出 + 序列化胶水)。

核心机制:每个 $ 后缀的函数被提取到独立 chunk,闭包变量通过 useLexicalScope 序列化/反序列化。不可序列化的变量在构建时报错。Manifest 记录 chunk 间依赖关系,驱动运行时 prefetch 策略。

核心权衡:理论最优 TTI(不执行 JS 即可交互)。代价是首次交互有 chunk 下载延迟、闭包变量必须可序列化(排除了大量 JS 编程模式)、跨文件调试极其困难、编译产物和源码差距最大。

Source Map 精度与调试体验的跷跷板

编译器介入越深,运行时越高效,但开发者在 DevTools 里看到的代码离自己写的代码越远。

框架编译变换深度Source Map 精度调试体验趋势
React(无 Compiler)极低极高最好
React(有 Compiler)下降正在恶化
Vue 3中(模板侧)/ 极低(setup 侧)高(setup)/ 中(模板)稳定
Solid中偏高稳定
Svelte 5中偏低较差在改善(Runes 比 $: 好调试)
Qwik最高最差跨文件调试本质上很难

Vue 的模板/逻辑分离设计在调试维度上换来了独特收益:编译深度的跷跷板被"拆成了两半"——模板侧承担了编译深度的代价,逻辑侧保持了几乎零编译变换的调试体验。

React 正在为收敛趋势支付代价——React Compiler 插入的缓存代码让曾经的"代码即所写"调试优势被侵蚀。

编译产物体积的交叉点分析

两种体积增长模型

固定运行时 + 轻量编译产物(React / Vue):运行时固定大小(React ~40KB+,Vue ~30KB+),每个组件编译产物增量小。

极小运行时 + 重量编译产物(Svelte):几乎无运行时,但每个组件携带完整的命令式 DOM 操作代码,编译产物增量大。

交叉点在哪里

text
Svelte 总体积 ≈ 2 + 2n KB(n = 组件数)
React 总体积 ≈ 40 + 0.5n KB

交叉点:n ≈ 25

约 25 个组件时两者体积相当,超过这个数 Svelte 反而更大。 真实项目的交叉点可能在 30-50 个组件之间(取决于组件复杂度和 tree shaking 效果)。

小型应用(<25 组件)Svelte 体积优势明显;大型应用(>100 组件)React/Vue 的固定运行时摊薄后更有优势。

Qwik 的体积模型完全不同——初始加载极小(qwikloader ~1KB),但总体积(所有 chunk 加起来)可能最大。Qwik 的赢面不在总体积,在于"用户实际需要执行的 JS 量"。

编译器篇总结:优化空间由响应式模型决定

编译器能做什么,不取决于编译器有多聪明,而取决于响应式模型给了多大的优化空间。

维度Svelte 5SolidReact CompilerVue 3Qwik
编译器角色翻译官翻译官校对员划重点拆包员
Transform 核心工作模板→DOM指令JSX→insert调用数据流分析+缓存Patch Flags注入代码拆分+序列化
运行时 diff有(被缓存加速)有(被标记加速)有(按需加载)
调试体验较差下降中好(setup侧)最差
体积模型线性增长固定+轻量增长固定+轻量增长固定+轻量增长按需加载
水合收益高(DOM路径精确)零(首次必miss)中(Patch Flags加速)最高(无水合)

编译器是响应式模型的"放大器"——它放大了模型的优势,也放大了模型的代价。 这就是为什么总览篇说"不存在最优框架,只存在特定约束下的最优选择"——编译器是设计选择的因果链上的一环,不是独立的评判标准。

Footnotes

  1. React Compiler 的解析器在不同版本中有变化,以官方仓库最新实现为准。