深入 Svelte 编译器:从 .svelte 源码到 JavaScript 输出
前置知识
- ›第 1 篇:架构与代码库全景图
- ›了解抽象语法树(AST)及 ESTree 格式
- ›熟悉用于树遍历的访问者模式
- ›具备基本的编译器概念(解析、分析、代码生成)
深入 Svelte 编译器:从 .svelte 源码到 JavaScript 输出
Svelte 编译器是整个框架得以运转的基石。它接收一个 .svelte 文件——其中混合了 HTML、CSS 和 JavaScript——并输出一个优化后的 JavaScript 模块,该模块从 svelte/internal/client(或 svelte/internal/server)导入所需内容。编译过程分为三个阶段:解析(parse)、分析(analyze)、转换(transform)。每个阶段都会生成对组件越来越丰富的描述。让我们完整地追踪这段旅程。
compile() 编排器
顶层的 compile() 函数是整个流程的入口。它接收源码和选项,然后协调三个阶段的执行:
export function compile(source, options) {
source = remove_bom(source);
state.reset({ warning: options.warningFilter, filename: options.filename });
const validated = validate_component_options(options, '');
let parsed = _parse(source);
const analysis = analyze_component(parsed, source, combined_options);
const result = transform_component(analysis, source, combined_options);
result.ast = to_public_ast(source, parsed, options.modernAst);
return result;
}
注意开头的 state.reset() 调用。在深入各个阶段之前,我们先来理解它的作用。
此外还有一个兄弟函数 compileModule(),专门处理 .svelte.js 文件——即使用了 rune 但本身不是组件的模块。它遵循相同的模式:analyze_module() → transform_module()。
flowchart TD
Source[".svelte source"] --> Parse["Phase 1: Parse<br/>→ AST (Root node)"]
Parse --> TS{"TypeScript?"}
TS -->|yes| Strip["remove_typescript_nodes()"]
TS -->|no| Analyze
Strip --> Analyze["Phase 2: Analyze<br/>→ ComponentAnalysis"]
Analyze --> Transform["Phase 3: Transform<br/>→ ESTree Program"]
Transform --> Print["esrap.print()<br/>→ JavaScript + source map"]
Print --> Result["CompileResult<br/>{js, css, warnings, ast}"]
第一阶段——解析:源码到 AST
解析器是手写的——没有使用 PEG 生成器,也没有借助现成工具。它是一个逐字符消费模板内容的状态机。
Parser 类管理着解析状态:一个 index 游标、一个存放未关闭节点的 stack,以及用于构建语法树的 fragments 栈。构造函数驱动整个状态机运转:
let state = fragment;
while (this.index < this.template.length) {
state = state(this) || fragment;
}
每个状态都是一个函数,它检查当前位置,然后返回下一个状态,或返回 void(此时回退到 fragment)。fragment 状态是调度中心——遇到 < 时分发到 element,遇到 { 时分发到 tag,否则处理 text:
export default function fragment(parser) {
if (parser.match('<')) return element;
if (parser.match('{')) return tag;
return text;
}
stateDiagram-v2
[*] --> fragment
fragment --> element: "<"
fragment --> tag: "{"
fragment --> text: other
element --> fragment: element closed
tag --> fragment: tag closed
text --> fragment: done
解析器最终生成一个 AST.Root 节点,包含三个重要的子节点:fragment(模板标记)、instance 和 module 脚本块(JavaScript 部分通过 Acorn 解析)。如果组件使用了 TypeScript,会在分析前执行 remove_typescript_nodes() 步骤,将类型注解从 JS AST 中剔除。
提示: 解析器支持
loose模式(parse(source, true)),即便输入不合法,也会尽力生成 AST。这对编辑器工具非常有用——用户正在输入时,需要对不完整的代码进行局部解析。
第二阶段——分析:作用域、校验与元数据
分析阶段的核心任务是让编译器真正理解组件的语义。analyze_component() 函数负责构建作用域层次结构、解析变量绑定、检测 rune,并对组件结构进行校验。
第一步是创建作用域。scope.js 中的 create_scopes() 函数遍历 AST,构建 ScopeRoot / Scope 层次结构。每个块({#if}、{#each}、函数体等)都会创建一个子作用域。绑定(binding)记录变量的声明位置及其类型——state、derived、prop、bindable_prop、normal、legacy_reactive 等。
Rune 检测通过 get_rune() 完成,它判断一个调用表达式是否属于已知的 rune($state、$derived、$effect、$props 等)。这正是编译器能够区分 $state(0) 与普通函数调用的关键所在。
接下来是主分析遍历。分析器使用 zimmerframe 库的 walk() 函数,配合包含约 60 个访问者的对象——几乎覆盖每一种节点类型:
flowchart TD
AST["Parsed AST"] --> Scopes["create_scopes()<br/>→ ScopeRoot + Scope hierarchy"]
Scopes --> Walk["walk(ast, state, visitors)<br/>~60 analysis visitors"]
Walk --> CSS["analyze_css() + prune()"]
CSS --> Result["ComponentAnalysis<br/>{runes, scope, bindings, metadata}"]
分析访问者负责执行校验(例如检查 $state 的使用是否正确)、收集元数据(例如追踪哪些事件需要委托处理),以及为节点附加注解。位于 phases/2-analyze/index.js#L92-L145 的 _ 兜底访问者负责处理 svelte-ignore 注释,并为每个节点进行作用域切换。
第三阶段——转换:AST 到 JavaScript
转换阶段负责生成最终输出。transform_component() 函数会根据 generate 选项做出关键分支:
const program =
options.generate === 'server'
? server_component(analysis, options)
: client_component(analysis, options);
对于客户端输出,client_component() 会对 AST 进行三次顺序遍历,分别对应组件的三个逻辑部分:
- Module 遍历 — 处理
<script context="module">(顶层模块作用域) - Instance 遍历 — 处理
<script>(组件实例作用域) - Template 遍历 — 处理 HTML 模板
flowchart LR
A["module AST"] -->|"walk 1"| M["Module output"]
B["instance AST"] -->|"walk 2"| I["Instance output"]
C["template AST"] -->|"walk 3"| T["Template output"]
M --> Program["Combined ESTree Program"]
I --> Program
T --> Program
Program --> esrap["esrap.print() → JS + sourcemap"]
三次遍历使用同一套访问者对象,但状态不同。Instance 遍历会设置 is_instance: true 并使用 instance 作用域。Template 遍历共享 instance 的转换状态(以便正确改写变量引用),但使用模板自身的作用域映射。
client_component() 第 148 行初始化的 state 对象值得深入研究——它包含 hoisted 数组(以 import * as $ from 'svelte/internal/client' 开头)、用于变量改写的 transform 记录,以及用于事件委托追踪的 events 集合。
三次遍历完成后,esrap.print() 将 ESTree program 转换为带有 source map 的 JavaScript。esrap 是 Svelte 自己的代码打印库,之所以选择它,是因为它支持 TypeScript 感知输出,并能精确生成 source map。
Rune 编译:$state 变成 $.state()
让我们追踪一个具体的 rune——$state——在整个流水线中的变化过程。当你写下:
<script>
let count = $state(0);
</script>
在第一阶段(解析),Acorn 将其解析为一个 VariableDeclaration,其初始值是一个 CallExpression,被调用的是名为 $state 的 Identifier。
在第二阶段(分析),get_rune() 将其识别为 $state rune,并为 count 创建 kind: 'state' 的绑定。
在第三阶段(转换),VariableDeclaration 访问者检测到该 rune,并生成对应的运行时调用:
if (rune === '$state' || rune === '$state.raw') {
const is_state = is_state_source(binding, context.state.analysis);
if (rune === '$state' && is_proxy) {
value = b.call('$.proxy', value);
}
if (is_state) {
value = b.call('$.state', value);
}
}
编译器会根据变量是否曾被重新赋值来决定:需要完整的响应式源($.state()),还是简单的代理就够了($.proxy())。如果 count 只是被修改(例如 count.x = 1),则不需要 $.state();如果被重新赋值(count = 5),则需要。
sequenceDiagram
participant Source as .svelte source
participant Parse as Parser
participant Analyze as Analyzer
participant Transform as Transformer
participant Output as JS Output
Source->>Parse: let count = $state(0)
Parse->>Analyze: VariableDeclaration { init: CallExpression { callee: $state } }
Analyze->>Analyze: get_rune() → "$state"
Analyze->>Analyze: binding.kind = "state"
Analyze->>Transform: ComponentAnalysis
Transform->>Transform: is_state_source(binding)?
Transform->>Output: let count = $.state($.proxy(0))
提示: 以
#compiler/builders导入的b模块提供了一套流畅的 API,用于构造 ESTree 节点。b.call('$.state', value)会创建一个指向$.state的CallExpression节点。借助这套 API,转换访问者的代码即便生成复杂的 AST 结构,也能保持良好的可读性。
模板构建器与 DOM 结构生成
当模板转换器遇到静态 HTML 时,它不会生成 createElement 调用,而是通过 Template 类构建模板描述。
Template 类维护着一棵由节点(元素、文本、注释)构成的树,表示 DOM 的静态结构。最终序列化时,会生成以下两种形式之一:
$.from_html(content, flags)— 一个 HTML 字符串,通过innerHTML解析一次,之后每个组件实例通过cloneNode(true)克隆复用$.from_tree(structure)— 一种程序化的树描述,用于 HTML 解析不适用的场景(例如 SVG、MathML 命名空间)
Template 还会追踪内容是否包含 <script> 标签(需要特殊处理),以及是否需要 importNode(针对 Firefox 的兼容处理)。它提供 push_element()、push_text()、pop_element() 等方法,在模板访问者遍历 AST 时逐步构建树结构。
动态内容——如表达式标签 {count}、控制流块 {#if} 等——会在模板中创建边界。静态部分构成可克隆的模板,动态部分则成为 effect 函数,在克隆后更新特定节点。
flowchart TD
Template["Template class"]
Template --> Static["Static HTML string:<br/>'<div><span>Hello</span> <!></div>'"]
Template --> FromHTML["$.from_html(string, flags)"]
FromHTML --> Clone["cloneNode(true)<br/>per instance"]
Clone --> Effects["$.template_effect(() => {<br/> $.set_text(node, count);<br/>})"]
模块级状态:state.js 模式
编译器中有一个贯穿始终的设计选择:使用模块级可变变量。state.js 模块导出了若干可变的 let 绑定,包括 warnings、filename、source、dev 和 runes:
export let warnings = [];
export let filename;
export let source;
export let dev;
export let runes = false;
第 139 行的 reset() 函数在每次编译前将所有状态清零:
export function reset(state) {
dev = false;
runes = false;
source = '';
filename = (state.filename ?? UNKNOWN_FILENAME).replace(/\\/g, '/');
warnings = [];
}
为什么不通过参数逐层传递这些值?答案是性能。编译器的访问者函数在每次编译中会被调用数千次,在热路径中传递上下文对象会带来额外开销。而模块级变量的访问几乎没有成本——本质上只是变量读取。这样做的代价是编译器在设计上是单线程的(不支持并发编译),但这完全没问题,因为编译器运行在构建工具中,通常是顺序处理文件或在独立的 worker 中并行处理。
adjust() 函数在解析和初步分析完成后调用,用于配置 dev 模式、rune 模式以及相对于 rootDir 的文件名。这种两阶段初始化模式——先 reset() 再 adjust()——的存在是因为部分状态依赖于解析过程中收集到的信息(例如是否使用了 TypeScript)。
下一步
编译器的输出是一个高度依赖 svelte/internal/client 的 JavaScript 模块。在下一篇文章中,我们将深入探索驱动这些运行时调用的响应式引擎——由 source、derived 和 effect 构成的信号图、用于高性能状态追踪的位标志系统,以及协调更新的批处理调度器。