从零开始的 swc 源代码学习 - 第二部分,词法分析

序言

这是 swc 源代码批判的第二部分,来探究 swc 的词法分析阶段。距离第一部分间隔了相当长的一段时间,期间很大一部分时间投入在 svgr 和 svgo 的 rust 版本开发,它们基于 swc:

开发期间向 swc 提交了 3 个 pr,来解决 swc 存在的一些问题:

通过对 swc 源代码的更深入了解,我将继续撰写这一系列文章。

单词

将字符序列转换为单词(token)序列的过程称为词法分析。进行词法分析的程序或者函数叫作称为 lexer。lexer 可以将单词分为不同的类型,如 INT(整数)、ID(标识符)、FLOAT(浮点数)等。单词至少包含两个信息:单词类型(识别词法结构)和 lexer 为该单词匹配的文本。

在 swc 中,单词被定义为 Token 枚举,其代码位于 crates/swc_ecma_parser/src/token.rs 文件中。

rust 复制代码
pub enum Token {
    /// Identifier, "null", "true", "false".
    ///
    /// Contains `null` and ``
    Word(Word),
    
    /// '=>'
    Arrow,
    
    /// '#'
    Hash,
    
    /// ...
}

为了降低单词文本分配和比较的性能开销,swc 利用 string_cache crate 对 JavaScript、HTML、CSS 的关键字、标签名和属性名等进行内部化。稍后将详细介绍 string_cache crate 的使用,现在你需要知道的是,在 swc 中通过 JsWord 结构体来使用这些内部化字符串。

swc 中还定义了另一个内部化字符串结构体Atom。可以使用 AtomGeneratorAtom::new.into() 来创建 Atom。如果你认为同样的值会被多次使用,使用 AtomGenerator。 否则,使用 .into() 来创建一个 Atom

JsWord 的区别是,JsWord 是一个有 phf(完美哈希函数 Perfect Hash Function),支持全局内部化字符串,而 Atom 是一个局部内部化字符串。全局内部化可以减少内存使用,但全局化意味着存在互斥锁。因为这个互斥锁,Atom 在多线程环境下表现得更好。但由于缺少 phf 或全局内部化,Atom 的比较和哈希操作会比 JsWord 的慢。

下列情况应使用 Atom 而非 JsWord

  • 长文本,不太可能被重复。
  • 原始值。

string_cache crate

用于内部化 AsRef<str> 类型数据的库。一些字符串可以在编译时使用 string-cache-codegen crate 进行内部化,或者可以使用没有编译时的内部化字符串 EmptyStaticAtomSetAtom 是给定集合(EmptyStaticAtomSet 或生成的 StaticAtomSet)的内部化字符串。生成的 Atom 将具有关联的宏,以在编译时内部化静态字符串。

Cargo.toml 中:

toml 复制代码
[dependencies]
string_cache = "0.8"

[dev-dependencies]
string_cache_codegen = "0.5"

build.rs 中:

rust 复制代码
extern crate string_cache_codegen;

use std::env;
use std::path::Path;

fn main() {
    string_cache_codegen::AtomType::new("foo::FooAtom", "foo_atom!")
        .atoms(&["foo", "bar"])
        .write_to_file(&Path::new(&env::var("OUT_DIR").unwrap()).join("foo_atom.rs"))
        .unwrap()
}

lib.rs 中:

rust 复制代码
extern crate string_cache;

mod foo {
    include!(concat!(env!("OUT_DIR"), "/foo_atom.rs"));
}

fn use_the_atom(t: &str) {
    match *t {
        foo_atom!("foo") => println!("Found foo!"),
        foo_atom!("bar") => println!("Found bar!"),
        // foo_atom!("baz") => println!("Found baz!"), - 会在编译时报错
        _ => {
            println!("String not interned");
            // We can intern strings at runtime as well
            foo::FooAtom::from(t)
        }
    }
}

使用词法分析器

在 swc 中,词法分析器被定义为 Lexer 结构体,通过它的 new 方法进行构造,new 方法接收4个参数

  • syntax - 需要解析的语法,可以是 ecmascript 或 typescript。
  • target - 编译目标,默认为 es5。
  • input - 需要解析的代码。
  • comments - 引用类型,用于记录代码中的注解
rust 复制代码
let cm: Lrc<SourceMap> = Default::default();

let fm = cm.new_source_file(
    FileName::Custom("test.js".into()),
    "function foo() {}".into(),
)

let lexer = Lexer::new(
    // 我们想解析 ecmascript
    Syntax::Es(Default::default()),
    // EsVersion 默认为 es5
    Default::default(),
    StringInput::from(&*fm),
    None,
)

let TokenAndSpan = lexer.next(); // 读取当前的单词

Input trait 和 StringInput 结构体

swc 并没有直接使用 String 类型作为输入,而是定义了 Input trait,包含了一系列方法,用于遍历、查询和修改输入字符串。这可能出于以下原因:

  1. 抽象Input trait 为处理输入字符串提供了一个抽象接口。它允许 swc 轻松地切换到不同类型的输入源,而无需更改大量代码。只要新的输入源实现了 Input trait,swc 就可以使用它来执行词法解析和其他文本处理任务。
  2. 灵活性StringInput 结构体提供了一些特定于 swc 需求的方法,这些方法在标准的 String 类型中可能不可用。例如,StringInput 结构体跟踪当前位置,提供了用于访问字符及其偏移量的方法等。而 Input trait 的抽象性,让 swc 可以针对自己的需求对输入源进行优化。
  3. 性能优化StringInput 结构体可以针对 swc 的特定需求进行性能优化。例如,它使用 str::CharIndices 迭代器来遍历字符串中的字符和相应的字节偏移量。这样,在处理 Unicode 字符串时,可以显著提高性能,因为它避免了多次计算字符和字节偏移量之间的关系。

swc 实现了一个名为 StringInput 的结构体,该结构体是 Input trait 的实现,用于从 SourceFile 结构体中读取字符。

Lexer 结构体

rust 复制代码
struct Lexer<'a, I: Input> {
    comments: Option<&'a dyn Comments>,
    /// [Some] if comment comment parsing is enabled. Otherwise [None]
    comments_buffer: Option<CommentsBuffer>,

    pub(crate) ctx: Context,
    input: I,
    start_pos: BytePos,

    state: State,
    pub(crate) syntax: Syntax,
    pub(crate) target: EsVersion,

    errors: Rc<RefCell<Vec<Error>>>,
    module_errors: Rc<RefCell<Vec<Error>>>,

    atoms: Rc<RefCell<AtomGenerator>>,

    buf: Rc<RefCell<String>>,
}

Lexer 结构体中的关键方法是 read_token,其功能与 babel 中的 getTokenFromCode 方法相似,即读取源代码文本,解析并返回 Token 结构体。read_token 方法的代码组织方式颇具创意,它参考了 ratel-rust 项目,使用名为 BYTE_HANDLERS(类型为 [ByteHandler; 256])的表来查找处理每个 ASCII 字符的函数。相比于使用 switch case 形式,这种方法可能会具有更好的性能,我不确定。

rust 复制代码
 fn read_token(&mut self) -> LexResult<Option<Token>> {
        let byte = match self.input.as_str().as_bytes().first() {
            Some(&v) => v,
            None => return Ok(None),
        };

        let handler = unsafe { *(&BYTE_HANDLERS as *const ByteHandler).offset(byte as isize) };

        match handler {
            Some(handler) => handler(self),
            None => {
                let start = self.cur_pos();
                self.input.bump_bytes(1);
                self.error_span(
                    pos_span(start),
                    SyntaxError::UnexpectedChar { c: byte as _ },
                )
            }
        }
    }

BYTE_HANDLERS 定义在 swc_ecma_parser/src/lexer/table.rs 文件中,代码如下:

rust 复制代码
/// 查找表,将任何输入字节映射到下面定义的处理函数。
pub(super) static BYTE_HANDLERS: [ByteHandler; 256] = [
    //   0    1    2    3    4    5    6    7    8    9    A    B    C    D    E    F   //
    EOF, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, // 0
    ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, ___, // 1
    ___, EXL, QOT, HSH, IDT, PRC, AMP, QOT, PNO, PNC, ATR, PLS, COM, MIN, PRD, SLH, // 2
    ZER, DIG, DIG, DIG, DIG, DIG, DIG, DIG, DIG, DIG, COL, SEM, LSS, EQL, MOR, QST, // 3
    AT_, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, // 4
    IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, BTO, IDT, BTC, CRT, IDT, // 5
    TPL, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, // 6
    IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, IDT, BEO, PIP, BEC, TLD, ERR, // 7
    UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, // 8
    UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, // 9
    UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, // A
    UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, // B
    UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, // C
    UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, // D
    UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, // E
    UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, UNI, // F
];

此数组将所有可能的字节值(0-255)映射到各自的处理函数。表中的每一行代表 16 个字节值,每一列代表这些值在当前行之内的位置。下面是一些处理函数的示例:

  • EOF:处理空字符(ASCII 值为 0)。
  • ___:处理非法字符。这是一个默认处理函数,用于处理未定义特殊行为的字符。
  • DIG:处理数字(ASCII 值在 48-57 之间,对应 '0' 到 '9')。

其他处理函数也以类似的方式工作,处理各种字符类型,如字母、标点符号等。

Span 结构体

从语法分析器的角度看,仅提供词法分析器产生的单词 Token 是不够的。它还需要知道每个单词在源代码中的确切位置。这样,当出现错误、警告或其他反馈时,能够准确地指向源代码中的对应位置。Span 结构体的作用就是存储这些单词的位置信息。

rust 复制代码
pub struct Span {
    /// 表示源代码片段的起始字节位置。
    pub lo: BytePos,
    /// 表示源代码片段的结束字节位置。
    pub hi: BytePos,
    /// 表示与宏扩展相关的上下文信息。如果这个代码片段是由宏扩展创建的,此字段包含宏来源的信息。
    pub ctxt: SyntaxContext,
}

为了进行更详细的处理,Lexer 在实现 Iterator trait 的 next 方法时,增强了 Token ,通过添加 Span 结构体来包含位置信息,从而将返回结果优化为 TokenAndSpan

rust 复制代码
#[derive(Debug, Clone, PartialEq)]
pub struct TokenAndSpan {
    pub token: Token,
    /// 在这个单词前有换行符吗?
    pub had_line_break: bool,
    pub span: Span,
}
相关推荐
正小安21 分钟前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch2 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光2 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   2 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   2 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web2 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常2 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇3 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr3 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
Tiffany_Ho4 小时前
【TypeScript】知识点梳理(三)
前端·typescript