Rollup 切换 parser 从 acorn 到 swc
Rollup 即将要从 acorn 切换到 swc,这是一项巨大的工作,还有很多事情要做。
PR 目标
将解析器从 acorn 切换到 swc,同时实际提高性能。作为 Rollup 4 的核心变化。
问题:直接使用 swc.parse 会带来巨大的通讯开销
不幸的是,这并不意味着可以直接使用 swc 的 JavaScript 引用。
import swc from '@swc/core';
const code = `
const a = 1;
function add(a, b) {
return a + b;
}
`;
swc
.parse(code, {
syntax: 'ecmascript',
comments: false,
script: true,
target: 'es3',
isModule: false
})
.then(module => {
module.type; // file type
module.body; // AST
});由于 swc 在内部通过序列化 AST 来实现 Javascript 与原生之间的数据通讯。
Rust 侧会将解析完成的 AST 序列化为字符串。
pub fn print<T>(
cm: Lrc<SourceMap>,
node: &T,
PrintArgs {
source_root,
source_file_name,
output_path,
inline_sources_content,
source_map,
source_map_names,
orig,
comments,
emit_source_map_columns,
preamble,
codegen_config,
output,
}: PrintArgs,
) -> Result<TransformOutput, Error>
where
T: Node + VisitWith<IdentCollector>,
{
node.emit_with(&mut emitter)
.context("failed to emit module")?;
}JS 接口侧再通过 JSON.parse 反序列化原生解析器返回的 AST 字符串为 JS 对象。
class Compiler {
async parse(
src: string,
options?: ParseOptions,
filename?: string
): Promise<Program> {
options = options || { syntax: 'ecmascript' };
options.syntax = options.syntax || 'ecmascript';
if (!bindings && !!fallbackBindings) {
throw new Error(
'Fallback bindings does not support this interface yet.'
);
} else if (!bindings) {
throw new Error('Bindings not found.');
}
if (bindings) {
const res = await bindings.parse(src, toBuffer(options), filename);
return JSON.parse(res);
} else if (fallbackBindings) {
return fallbackBindings.parse(src, options);
}
throw new Error('Bindings not found.');
}
}根据我的经验,对复杂 AST 进行 序列化 和 反序列化 的成本几乎侵蚀了切换为原生解析器(Rust)的性能优势。
问题:swc 的 AST 与 Rollup 的 AST 不兼容
swc 具有非常不同的 AST(即使使用了 ESTree compat 模块,它仍然是 Babel AST,而不是 ESTree AST)。
问题: 文件编码与 Rollup 不同
swc 使用 utf-8 文件位置,而 Rollup 依赖于标准的 JavaScript utf-16 位置。
UTF-8 和 UTF-16 是两种不同的字符编码方式,用于表示文本中的字符。它们的主要区别在于每个字符所占用的字节数和编码方式。
UTF-8 与 UTF-16
UTF-8:
可变长度编码:
UTF-8 使用 1 到 4 个字节来表示一个字符。ASCII字符(如英文字母和数字)使用1个字节,而其他字符(如汉字)可能使用 2 到 4 个字节。
向后兼容ASCII:
由于 ASCII 字符在 UTF-8 中只占用 1 个字节,UTF-8 与 ASCII 编码完全兼容。
UTF-16:
固定或可变长度编码:
UTF-16 通常使用 2 个字节来表示大多数常用字符,但对于某些特殊字符(如表情符号),可能需要 4 个字节。
不兼容ASCII:
UTF-16 不与 ASCII 兼容,因为 ASCII 字符在 UTF-16 中需要2个字节。
示例假设:
对于字符串 A你 编码结果如下。
UTF-8编码:
"A":1 个字节,编码为 0x41
"你":3 个字节,编码为 0xE4BDA0
UTF-16编码:
"A":2 个字节,编码为 0x0041
"你":2 个字节,编码为 0x4F60
在处理文本时,UTF-8 和 UTF-16 的选择会影响到文件的大小和字符位置的计算。例如,UTF-8 的字符位置是基于字节的,而 UTF-16 的字符位置是基于 2字节 的单位。这就是为什么在处理AST(抽象语法树)时,文件位置的编码方式会影响到解析和转换的过程。
解决方案
问题:无法直接使用
swc的JavaScript引用rollup会直接使用swc的Rust引用,并将AST转换为二进制格式,然后将其作为 (数组)缓冲区 传递给JavaScript。传递ArrayBuffer基本上是一个无损耗的操作,所以我们只需要教JavaScript侧如何运转ArrayBuffer即可。此外,ArrayBuffer的大小只有字符串化JSON的三分之一左右。最后,这将使我们能够轻松地将 AST 传递给不同的线程,或者更确切地说,在 WebWorker 中进行解析,解析完成后将 ArrayBuffer 无损地传递给主线程。问题:swc 的 AST 与 Rollup 的 AST 不兼容
考虑了
AST的差异,并更正了文件位置。
为了与 Rust 代码交互,我在 NodeJS 中使用 napi-rs,对于浏览器端可能会使用 wasm-pack 来进行构建。目前,实际转换已经完成。作为该过程的副产品,自定义 swc AST 被转换为符合 ESTree 的 AST,现已通过 Rollup 中的所有 acorn AST 深度比较测试用例。
注意
swc 转译的 AST 并没有做语法分析,也就是说以下代码 swc 也可以正常解析为 AST。
const a = 1;
const a = 2;这与 acorn 的解析方式不一样,acorn 会做语法分析,认为存在如下的语法错误。
Line 2: Identifier 'a' has already been declared.
也就是说 Rollup 根据原生转译过的 AST 二进制流来构建 AST 类实例时,需要做语法分析。
从以下 PR 和讨论
perf: run lint while constructing nodes
中可知,在作用域分析迁移到 Rust 之前,Rollup 不会对 Rust 侧的 AST 进行语法分析。原先 Rollup 会对 swc 产物做 lint 检查(即 swc_ecma_lints)。
use swc_ecma_lints::{rule::Rule, rules, rules::LintParams};
pub fn try_with_handler<F>(
code: &str,
cm: &Arc<SourceMap>,
es_version: EsVersion,
op: F,
) -> Result<Program, Vec<u8>>
where
F: FnOnce(&Handler) -> Result<Program, Error>,
{
let wr = Box::<Writer>::default();
let emitter = ErrorEmitter { wr: wr.clone() };
let handler = Handler::with_emitter(true, false, Box::new(emitter));
let result = HANDLER.set(&handler, || op(&handler));
match result {
Ok(mut program) => {
let unresolved_mark = Mark::new();
let top_level_mark = Mark::new();
let unresolved_ctxt = SyntaxContext::empty().apply_mark(unresolved_mark);
let top_level_ctxt = SyntaxContext::empty().apply_mark(top_level_mark);
program.visit_mut_with(&mut resolver(unresolved_mark, top_level_mark, false));
let mut rules = rules::all(LintParams {
program: &program,
lint_config: &Default::default(),
unresolved_ctxt,
top_level_ctxt,
es_version,
source_map: cm.clone(),
});
HANDLER.set(&handler, || match &program {
Program::Module(m) => {
rules.lint_module(m);
}
Program::Script(s) => {
rules.lint_script(s);
}
});
if handler.has_errors() {
let buffer = create_error_buffer(&wr, code);
Err(buffer)
} else {
Ok(program)
}
}
Err(_) => {
if handler.has_errors() {
let buffer = create_error_buffer(&wr, code);
Err(buffer)
} else {
panic!("Unexpected error in parse")
}
}
}
}可以了解到由于 Rust 侧的 swc 和 Rollup 均没有做作用域分析,也就是说没有做语法检查。取而代之的是 Rollup 会在 Rust 侧对 swc 产物做 lint 操作来间接做语法检查,这无疑增加了 Rollup 的性能负担。
现阶段 Rollup 将 Rust 侧的语法检查移除,可见如下代码。
pub fn try_with_handler<F>(
code: &str,
cm: &Arc<SourceMap>,
es_version: EsVersion,
op: F,
) -> Result<Program, Vec<u8>>
where
F: FnOnce(&Handler) -> Result<Program, Error>,
{
let wr = Box::<Writer>::default();
let emitter = ErrorEmitter { wr: wr.clone() };
let handler = Handler::with_emitter(true, false, Box::new(emitter));
let result = HANDLER.set(&handler, || op(&handler));
result.map_err(|_| {
if handler.has_errors() {
create_error_buffer(&wr, code)
} else {
panic!("Unexpected error in parse")
}
})
}同时将语法检测任务交付给 JavaScript 侧做处理。Rollup 会在 JavaScript AST 节点的 initialise() 阶段做语法分析。
语法分析检测的要点如下:
const_assignduplicate_bindingsduplicate_exportsno_dupe_args
