Skip to content

原生解析器

相比 JavaScript, Rust 原生语言在 算法执行 上具有性能优势。Rollup 决定将由 JavaScript 侧的 Acorn 解析器切换到 Rust 侧的 SWC 解析器,具备高效地解析复杂 AST 的能力,这也作为 Rollup v4 的核心变化

挑战

原生交互

直接使用 SWCJavaScript 引用,通过 SWC.parseJavaScript 接口来解析复杂 AST 会带来巨大的通讯开销。

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 源码可以发现 SWC 在内部会使用 serde_json 库将解析完成的 program 对象序列化为 JSON 字符串,传递给 JavaScript 侧。

rust
#[napi]
impl Task for ParseTask {
  type JsValue = String;
  type Output = String;

  fn compute(&mut self) -> napi::Result<Self::Output> {
    let options: ParseOptions = deserialize_json(&self.options)?;
    let fm = self
      .c
      .cm
      .new_source_file(self.filename.clone().into(), self.src.clone());

    let comments = if options.comments {
      Some(self.c.comments() as &dyn Comments)
    } else {
      None
    };

    let program = try_with(self.c.cm.clone(), false, ErrorFormat::Normal, |handler| {
      let mut p = self.c.parse_js(
        fm,
        handler,
        options.target,
        options.syntax,
        options.is_module,
        comments,
      )?;

      p.visit_mut_with(&mut resolver(
        Mark::new(),
        Mark::new(),
        options.syntax.typescript(),
      ));

      Ok(p)
    })
    .convert_err()?;

    let ast_json = serde_json::to_string(&program)?;

    Ok(ast_json)
  }

  fn resolve(&mut self, _env: Env, result: Self::Output) -> napi::Result<Self::JsValue> {
    Ok(result)
  }
}

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

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.');
  }
}

RustJavaScript 之间,反复的对 AST 进行 序列化(Rust侧)反序列化(JavaScript侧),那么解析复杂 AST 时将几乎侵蚀了切换为原生解析器(Rust)的性能优势。

抽象语法树兼容性

SWCRust 侧设计了特有的 AST 结构,而 Rollup 依赖于标准的 ESTree AST,两者在 AST 结构上存在差异,因此需要进行兼容性处理。

值得注意的是,SWC 提供了 swc_estree_compat 兼容层,提供解析产物为 Babel ASTESTree AST 两种 AST 结构,但还存在 性能问题

Nearly, but it would be very slow at the moment because of JSON.parse of large AST is very slow

文件编码

SWC 使用 UTF-8 编码,而 Rollup 依赖于标准 JavaScriptUTF-16 编码。

UTF-8 与 UTF-16 的区别

UTF-8

可变长度编码:

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

  • 1 字节ASCII 字符(U+0000U+007F)。
  • 2 字节:扩展拉丁文字符(U+0080U+07FF)。
  • 3 字节:基本多文种平面(BMP)字符(U+0800U+FFFF)。
  • 4 字节:辅助平面字符(U+10000U+10FFFF)。

向后兼容 ASCII:

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

编码效率:

  • 对英语和 ASCII 文本效率高(每个字符 1 字节)。
  • 对非拉丁字符(如中文、日文等),通常需要 3 个字节。
  • 对辅助平面字符(如表情符号),需要 4 个字节。

适用场景:

  • 更适合网络传输和存储,尤其是以 ASCII 为主的文本。
  • 常用于网页、JSON 文件等场景。

UTF-16:

固定或可变长度编码:

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

  • 2 字节BMP 范围内的字符(U+0000U+FFFF,除去代理对)。
  • 4 字节:超出 BMP 的字符(U+10000U+10FFFF),使用两个 16 位单元(称为代理对)。

不兼容 ASCII

UTF-16 不与 ASCII 兼容,因为 ASCII 字符在 UTF-16 中需要 2 个字节。但 UTF-8UTF-16 处理 ASCII 的每一个字符均可视为一单位。

编码效率:

  • BMP 范围内字符(如大部分中文、日文)效率较高(每个字符 2 字节)。
  • ASCII 字符效率较低(每个字符 2 字节)。
  • 对辅助平面字符效率与 UTF-8 类似(需要 4 字节)。

适用场景:

  • 更适合内存操作,尤其是在以 BMP 范围字符为主的场景(如中文环境)。
  • 常用于 WindowsJavaScriptJava 等的内部字符表示。

以字符串 A你 为例,两种编码方式的编码结果如下:

UTF-8 编码:

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

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

UTF-16 编码:

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

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

SWC(Rust) 采用的是 字节偏移量,换句话说在计算 "A你" 的位置偏移量时,采用的是 字节偏移量 计算方式:

"A你": "(1) + A(1) + 你(3) + "(1) = 6 个字节 (即 Rust 计算:"A你".len() = 4)。

SWC AST 中记录的位置信息如下:

SWC Abstract Syntax Tree
json
{
  "type": "Module",
  "span": {
    "start": 0,
    "end": 6,
    "ctxt": 0
  },
  "body": [
    {
      "type": "ExpressionStatement",
      "span": {
        "start": 0,
        "end": 6,
        "ctxt": 0
      },
      "expression": {
        "type": "StringLiteral",
        "span": {
          "start": 0,
          "end": 6,
          "ctxt": 0
        },
        "value": "A你",
        "hasEscape": false,
        "kind": {
          "type": "normal",
          "containsQuote": true
        }
      }
    }
  ],
  "interpreter": null
}

JavaScript 采用的是 字符偏移量,采用的是 UTF-16 编码模式,可以将所有的字符划分为 2 字节以及 4 字节。而对于 JavaScript 来说一个字符的基本单位是 2 字节。换句话说特殊字符(表情包)占据了 4 字节,那么划分为字符就是 2 个字符。

那么计算 "A你" 的位置偏移量时,采用的是 字符偏移量 计算方式,即:

"A你": "(1) + A(1) + 你(1) + "(1) = 4 个字符 (即 JavaScript 计算:"A你".length = 2)。

ESTree AST 中记录的位置信息如下:

ESTree Abstract Syntax Tree
json
{
  "type": "Program",
  "start": 0,
  "end": 4,
  "body": [
    {
      "type": "ExpressionStatement",
      "start": 0,
      "end": 4,
      "expression": {
        "type": "Literal",
        "start": 0,
        "end": 4,
        "value": "A你",
        "raw": "\"A你\""
      },
      "directive": "A你"
    }
  ],
  "sourceType": "module"
}

小结

上述所描述的现象,正是 字符偏移量(ESTree)和 字节偏移量(SWC)产生分歧的根本原因。

ESTree / Babel / Acorn (字符偏移量):

遵循 JavaScriptString.length 逻辑。

计算的是 UTF-16 编码单元(Code Units) 的数量。

"你好": "(1) + 你(1) + 好(1) + "(1) = 4 个单元 (即长度 4)。


"👍": "(1) + 👍(2) + "(1) = 4 个单元 (即长度 4)。

JavaScript(以及 ESTree)在计算长度和偏移时,其 一个字符 的单位,是指 2 字节的 编码单元,这导致 4 字节的 Emoji 被算作 2 个字符 的长度。

SWC (字节偏移量):

计算的是源文件(通常是 UTF-8 编码)的 字节(Bytes) 数量。

"你好": "(1) + 你(3) + 好(3) + "(1) = 8 个字节 (即长度 8)。


"👍": "(1) + 👍(4) + "(1) = 6 个字节 (即长度 6)。

SourceMap 这一章节详细说明了 Rollup 内部是如何生成 SourceMap,其中 Rollup 会依赖 ESTree AST 提供的 位置信息映射标记

ts
export class NodeBase extends ExpressionEntity implements ExpressionNode {
  /**
   * Override to perform special initialisation steps after the scope is
   * initialised
   */
  initialise(): void {
    this.scope.context.magicString.addSourcemapLocation(this.start);
    this.scope.context.magicString.addSourcemapLocation(this.end);
  }
}

因此,由于原生编程语言(Rust)和 JavaScript 编码方式不一样,因此获取的 AST 位置信息不一致,RollupRust 原生侧需要重新调整 SWC AST 的位置信息,使其符合 JavaScript字符偏移量 计算方式。

性能

对抽象语法树兼容性的优化

Rust 侧借助 SWC 的能力将代码解析为 SWC AST

rust
use swc_compiler_base::parse_js;

pub fn parse_ast(code: String, allow_return_outside_function: bool, jsx: bool) -> Vec<u8> {
  // 省略其他代码
  GLOBALS.set(&Globals::default(), || {
    let result = catch_unwind(AssertUnwindSafe(|| {
      let result = try_with_handler(&code_reference, |handler| {
        parse_js(
          cm,
          file,
          handler,
          target,
          syntax,
          IsModule::Unknown,
          Some(&comments),
        )
      });
      match result {
        Err(buffer) => buffer,
        Ok(program) => {
          let annotations = comments.take_annotations();
          let converter = AstConverter::new(&code_reference, &annotations);
          converter.convert_ast_to_buffer(&program)
        }
      }
    }));
    result.unwrap_or_else(|err| {
      let msg = if let Some(msg) = err.downcast_ref::<&str>() {
        msg
      } else if let Some(msg) = err.downcast_ref::<String>() {
        msg
      } else {
        "Unknown rust panic message"
      };
      get_panic_error_buffer(msg)
    })
  })
}

通过 converter.convert_ast_to_buffer(&program) 方法递归解析经 SWC 解析完成的 SWC AST 树,重新计算 SWC AST 节点位置信息所对应的 ESTree AST 的位置信息

rust
/// Converts the given UTF-8 byte index to a UTF-16 byte index.
///
/// To be performant, this method assumes that the given index is not smaller
/// than the previous index. Additionally, it handles "annotations" like
/// `@__PURE__` comments in the process.
///
/// The logic for those comments is as follows:
/// - If the current index is at the start of an annotation, the annotation
///   is collected and the index is advanced to the end of the annotation.
/// - Otherwise, we check if the next character is a white-space character.
///   If not, we invalidate all collected annotations.
///   This is to ensure that we only collect annotations that directly precede
///   an expression and are not e.g. separated by a comma.
/// - If annotations are relevant for an expression, it can "take" the
///   collected annotations by calling `take_collected_annotations`. This
///   clears the internal buffer and returns the collected annotations.
/// - Invalidated annotations are attached to the Program node so that they
///   can all be removed from the source code later.
/// - If an annotation can influence a child that is separated by some
///   non-whitespace from the annotation, `keep_annotations_for_next` will
///   prevent annotations from being invalidated when the next position is
///   converted.
pub(crate) fn convert(&mut self, utf8_index: u32, keep_annotations_for_next: bool) -> u32 {
  if self.current_utf8_index > utf8_index {
    panic!(
      "Cannot convert positions backwards: {} < {}",
      utf8_index, self.current_utf8_index
    );
  }
  while self.current_utf8_index < utf8_index {
    if self.current_utf8_index == self.next_annotation_start {
      let start = self.current_utf16_index;
      let (next_comment_end, next_comment_kind) = self
        .next_annotation
        .map(|a| (a.comment.span.hi.0 - 1, a.kind.clone()))
        .unwrap();
      while self.current_utf8_index < next_comment_end {
        let character = self.character_iterator.next().unwrap();
        self.current_utf8_index += character.len_utf8() as u32;
        self.current_utf16_index += character.len_utf16() as u32;
      }
      if let Annotation(kind) = next_comment_kind {
        self.collected_annotations.push(ConvertedAnnotation {
          start,
          end: self.current_utf16_index,
          kind,
        });
      }
      self.next_annotation = self.annotation_iterator.next();
      self.next_annotation_start = get_annotation_start(self.next_annotation);
    } else {
      let character = self.character_iterator.next().unwrap();
      if !(self.keep_annotations || self.collected_annotations.is_empty()) {
        match character {
          ' ' | '\t' | '\r' | '\n' => {}
          _ => {
            self.invalidate_collected_annotations();
          }
        }
      }
      self.current_utf8_index += character.len_utf8() as u32;
      self.current_utf16_index += character.len_utf16() as u32;
    }
  }
  self.keep_annotations = keep_annotations_for_next;
  self.current_utf16_index
}

同时还需要收集 ESTree AST 节点结构所需的信息。

rust
pub(crate) fn convert_statement(&mut self, statement: &Stmt) {
  match statement {
    Stmt::Break(break_statement) => self.store_break_statement(break_statement),
    Stmt::Block(block_statement) => self.store_block_statement(block_statement, false),
    Stmt::Continue(continue_statement) => self.store_continue_statement(continue_statement),
    Stmt::Decl(declaration) => self.convert_declaration(declaration),
    Stmt::Debugger(debugger_statement) => self.store_debugger_statement(debugger_statement),
    Stmt::DoWhile(do_while_statement) => self.store_do_while_statement(do_while_statement),
    Stmt::Empty(empty_statement) => self.store_empty_statement(empty_statement),
    Stmt::Expr(expression_statement) => self.store_expression_statement(expression_statement),
    Stmt::For(for_statement) => self.store_for_statement(for_statement),
    Stmt::ForIn(for_in_statement) => self.store_for_in_statement(for_in_statement),
    Stmt::ForOf(for_of_statement) => self.store_for_of_statement(for_of_statement),
    Stmt::If(if_statement) => self.store_if_statement(if_statement),
    Stmt::Labeled(labeled_statement) => self.store_labeled_statement(labeled_statement),
    Stmt::Return(return_statement) => self.store_return_statement(return_statement),
    Stmt::Switch(switch_statement) => self.store_switch_statement(switch_statement),
    Stmt::Throw(throw_statement) => self.store_throw_statement(throw_statement),
    Stmt::Try(try_statement) => self.store_try_statement(try_statement),
    Stmt::While(while_statement) => self.store_while_statement(while_statement),
    Stmt::With(_) => unimplemented!("Cannot convert Stmt::With"),
  }
}

通过 SWC AST 节点结构获取 ESTree AST 节点所需的信息,使用 UTF-16 编码方式重新计算 ESTree AST 规范下的 位置信息

rust
pub(crate) fn convert_item_list_with_state<T, S, F>(
    &mut self,
    item_list: &[T],
    state: &mut S,
    reference_position: usize,
    convert_item: F,
  ) where
  F: Fn(&mut AstConverter, &T, &mut S) -> bool,
{
  // for an empty list, we leave the referenced position at zero
  if item_list.is_empty() {
    return;
  }
  self.update_reference_position(reference_position);
  // store number of items in first position
  self
    .buffer
    .extend_from_slice(&(item_list.len() as u32).to_ne_bytes());
  let mut reference_position = self.buffer.len();
  // make room for the reference positions of the items
  self
    .buffer
    .resize(self.buffer.len() + item_list.len() * 4, 0);
  for item in item_list {
    let insert_position = (self.buffer.len() as u32) >> 2;
    if convert_item(self, item, state) {
      self.buffer[reference_position..reference_position + 4]
        .copy_from_slice(&insert_position.to_ne_bytes());
    }
    reference_position += 4;
  }
}

当然其中也会对 comments 节点做收集,为后续 RollupTree Shaking 做准备。需要注意的是 ESTree AST 规范是不包含 comments 节点的,但 comments 节点的信息对于 RollupTree Shaking 至关重要,可以增强 Tree Shaking 的能力。

Rollup 会收集这些注释信息在 ESTree AST 中,并通过 _rollupAnnotations 属性进行存储。也就是说,最终返回的 ESTree AST 是额外包含 _rollupAnnotations 属性的,其结构是符合 ESTree AST 规范的。

rust
pub(crate) fn take_collected_annotations(
  &mut self,
  kind: AnnotationKind,
) -> Vec<ConvertedAnnotation> {
  let mut relevant_annotations = Vec::new();
  for annotation in self.collected_annotations.drain(..) {
    if annotation.kind == kind {
      relevant_annotations.push(annotation);
    } else {
      self.invalid_annotations.push(annotation);
    }
  }
  relevant_annotations
}
impl<'a> AstConverter<'a> {
  pub(crate) fn store_call_expression(
    &mut self,
    span: &Span,
    is_optional: bool,
    callee: &StoredCallee,
    arguments: &[ExprOrSpread],
    is_chained: bool,
  ) {
  // annotations
  let annotations = self
    .index_converter
    .take_collected_annotations(AnnotationKind::Pure);
}
impl SequentialComments {
  pub(crate) fn add_comment(&self, comment: Comment) {
    if comment.text.starts_with('#') && comment.text.contains("sourceMappingURL=") {
      self.annotations.borrow_mut().push(AnnotationWithType {
        comment,
        kind: CommentKind::Annotation(AnnotationKind::SourceMappingUrl),
      });
      return;
    }
    let mut search_position = comment
      .text
      .chars()
      .nth(0)
      .map(|first_char| first_char.len_utf8())
      .unwrap_or(0);
    while let Some(Some(match_position)) = comment.text.get(search_position..).map(|s| s.find("__"))
    {
      search_position += match_position;
      // Using a byte reference avoids UTF8 character boundary checks
      match &comment.text.as_bytes()[search_position - 1] {
        b'@' | b'#' => {
          let annotation_slice = &comment.text[search_position..];
          if annotation_slice.starts_with("__PURE__") {
            self.annotations.borrow_mut().push(AnnotationWithType {
              comment,
              kind: CommentKind::Annotation(AnnotationKind::Pure),
            });
            return;
          }
          if annotation_slice.starts_with("__NO_SIDE_EFFECTS__") {
            self.annotations.borrow_mut().push(AnnotationWithType {
              comment,
              kind: CommentKind::Annotation(AnnotationKind::NoSideEffects),
            });
            return;
          }
        }
        _ => {}
      }
      search_position += 2;
    }
    self.annotations.borrow_mut().push(AnnotationWithType {
      comment,
      kind: CommentKind::Comment,
    });
  }

  pub(crate) fn take_annotations(self) -> Vec<AnnotationWithType> {
    self.annotations.take()
  }
}

最后返回给 Rollup 侧是兼容 ESTree ASTArrayBuffer 结构,JavaScript 侧则需引导解析 ArrayBuffer 的兼容 ESTree AST 结构来实例化 Rollup 内部实现的 AST Class Node

ts
export default class Module {
  async setSource({
    ast,
    code,
    customTransformCache,
    originalCode,
    originalSourcemap,
    resolvedIds,
    sourcemapChain,
    transformDependencies,
    transformFiles,
    ...moduleOptions
  }: TransformModuleJSON & {
    resolvedIds?: ResolvedIdMap;
    transformFiles?: EmittedFile[] | undefined;
  }): Promise<void> {
    // Measuring asynchronous code does not provide reasonable results
    timeEnd('generate ast', 3);
    const astBuffer = await parseAsync(
      code,
      false,
      this.options.jsx !== false
    );
    timeStart('generate ast', 3);
    this.ast = convertProgram(astBuffer, programParent, this.scope);
  }
}

Rollupbuffer 层面的引导方式

ts
function convertNode(
  parent: Node | { context: AstContext; type: string },
  parentScope: ChildScope,
  position: number,
  buffer: AstBuffer
): any {
  const nodeType = buffer[position];
  const NodeConstructor = nodeConstructors[nodeType];
  /* istanbul ignore if: This should never be executed but is a safeguard against faulty buffers */
  if (!NodeConstructor) {
    console.trace();
    throw new Error(`Unknown node type: ${nodeType}`);
  }
  const node = new NodeConstructor(parent, parentScope);
  node.type = nodeTypeStrings[nodeType];
  node.start = buffer[position + 1];
  node.end = buffer[position + 2];
  bufferParsers[nodeType](node, position + 3, buffer);
  node.initialise();
  return node;
}

对原生交互的优化

由上述可知,直接使用 SWC 暴露的 JavaScript 引用会在 RustJavaScript 之间进行反复 序列化反序列化 AST 的操作。在处理复杂的 AST 时,解析效率几乎侵蚀了切换为原生解析器(Rust)的性能优势。

解决方案如下

采用 ArrayBuffer 来做 RustJavaScript 之间传输解析完成的 AST

不考虑使用 SWCJavaScript 引用,而是直接在 Rust 侧使用 SWCRust 侧引用。

rust
use swc_compiler_base::parse_js;

pub fn parse_ast(code: String, allow_return_outside_function: bool, jsx: bool) -> Vec<u8> {
  GLOBALS.set(&Globals::default(), || {
    let result = catch_unwind(AssertUnwindSafe(|| {
      let result = try_with_handler(&code_reference, |handler| {
        parse_js(
          cm,
          file,
          handler,
          target,
          syntax,
          IsModule::Unknown,
          Some(&comments),
        )
      });
      match result {
        Err(buffer) => buffer,
        Ok(program) => {
          let annotations = comments.take_annotations();
          let converter = AstConverter::new(&code_reference, &annotations);
          converter.convert_ast_to_buffer(&program)
        }
      }
    }));
  });
}

同时 Rollup 会在 Rust 侧将 SWC 解析完成的 SWC AST 转换为兼容 ESTree AST二进制格式,然后将其作为 (数组)缓冲区 传递给 JavaScript

rust
match result {
  Err(buffer) => buffer,
  Ok(program) => {
    let annotations = comments.take_annotations();
    let converter = AstConverter::new(&code_reference, &annotations);
    converter.convert_ast_to_buffer(&program)
  }
}

传递 ArrayBuffer 基本上是一个无损耗的操作,所以我们只需要教 JavaScript 侧如何根据 ArrayBuffer 结构解析出 AST 实例即可,此外,ArrayBuffer 的大小只有字符串化 JSON 的三分之一左右。

ArrayBuffer 数据格式在不同线程间传递也是高效的,例如可以在 WebWorker 中进行解析,解析完成后将 ArrayBuffer 数据格式的 AST 无损地传递给主线程。

Node.js 侧使用 napi-rsRust 代码交互,浏览器端采用 wasm-pack 来进行构建。

优化语义分析

解析器 语义分析设计

Rust 侧直接调用 SWC 提供的 use swc_compiler_base::parse_js 并不会执行 语义分析,只处理 词法分析语法分析。也就是说以下代码在 SWC 中可以正常解析为 SWC AST,而不会报错。

js
const a = 1;
const a = 2;

这与 Acorn 的解析方式不一样,Acorn 在生成 AST 时会额外执行完整的 静态语义分析 --- Static Semantics: Early Errors,即在执行程序前检测错误。

ECMAScript Static Semantics: Early Errors

Early Errors 是 ECMAScript 规范中定义的静态语义错误检测机制。根据 ECMA-262 规范,这些错误必须在代码执行前的解析阶段被检测和报告。

规范权威来源:

本质的原因是 Acorn 被设计为一个符合 ECMAScript 规范的解析器,ECMAScriptJavaScript 引擎执行代码之前,会要求执行 Static Semantics: Early Errors 的步骤(本质是 静态语义分析),在解析和早期语法分析阶段就需要检测和报告的错误。这些错误是通过静态解析的,意味着不需要实际运行代码就能发现它们。

Browsers、Node.js 等内置 JavaScript 引擎在执行代码之前,也是会执行 Static Semantics: Early Errors 的步骤。

规范的意义在于:

  1. 提前发现问题:在代码实际运行之前就能发现潜在的错误,避免运行时才暴露的问题。
  2. 提高性能:由于这些检查是在静态分析阶段完成的,不需要等到运行时才能发现错误,这样可以提高代码的执行效率。
  3. 保证语言的一致性:通过统一的早期错误检查机制,确保 JavaScript 代码在不同环境下都能得到一致的处理。
  4. 帮助开发者写出更规范的代码:这些规则实际上也在指导开发者遵循更好的编程实践。

SWCBabel 等解析器在生成 AST 时并不会执行 Static Semantics: Early Errors 的步骤,也就是说他们与 Acorn 的设计目标不同。那么接下来先介绍一下前者为什么要将 语法分析静态语义分析 进行分离。

  1. 性能和复杂性权衡

    实现 Early Errors 检测需要解析器做如下几件事情:

    • 模拟和维护当前执行语句的执行上下文的作用域及其作用域链。
    • 静态规则检查。
      • 语言规范定义的其他静态语义规则检测。
      • 语法限制规则检测。
      • 模块系统的静态验证规则检测。

    虽然检测的复杂度并非很高,但在大型项目中,若用户每次转译新代码都需要进行 Early Errors 检查,那么累计的复杂度对完整的 Early Errors 检查可能会带来部分性能开销,这是不可忽视的。

  2. 工具链的分工

    SWCBabel 等解析器的关注点在于 代码转换,主要是以插件的形式注入到构建体系的代码转换流程中。而工具若考虑强融入于各个构建体系的生态中,最容易的做法就是保持 单一职责原则

    通过将解析和语义分析分开:

    • 解析器 可以专注于生成准确的 AST
    • 语义分析器 可以专注于检查代码的正确性。
    • 每个部分 都更容易维护和优化。
  3. 灵活性

    在复杂应用模块的转译过程通常并非一蹴而就,而是会存在中间态,中间态的代码很大程度上是不符合语义规范的。如果转译工具进行严格的语义分析,这样的代码将无法通过编译,影响能力扩展。现代开发工具链通过将不同的检查分散到不同阶段,按需执行语义分析,在开发灵活性和代码质量之间取得了平衡。

BabelSWC 选择将语法分析和 Early Errors 检测的职责进行分离,在插件转译代码阶段将代码解析为 AST 只做词法分析和语法分析,并不会执行 Early Errors 检查(静态语义分析),而是在合适的时机(如 Rolluptransform 阶段完成)由 Bundlers(如 Rollup) 来控制并执行 Early Errors 检查。

这种设计选择反映了工程实践中的一个重要原则:有时候,将一个复杂的问题分解成多个独立的步骤,可能比试图在一个步骤中解决所有问题更有效。这让每个工具都能够专注于自己的核心任务,从而提供更好的功能和性能。

Rollup Plugin System Design Inspiration

上述的设计方式在 Rollup 的插件体系中也有一定的体现,当用户插件在 load(或 transform) 钩子中将 AST 进行返回,那么 Rollup 在后续的 transform 钩子中就会复用用户插件返回的 AST。在 Rollup 完成 transform 阶段之前,Rollup 不会对复用的 AST 进行任何的语义分析。

js
const a = 1;
const a = 2;

对于上述例子 Acorn 会提供如下 报错信息

js
while (this.type !== tt.braceR) {
  const element = this.parseClassElement(node.superClass !== null);
  if (element) {
    classBody.body.push(element);
    if (
      element.type === 'MethodDefinition' &&
      element.kind === 'constructor'
    ) {
      if (hadConstructor)
        this.raiseRecoverable(
          element.start,
          'Duplicate constructor in the same class'
        );
      hadConstructor = true;
    } else if (
      element.key &&
      element.key.type === 'PrivateIdentifier' &&
      isPrivateNameConflicted(privateNameMap, element)
    ) {
      this.raiseRecoverable(
        element.key.start,
        `Identifier '#${element.key.name}' has already been declared`
      );
    }
  }
}

报错提示

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

因此 Rollup 需要借助 swc_ecma_lints 的能力来实现更为完整的 语义分析

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

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)
    }
  }
}

JavaScript 侧实现语义分析

但是从以下 PR讨论 中可知

经测试发现通过 swc_ecma_lints 检测的效率并不是很高。

为了优化这个问题,Rollup 原生解析器中,暂时决定在 Rust 侧还未实现 作用域分析 前,Rollup 移除了在 Rust 侧对 AST 执行完整的 语义分析

rust
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) 
    } 
  } 
} 
result.map_err(|_| { 
  if handler.has_errors() { 
    create_error_buffer(&wr, code) 
  } else { 
    panic!("Unexpected error in parse") 
  } 
}) 

语义分析 的任务交付给 JavaScript 侧做处理。

Rollup 会在实例化 AST Class Node 的回溯阶段时,执行更为完善的 语义分析。经测试,JavaScript 侧执行 语义分析 的效率比借助原生 SWCswc_ecma_lints 要快得多,可见在 JavaScript 侧执行 语义分析 并没有对 Rollup 的性能造成太大影响。

Early Errors 检测能力对比

为了验证上述设计的实际效果,我们基于 ECMAScript 规范编写了完整的 Early Errors 测试套件,涵盖 11 个主要分类的 97 个测试用例。测试结果如下:

测试环境与方法

测试环境:

  • Node.js: v22.x
  • Acorn: ^8.14.0
  • Rollup: ^4.53.3

测试方法:

  • 每个测试用例包含一段应该触发 Early Error 的代码
  • 测试解析器是否正确检测并报告错误
  • 部分测试用例验证合法代码不应报错

测试覆盖范围:

  1. 标识符和绑定错误 (Identifier and Binding Errors)
  2. 函数参数错误 (Function Parameter Errors)
  3. 函数体错误 (Function Body Errors)
  4. 类错误 (Class Errors)
  5. 模块错误 (Module Errors)
  6. 控制流错误 (Control Flow Errors)
  7. 赋值错误 (Assignment Errors)
  8. 字面量错误 (Literal Errors)
  9. 严格模式错误 (Strict Mode Errors)
  10. 正则表达式错误 (Regular Expression Errors)
  11. for-in/of 错误 (For-in/of Errors)
解析器/模式通过率通过/总数说明
Acorn100%97/97完整实现 Early Errors
SWC Parser (默认)38.1%37/97语法分析 + 严格模式检测
Rollup parseAst33.0%32/97语法分析(IsModule::Unknown 配置)
Rollup Full Build54.6%53/97parseAst + JavaScript 侧语义分析

关键发现

Rollup 的 parseAst 函数不完整实现 ECMAScript Early Errors 检测。

这验证了上述论述:SWC 在生成 AST 时并不会执行 Static Semantics: Early Errors 的步骤,将 语义分析 的任务交付给 JavaScript 侧做处理。

详细检测能力分析

1. Rollup parseAst 可检测的错误(纯语法层面):

这些错误无需作用域分析,在词法/语法分析阶段即可检测:

错误类型示例规范引用
控制流位置break; / continue;Section 14.8.1
return 位置return 1; (函数外)Section 15.1.1
yield/await 位置function f() { yield 1; }Section 15.5.1
字面量赋值1 = 2;Section 13.15.1
rest 语法let [...a, b] = x;Section 13.2.3
正则表达式/a/gg;Section 22.2.1.1
数字分隔符1__0;Section 12.9.1
类构造函数重复/async/generator constructorSection 15.7.1
标签错误L: L: for(;;) {}Section 14.13.1

语法分析阶段检测率: 32/40 (80.0%)

2. Rollup Full Build 额外检测的错误(需要作用域分析):

这些错误在 AST 节点实例化时通过 initialise() 方法检测:

错误类型示例检测位置规范引用
重复 let/const 声明let a=1; let a=2;Scope.addDeclaration()Section 14.3.1.1
重复参数function f(a, a) {}ParameterScope.addParameterDeclaration()Section 15.1.1
重复导出export default 1; export default 2;Module.assertUniqueExportName()Section 16.2.1.1
重复导入绑定import { a, a } from "x"Module.addImport()Section 16.2.1.1
const 重新赋值const x=1; x=2;AssignmentExpression.initialise()Section 14.3.1.1

语义分析阶段检测率: 21/57 (36.8%)

3. Rollup 未实现的 Early Errors:

以下 Early Errors 在 Rollup 中未被检测:

错误类型示例规范引用说明
eval/arguments 限制function f(eval) {}Section 15.1.1严格模式保留字
await 作为标识符let await = 1;Section 15.8.1模块顶层/async 限制
八进制字面量010;Section 12.9.4.1严格模式禁止
八进制转义"\07";Section 12.9.4.1严格模式禁止
重复私有字段class A { #x; #x; }Section 15.7.1类私有字段检测
重复 proto{ __proto__: 1, __proto__: 2 }Section 13.2.5.1对象字面量限制
delete 标识符delete x;Section 13.5.1.1严格模式禁止
let 作为变量名var let = 1;Section 13.3.1.1严格模式保留字
super() 位置class A { foo() { super(); } }Section 15.7.1只能在 constructor 中

架构图

bash
┌─────────────────────────────────────────────────────────────┐
                      Rust (SWC)                          │
├─────────────────────────────────────────────────────────────┤
  源代码 词法分析 语法分析 SWC AST ArrayBuffer

 基本语法错误检测 (break/continue/return 位置等)          │
 不执行作用域分析
 不检测重复声明/导出

  语法分析阶段检测率: 80.0% (32/40)                           │
  总体检测率: 33.0% (32/97)                                   │
└─────────────────────┬───────────────────────────────────────┘
 ArrayBuffer (二进制格式)

┌─────────────────────────────────────────────────────────────┐
                   JavaScript (Rollup)                     │
├─────────────────────────────────────────────────────────────┤
  convertNode()  new NodeConstructor()  node.initialise()  

 构建作用域链 (Scope/ChildScope)                          │
 检测重复声明 (addDeclaration)                             │
 检测重复参数 (addParameterDeclaration)                    │
 检测重复导出 (addExport  assertUniqueExportName)         │
 检测 const 重新赋值 (AssignmentExpression.initialise)     │
 部分严格模式限制未实现

  语义分析阶段检测率: 36.8% (21/57)                           │
  Full Build 总体检测率: 54.6% (53/97)                        │
└─────────────────────────────────────────────────────────────┘

实际影响

  • parseAst(): 只得到语法错误,无语义分析。适合只需要 AST 结构的场景。
  • Full Build: 得到核心语义错误(重复声明/导出等),但不是所有 Early Errors。适合实际打包场景。
  • Acorn: 100% Early Errors 检测。适合需要完整规范验证的场景。

设计权衡

Rollup 的设计选择反映了工程实践中的权衡:

  1. 实现了对打包最关键的语义检测:重复绑定、重复导出等会导致运行时错误
  2. 省略了部分严格模式相关检测:这些通常由 IDE 或 linter(如 ESLint)处理
  3. 保持了性能优势:避免在 Rust 侧做完整的语义分析

这种分层设计让每个工具专注于自己的核心任务,同时保证了最终产物的正确性。

深度分析:Early Errors 检测机制

1. Early Errors 的定义与分类

根据 ECMAScript 规范(ECMA-262),Early Errors 是指在代码执行前的静态分析阶段必须被检测和报告的错误。这些错误涵盖了从语法约束到语义约束的多个层面。

从实现角度,Early Errors 可分为两个主要类别:

第一类:语法分析阶段可检测的错误(约 41%)

此类错误仅需当前语法上下文信息即可判定,无需维护符号表或作用域链。典型示例包括:

  • 控制流语句的位置约束:如 break/continue 必须在循环或 switch
  • 赋值目标的合法性检查:如字面量不能作为赋值左值
  • 解构语法约束:如 rest 元素必须位于最后
  • 字面量语法约束:如数字分隔符、正则表达式语法
  • 类构造函数语法约束:如不能有重复 constructor、不能是 async/generator

第二类:语义分析阶段的错误(约 59%)

此类错误需要构建和维护符号表、作用域链或模块绑定表才能检测。包括:

  • 重复声明检测let/const/var 之间的冲突
  • 重复参数检测:函数参数名不能重复
  • 重复导出/导入检测:模块导出/导入绑定不能重复
  • const 重新赋值检测:常量不能被重新赋值
  • 严格模式标识符限制eval/arguments 的使用限制

基于 97 个规范测试用例的完整测试表明,不同解析器的覆盖率因设计目标和实现策略的不同而存在显著差异。


2. 解析器检测能力的定量对比

通过系统化测试验证,各解析器的 Early Errors 检测能力存在明显分层:

解析器语法分析阶段语义分析阶段总计说明
Acorn40/40 (100%)57/57 (100%)97/97 (100%)完整实现
SWC Parser (默认)37/40 (92.5%)0/57 (0%)37/97 (38.1%)包含严格模式检测
SWC Parser (Unknown)32/40 (80%)0/57 (0%)32/97 (33.0%)不检测严格模式
Rollup parseAst32/40 (80%)0/57 (0%)32/97 (33.0%)= SWC Unknown 模式
Rollup Full Build32/40 (80%)21/57 (36.8%)53/97 (54.6%)+ JS 侧语义分析

关键发现

  • Acorn 作为符合 ECMAScript 规范的完整实现,在两个阶段均实现了完整检测
  • SWC Parser 完全不执行需要符号表的语义分析,但在默认配置下能检测严格模式约束
  • Rollup parseAst 使用 SWC 作为底层解析器,但配置差异导致检测能力略低于 SWC 默认配置
  • Rollup Full Build 在 JavaScript 侧实现了对打包场景最关键的语义检测

3. SWC Parser 与 Rollup parseAst 的差异分析

实际测试发现,SWC Parser(通过 @swc/core JavaScript API 直接调用)能够检测 5 个 Rollup parseAst 无法检测的错误:

错误类型示例SWC (默认)Rollup parseAst特征
for-in 初始化器for (var a = 1 in x) {}严格模式限制
eval 作为参数名function f(eval) {}严格模式限制
await 作为标识符let await = 1;模块模式限制
八进制字面量010;严格模式限制
delete 标识符delete x;严格模式限制

这些错误具有共同特征:均与 ECMAScript 的严格模式模块模式的语义限制相关。

根本原因验证

通过版本验证确认,@swc/core 1.15.2Rollup 4.53.3 内置的 swc_ecma_parser 27.0.2 版本差异不大,且在相同配置下行为完全一致。深入分析 Rollup 源码(rust/parse_ast/src/lib.rs)发现:

rust
parse_js(
  cm, file, handler, target, syntax,
  IsModule::Unknown,  // ← 关键配置
  Some(&comments),
)

对照实验结果

SWC 配置检测 for (var a = 1 in x) {}检测率
默认 (未指定 isModule)✅ ERROR37/97
isModule: true✅ ERROR37/97
isModule: false❌ NO ERROR32/97
isModule: "unknown"❌ NO ERROR32/97
Rollup parseAst❌ NO ERROR32/97

结论:Rollup parseAst 与 SWC Parser 的差异根本原因在于配置方式的不同IsModule::Unknown),而非解析器版本或实现差异。


4. 设计决策的深层次权衡

Rollup 选择使用 IsModule::Unknown 配置并非实现缺陷,而是基于现代构建工具链架构的深思熟虑的工程权衡。这一设计决策从多个维度体现了在完整性、灵活性和性能之间的精准平衡。

从灵活性的角度审视,Unknown 模式赋予解析器自动判断代码类型的能力,使其能够适配模块代码和脚本代码两种截然不同的语义环境。这种设计避免了因模式预判错误导致合法代码解析失败的风险,特别是在处理第三方库或遗留代码时,这种容错能力显得尤为重要。更进一步,这一配置支持用户在插件系统中返回中间态 AST,这些中间态代码可能在转换过程中暂时不符合严格模式的约束,但最终会在后续阶段被规范化处理。

从容错性的角度考量,ECMAScript 规范对严格模式和脚本模式定义了不同的语义规则。某些在严格模式下被归类为 Early Error 的代码结构,在脚本模式下是完全合法的。若 parseAst 阶段强制执行严格模式检测,将导致这类合法代码被错误拒绝。Unknown 模式通过推迟最终的正确性判定至 Full Build 阶段,在保持解析器高可用性的同时,将语义完整性检查的责任转移到更合适的执行阶段。

从架构分层的角度分析,Rollup 的设计哲学强调职责分离。parseAst 阶段专注于高效完成词法分析和语法分析,并将 SWC AST 转换为紧凑的 ArrayBuffer 格式,从而规避了 JSON 序列化带来的显著性能开销。这一阶段的核心目标是生成正确的 AST 结构表示,而非执行完整的语义验证。真正的语义分析被系统性地安排在 JavaScript 侧的 AST 节点实例化阶段执行,此时通过 node.initialise() 方法构建作用域链、维护符号表,并实施针对打包场景最关键的语义检测。这种跨语言边界的职责划分,既发挥了 Rust 在语法解析上的性能优势,又利用了 JavaScript 在动态语义分析上的灵活性。

从实证数据来看,Full Build 阶段实现了 54.6% 的 Early Errors 检测率,这一数字并非随意选择,而是精确覆盖了对打包场景最具威胁的语义错误类别。重复声明、重复参数、重复导出和 const 重新赋值等错误,若未在构建时检测,将导致运行时错误或不可预测的行为。而被有意省略的部分严格模式限制(如 eval 作为参数名、八进制字面量等),在现代开发工作流中通常由 IDE 的实时诊断或 ESLint 等静态分析工具提前捕获,因此无需在打包器层面重复实施。这种分层防御的策略,既保证了关键错误的零遗漏,又避免了不必要的性能开销。


5. 术语澄清与准确表述

基于上述系统性的测试验证和源码分析,有必要对相关术语进行规范化定义,以消除认知偏差并建立统一的理解框架。

在 ECMAScript 规范的语境下,Early Errors 特指必须在代码执行前的静态分析阶段被检测和报告的错误集合。这一概念涵盖了从语法约束到语义约束的完整错误谱系,包含语法分析和语义分析两个不可分割的阶段。本研究基于 ECMA-262 规范构建的 97 个典型测试场景,全面覆盖了标识符绑定、函数参数、类定义、模块系统、控制流、赋值表达式、字面量语法、严格模式限制等多个维度,形成了对 Early Errors 检测能力的量化评估基准。

从实现层面剖析,语法分析阶段的 Early Errors 特指那些无需维护符号表或作用域链、仅依赖当前语法上下文信息即可判定的错误。这类错误约占测试场景的 41%(40/97),典型代表包括控制流语句的位置约束(如 break/continue 的上下文限制)、赋值目标的合法性检查(如禁止向字面量赋值)、解构语法约束(如 rest 元素的位置要求)等。其共同特征是检测逻辑可以在 AST 构建的过程中通过栈式上下文追踪直接完成,无需额外的数据结构支撑。相对地,语义分析阶段的 Early Errors 则特指那些必须依赖符号表、作用域链或模块绑定表才能准确判定的错误。这类错误约占测试场景的 59%(57/97),包括重复声明检测(需要查询当前作用域是否已存在同名绑定)、重复参数检测(需要维护参数作用域)、重复导出检测(需要维护模块导出表)等。其本质是通过构建程序的静态语义模型来验证代码的合规性。

针对不同解析器的检测能力,需要建立精确的量化描述。SWC Parser 在默认配置(isModule 未指定)或显式配置为模块模式(isModule: true)时,能够检测 37 个 Early Errors(占比 38.1%),这其中包含了严格模式和模块模式特有的语义约束。然而,当配置为脚本模式(isModule: false)或未知模式(isModule: "unknown")时,检测能力降至 32 个(占比 33.0%),主要是因为严格模式相关的 5 个检测被禁用。这一行为符合 ECMAScript 规范对不同代码类型的语义差异定义,并非解析器的实现缺陷。

Rollup parseAst 的检测能力完全等价于 SWC Parser 在 IsModule::Unknown 配置下的表现,即 32 个 Early Errors(占比 33.0%)。通过源码分析确认,Rollup 在调用 SWC 的 parse_js 函数时显式传入了 IsModule::Unknown 参数,这一配置选择直接决定了其在严格模式约束检测上的行为特征。值得强调的是,这 32 个被检测的错误并非完全对应于"语法分析阶段的 Early Errors"这一理论分类,因为其中部分错误(如 for-of 初始化器)在理论上属于语法分析阶段,但受到模式配置的影响而表现出差异化的检测行为。因此,更准确的描述应当是:Rollup parseAst 检测的是在 Unknown 模式下 SWC 能够识别的语法约束,其中不包括严格模式或模块模式特有的约束。

Rollup Full Build 的检测能力是 parseAst 和 JavaScript 侧语义分析的叠加结果,总计 53 个 Early Errors(占比 54.6%)。其中,32 个来自 parseAst 阶段的语法约束检测,21 个来自 JavaScript 侧在 AST 节点实例化过程中通过 node.initialise() 方法执行的语义分析。这 21 个额外检测的错误精确覆盖了对打包场景最具威胁性的语义违规,包括 let/const/var 声明冲突、函数参数重复、模块导出/导入重复、const 常量重新赋值等。这一检测范围的选择并非偶然,而是基于对 JavaScript 运行时行为和构建工具责任边界的深刻理解。

综合上述分析,可以形成如下准确的表述范式:SWC Parser 和 Rollup parseAst 在功能定位上均专注于语法分析阶段的 Early Errors 检测,但由于配置差异(IsModule::Unknown 对比 isModule:true),两者在严格模式相关错误的检测上存在 5 个错误的定量差距。完整的 Early Errors 检测必须同时涵盖语法分析和语义分析两个阶段,在当前的主流 JavaScript 解析器生态中,只有 Acorn 实现了这一规范要求的完整性目标。Rollup 通过架构分层的设计策略,在 JavaScript 侧有选择地补充了部分语义分析能力,这种实现方式在保证核心错误零遗漏的前提下,实现了性能、灵活性和正确性的工程最优解。


语义分析检测点

语义分析的任务主要包含如下几个方面:

  1. const_assign

    例子:

    ts
    export function logConstVariableReassignError() {
      return {
        code: CONST_REASSIGN,
        message: 'Cannot reassign a variable declared with `const`'
      };
    }
    ts
    // case
    const x = 1;
    x = 'string';
    
    // implementation
    export default class AssignmentExpression extends NodeBase {
      initialise(): void {
        super.initialise();
        if (this.left instanceof Identifier) {
          const variable = this.scope.variables.get(this.left.name);
          if (variable?.kind === 'const') {
            this.scope.context.error(
              logConstVariableReassignError(),
              this.left.start
            );
          }
        }
        this.left.setAssignedValue(this.right);
      }
    }
  2. duplicate_bindings

    ts
    export function logRedeclarationError(name: string): RollupLog {
      return {
        code: REDECLARATION_ERROR,
        message: `Identifier "${name}" has already been declared`
      };
    }
    ts
    // case
    import { x } from './b';
    const x = 1;
    
    // case2
    import { x } from './b';
    import { x } from './b';
    
    // implementation
    export default class Module {
      private addImport(node: ImportDeclaration): void {
        const source = node.source.value;
        this.addSource(source, node);
    
        for (const specifier of node.specifiers) {
          const localName = specifier.local.name;
          if (
            this.scope.variables.has(localName) ||
            this.importDescriptions.has(localName)
          ) {
            this.error(
              logRedeclarationError(localName),
              specifier.local.start
            );
          }
    
          const name =
            specifier instanceof ImportDefaultSpecifier
              ? 'default'
              : specifier instanceof ImportNamespaceSpecifier
                ? '*'
                : specifier.imported instanceof Identifier
                  ? specifier.imported.name
                  : specifier.imported.value;
          this.importDescriptions.set(localName, {
            module: null as never, // filled in later
            name,
            source,
            start: specifier.start
          });
        }
      }
    }
    ts
    // case
    {
      const a = 1;
      const a = 1;
    }
    
    // implementation
    export default class BlockScope extends ChildScope {
      addDeclaration(
        identifier: Identifier,
        context: AstContext,
        init: ExpressionEntity,
        destructuredInitPath: ObjectPath,
        kind: VariableKind
      ): LocalVariable {
        if (kind === 'var') {
          const name = identifier.name;
          const existingVariable =
            this.hoistedVariables?.get(name) ||
            (this.variables.get(name) as LocalVariable | undefined);
          if (existingVariable) {
            if (
              existingVariable.kind === 'var' ||
              (kind === 'var' && existingVariable.kind === 'parameter')
            ) {
              existingVariable.addDeclaration(identifier, init);
              return existingVariable;
            }
            return context.error(
              logRedeclarationError(name),
              identifier.start
            );
          }
          const declaredVariable = this.parent.addDeclaration(
            identifier,
            context,
            init,
            destructuredInitPath,
            kind
          );
          // Necessary to make sure the init is deoptimized for conditional declarations.
          // We cannot call deoptimizePath here.
          declaredVariable.markInitializersForDeoptimization();
          // We add the variable to this and all parent scopes to reliably detect conflicts
          this.addHoistedVariable(name, declaredVariable);
          return declaredVariable;
        }
        return super.addDeclaration(
          identifier,
          context,
          init,
          destructuredInitPath,
          kind
        );
      }
    }
    ts
    // case
    try {
    } catch (e) {
      const a = 1;
      const a = 2;
    }
    
    // implementation
    export default class CatchBodyScope extends ChildScope {
      addDeclaration(
        identifier: Identifier,
        context: AstContext,
        init: ExpressionEntity,
        destructuredInitPath: ObjectPath,
        kind: VariableKind
      ): LocalVariable {
        if (kind === 'var') {
          const name = identifier.name;
          const existingVariable =
            this.hoistedVariables?.get(name) ||
            (this.variables.get(name) as LocalVariable | undefined);
          if (existingVariable) {
            const existingKind = existingVariable.kind;
            if (
              existingKind === 'parameter' &&
              // If this is a destructured parameter, it is forbidden to redeclare
              existingVariable.declarations[0].parent.type ===
                NodeType.CatchClause
            ) {
              // If this is a var with the same name as the catch scope parameter,
              // the assignment actually goes to the parameter and the var is
              // hoisted without assignment. Locally, it is shadowed by the
              // parameter
              const declaredVariable = this.parent.parent.addDeclaration(
                identifier,
                context,
                UNDEFINED_EXPRESSION,
                destructuredInitPath,
                kind
              );
              // To avoid the need to rewrite the declaration, we link the variable
              // names. If we ever implement a logic that splits initialization and
              // assignment for hoisted vars, the "renderLikeHoisted" logic can be
              // removed again.
              // We do not need to check whether there already is a linked
              // variable because then declaredVariable would be that linked
              // variable.
              existingVariable.renderLikeHoisted(declaredVariable);
              this.addHoistedVariable(name, declaredVariable);
              return declaredVariable;
            }
            if (existingKind === 'var') {
              existingVariable.addDeclaration(identifier, init);
              return existingVariable;
            }
            return context.error(
              logRedeclarationError(name),
              identifier.start
            );
          }
        }
      }
    }
    ts
    // case
    function fn() {
      const a = 1;
      const a = 2;
    }
    
    // implementation
    export default class FunctionBodyScope extends ChildScope {
      // There is stuff that is only allowed in function scopes, i.e. functions can
      // be redeclared, functions and var can redeclare each other
      addDeclaration(
        identifier: Identifier,
        context: AstContext,
        init: ExpressionEntity,
        destructuredInitPath: ObjectPath,
        kind: VariableKind
      ): LocalVariable {
        const name = identifier.name;
        const existingVariable =
          this.hoistedVariables?.get(name) ||
          (this.variables.get(name) as LocalVariable);
        if (existingVariable) {
          const existingKind = existingVariable.kind;
          if (
            (kind === 'var' || kind === 'function') &&
            (existingKind === 'var' ||
              existingKind === 'function' ||
              existingKind === 'parameter')
          ) {
            existingVariable.addDeclaration(identifier, init);
            return existingVariable;
          }
          context.error(logRedeclarationError(name), identifier.start);
        }
        const newVariable = new LocalVariable(
          identifier.name,
          identifier,
          init,
          destructuredInitPath,
          context,
          kind
        );
        this.variables.set(name, newVariable);
        return newVariable;
      }
    }
    ts
    // case1
    import { a } from './b';
    const a = 1;
    
    // case2
    import { a } from './b';
    import { a } from './b';
    
    // implementation
    export default class ModuleScope extends ChildScope {
      addDeclaration(
        identifier: Identifier,
        context: AstContext,
        init: ExpressionEntity,
        destructuredInitPath: ObjectPath,
        kind: VariableKind
      ): LocalVariable {
        if (this.context.module.importDescriptions.has(identifier.name)) {
          context.error(
            logRedeclarationError(identifier.name),
            identifier.start
          );
        }
        return super.addDeclaration(
          identifier,
          context,
          init,
          destructuredInitPath,
          kind
        );
      }
    }
    ts
    // case
    const a = 1;
    const a = 2;
    
    export default class Scope {
      /*
    Redeclaration rules:
    - var can redeclare var
    - in function scopes, function and var can redeclare function and var
    - var is hoisted across scopes, function remains in the scope it is declared
    - var and function can redeclare function parameters, but parameters cannot redeclare parameters
    - function cannot redeclare catch scope parameters
    - var can redeclare catch scope parameters in a way
    	- if the parameter is an identifier and not a pattern
    	- then the variable is still declared in the hoisted outer scope, but the initializer is assigned to the parameter
    - const, let, class, and function except in the cases above cannot redeclare anything
     */
      addDeclaration(
        identifier: Identifier,
        context: AstContext,
        init: ExpressionEntity,
        destructuredInitPath: ObjectPath,
        kind: VariableKind
      ): LocalVariable {
        const name = identifier.name;
        const existingVariable =
          this.hoistedVariables?.get(name) ||
          (this.variables.get(name) as LocalVariable);
        if (existingVariable) {
          if (kind === 'var' && existingVariable.kind === 'var') {
            existingVariable.addDeclaration(identifier, init);
            return existingVariable;
          }
          context.error(logRedeclarationError(name), identifier.start);
        }
        const newVariable = new LocalVariable(
          identifier.name,
          identifier,
          init,
          destructuredInitPath,
          context,
          kind
        );
        this.variables.set(name, newVariable);
        return newVariable;
      }
    }
  3. duplicate_exports

    ts
    export function logDuplicateExportError(name: string): RollupLog {
      return {
        code: DUPLICATE_EXPORT,
        message: `Duplicate export "${name}"`
      };
    }
    
    export default class Module {
      private assertUniqueExportName(name: string, nodeStart: number) {
        if (this.exports.has(name) || this.reexportDescriptions.has(name)) {
          this.error(logDuplicateExportError(name), nodeStart);
        }
      }
    }
    ts
    // case
    export default 1;
    export default 2;
    
    // implementation
    export default class Module {
      private addExport(
        node:
          | ExportAllDeclaration
          | ExportNamedDeclaration
          | ExportDefaultDeclaration
      ): void {
        if (node instanceof ExportDefaultDeclaration) {
          // export default foo;
    
          this.assertUniqueExportName('default', node.start);
          this.exports.set('default', {
            identifier: node.variable.getAssignedVariableName(),
            localName: 'default'
          });
        }
      }
    }
    ts
    // case
    export * as a from './b';
    export * as a from './b';
    
    // implementation
    export default class Module {
      private addExport(
        node: ExportAllDeclaration | ExportNamedDeclaration
      ): void {
        if (node instanceof ExportAllDeclaration) {
          const source = node.source.value;
          this.addSource(source, node);
          if (node.exported) {
            // export * as name from './other'
    
            const name =
              node.exported instanceof Literal
                ? node.exported.value
                : node.exported.name;
            this.assertUniqueExportName(name, node.exported.start);
            this.reexportDescriptions.set(name, {
              localName: '*',
              module: null as never, // filled in later,
              source,
              start: node.start
            });
          } else {
            // export * from './other'
    
            this.exportAllSources.add(source);
          }
        }
      }
    }
    ts
    // case
    export { a } from './b';
    export { a } from './b';
    
    // implementation
    export default class Module {
      private addExport(
        node: ExportAllDeclaration | ExportNamedDeclaration
      ): void {
        if (node.source instanceof Literal) {
          // export { name } from './other'
    
          const source = node.source.value;
          this.addSource(source, node);
          for (const { exported, local, start } of node.specifiers) {
            const name =
              exported instanceof Literal ? exported.value : exported.name;
            this.assertUniqueExportName(name, start);
            this.reexportDescriptions.set(name, {
              localName: local instanceof Literal ? local.value : local.name,
              module: null as never, // filled in later,
              source,
              start
            });
          }
        }
      }
    }
    ts
    // case1
    export const a = 1;
    export const a = 2;
    
    // case2
    export function a() {}
    export function a() {}
    
    // case3
    export { a, a };
    
    // implementation
    export default class Module {
      private addExport(node: ExportNamedDeclaration): void {
        if (node.declaration) {
          const declaration = node.declaration;
          if (declaration instanceof VariableDeclaration) {
            // export var { foo, bar } = ...
            // export var foo = 1, bar = 2;
    
            for (const declarator of declaration.declarations) {
              for (const localName of extractAssignedNames(declarator.id)) {
                this.assertUniqueExportName(localName, declarator.id.start);
                this.exports.set(localName, { identifier: null, localName });
              }
            }
          } else {
            // export function foo () {}
    
            const localName = (declaration.id as Identifier).name;
            this.assertUniqueExportName(localName, declaration.id!.start);
            this.exports.set(localName, { identifier: null, localName });
          }
        }
      }
    }
  4. no_dupe_args

    ts
    export function logDuplicateArgumentNameError(name: string): RollupLog {
      return {
        code: DUPLICATE_ARGUMENT_NAME,
        message: `Duplicate argument name "${name}"`
      };
    }
    ts
    // case
    function fn(a, a) {}
    
    // implementation
    export default class ParameterScope extends ChildScope {
      /**
       * Adds a parameter to this scope. Parameters must be added in the correct
       * order, i.e. from left to right.
       */
      addParameterDeclaration(
        identifier: Identifier,
        argumentPath: ObjectPath
      ): ParameterVariable {
        const { name, start } = identifier;
        const existingParameter = this.variables.get(name);
        if (existingParameter) {
          return this.context.error(
            logDuplicateArgumentNameError(name),
            start
          );
        }
        const variable = new ParameterVariable(
          name,
          identifier,
          argumentPath,
          this.context
        );
        this.variables.set(name, variable);
        // We also add it to the body scope to detect name conflicts with local
        // variables. We still need the intermediate scope, though, as parameter
        // defaults are NOT taken from the body scope but from the parameters or
        // outside scope.
        this.bodyScope.addHoistedVariable(name, variable);
        return variable;
      }
    }

从上述的实现中可以看到 语义分析 的阶段是十分依赖于 AST Node 所处的词法作用域。当然上述的语义分析是最基本的,Rollup 内部还会进行一些其他的语义分析,例如副作用分析、模块间的循环依赖分析、语法的严格限制(例如 namespace object 不能调用,导入的引用不能重新赋值等语义分析)等,这些是 Acorn 无法做到的。

由于 swc_ecma_lints 内部实现可能存在性能问题,这是一个暂时的方案,后续 Rollup 会在 Rust 侧添加执行上下文中的作用域分析,在 Rust 侧实现完整的语义分析。到时会将完整的 语义分析 任务交付给 Rust 侧做处理。

优化抽象语法树解析

Rollup 为插件上下文提供 this.parser 让用户插件使用原生 SWC 的能力来解析 codeAST。用户插件可以在 loadtransform 钩子中返回已解析的 ASTRollup 会复用用户插件中已解析的 AST

若用户插件没有解析 AST(即插件在 loadtransform 钩子中没有返回 AST),那么 AST 会做兜底处理,在 transform 阶段完成后借助原生 Rust 的能力将转译后的代码解析为 ESTree AST

precautions for using this.parser

现阶段 Rollup 移除了 Rust 侧对 AST 的语义分析。换句话说在插件上下文中使用 Rollup 提供的 this.parser api 来将代码解析的 AST 并未完成语义分析。

若用户插件在实现阶段 需要 判断生成的 AST 是否符合语义分析的要求,那么需要用户插件自行借助其他工具对 AST 进行语义分析。

若用户在实现阶段 不需要 确保生成的 AST 是符合语义分析的,那么 Rollup 在递归实例 AST Node 类时会自动执行语义分析。

即使有了原生的解析能力,但原生生成复杂的 AST 依旧需要耗费时间。在 watch 模式下,Rollup缓存(详情可见 Rollup Incremental Build 一节) ESTree AST 来跳过原生 SWC 解析 AST 的过程,递归 ESTree AST 的结构来实例化 Rollup 内部的 AST 对象实例。

性能对比

Rollup 优化方案 vs 直接使用 SWC JavaScript API

在深入分析性能对比之前,需要明确一个关键概念:Rollup 的原生解析优化方案与直接使用 @swc/coreJavaScript API 是完全不同的实现方式

关键区别

直接使用 @swc/core JavaScript API:

ts
import swc from '@swc/core';
const ast = await swc.parse(code); // JSON 序列化/反序列化

Rollup 优化方案:

ts
// Rust 侧: SWC 解析 → SWC AST -> 转换为 ESTree AST → 写入 ArrayBuffer
// JavaScript 侧: 直接从 ArrayBuffer 构建 AST 实例
const astBuffer = await parseAsync(code);
const ast = convertProgram(astBuffer); // 无 JSON 解析

前者需要经历完整的 JSON 序列化(Rust) → JSON 反序列化(JavaScript) 过程,而后者通过 ArrayBuffer 二进制传输 几乎无损耗地传递数据。

纯解析器性能基准测试

为了验证 原生交互挑战 中提到的序列化开销问题,我们进行了纯解析器的性能基准测试,对比直接使用各解析器的 JavaScript API 性能。

测试环境

bash
Node.js:  v22.14.0
Platform: darwin arm64 (Apple M1)
Memory:   16GB

解析器版本:
- @swc/core:     ^1.15.2  (Rust 实现)
- rollup:        ^4.53.2  (Rust 实现)
- acorn:         ^8.15.0  (纯 JavaScript)
- @babel/parser: ^7.28.5  (纯 JavaScript)

测试结果

文件大小SWCRollupAcornBabel最快SWC 慢倍数
colors.js1.1 KB11,63155,04655,69451,696Acorn4.79x
underscore42.5 KB218947894818Rollup4.34x
backbone58.7 KB201835805681Rollup4.15x
mootools156.7 KB43194183159Rollup4.54x
jquery262 KB2914113999Rollup4.86x
yui330.4 KB42173202163Acorn4.78x
jquery.mobile442.2 KB20849350Acorn4.52x
angular701.9 KB259611767Acorn4.70x
three.js1.2 MB6242314Rollup3.87x
larger.js2.3 MB315129Rollup4.37x
typescript.js8.2 MB1442Rollup/Acorn4.00x

颜色说明:绿色 = 最快红色 = 最慢 (SWC)橙色 = 性能差距倍数

测试结果解读

测试发现直接使用 @swc/coreJavaScript API 在所有测试中 都是最慢的,平均比纯 JavaScript 实现慢 4.3 倍

这正是 原生交互挑战 中描述的问题:

bash
完整调用链:
JavaScript
 [FFI 调用开销]
Rust 解析器 (快!)
 [JSON 序列化: serde_json::to_string]
JSON 字符串
 [传输]
JavaScript
 [JSON 反序列化: JSON.parse]
JavaScript AST 对象

总开销 = FFI + 序列化 + 反序列化 >> Rust 算法优势

AST 序列化大小对比

文件源码大小SWC ASTRollup ASTAcorn ASTBabel ASTSWC/AcornBabel/Acorn
colors.js1.1 KB8,8856,8266,82621,4681.30x3.15x
underscore42.5 KB611,325409,026409,0261,158,5541.49x2.83x
backbone58.7 KB676,338492,315492,3151,407,9891.37x2.86x
mootools156.7 KB3,025,6562,207,3242,207,3245,490,0151.37x2.49x
jquery262 KB3,706,1722,684,1402,684,1407,296,2181.38x2.72x
yui330.4 KB2,687,8942,100,7332,100,7335,743,7291.28x2.73x
jquery.mobile442.2 KB5,853,2544,627,7874,627,78712,238,3611.26x2.64x
angular701.9 KB4,371,8593,000,6173,000,6179,127,2921.46x3.04x
three.js1.2 MB18,546,95413,789,21913,751,31034,315,4731.35x2.50x
larger.js2.3 MB35,738,74627,911,67427,835,50469,421,9561.28x2.49x
typescript.js8.2 MB91,256,34967,612,83767,567,418178,461,4261.35x2.64x
平均-----1.35x2.74x

颜色说明:绿色 = 最优(最小 AST 或最低倍数)黄色 = 中等(Babel AST 或中等倍数)红色 = 较差(高倍数)

单位: 序列化后字符数

关键发现:

  • SWC AST 平均比 Acorn35%(1.35倍),意味着需要序列化更多数据。
  • Babel AST 平均比 Acorn174%(2.74倍),但 Babel 是纯 JS 实现,无需跨语言序列化。
  • 解析 8MBTypeScript.js 时,SWC 需要序列化 91MBAST JSON
  • 即使小文件(1KB)也需要序列化近 9KBAST 数据。
  • 序列化开销随文件增大而线性增长,这就是为什么直接使用 SWC JavaScript API 会如此缓慢。

Rollup 优化方案的性能

正是因为直接使用 SWCJavaScript API 存在严重的序列化开销问题Rollup 采用了 ArrayBuffer 优化方案

  1. 避免 JSON 序列化:直接在 Rust 侧写入二进制 ArrayBuffer
  2. 避免 JSON 反序列化JavaScript 侧直接从 ArrayBuffer 读取数据。
  3. 体积更小ArrayBuffer 大小只有 JSON1/3 左右。

测试还发现当解析的字符量达到 319,869,952 时,Acorn 解析 AST 会报 栈溢出 错误。

bash
<--- Last few GCs --->

[69821:0x120078000]    15364 ms: Mark-sweep 4062.9 (4143.2) -> 4059.0 (4143.2) MB, 703.2 / 0.0 ms  (average mu = 0.293, current mu = 0.102) allocation failure; scavenge might not succeed
[69821:0x120078000]    16770 ms: Mark-sweep 4075.3 (4143.2) -> 4071.5 (4169.0) MB, 1383.5 / 0.0 ms  (average mu = 0.143, current mu = 0.016) allocation failure; scavenge might not succeed


<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

换句话说,319,869,952 字符换算如下:

  • UTF-8 文件大小:约 320 MB(假设主要为 ASCII)
  • JavaScript 内存占用:约 640 MB(UTF-16 编码,双倍)
  • JSON.parse 后对象:可能超过 1 GB

这解释了为什么 Acorn 会在这个规模下出现内存溢出问题。

性能分析总结

  1. 直接使用 SWC JavaScript API 的问题

    问题原因影响
    FFI 调用开销JavaScript ↔ Rust 边界高频调用时成本累积
    JSON 序列化serde_json::to_string大型 AST 序列化耗时
    JSON 反序列化JSON.parse解析大型 JSON 字符串缓慢
    内存占用生成中间 JSON 字符串额外内存分配

    测试结果:比纯 JavaScript 解析器慢 3.87 - 4.86 倍(平均 4.43 倍

  2. Rollup ArrayBuffer 优化方案的优势

    优化点实现方式收益
    避免 JSON 序列化直接写入 ArrayBuffer减少 Rust 侧开销
    避免 JSON 反序列化从 ArrayBuffer 直接读取减少 JavaScript 侧开销
    体积优化二进制格式约 1/3 的体积
    零拷贝传输SharedArrayBuffer线程间高效传递

    测试结果

    • 小文件优势明显:在 1.1KB 文件中,Rollup 达到 55,046 ops/sec,比 Acorn (55,694 ops/sec) 仅慢 1.16%
    • 中大文件表现优异:在 42.5KB - 2.3MB 文件中,Rollup 比 Acorn 快 5.9% - 25%
    • 超大文件持平:在 8.2MB TypeScript.js 中,Rollup 和 Acorn 均为 4 ops/sec
  3. AST 序列化开销分析

    根据实际测试数据,AST 序列化大小直接影响性能:

    文件源码大小SWC ASTAcorn ASTBabel ASTSWC 膨胀倍数Babel 膨胀倍数
    colors.js1.1 KB8,885 (≈8.9 KB)6,826 (≈6.8 KB)21,468 (≈21.5 KB)1.30x3.15x
    jquery262 KB3,706,172 (≈3.71 MB)2,684,140 (≈2.68 MB)7,296,218 (≈7.30 MB)1.38x2.72x
    typescript.js8.2 MB91,256,349 (≈91.3 MB)67,567,418 (≈67.6 MB)178,461,426 (≈178.5 MB)1.35x2.64x
    📐 数据换算说明:字符数与字节数的关系

    表格中的数字来自 JSON.stringify(ast).length,表示 字符数(UTF-16 代码单元数)。

    换算采用 SI 单位制(十进制)

    bash
    1 KB = 1,000 字节
    1 MB = 1,000,000 字节
    1 GB = 1,000,000,000 字节

    注:也可使用二进制单位制(1 KiB = 1,024 字节,1 MiB = 1,048,576 字节),但为与 benchmark 数据源保持一致,本文统一使用 SI 单位制

    为什么字符数 ≈ UTF-8 文件大小?

    因为 JSON AST 的内容主要由 ASCII 字符 组成(字母、数字、标点、关键字如 "type", "start" 等),而 ASCII 字符在 UTF-8 编码中占用 1 字节

    javascript
    const astJson = JSON.stringify(ast);
    console.log(astJson.length);                    // 91,256,349 字符
    console.log(Buffer.byteLength(astJson, 'utf8')); // ≈91,256,349 字节
    
    // 使用 SI 单位制换算
    fs.writeFileSync('ast.json', astJson, 'utf8');
    console.log(fs.statSync('ast.json').size);       // 91,256,349 字节
    console.log((91_256_349 / 1_000_000).toFixed(1)); // 91.3 MB

    但 JavaScript 内存占用是双倍!

    JavaScript 内部使用 UTF-16 编码存储字符串。在 UTF-16 中,每个 代码单元 占用 2 字节

    javascript
    console.log(Buffer.byteLength(astJson, 'utf16le')); // ≈182.5 MB

    为什么内存是双倍?

    • 1 个 UTF-16 代码单元 = 2 字节(固定)
    • JSON AST 的字符几乎全是 ASCII/BMP 字符"type", "start", {, } 等)
    • 每个 BMP 字符 = 1 个代码单元 = 2 字节
    • 因此:91,256,349 个字符 = 91,256,349 个代码单元 × 2 = 182,512,698 字节 ≈ 182.5 MB

    UTF-16 中的特殊情况

    辅助平面字符(如 emoji 😀)需要 2 个代码单元(称为代理对):

    javascript
    const emoji = "😀";
    console.log(emoji.length);                        // 2 (2个UTF-16代码单元)
    console.log(Buffer.byteLength(emoji, 'utf16le')); // 4 字节
    console.log([...emoji].length);                   // 1 (真正的字符数)

    但 JSON AST 中不包含 emoji,所以可以简化理解为:1 个字符 = 2 字节

    完整的编码转换流程

    阶段数据大小编码说明
    Rust 序列化≈91.3 MBUTF-8serde_json::to_string
    跨 FFI 传输≈91.3 MBUTF-8传递给 JavaScript
    JavaScript 内存≈182.5 MBUTF-16Node.js 自动转换为 UTF-16
    JSON.parse 后对象数百 MB-解析后的对象结构占用更大内存

    这也解释了为什么 JSON.parse 大型 JSON 如此缓慢:不仅要解析 91M 字符,还要在内存中构建占用数百 MB 的对象结构。

    :::

    关键发现

    • SWC AST 平均比 Acorn 大 35%(1.35 倍),需要序列化更多数据
    • Babel AST 平均比 Acorn 大 174%(2.74 倍),但 Babel 是纯 JS 实现,无需跨语言序列化
    • 解析 8MB 的 TypeScript.js 时,SWC 需要序列化 91.3MB 的 UTF-8 JSON(JavaScript 内存占用 182.5MB
    • 即使小文件(1KB)也需要序列化近 9KB 的 AST 数据
    • 序列化开销随文件增大而线性增长,这是直接使用 SWC JavaScript API 缓慢的根本原因
    • 内存压力双重打击:UTF-8 序列化 + UTF-16 反序列化,实际内存占用接近文件大小的 2 倍
  4. 稳定性分析

    基于相对误差范围(RME %)的测试数据:

    解析器平均误差最佳场景特点
    Acorn±3.65%angular (±1.25%)最稳定,适合生产环境
    Rollup±3.69%mootools (±0.88%)稳定性与 Acorn 接近
    Babel±4.09%colors.js (±1.01%)小文件稳定,大文件波动较大
    SWC±4.51%typescript.js (±0.98%)超大文件稳定性出色(无 GC 暂停)

    意外发现:SWC 虽然速度最慢,但在超大文件中稳定性表现出色。这是因为 Rust 没有 GC 暂停,而纯 JS 实现会受 V8 GC 和 JIT 优化影响。

  5. 性能倍数详细对比

    SWC 相对于最快解析器的性能差距:

    文件文件大小最快解析器SWC 慢倍数
    colors.js1.1 KBAcorn4.79x
    jquery262 KBRollup4.86x
    mootools156.7 KBRollup4.54x
    three.js1.2 MBRollup3.87x
    typescript.js8.2 MBRollup/Acorn4.00x

    平均慢倍数4.43x(范围:3.87x - 4.86x)

  6. 趋势分析

    • Rollup 优化方案:解析时间增长幅度小,适合大规模模块解析
    • Acorn:解析时间增长幅度较大,但在超大模块场景仍可胜任
    • SWC:一致性地比纯 JS 实现慢 4.43 倍,证明 FFI + JSON 序列化开销超过算法优势
    • 极端场景:在 300MB+ 代码量下,Acorn 会出现内存溢出,而 Rollup 优化方案可以正常处理

核心结论

Rollup 的性能提升并非来自简单地切换到 SWC,而是来自 精心设计的 ArrayBuffer 优化方案

如果直接使用 @swc/core 的 JavaScript API,性能反而会大幅下降(平均慢 4.43 倍)。

关键数据支撑

  • SWC 需要序列化比 Acorn 大 35% 的 AST(8MB 源码 → 91MB UTF-8 JSON → 182MB UTF-16 内存)
  • 内存压力双重打击:UTF-8 序列化(91MB)+ UTF-16 反序列化(182MB),实际内存占用接近文件大小的 2 倍
  • FFI 边界 + JSON 序列化/反序列化 + 编码转换的三重开销完全抵消了 Rust 的算法优势
  • Rollup 通过 ArrayBuffer 避免 JSON 序列化和编码转换,体积减少至 1/3,实现了真正的性能提升

这个案例很好地说明了:原生代码不等于自动更快,必须考虑跨语言边界成本、数据序列化和字符编码转换。Rollup 通过消除序列化瓶颈和编码转换开销,才真正发挥了 Rust 的性能优势。

根据 CC BY-SA 4.0 许可证发布。 (ca50eaa)