SWC 食用指南
SWC 是一个由 Rust 语言编写的 JavaScript / TypeScript 源代码的解析、打包工具。功能上比较类似 Babel 项目,但是在性能上会比 Babel 快很多。
很多时候我会希望用它来从抽象语法树的层面来对生成的一些 JavaScript 代码做点修改,但该项目的文档让我感觉到非常混乱,而且也鲜有样例代码,使用起来非常痛苦。
因此我会将我折腾的有关代码解析、转译的有关内容写在这里,你们可以借鉴我的经验,自己看着办。
注意:本文不会接触到与 SWC 打包功能相关的内容,因为我觉得这个功能没什么卵用。
基础使用之 Rust 部分
SWC 项目主要使用 Rust 语言编写,我们可以直接在 Rust 里面使用该工具。
cargo add swc_core --features common,ecma_ast,ecma_parser,ecma_visit,ecma_transforms,ecma_codegen
之后可以开始编写代码来实现我们需要的功能。
解析代码生成 AST
首先是将源代码转换成 AST 的部分。
let cm = Rc::new(SourceMap::default());
let fm = cm.new_source_file(FileName::Custom("input.js".to_string()), source.to_string());
let comments = SingleThreadedComments::default();
let lexer = Lexer::new(
Syntax::Es(EsSyntax::default()),
EsVersion::EsNext,
SourceFileInput::from(&*fm),
Some(&comments),
);
let mut parser = Parser::new_from(lexer);
let mut module = parser.parse_module().unwrap(); // 如果是 ES Module
// let mut script = parser.parse_script().unwrap(); // 如果不是
// let mut program = parser.parse_program().unwrap(); // 如果你不知道
// do something with AST...
我也不知道上面的代码发生了什么,但是复制粘贴过来就可以用了。
此外,SWC 也有一个重要的概念,即为 SyntaxContext,用来区分不同上下文中的标识符是否为同一个引用。具体来说,每一个标识符(Identifier)都会自带一个 SyntaxContext 序号,如果标识符名称和 SyntaxContext 序号都一致,那么才可以断定为同一个引用。
let ident = Ident::new(Atom::from("x"), Span::dummy());
ident.to_string(); // "x#0"
ident.sym.to_string(); // "x"
ident.span.ctxt; // SyntaxContext(0)
let x = 1; // x#0
{
let x = 2; // x#1
}
console.log(x); // x#0
但注意的是,上面给出的解析代码并不会自动计算 SyntaxContext 的值,你需要使用下面的代码来手动计算 SyntaxContext。
let globals = Globals::new();
GLOBALS.set(&globals, || {
let mut resolver = resolver(Mark::new(), Mark::new(), false);
module.visit_mut_with(&mut resolver);
});
遍历 AST
遍历 AST 是最基本的操作,在 SWC 中相关的就是 Visitor 的概念了。
Visitor 本质实际上就是对 AST 的递归搜索,通过这种方式在代码中寻找匹配的内容,或者做出修改。
Visitor 可以通过实现 Visit
、VisitMut
或者 Fold
特征来定义。
三者之间的主要区别是
Visit
以不可变借用遍历节点,不需要返回值;VisitMut
以可变借用遍历节点,不需要返回值;Fold
以转移所有权方式遍历节点,并将节点替换为返回值。
可以使用 as_folder
函数将实现了 Visit
/ VisitMut
的 Visitor 转换成 Fold
。
在实现 Visitor 时候,可以用下面几个宏定义来跳过 TypeScript 有关节点的遍历,来优化程序性能。
noop_fold_type!();
- 适用于Fold
noop_visit_type!();
- 适用于Visit
noop_visit_mut_type!();
- 适用于VisitMut
使用方式如下
// 定义一个访问者
#[derive(Default)]
struct TransformVisitor {
id: Vec<String>,
}
// 实现 Visit 特征
impl Visit for TransformVisitor {
noop_visit_type!();
fn visit_ident(&mut self, n: &Ident) {
self.id.push(n.sym.to_string());
// n.visit_children_with(self); // 如果要遍历子节点
}
}
// 然后用
let mut visitor = TransformVisitor::default();
module.visit_with(&mut visitor); // 遍历语法树
// visitor.id => 即为程序中的所有 ident
生成代码
最后便是生成代码的部分了,也没有什么好说的,直接复制粘贴就好了
let mut buf = vec![];
{
let mut emitter = Emitter {
cfg: Default::default(),
cm: cm.clone(),
comments: Some(&comments),
wr: JsWriter::new(cm, "\n", &mut buf, None),
};
emitter.emit_module(&module).unwrap()
}
String::from_utf8_lossy(&buf).to_string()
至此你已经学会了如何用 Rust 编写 SWC 程序了!可喜可贺。
测试
官方有几个宏定义专门用来帮你写测试用的,可以省去很多麻烦。
#[cfg(test)]
mod test {
use swc_core::ecma::{transforms::testing::test_inline, visit::as_folder};
use crate::TransformVisitor;
test_inline!(
Default::default(),
|_| as_folder(TransformVisitor::default()),
visitor_should_not_change_console_log,
r#"console.log("hello world");"#,
r#"console.log("hello world");"#
);
}
基础使用之 NodeJS 部分
虽然上面的东西有点复杂,但实际上官方已经帮我们解决了大部分麻烦事了。
我们可以直接用 npm 仓库中的 @swc/core
模块来实现大部分我们希望的操作。
npm i @swc/core
这个模块提供了许多函数,主要包括
parse
函数用来从源代码解析成 AST;print
函数用来从 AST 转换回源代码;transform
函数用来直接从源代码应用若干变换,直接生成目标代码;minify
函数用来压缩代码(我没用过);bundle
函数用来打包代码(我没用过)。
我们主要关心 parse
、print
和 transform
三个函数。
parse 函数和 print 函数
顾名思义,parse
函数用来实现从源代码到 AST 的转换过程,并且会返回一个类型丰富的 Js 对象。
例如,如果想要解析一份 Js 代码,并根据 AST 树查找一些固定的匹配,比起使用正则表达式,你可以这么写
import { parse } from "@swc/core";
const source = `
const a = func(114514);
export const b = (0, func)(114514);
export const c = 1, d = func(114514);
console.log("export const e = func(114514);");
`;
const ast = await parse(source, {
syntax: "ecmascript", // ecmascript / typescript
jsx: false, // 是否启用 jsx / tsx 标准
// isModule: false, // 如果不是 ES Module 就要写 false
});
for (const item of ast.body) {
// 找到所有的 export const/let/var ...
if (
item.type === "ExportDeclaration" &&
item.declaration.type === "VariableDeclaration"
) {
for (const decl of item.declaration.declarations) {
// 找到所有的 export const name = ...
if (decl.id.type === "Identifier") {
const name = decl.id.value;
// 找到所有的 export const name = func(...)
if (
decl.init &&
decl.init.type === "CallExpression" &&
decl.init.callee.type === "Identifier" &&
decl.init.callee.value === "func"
) {
// 将找到的结果输出
console.log(`found: ${name}`);
}
}
}
}
}
执行上面的代码,可以得到下面的输出。
found: d
进一步修改,我们在找到结果的同时修改 AST 代码树。
// 将找到的结果输出
// console.log(`found: ${name}`);
// 将原来的函数套一层 __my_transform(xxx, null);
const old = decl.init;
decl.init = {
type: "CallExpression",
callee: {
type: "Identifier",
value: "__my_transform",
span: { start: 0, end: 0, ctxt: 0 },
},
arguments: [
{ expression: old },
{
expression: {
type: "NullLiteral",
span: { start: 0, end: 0, ctxt: 0 },
},
},
],
span: { start: 0, end: 0, ctxt: 0 },
};
// =====
// 最后使用 print 函数将 AST 变为 Js 代码
const { code } = await print(ast);
console.log(code);
最后你可以看到如下的输出。
const a = func(114514);
export const b = (0, func)(114514);
export const c = 1,
d = __my_transform(func(114514), null);
console.log("export const e = func(114514);");
这便是关于 parse
函数和 print
函数的最简单使用方式。
注意,上面展示的方式仅是对 parse
和 print
函数使用方法的说明。 在实际生产中,建议直接使用 transform
函数来将源代码直接转换成目标代码。 parse
函数会在把 AST 从 Rust 转换成 Js 对象的时候产生严重的性能损失, print
函数也会在把 AST 从 Js 对象转换成 Rust 的时候耗费大量时间, 因此只适合一些简单测试或者仅需要将代码转换成 AST 分析的场景。
transform 函数
transform
函数的作用是直接读取源代码,和一些需要应用的变换过程(使用插件系统),在 Rust 内部直接完成从源代码到最终代码的转换流程。 这将会省去大部分 Rust 程序与 Js 代码之间相互转换的性能开销,从而获得最高效的性能体验。
这里以我自己开发的一个 @swwind/treeshake-events
插件作为样例来展示 transform
的使用方法。
import { transform } from "@swc/core";
import treeshakeEvents from "@swwind/treeshake-events";
const source = `
import { jsx as _jsx } from "preact/jsx-runtime";
_jsx("div", {
id: "app",
onClick: () => console.log(114514)
});
`;
const { code } = await transform(source, {
jsc: {
parser: {
syntax: "ecmascript",
jsx: false,
},
experimental: {
plugins: [treeshakeEvents()],
},
target: "esnext",
preserveAllComments: true, // 保留所有的注释
},
isModule: true,
// sourceMaps: "inline", // 如果你要 source map
});
console.log(code);
执行程序,可以看到生成的最终代码如下。
import { jsx as _jsx } from "preact/jsx-runtime";
_jsx("div", {
id: "app"
});
这便是 transform
函数的基本使用方法。
值得注意的是,transform
是唯一可以在结果中保留源程序中注释的方式,因为技术原因,注释相关的内容会在 parse
函数将 AST 从 Rust 转换成 Js 对象的时候丢失。
此外,使用 transform
函数也可以很好地生成 source map 等信息,因此在有条件使用 transform
函数的时候应当尽量选择这种解决方案。
遍历 AST
经常用 Babel 的朋友都知道,Babel 一般是用 @babel/traverse
来对生成的 AST 进行遍历的。
SWC 在这方面则比较微妙,因为惜字如金的官方文档里面只有用 Rust 进行树遍历的说明和样例。
但如果我一定要用 Js 进行遍历呢?其实也不是不行。SWC 有一套官方的玩意可以用来给你干这个。
// 奇怪的入口地址
import { Visitor } from "@swc/core/Visitor.js";
import { parse } from "@swc/core";
const source = `
const a = func(114514);
export const b = (0, func)(114514);
export const c = 1, d = func(114514);
console.log("export const e = func(114514);");
`;
const ast = await parse(source, { syntax: "ecmascript", jsx: false });
// 使用继承的方式来 override 自定义 visit 函数,需要返回修改后的节点(所以实际上可能是 Fold)
class MyVisitor extends Visitor {
// 重载函数表示覆盖默认行为
visitIdentifier(id) {
console.log(`identifier = ${id.value}`);
return id;
}
// 如果没有重载函数,默认就是递归访问 children 的逻辑
visitProgram(program) {
// 当然也可以使用 super 来手动调用访问 children 的逻辑
return super.visitProgram(program);
}
}
// 最后直接这么用就好了
const visitor = new MyVisitor();
const newAst = visitor.visitProgram(ast);
// console.log(print(newAst)); // 如果你要再转换成 Js
这样将会输出下面的内容
identifier = a
identifier = func
identifier = b
identifier = func
identifier = c
identifier = d
identifier = func
identifier = console
identifier = log
插件开发
SWC 的插件还是比较实验性的内容,因此接口之类的可能会发生变化。
目前来讲编写 SWC 插件只推荐使用 Rust 语言,并且编译成 wasm 来使用。
在开发插件之前,你需要熟悉 Rust 相关技术,以及确定使用的 @swc/core
包和 crates.io 里面的 swc_core
版本一致(版本对照表可以参考这个链接)。
首先需要安装编译成 wasm32-wasi
目标的 Rust 工具链,可以使用下面的指令添加(使用 rustup
)。
rustup target add wasm32-wasi
之后,你可以使用下面的指令创建一个简单的插件模板。
npx swc plugin new --target-type wasm32-wasi my-first-plugin
Rust 部分
首先我们观察生成的 src/lib.rs
文件,可以看到如下的内容。
struct TransformVisitor;
impl Visit for TransformVisitor {
// TODO
}
#[plugin_transform]
pub fn process_transform(
program: Program,
_metadata: TransformPluginProgramMetadata,
) -> Program {
program.visit_with(&mut TransformVisitor);
program
}
可以看到,插件的实际运行函数应该就是 process_transform
,并且在默认的代码使用了 TransformVisitor
进行了遍历操作。
因此,我们只需要实现自己的 Visitor,然后用它来对输入的 Program 进行修改再返回就可以了。
但你可能会好奇如何读取插件的配置信息,因为 SWC 文档里面完全没有提到这个。
目前来说,SWC 在使用插件的时候都强制需要写配置选项,并且选项在传入 Rust 过程中会使用 JSON 格式编码,需要插件自己解码。
在 Rust 中实现 JSON 解码过程,一般需要用 serde_json
相关的库。
cargo add serde --features derive
cargo add serde_json
之后在代码中从 metadata
中获取配置信息。
#[derive(Deserialize)]
struct TransformConfig {
#[serde(default = "default_jsxs")]
jsxs: Vec<String>, // 默认值使用 default_jsxs 函数生成
#[serde(default = "default_matches")]
matches: Vec<String>, // 默认值使用 default_matches 函数生成
}
fn default_jsxs() -> Vec<String> {
vec![
String::from("jsx"),
String::from("jsxs"),
String::from("jsxDEV"),
]
}
fn default_matches() -> Vec<String> {
vec![String::from("^on[A-Z]")]
}
impl Default for TransformConfig {
fn default() -> Self {
Self {
jsxs: default_jsxs(),
matches: default_matches(),
}
}
}
#[plugin_transform]
pub fn process_transform(
mut program: Program,
metadata: TransformPluginProgramMetadata,
) -> Program {
let config = metadata
.get_transform_plugin_config()
.and_then(|x| Some(serde_json::from_str::<TransformConfig>(&x).unwrap()))
.unwrap_or_default();
// 通过 config 来初始化我们的 Visitor
program.visit_mut_with(&mut TransformVisitor::from_config(config));
program
}
最后和上面一样写测试就好了。
编译成 wasm
在你初始化插件的时候,SWC 应该会自动帮你添加一个 .cargo/config
文件,里面有如下内容。
# 几个缩写指令
[alias]
build-wasi = "build --target wasm32-wasi"
build-wasm32 = "build --target wasm32-unknown-unknown"
此外,需要手动编辑 Cargo.toml
文件,添加如下内容,开启编译器的最大优化。
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
strip = "symbols"
最后直接使用 cargo build-wasi --release
进行编译,就可以在 target/wasm32-wasi/release/
中找到生成的 wasm 文件。
导出设置
为了方便,我们在构建之后将 wasm 文件拷贝到项目根目录下面。编辑 package.json
,添加下面的几个 script。
{
"scripts": {
"build": "cargo build-wasi --release && cp target/wasm32-wasi/release/my_transform_plugin.wasm .",
"prepublishOnly": "npm run build"
}
}
之后,你要做的就是将 wasm 的文件地址导出给 SWC 就行了。
官方方案
官方的方案是,编辑 package.json
文件,在其中的 main
字段中填入 wasm 文件的路径。
{
"main": "./my_transform_plugin.wasm"
}
之后在使用的时候直接给出包名和配置即可。
await transform(code, {
jsc: {
experimental: {
plugins: [["my-transform-plugin", {}]],
},
},
});
但是这套方案有个非常致命的问题,就是只能在 npm 包管理器下面工作。
SWC 目前的依赖查找算法比较原始,只会去 node_modules 里面找文件,于是这套方案在 pnpm 和 yarn pnp 下面就会因为找不到目标文件而报错。
因此,我建议使用下面的解决方案。
我的方案
我的方案是使用脚本返回 wasm 路径,这样就可以更友好地支持所有包管理器,并且能够引入配置选项的 TypeScript 类型。
手动添加三个文件。
// index.cjs
const path = require("node:path");
const wasmPath = path.join(__dirname, "./my_transform_plugin.wasm");
module.exports = {
wasm: wasmPath,
default: function (options) {
return [wasmPath, options || {}];
},
};
// index.mjs
import { fileURLToPath } from "node:url";
export const wasm = fileURLToPath(
new URL("./my_transform_plugin.wasm", import.meta.url),
);
export default function (options) {
return [wasm, options || {}];
}
// index.d.ts
export interface PluginOptions {
jsxs?: string[];
matches?: string[];
}
export declare const wasm: string;
declare function plugin(
options?: PluginOptions | undefined,
): [string, PluginOptions];
export default plugin;
最后在 package.json
中添加下面的字段。
{
"main": "./index.cjs",
"types": "./index.d.ts",
"module": "./index.mjs",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.mjs",
"require": "./index.cjs"
}
}
}
最后就可以随便用了,类型什么的都有,用起来不要太爽。
import plugin from "my-transform-plugin";
await transform(code, {
jsc: {
experimental: {
plugins: [plugin()],
},
},
});
告诫
不要想着靠 SWC 去改变什么,你什么都做不到的。
评论
少女祈祷中...