Read OSS

深入 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(模板标记)、instancemodule 脚本块(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)记录变量的声明位置及其类型——statederivedpropbindable_propnormallegacy_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 进行三次顺序遍历,分别对应组件的三个逻辑部分:

  1. Module 遍历 — 处理 <script context="module">(顶层模块作用域)
  2. Instance 遍历 — 处理 <script>(组件实例作用域)
  3. 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,被调用的是名为 $stateIdentifier

第二阶段(分析)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) 会创建一个指向 $.stateCallExpression 节点。借助这套 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/>'&lt;div&gt;&lt;span&gt;Hello&lt;/span&gt; &lt;!&gt;&lt;/div&gt;'"]
    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 绑定,包括 warningsfilenamesourcedevrunes

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 构成的信号图、用于高性能状态追踪的位标志系统,以及协调更新的批处理调度器。