原生解析器
相比 JavaScript, Rust 原生语言在 算法执行 上具有性能优势。Rollup 决定将由 JavaScript 侧的 Acorn 解析器切换到 Rust 侧的 SWC 解析器,具备高效地解析复杂 AST 的能力,这也作为 Rollup v4 的核心变化。
挑战
原生交互
直接使用 SWC 的 JavaScript 引用,通过 SWC.parse 的 JavaScript 接口来解析复杂 AST 会带来巨大的通讯开销。
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 侧。
#[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 对象。
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.');
}
}在 Rust 和 JavaScript 之间,反复的对 AST 进行 序列化(Rust侧) 和 反序列化(JavaScript侧),那么解析复杂 AST 时将几乎侵蚀了切换为原生解析器(Rust)的性能优势。
抽象语法树兼容性
SWC 为 Rust 侧设计了特有的 AST 结构,而 Rollup 依赖于标准的 ESTree AST,两者在 AST 结构上存在差异,因此需要进行兼容性处理。
值得注意的是,SWC 提供了 swc_estree_compat 兼容层,提供解析产物为 Babel AST 和 ESTree 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 依赖于标准 JavaScript 的 UTF-16 编码。
UTF-8 与 UTF-16 的区别
UTF-8:
可变长度编码:
UTF-8 使用 1 ~ 4 个字节来表示一个字符。ASCII 字符(例如英文字母和数字)均使用 1 个字节表示,而其他字符(例如汉字)可能使用 2 ~ 4 个字节。
1 字节:ASCII字符(U+0000至U+007F)。2 字节:扩展拉丁文字符(U+0080至U+07FF)。3 字节:基本多文种平面(BMP)字符(U+0800至U+FFFF)。4 字节:辅助平面字符(U+10000至U+10FFFF)。
向后兼容 ASCII:
由于 ASCII 字符在 UTF-8 中只占用 1 个字节,UTF-8 与 ASCII 编码完全兼容。
编码效率:
- 对英语和
ASCII文本效率高(每个字符 1 字节)。 - 对非拉丁字符(如中文、日文等),通常需要 3 个字节。
- 对辅助平面字符(如表情符号),需要 4 个字节。
适用场景:
- 更适合网络传输和存储,尤其是以
ASCII为主的文本。 - 常用于网页、
JSON文件等场景。
UTF-16:
固定或可变长度编码:
UTF-16 通常使用 2 个字节来表示大多数常用字符,但对于某些特殊字符(如表情符号),可能需要 4 个字节。
2 字节:BMP范围内的字符(U+0000至U+FFFF,除去代理对)。4 字节:超出BMP的字符(U+10000至U+10FFFF),使用两个16位单元(称为代理对)。
不兼容 ASCII:
UTF-16 不与 ASCII 兼容,因为 ASCII 字符在 UTF-16 中需要 2 个字节。但 UTF-8 和 UTF-16 处理 ASCII 的每一个字符均可视为一单位。
编码效率:
- 对
BMP范围内字符(如大部分中文、日文)效率较高(每个字符 2 字节)。 - 对
ASCII字符效率较低(每个字符 2 字节)。 - 对辅助平面字符效率与
UTF-8类似(需要 4 字节)。
适用场景:
- 更适合内存操作,尤其是在以
BMP范围字符为主的场景(如中文环境)。 - 常用于
Windows、JavaScript和Java等的内部字符表示。
以字符串 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 中记录的位置信息如下:
{
"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 中记录的位置信息如下:
{
"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 (字符偏移量):
遵循 JavaScript 的 String.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 提供的 位置信息 做 映射标记。
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 位置信息不一致,Rollup 在 Rust 原生侧需要重新调整 SWC AST 的位置信息,使其符合 JavaScript 的 字符偏移量 计算方式。
性能
对抽象语法树兼容性的优化
在 Rust 侧借助 SWC 的能力将代码解析为 SWC AST 后
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 的位置信息
/// 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 节点结构所需的信息。
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 规范下的 位置信息。
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 节点做收集,为后续 Rollup 的 Tree Shaking 做准备。需要注意的是 ESTree AST 规范是不包含 comments 节点的,但 comments 节点的信息对于 Rollup 的 Tree Shaking 至关重要,可以增强 Tree Shaking 的能力。
Rollup 会收集这些注释信息在 ESTree AST 中,并通过 _rollupAnnotations 属性进行存储。也就是说,最终返回的 ESTree AST 是额外包含 _rollupAnnotations 属性的,其结构是符合 ESTree AST 规范的。
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 AST 的 ArrayBuffer 结构,JavaScript 侧则需引导解析 ArrayBuffer 的兼容 ESTree AST 结构来实例化 Rollup 内部实现的 AST Class Node。
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);
}
}Rollup 在 buffer 层面的引导方式
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 引用会在 Rust 与 JavaScript 之间进行反复 序列化 和 反序列化 AST 的操作。在处理复杂的 AST 时,解析效率几乎侵蚀了切换为原生解析器(Rust)的性能优势。
解决方案如下:
采用
ArrayBuffer来做Rust与JavaScript之间传输解析完成的AST。
不考虑使用 SWC 的 JavaScript 引用,而是直接在 Rust 侧使用 SWC 的 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。
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-rs 与 Rust 代码交互,浏览器端采用 wasm-pack 来进行构建。
优化语义分析
解析器 语义分析设计
Rust 侧直接调用 SWC 提供的 use swc_compiler_base::parse_js 并不会执行 语义分析,只处理 词法分析 和 语法分析。也就是说以下代码在 SWC 中可以正常解析为 SWC AST,而不会报错。
const a = 1;
const a = 2;这与 Acorn 的解析方式不一样,Acorn 在生成 AST 时会额外执行完整的 静态语义分析 --- Static Semantics: Early Errors,即在执行程序前检测错误。
ECMAScript Static Semantics: Early Errors
Early Errors 是 ECMAScript 规范中定义的静态语义错误检测机制。根据 ECMA-262 规范,这些错误必须在代码执行前的解析阶段被检测和报告。
规范权威来源:
- ECMA-262 Section 13.2.1 - Block 语句 Early Errors
- ECMA-262 Section 14.3.1.1 - let/const 声明 Early Errors
- ECMA-262 Section 15.1.1 - 函数定义 Early Errors
- ECMA-262 Section 15.7.1 - 类定义 Early Errors
- ECMA-262 Section 16.2.1.1 - 模块语义 Early Errors
本质的原因是 Acorn 被设计为一个符合 ECMAScript 规范的解析器,ECMAScript 在 JavaScript 引擎执行代码之前,会要求执行 Static Semantics: Early Errors 的步骤(本质是 静态语义分析),在解析和早期语法分析阶段就需要检测和报告的错误。这些错误是通过静态解析的,意味着不需要实际运行代码就能发现它们。
Browsers、Node.js 等内置
JavaScript引擎在执行代码之前,也是会执行Static Semantics: Early Errors的步骤。
规范的意义在于:
- 提前发现问题:在代码实际运行之前就能发现潜在的错误,避免运行时才暴露的问题。
- 提高性能:由于这些检查是在静态分析阶段完成的,不需要等到运行时才能发现错误,这样可以提高代码的执行效率。
- 保证语言的一致性:通过统一的早期错误检查机制,确保
JavaScript代码在不同环境下都能得到一致的处理。 - 帮助开发者写出更规范的代码:这些规则实际上也在指导开发者遵循更好的编程实践。
SWC、Babel 等解析器在生成 AST 时并不会执行 Static Semantics: Early Errors 的步骤,也就是说他们与 Acorn 的设计目标不同。那么接下来先介绍一下前者为什么要将 语法分析 和 静态语义分析 进行分离。
性能和复杂性权衡
实现
Early Errors检测需要解析器做如下几件事情:- 模拟和维护当前执行语句的执行上下文的作用域及其作用域链。
- 静态规则检查。
- 语言规范定义的其他静态语义规则检测。
- 语法限制规则检测。
- 模块系统的静态验证规则检测。
虽然检测的复杂度并非很高,但在大型项目中,若用户每次转译新代码都需要进行
Early Errors检查,那么累计的复杂度对完整的Early Errors检查可能会带来部分性能开销,这是不可忽视的。工具链的分工
SWC、Babel等解析器的关注点在于 代码转换,主要是以插件的形式注入到构建体系的代码转换流程中。而工具若考虑强融入于各个构建体系的生态中,最容易的做法就是保持 单一职责原则。通过将解析和语义分析分开:
- 解析器 可以专注于生成准确的
AST。 - 语义分析器 可以专注于检查代码的正确性。
- 每个部分 都更容易维护和优化。
- 解析器 可以专注于生成准确的
灵活性
在复杂应用模块的转译过程通常并非一蹴而就,而是会存在中间态,中间态的代码很大程度上是不符合语义规范的。如果转译工具进行严格的语义分析,这样的代码将无法通过编译,影响能力扩展。现代开发工具链通过将不同的检查分散到不同阶段,按需执行语义分析,在开发灵活性和代码质量之间取得了平衡。
Babel、SWC 选择将语法分析和 Early Errors 检测的职责进行分离,在插件转译代码阶段将代码解析为 AST 只做词法分析和语法分析,并不会执行 Early Errors 检查(静态语义分析),而是在合适的时机(如 Rollup 的 transform 阶段完成)由 Bundlers(如 Rollup) 来控制并执行 Early Errors 检查。
这种设计选择反映了工程实践中的一个重要原则:有时候,将一个复杂的问题分解成多个独立的步骤,可能比试图在一个步骤中解决所有问题更有效。这让每个工具都能够专注于自己的核心任务,从而提供更好的功能和性能。
Rollup Plugin System Design Inspiration
上述的设计方式在 Rollup 的插件体系中也有一定的体现,当用户插件在 load(或 transform) 钩子中将 AST 进行返回,那么 Rollup 在后续的 transform 钩子中就会复用用户插件返回的 AST。在 Rollup 完成 transform 阶段之前,Rollup 不会对复用的 AST 进行任何的语义分析。
const a = 1;
const a = 2;对于上述例子 Acorn 会提供如下 报错信息。
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 的能力来实现更为完整的 语义分析。
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 执行完整的 语义分析。
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 侧执行 语义分析 的效率比借助原生 SWC 的 swc_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 的代码
- 测试解析器是否正确检测并报告错误
- 部分测试用例验证合法代码不应报错
测试覆盖范围:
- 标识符和绑定错误 (Identifier and Binding Errors)
- 函数参数错误 (Function Parameter Errors)
- 函数体错误 (Function Body Errors)
- 类错误 (Class Errors)
- 模块错误 (Module Errors)
- 控制流错误 (Control Flow Errors)
- 赋值错误 (Assignment Errors)
- 字面量错误 (Literal Errors)
- 严格模式错误 (Strict Mode Errors)
- 正则表达式错误 (Regular Expression Errors)
- for-in/of 错误 (For-in/of Errors)
| 解析器/模式 | 通过率 | 通过/总数 | 说明 |
|---|---|---|---|
| Acorn | 100% | 97/97 | 完整实现 Early Errors |
| SWC Parser (默认) | 38.1% | 37/97 | 语法分析 + 严格模式检测 |
| Rollup parseAst | 33.0% | 32/97 | 语法分析(IsModule::Unknown 配置) |
| Rollup Full Build | 54.6% | 53/97 | parseAst + 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 constructor | Section 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 中 |
架构图
┌─────────────────────────────────────────────────────────────┐
│ 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 的设计选择反映了工程实践中的权衡:
- 实现了对打包最关键的语义检测:重复绑定、重复导出等会导致运行时错误
- 省略了部分严格模式相关检测:这些通常由 IDE 或 linter(如 ESLint)处理
- 保持了性能优势:避免在 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 检测能力存在明显分层:
| 解析器 | 语法分析阶段 | 语义分析阶段 | 总计 | 说明 |
|---|---|---|---|---|
| Acorn | 40/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 parseAst | 32/40 (80%) | 0/57 (0%) | 32/97 (33.0%) | = SWC Unknown 模式 |
| Rollup Full Build | 32/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.2 与 Rollup 4.53.3 内置的 swc_ecma_parser 27.0.2 版本差异不大,且在相同配置下行为完全一致。深入分析 Rollup 源码(rust/parse_ast/src/lib.rs)发现:
parse_js(
cm, file, handler, target, syntax,
IsModule::Unknown, // ← 关键配置
Some(&comments),
)对照实验结果:
| SWC 配置 | 检测 for (var a = 1 in x) {} | 检测率 |
|---|---|---|
| 默认 (未指定 isModule) | ✅ ERROR | 37/97 |
| isModule: true | ✅ ERROR | 37/97 |
| isModule: false | ❌ NO ERROR | 32/97 |
| isModule: "unknown" | ❌ NO ERROR | 32/97 |
| Rollup parseAst | ❌ NO ERROR | 32/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 侧有选择地补充了部分语义分析能力,这种实现方式在保证核心错误零遗漏的前提下,实现了性能、灵活性和正确性的工程最优解。
语义分析检测点
语义分析的任务主要包含如下几个方面:
const_assign例子:
tsexport 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); } }duplicate_bindingstsexport 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; } }duplicate_exportstsexport 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 }); } } } }no_dupe_argstsexport 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 的能力来解析 code 为 AST。用户插件可以在 load 和 transform 钩子中返回已解析的 AST,Rollup 会复用用户插件中已解析的 AST。
若用户插件没有解析 AST(即插件在 load 和 transform 钩子中没有返回 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/core 的 JavaScript API 是完全不同的实现方式。
关键区别
直接使用 @swc/core JavaScript API:
import swc from '@swc/core';
const ast = await swc.parse(code); // JSON 序列化/反序列化Rollup 优化方案:
// 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 性能。
测试环境
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)测试结果
| 文件 | 大小 | SWC | Rollup | Acorn | Babel | 最快 | SWC 慢倍数 |
|---|---|---|---|---|---|---|---|
| colors.js | 1.1 KB | 11,631 | 55,046 | 55,694 | 51,696 | Acorn | 4.79x |
| underscore | 42.5 KB | 218 | 947 | 894 | 818 | Rollup | 4.34x |
| backbone | 58.7 KB | 201 | 835 | 805 | 681 | Rollup | 4.15x |
| mootools | 156.7 KB | 43 | 194 | 183 | 159 | Rollup | 4.54x |
| jquery | 262 KB | 29 | 141 | 139 | 99 | Rollup | 4.86x |
| yui | 330.4 KB | 42 | 173 | 202 | 163 | Acorn | 4.78x |
| jquery.mobile | 442.2 KB | 20 | 84 | 93 | 50 | Acorn | 4.52x |
| angular | 701.9 KB | 25 | 96 | 117 | 67 | Acorn | 4.70x |
| three.js | 1.2 MB | 6 | 24 | 23 | 14 | Rollup | 3.87x |
| larger.js | 2.3 MB | 3 | 15 | 12 | 9 | Rollup | 4.37x |
| typescript.js | 8.2 MB | 1 | 4 | 4 | 2 | Rollup/Acorn | 4.00x |
颜色说明:绿色 = 最快红色 = 最慢 (SWC)橙色 = 性能差距倍数
测试结果解读
测试发现直接使用 @swc/core 的 JavaScript API 在所有测试中 都是最慢的,平均比纯 JavaScript 实现慢 4.3 倍。
这正是 原生交互挑战 中描述的问题:
完整调用链:
JavaScript
↓ [FFI 调用开销]
Rust 解析器 (快!)
↓ [JSON 序列化: serde_json::to_string]
JSON 字符串
↓ [传输]
JavaScript
↓ [JSON 反序列化: JSON.parse]
JavaScript AST 对象
总开销 = FFI + 序列化 + 反序列化 >> Rust 算法优势AST 序列化大小对比
| 文件 | 源码大小 | SWC AST | Rollup AST | Acorn AST | Babel AST | SWC/Acorn | Babel/Acorn |
|---|---|---|---|---|---|---|---|
| colors.js | 1.1 KB | 8,885 | 6,826 | 6,826 | 21,468 | 1.30x | 3.15x |
| underscore | 42.5 KB | 611,325 | 409,026 | 409,026 | 1,158,554 | 1.49x | 2.83x |
| backbone | 58.7 KB | 676,338 | 492,315 | 492,315 | 1,407,989 | 1.37x | 2.86x |
| mootools | 156.7 KB | 3,025,656 | 2,207,324 | 2,207,324 | 5,490,015 | 1.37x | 2.49x |
| jquery | 262 KB | 3,706,172 | 2,684,140 | 2,684,140 | 7,296,218 | 1.38x | 2.72x |
| yui | 330.4 KB | 2,687,894 | 2,100,733 | 2,100,733 | 5,743,729 | 1.28x | 2.73x |
| jquery.mobile | 442.2 KB | 5,853,254 | 4,627,787 | 4,627,787 | 12,238,361 | 1.26x | 2.64x |
| angular | 701.9 KB | 4,371,859 | 3,000,617 | 3,000,617 | 9,127,292 | 1.46x | 3.04x |
| three.js | 1.2 MB | 18,546,954 | 13,789,219 | 13,751,310 | 34,315,473 | 1.35x | 2.50x |
| larger.js | 2.3 MB | 35,738,746 | 27,911,674 | 27,835,504 | 69,421,956 | 1.28x | 2.49x |
| typescript.js | 8.2 MB | 91,256,349 | 67,612,837 | 67,567,418 | 178,461,426 | 1.35x | 2.64x |
| 平均 | - | - | - | - | - | 1.35x | 2.74x |
颜色说明:绿色 = 最优(最小 AST 或最低倍数)黄色 = 中等(Babel AST 或中等倍数)红色 = 较差(高倍数)
单位: 序列化后字符数
关键发现:
SWC AST平均比Acorn大 35%(1.35倍),意味着需要序列化更多数据。Babel AST平均比Acorn大 174%(2.74倍),但Babel是纯JS实现,无需跨语言序列化。- 解析
8MB的TypeScript.js时,SWC需要序列化 91MB 的AST JSON。 - 即使小文件(
1KB)也需要序列化近9KB的AST数据。 - 序列化开销随文件增大而线性增长,这就是为什么直接使用
SWCJavaScript API会如此缓慢。
Rollup 优化方案的性能
正是因为直接使用 SWC 的 JavaScript API 存在严重的序列化开销问题,Rollup 采用了 ArrayBuffer 优化方案:
- 避免 JSON 序列化:直接在
Rust侧写入二进制ArrayBuffer。 - 避免 JSON 反序列化:
JavaScript侧直接从ArrayBuffer读取数据。 - 体积更小:
ArrayBuffer大小只有JSON的 1/3 左右。
测试还发现当解析的字符量达到 319,869,952 时,Acorn 解析 AST 会报 栈溢出 错误。
<--- 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 会在这个规模下出现内存溢出问题。
性能分析总结
直接使用 SWC JavaScript API 的问题:
问题 原因 影响 FFI 调用开销 JavaScript ↔ Rust 边界 高频调用时成本累积 JSON 序列化 serde_json::to_string大型 AST 序列化耗时 JSON 反序列化 JSON.parse解析大型 JSON 字符串缓慢 内存占用 生成中间 JSON 字符串 额外内存分配 测试结果:比纯 JavaScript 解析器慢 3.87 - 4.86 倍(平均 4.43 倍)
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
AST 序列化开销分析:
根据实际测试数据,AST 序列化大小直接影响性能:
文件 源码大小 SWC AST Acorn AST Babel AST SWC 膨胀倍数 Babel 膨胀倍数 colors.js 1.1 KB 8,885 (≈8.9 KB) 6,826 (≈6.8 KB) 21,468 (≈21.5 KB) 1.30x 3.15x jquery 262 KB 3,706,172 (≈3.71 MB) 2,684,140 (≈2.68 MB) 7,296,218 (≈7.30 MB) 1.38x 2.72x typescript.js 8.2 MB 91,256,349 (≈91.3 MB) 67,567,418 (≈67.6 MB) 178,461,426 (≈178.5 MB) 1.35x 2.64x 📐 数据换算说明:字符数与字节数的关系
表格中的数字来自
JSON.stringify(ast).length,表示 字符数(UTF-16 代码单元数)。换算采用 SI 单位制(十进制):
bash1 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 字节:javascriptconst 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 字节:
javascriptconsole.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 个代码单元(称为代理对):javascriptconst 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 MB UTF-8 serde_json::to_string跨 FFI 传输 ≈91.3 MB UTF-8 传递给 JavaScript JavaScript 内存 ≈182.5 MB UTF-16 Node.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 倍
稳定性分析:
基于相对误差范围(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 优化影响。
性能倍数详细对比:
SWC 相对于最快解析器的性能差距:
文件 文件大小 最快解析器 SWC 慢倍数 colors.js 1.1 KB Acorn 4.79x jquery 262 KB Rollup 4.86x mootools 156.7 KB Rollup 4.54x three.js 1.2 MB Rollup 3.87x typescript.js 8.2 MB Rollup/Acorn 4.00x 平均慢倍数:4.43x(范围:3.87x - 4.86x)
趋势分析:
- 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 的性能优势。