深入理解 swc - 第一部分,使用

2,959 阅读8分钟

序言

万字 esbuild 源代码批判,让你能够直接 PR 这篇文章中,深入研究了 esbuild 项目,为了达到极致的编译速度,esbuild 的编译过程被设计得非常紧凑,只包含两次对整个抽象语法树(AST)的全量传递。

然而,这种极致的效率并非没有代价。在 esbuild 中,解析 AST、进行语法降级以适应低版本环境、以及代码压缩等步骤是紧密耦合的。由于这种紧密耦合,基于 esbuild 开发 Vue 或 Svelte 编译器会变得困难(目前 esbuild 对 Vue 和 Svelte 的处理方式是使用 JavaScript 编写的编译器进行预处理,然后通过进程间通信传递转换后的结果)。此外,esbuild 的核心数据结构、类型和函数定义全部位于项目的 internal 目录下,这使开发者无法直接访问。

正因为如此,我们有必要深入研究 swc 项目。与 esbuild 不同,swc 提供了更灵活而且可扩展的架构,使得开发者可以更容易地基于 swc 开发出自己的编译器或工具。

目录结构

  • bindings swc 对特定平台的绑定,每个子目录都是一个 Rust crate
    • binding_core_node swc 的 Node.js 扩展
    • binding_core_wasm swc 的 WASM 程序
    • swc_cli swc 的命令行程序
  • crates
    • ast_node 定义 AST 节点的宏
    • better_scoped_tls 实现线程局部变量
    • binding_macros 用于构建 WASM 程序的宏
    • preset_env_base 解析 browserslist
    • string_enum 将枚举打印为字符串的宏
    • swc swc 的主 crate
    • swc_atoms 生成 JsWord
    • swc_bundler swc 的 bundler
    • swc_cached swc 的缓存配置
    • swc_common swc 的工具方法
    • swc_config swc 配置
    • swc_config_macro 用于 swc 配置的宏
    • swc_core 注册 swc 插件
    • swc_css css 模块聚合
    • swc_css_ast css AST 的结构体和枚举
    • swc_css_codegen css 代码生成器
    • swc_css_codegen_macros 用于 css 代码生成器的宏
    • swc_css_lints css lint
    • swc_css_minifier css 代码压缩
    • swc_css_parser css 语法分析器
    • swc_css_visit css AST 的 visitor
    • swc_ecma_ast js AST 的结构体和枚举
    • swc_ecma_codegen js 代码生成器
    • swc_ecma_codegen_macros 用于 js 代码生成器的宏
    • swc_ecma_visit js AST 的 visitor
    • ...
  • Cargo.toml Rust Cargo 清单文件
  • ...

swc 通过 Cargo 的 workspace 管理多个相关的协同开发的 crate,它们位于 crates 目录下。

bindings 目录下的 crate 未放到 workspace 下的原因是,绑定程序使用已发布的 swc_core SDK 来构建特定平台的绑定。若放置到 workspace 下时,可能使用并未发布的依赖的版本,导致混乱。

使用 swc

阅读 swc 的源代码,一个好的起点是查看其示例代码。在 swc 项目的crates/swc/examples/transform.rs中,你可以找到一个简单的使用 swc 的例子。这段代码旨在展示如何使用 swc 进行代码转换。

use std::{path::Path, sync::Arc};

use anyhow::Context;
use swc::{self, config::Options, try_with_handler, HandlerOpts};
use swc_common::SourceMap;

fn main() {
    let cm = Arc::<SourceMap>::default();

    let c = swc::Compiler::new(cm.clone());

    let output = try_with_handler(
        cm.clone(),
        HandlerOpts {
            ..Default::default()
        },
        |handler| {
            let fm = cm
                .load_file(Path::new("examples/transform-input.js"))
                .expect("failed to load file");

            c.process_js_file(
                fm,
                handler,
                &Options {
                    ..Default::default()
                },
            )
            .context("failed to process file")
        },
    )
    .unwrap();

    println!("{}", output.code);
}

上面的程序做了以下事情:

  1. 首先,创建一个 SourceMap 实例。SourceMap 是 swc 用于追踪源代码的结构体。由于它需要在多个线程之间共享,所以使用 Arc(自动引用计数)智能指针来持有它。
  2. 然后,创建一个 Compiler 实例。Compiler 是 swc 的主要接口,提供了处理 JavaScript 文件的方法。
  3. 使用 SourceMapload_file 方法加载一个JavaScript文件(具体的文件路径是"examples/transform-input.js")。
  4. 使用 Compilerprocess_js_file 方法处理加载的 JavaScript 文件。这个方法接受一个 Handler 实例作为参数,用于处理可能发生的错误。
  5. try_with_handler 函数用于创建一个 Handler 实例,并将它传递给 process_js_file 方法。它返回一个 Result 枚举,如果处理成功,我们可以使用 unwrap 方法从 Ok(T) 中取出结果;如果处理失败,程序将直接退出。

了解了这段代码的基本流程后,我们可以更详细地探讨 SourceMapCompiler 这两个结构体。SourceMap 主要用于追踪源代码的位置信息,它可以帮助我们找到源代码中的错误;Compiler则提供了各种处理JavaScript文件的方法,包括解析、转换和生成代码等。

SourceMap 结构体

SourceMap 记录所有所使用的源代码,可以将字节位置映射到源代码中的具体位置。解析过程中的每个源数据(通常是文件、字符串或宏扩展)都存储在 SourceMap 中,表示为 SourceFiles。字节位置存储在 span 中,并在编译器中广泛使用。它是 SourceMap 绝对位置,可以将其转换为行列信息、源代码片段等。

mermaid-diagram-2023-04-05-154908.png

SourceFile 结构体

SourceFile是swc中一个非常重要的结构体,它代表一个源代码文件。其结构如下:

struct SourceFile {
    /// 源文件名。根据惯例,非源自文件系统的源码名位于尖括号之间,例如`<anon>`
    pub name: FileName,
    /// 全部源代码
    pub src: Lrc<String>,
    /// 源代码的 hash
    pub src_hash: u128,
    /// 此源代码位于 `SourceMap` 中的起始位置
    pub start_pos: BytePos,
    /// 此源代码位于 `SourceMap` 中的结束位置
    pub end_pos: BytePos,
    /// 源代码中所有行的起始位置
    pub lines: Vec<BytePos>,
    /// 文件名 hash
    pub name_hash: u128,
}

Lrc 结构体

在swc中,Lrc 是一个被广泛使用的结构体,它是对 Rust 标准库中 RcArc 的一个别名。它的具体定义在 swc_common 的 crate 中。

通过Rust的 cfg 宏,可以在编译时根据特性(feature)的配置来选择使用 RcArc。如果特性配置中包含"concurrent",则 Lrc 会被定义为 Arc;否则,Lrc 会被定义为 Rc

下面是这段代码的定义:

#[cfg(feature = "concurrent")]
mod concurrent {
    pub use std::{
        sync::Arc as Lrc,
    };
}

#[cfg(not(feature = "concurrent"))]
mod single {
    pub use std::{
        rc::{Rc as Lrc, Weak},
    };
}

这段代码使用了 Rust 的条件编译功能,根据是否启用"concurrent"特性,来选择导入 Arc 还是 Rc。"concurrent"特性在需要多线程共享数据时启用,这时 Lrc 会被定义为多线程安全的 Arc;在其他情况下,Lrc 则被定义为单线程环境下的 Rc

Compiler 结构体

Compiler 是 swc 中一个核心的结构体,它包含了一个 SourceMap 实例,并提供了一系列用于处理 JavaScript 文件的方法。

struct Compiler {
    pub cm: Arc<SourceMap>,
}

Handler 结构体

Compiler 提供的所有方法都需要传入一个 Handler 结构体,除了会导致 swc 立即退出的错误,其他错误会通过 Handler 结构体记录用于后续进行错误报告。Handler 结构体如下:

pub struct Handler {
    // 如何对待不同等级的错误信息
    pub flags: HandlerFlags,

    err_count: AtomicUsize,
    emitter: Lock<Box<dyn Emitter>>,
    continue_after_error: LockCell<bool>,
    
    // 所有记录的错误信息
    delayed_span_bugs: Lock<Vec<Diagnostic>>,

    taught_diagnostics: Lock<AHashSet<DiagnosticId>>,

    emitted_diagnostic_codes: Lock<AHashSet<DiagnosticId>>,

    // 该集合包含发出的每个错误信息的 hash,这些 hash 用于避免发出两次相同的错误。
    emitted_diagnostics: Lock<AHashSet<u128>>,
}

swc 中提供了一个名为 HANDLER 的全局变量,它是一个线程局部的变量。在 try_with_handler 方法中,会创建一个 Handler 实例,并将这个实例注册到 HANDLER 全局变量上。这种设计使得在开发 swc 扩展时,能够更简单地进行错误报告。

以下是如何使用HANDLER进行错误报告的示例:

use swc_common::errors::HANDLER;

fn main() {
    HANDLER.with(|handler| {
        // 使用 HANDLER.with 访问当前文件的 Handler 实例。
        
        // struct_span_err 方法接收错误信息。
        // struct_span_err 方法接收的 span 用于定位错误代码的位置。
        
        // 还可以通过 span_note 方法提供一些附加信息。
        handler
            .struct_span_err(
                span,
                &format!("`{}` used as parameter more than once", js_word),
            )
            .span_note(
                old_span,
                &format!("previous definition of `{}` here", js_word),
            )
            .emit();
    });
}

处理流程

回到 Compilerprocess_js_file方法,这是一个典型的编译过程,它将经历如下阶段:

  1. 词法分析(Lexical Analysis):该阶段将源代码分割成一系列的词素(tokens)。

  2. 语法分析(Syntax Analysis):语法分析会将词素组成抽象语法树(AST)。这个树状结构表示了源代码的语法结构。

  3. 代码生成(Code Generation):最后,编译器将通过遍历抽象语法树来生成目标代码。

这个流程是大多数编译器的基本工作流程,而 swc 的 process_js_file 方法也遵循了这一流程。

mermaid-diagram-2023-04-05-181903.png

TransformOutput 结构体

process_js_file 方法返回一个 TransformOutput 结构体,它包含了转换后的代码和可能存在的 source map。

struct TransformOutput {
    pub code: String,
    pub map: Option<String>,
}

只有当 process_js_file 方法参数中配置了 source_maps 才会生成 source map,故其类型为 Option<String>

c.process_js_file(
    fm,
    handler,
    &Options {
        source_maps: Some(swc::config::SourceMapsConfig::Bool(true)),
        ..Default::default()
    },
)

最后

在此部分,我们主要介绍了 swc 的基本使用以及两个核心数据结构:SourceMapCompiler。通过这些介绍,你应该已经对如何使用 swc 有了大致的理解,以及 SourceMapCompiler 在其中起到的关键作用。

然而,要完全理解 swc 的内部工作机制,我们还需要深入其源码,特别是 process_js_file 方法的实现。在下一部分,我们将按照 process_js_file 方法的处理流程,逐步展开 swc 的内部实现,包括词法分析、语法分析和代码生成等阶段。这将帮助我们更深入地理解 swc 的工作原理和设计思路。