Native Parser
Reference Materials
相比 javascript
, rust
原生语言与生俱来具有强大的性能表现。rollup
决定将由 javascript
侧的 acorn
解析器切换到 rust
侧的 swc
解析器,具备高效地解析复杂 ast
的能力,这也作为 rollup v4
的核心变化。
Challenges
Native Interaction
直接使用 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
)的性能优势。
Ast Compatibility
swc
即使是 estree compat
模块,它仍然是 babel ast
,而不是 estree ast
。但 rollup
依赖于标准的 estree ast
。
File Encoding
swc
使用 utf-8
编码,而 rollup
依赖于标准 javascript
的 utf-16
编码。
utf-8
和 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
utf-8 的字符位置是基于字节的,而 utf-16 的字符位置是基于 2字节 的单位。
小结:
特性 | utf-8 | utf-16 |
---|---|---|
编码长度 | 1-4 字节 | 2 或 4 字节 |
ascii 兼容性 | 兼容 | 不兼容 |
对 ASCII 文本效率 | 高(1 字节/字符) | 低(2 字节/字符) |
对非拉丁文本效率 | 较低(3 字节/字符) | 较高(2 字节/字符) |
字节序问题 | 无需考虑 | 需要 BOM 标记 |
使用场景 | 网络协议、文件存储 | 内存操作、大型文本处理 |
在处理文本时,utf-8 和 utf-16 的选择会影响到文件的大小和 字符位置 的计算。那么这会影响到 ast
中的 字符位置 的确定,可以参考一下例子:
const info = '你好';
那么通过 babel ast
和 estree ast
规范解析的 ast
在 字符位置 上会有所不同。
{
"type": "Module",
"span": {
"start": 0,
"end": 19,
"ctxt": 0
},
"body": [
{
"type": "VariableDeclaration",
"span": {
"start": 0,
"end": 19,
"ctxt": 0
},
"kind": "const",
"declare": false,
"declarations": [
{
"type": "VariableDeclarator",
"span": {
"start": 6,
"end": 18,
"ctxt": 0
},
"id": {
"type": "Identifier",
"span": {
"start": 6,
"end": 7,
"ctxt": 0
},
"value": "a",
"optional": false,
"typeAnnotation": null
},
"init": {
"type": "StringLiteral",
"span": {
"start": 10,
"end": 18,
"ctxt": 0
},
"value": "你好",
"hasEscape": false,
"kind": {
"type": "normal",
"containsQuote": true
}
},
"definite": false
}
]
}
],
"interpreter": null
}
{
"type": "Program",
"start": 0,
"end": 15,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 15,
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 14,
"id": {
"type": "Identifier",
"start": 6,
"end": 7,
"name": "a"
},
"init": {
"type": "Literal",
"start": 10,
"end": 14,
"value": "你好",
"raw": "\"你好\""
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
可以发现两种不同规范的 ast
在处理特殊字符时,由于编码方式不同,所解析的 ast node
位置存在差异化。其中 babel ast tree
解析 utf-8
编码过的 你好 字面量的 ast node
位置区间为 [10, 18)
,而 estree ast tree
解析的经 utf-16
编码过的字面量的 ast node
位置区间为 [10, 14)
。
source map 这一章节详细说明了 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);
}
}
因此,编码不一样会导致最终 rollup
生成的 sourcemap
发生严重偏移。
Performance
Optimize Ast Compatibility
在 rust
侧借助 swc
的能力将代码解析为 babel 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
解析完成的 babel ast
树,重新计算 babel ast node
节点位置信息所对应的 estree ast node
的位置信息
/// 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 node
结构所需的信息。
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"),
}
}
通过 babel ast node
的结构获取 estree ast node
所需的信息,使用 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
做准备。需要注意的是,babel ast
规范是包含 comments
节点的,而 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
结构,rollup
侧则需引导解析 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;
}
Optimize Native Interaction
有上述可知,直接使用 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
解析完成的 babel 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
即可。此外,arraybuffer
的大小只有字符串化 json
的三分之一左右。最后,这将使我们能够轻松地将 arraybuffer
数据格式的 ast
传递给不同的线程,例如可以在 WebWorker
中进行解析,解析完成后将 arraybuffer
数据格式的 ast
无损地传递给主线程。
在 nodejs
侧使用 napi-rs
与 rust
代码交互,浏览器端采用 wasm-pack
来进行构建。
Optimize Semantic Analysis
Parser
Semantic Analysis Design
rust
侧直接调用 swc
提供的 use swc_compiler_base::parse_js
并不会执行 语义分析,只处理 词法分析 和 语法分析。也就是说以下代码在 swc
中可以正常解析为 babel ast
。
const a = 1;
const a = 2;
这与 acorn
的解析方式不一样,acorn
在生成 ast
时的 语法分析 阶段会额外执行 部分
的 语义分析 --- Early Errors
,执行程序前就可以发现错误。
本质的原因是 acorn
被设计为一个符合 ECMAScript
规范的解析器,ECMAScript
在 javascript
引擎执行代码之前,会要求执行 Static Semantics: Early Errors
的步骤(本质是 静态语义分析),在解析和早期语法分析阶段就需要检测和报告的错误。这些错误是通过静态解析的,意味着不需要实际运行代码就能发现它们。
browsers、nodejs 等内置
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)
}
}
}
Implement Semantic Analysis On JavaScript Side
但是从以下 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
的性能造成太大影响。
Semantic Analysis Detection Point
语义分析的任务主要包含如下几个方面:
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_bindings
tsexport 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_exports
tsexport 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_args
tsexport 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
侧做处理。
Optimize Ast Parsing
rollup
为插件上下文提供 this.parser
让用户插件使用原生 swc
的能力来解析 code
为 ast
。用户插件可以在 load
和 transform
钩子中返回已解析的 ast
,rollup
会复用用户插件中已解析的 ast
。
若用户插件没有解析 ast
(即插件在 load
和 transform
钩子中没有返回 ast
),那么 ast
会做兜底处理,在 transform
阶段完成后借助原生 rust
的能力将转译后的代码解析为 compat 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 class node
。
Performance Comparison
测试了下 rollup
在 4.28.1
和 3.29.5
版本下的解析能力对比,其中:
4.28.1
版本下是采用原生 swc
解析 ast
,从 rust
侧通过 arraybuffer
格式将 compatible estree ast
传递给 javascript
侧。
3.29.5
版本下是采用 acorn
解析 ast
。
每组各测试 5
次取平均值。
Code Length (Character) | SWC Parsing Time (ms) | Acorn Parsing Time (ms) |
---|---|---|
312.4K | 13.47 | 73.92 |
624.7K | 21.78 | 83.80 |
1.2M | 36.03 | 124.82 |
2.5M | 68.88 | 182.45 |
5.0M | 136.52 | 272.53 |
10.0M | 266.87 | 608.72 |
20.0M | 578.00 | 1178.82 |
159.9M | 4155.64 | 7276.24 |
319.9M | 10081.40 | - |
经测试发现当解析的字符量达到 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
从测试结果来看,切换到原生解析器的解析速度明显快于 acorn
。
整体表现:
使用原生解析器(内置
swc
)的平均解析时间相对较短,且随着代码长度的增加,增速较为缓和。使用非原生解析器(内置
acorn
)的解析时间在大代码量时增速显著,表现出较高的性能开销。
数据对比:
- 小代码量(
312,373
字符):差距较为明显,约为5.5
倍(13.47
ms vs73.92
ms)。 - 中代码量(
9,995,936
字符):差距约为2.28
倍(266.87
ms vs608.72
ms)。 - 大代码量(
159,934,976
字符):差距为1.75
倍(4155.64
ms vs7276.24
ms)。
Module Character Quantity Concept
module Code Length (Character) rollup.js 312,373 - 小代码量(
趋势分析:
- 使用原生解析器(内置
swc
)的解析时间增长幅度较小,适合更大规模模块的解析需求。 - 使用非原生解析器(内置
acorn
)的解析时间增长幅度较大,在超大模块解析效率明显不足。
- 使用原生解析器(内置