Skip to content

Rollup 切换 parseracornswc

Rollup 即将要从 acorn 切换到 swc,这是一项巨大的工作,还有很多事情要做。

PR 目标

将解析器从 acorn 切换到 swc,同时实际提高性能。作为 Rollup 4 的核心变化。

问题:直接使用 swc.parse 会带来巨大的通讯开销

不幸的是,这并不意味着可以直接使用 swcJavaScript 引用。

ts
import swc from '@swc/core';

const code = `
  const a = 1;
  function add(a, b) {
    return a + b;
  }
`;
swc
  .parse(code, {
    syntax: 'ecmascript',
    comments: false,
    script: true,
    target: 'es3',
    isModule: false
  })
  .then(module => {
    module.type; // file type
    module.body; // AST
  });

由于 swc 在内部通过序列化 AST 来实现 Javascript 与原生之间的数据通讯。

Rust 侧会将解析完成的 AST 序列化为字符串。

rust
pub fn print<T>(
  cm: Lrc<SourceMap>,
  node: &T,
  PrintArgs {
    source_root,
    source_file_name,
    output_path,
    inline_sources_content,
    source_map,
    source_map_names,
    orig,
    comments,
    emit_source_map_columns,
    preamble,
    codegen_config,
    output,
  }: PrintArgs,
) -> Result<TransformOutput, Error>
where
    T: Node + VisitWith<IdentCollector>,
{
    node.emit_with(&mut emitter)
      .context("failed to emit module")?;
}

JS 接口侧再通过 JSON.parse 反序列化原生解析器返回的 AST 字符串为 JS 对象。

ts
class Compiler {
  async parse(
        src: string,
        options?: ParseOptions,
        filename?: string
    ): Promise<Program> {
        options = options || { syntax: 'ecmascript' };
        options.syntax = options.syntax || 'ecmascript';

        if (!bindings && !!fallbackBindings) {
            throw new Error(
                'Fallback bindings does not support this interface yet.'
            );
        } else if (!bindings) {
            throw new Error('Bindings not found.');
        }

        if (bindings) {
            const res = await bindings.parse(src, toBuffer(options), filename);
            return JSON.parse(res);
        } else if (fallbackBindings) {
            return fallbackBindings.parse(src, options);
        }
        throw new Error('Bindings not found.');
    }
}

根据我的经验,对复杂 AST 进行 序列化反序列化 的成本几乎侵蚀了切换为原生解析器(Rust)的性能优势。

问题:swc 的 AST 与 Rollup 的 AST 不兼容

swc 具有非常不同的 AST(即使使用了 ESTree compat 模块,它仍然是 Babel AST,而不是 ESTree AST)。

问题: 文件编码与 Rollup 不同

swc 使用 utf-8 文件位置,而 Rollup 依赖于标准的 JavaScript utf-16 位置。

UTF-8UTF-16 是两种不同的字符编码方式,用于表示文本中的字符。它们的主要区别在于每个字符所占用的字节数和编码方式。

UTF-8 与 UTF-16

UTF-8:

可变长度编码:

UTF-8 使用 1 到 4 个字节来表示一个字符。ASCII字符(如英文字母和数字)使用1个字节,而其他字符(如汉字)可能使用 2 到 4 个字节。

向后兼容ASCII:

由于 ASCII 字符在 UTF-8 中只占用 1 个字节,UTF-8 与 ASCII 编码完全兼容。

UTF-16:

固定或可变长度编码:

UTF-16 通常使用 2 个字节来表示大多数常用字符,但对于某些特殊字符(如表情符号),可能需要 4 个字节。

不兼容ASCII:

UTF-16 不与 ASCII 兼容,因为 ASCII 字符在 UTF-16 中需要2个字节。

示例假设:

对于字符串 A你 编码结果如下。

UTF-8编码:

"A"1 个字节,编码为 0x41

"你"3 个字节,编码为 0xE4BDA0

UTF-16编码:

"A"2 个字节,编码为 0x0041

"你"2 个字节,编码为 0x4F60

在处理文本时,UTF-8UTF-16 的选择会影响到文件的大小和字符位置的计算。例如,UTF-8 的字符位置是基于字节的,而 UTF-16 的字符位置是基于 2字节 的单位。这就是为什么在处理AST(抽象语法树)时,文件位置的编码方式会影响到解析和转换的过程。

解决方案

  1. 问题:无法直接使用 swcJavaScript 引用

    rollup 会直接使用 swcRust 引用,并将 AST 转换为 二进制格式,然后将其作为 (数组)缓冲区 传递给 JavaScript。传递 ArrayBuffer 基本上是一个无损耗的操作,所以我们只需要教 JavaScript 侧如何运转 ArrayBuffer 即可。此外,ArrayBuffer 的大小只有字符串化 JSON 的三分之一左右。最后,这将使我们能够轻松地将 AST 传递给不同的线程,或者更确切地说,在 WebWorker 中进行解析,解析完成后将 ArrayBuffer 无损地传递给主线程。

  2. 问题:swc 的 AST 与 Rollup 的 AST 不兼容

    考虑了 AST 的差异,并更正了文件位置。

为了与 Rust 代码交互,我在 NodeJS 中使用 napi-rs,对于浏览器端可能会使用 wasm-pack 来进行构建。目前,实际转换已经完成。作为该过程的副产品,自定义 swc AST 被转换为符合 ESTreeAST,现已通过 Rollup 中的所有 acorn AST 深度比较测试用例。

注意

swc 转译的 AST 并没有做语法分析,也就是说以下代码 swc 也可以正常解析为 AST

js
const a = 1;
const a = 2;

这与 acorn 的解析方式不一样,acorn 会做语法分析,认为存在如下的语法错误。

Line 2: Identifier 'a' has already been declared.

也就是说 Rollup 根据原生转译过的 AST 二进制流来构建 AST 类实例时,需要做语法分析。

从以下 PR 和讨论

perf: run lint while constructing nodes

#5205 (comment)

中可知,在作用域分析迁移到 Rust 之前,Rollup 不会对 Rust 侧的 AST 进行语法分析。原先 Rollup 会对 swc 产物做 lint 检查(即 swc_ecma_lints)。

rust
use swc_ecma_lints::{rule::Rule, rules, rules::LintParams};

pub fn try_with_handler<F>(
  code: &str,
  cm: &Arc<SourceMap>,
  es_version: EsVersion,
  op: F,
) -> Result<Program, Vec<u8>>
where
  F: FnOnce(&Handler) -> Result<Program, Error>,
{
  let wr = Box::<Writer>::default();
  let emitter = ErrorEmitter { wr: wr.clone() };
  let handler = Handler::with_emitter(true, false, Box::new(emitter));

  let result = HANDLER.set(&handler, || op(&handler));

  match result {
    Ok(mut program) => {
      let unresolved_mark = Mark::new();
      let top_level_mark = Mark::new();
      let unresolved_ctxt = SyntaxContext::empty().apply_mark(unresolved_mark);
      let top_level_ctxt = SyntaxContext::empty().apply_mark(top_level_mark);

      program.visit_mut_with(&mut resolver(unresolved_mark, top_level_mark, false));

      let mut rules = rules::all(LintParams {
        program: &program,
        lint_config: &Default::default(),
        unresolved_ctxt,
        top_level_ctxt,
        es_version,
        source_map: cm.clone(),
      });

      HANDLER.set(&handler, || match &program {
        Program::Module(m) => {
          rules.lint_module(m);
        }
        Program::Script(s) => {
          rules.lint_script(s);
        }
      });

      if handler.has_errors() {
        let buffer = create_error_buffer(&wr, code);
        Err(buffer)
      } else {
        Ok(program)
      }
    }
    Err(_) => {
      if handler.has_errors() {
        let buffer = create_error_buffer(&wr, code);
        Err(buffer)
      } else {
        panic!("Unexpected error in parse")
      }
    }
  }
}

可以了解到由于 Rust 侧的 swcRollup 均没有做作用域分析,也就是说没有做语法检查。取而代之的是 Rollup 会在 Rust 侧对 swc 产物做 lint 操作来间接做语法检查,这无疑增加了 Rollup 的性能负担。

现阶段 RollupRust 侧的语法检查移除,可见如下代码。

rust
pub fn try_with_handler<F>(
  code: &str,
  cm: &Arc<SourceMap>,
  es_version: EsVersion,
  op: F,
) -> Result<Program, Vec<u8>>
where
  F: FnOnce(&Handler) -> Result<Program, Error>,
{
  let wr = Box::<Writer>::default();
  let emitter = ErrorEmitter { wr: wr.clone() };
  let handler = Handler::with_emitter(true, false, Box::new(emitter));

  let result = HANDLER.set(&handler, || op(&handler));

  result.map_err(|_| {
    if handler.has_errors() {
      create_error_buffer(&wr, code)
    } else {
      panic!("Unexpected error in parse")
    }
  })
}

同时将语法检测任务交付给 JavaScript 侧做处理。Rollup 会在 JavaScript AST 节点的 initialise() 阶段做语法分析。

语法分析检测的要点如下:

  1. const_assign
  2. duplicate_bindings
  3. duplicate_exports
  4. no_dupe_args

Released under the MIT License. (dev)