🫣

【Rust】SWCプラグインを作って得た学び

2023/03/22に公開

SWCとは?

SWCとはRustで書かれたWebのプロジェクトで公式サイトには

SWC can be used for both compilation and bundling. For compilation, it takes JavaScript / TypeScript files using modern JavaScript features and outputs valid code that is supported by all major browsers.

とあり,コンパイルに関していえば,JavaScriptやTypeScriptからほとんどのモダンブラウザでサポートされるコードを出力することができます
またシングルスレッドでBabelの20倍、4コアで70倍もの高速化を実現するらしいです.

SWCは韓国出身のDonnyさんを中心に開発されており,今も活発に活動されています.

きっかけ

インターンシップのプロジェクト(Next.js&TypeScript)でSWCへの移行に関するPoCタスクに挑戦しました.
それまでBabelのカスタムプラグインを使っていたため,SWCプラグインに移行することが課題でした.
この課題に取り組むことで自分のスキルアップができると思い,積極的に取り組みました.

またRustは前から気になっていたのでとても良い機会だと思い,Rustのキャッチアップから始めました.

キャッチアップ

Rust

Stack Overflowの調査で最も愛されている言語として知られているRustは初めは難しそうな印象がありました.

GitHubのソースコードを見るところから始めると難しいだろうと考え,最初にThe Rust Programming Languageを読みました.

このサイトで基本的な知識を習得し,とりあえずはそれだけでSWCプラグインの作成に着手しました.完全に理解できたわけではありませんでしたが,とにかく進めていくことにしました.

SWCプラグイン

SWCは現在急速に進行しているプロジェクトであり,日本語だけでなく英語のドキュメントも十分にありません.

そういった状況の中で僕が参考にしたのは

このあたりです.本当に探してもこのくらいの情報しか見つかりません(笑).
そしてこの時点で驚いたのがSosuke Suzukiさんakfm.satoさんなどフロントエンド界隈でよく見る方々がすでにSWCプラグイン作成に挑戦されていたことです.この頃は今よりももっと情報が少ない頃だったと思うと本当にすごいです.

手順

RustとSWCについてざっくりと学んだ後は以下のように進めていきました.

  1. 機能がないプラグインを作ってみる
  2. 簡単なプラグインを作る
  3. Babelのカスタムプラグインの機能を読み解く
  4. それをSWCプラグインで書く
  5. プロジェクトに導入してみる

実際は,手順2の後に手順5をすぐに行っておくべきだったと後悔しています.
まずは最小限のプラグインを作成して,それがプロジェクトで正常に動作することを確認することが大切です.

準備

全てを始める前にRustをWASMにコンパイルできるようにします.
WASMについては以下の記事がわかりやすいです.
https://medium.com/dscvitpune/introduction-to-webassembly-wasm-54d505d6d569

以下のコマンドを実行するとWASMをサポートするようになります.

rustup target add wasm32-wasi

続いてSWCのためのRust製のCLIをインストールします.

cargo install swc_cli

これで準備は完了です🎉

1. 機能のないプラグインを作成

ドキュメントを参考に機能のないプラグインを作ってみます.

プラグインを作成
swc plugin new --target-type wasm32-wasi my-first-plugin

上記のコマンドでmy-first-pluginという名前のSWCプラグインプロジェクトが作成されます.

いくつかファイルがあると思いますが,src/lib.rsがプラグインを実行するファイルになります.
コマンド実行後では以下のような状態になっていると思います.(コメントを一部除去しています)

lib.rs
use swc_core::ecma::{
    ast::Program,
    transforms::testing::test,
    visit::{as_folder, FoldWith, VisitMut},
};
use swc_core::plugin::{plugin_transform, proxies::TransformPluginProgramMetadata};

pub struct TransformVisitor;

impl VisitMut for TransformVisitor {
    // Implement necessary visit_mut_* methods for actual custom transform.
    // A comprehensive list of possible visitor methods can be found here:
    // https://rustdoc.swc.rs/swc_ecma_visit/trait.VisitMut.html
}

#[plugin_transform]
pub fn process_transform(program: Program, _metadata: TransformPluginProgramMetadata) -> Program {
    program.fold_with(&mut as_folder(TransformVisitor))
}

test!(
    Default::default(),
    |_| as_folder(TransformVisitor),
    boo,
    // Input codes
    r#"console.log("transform");"#,
    // Output codes after transformed with plugin
    r#"console.log("transform");"#
);

impl VisitMut for TransformVisitor {}の中にプラグインの関数を実装していくのですが,まずはこのままの状態 でテストをしてみましょう.

テストの中身はtest!以下の部分になりますが,入力と出力で何も変わっていないのがわかると思います.

テストを実行
cargo test

上記のコマンドを実行し成功すれば問題ないです.

.cargo/configにビルドのためのエイリアスが登録されているのでそれを使ってビルドしましょう!

ビルド
cargo build-wasi

こちらも上記のコマンドを実行し成功すれば問題ないです.ビルド成果物はtarget/wasm32-wasi/debug/my_first_plugin.wasmになります.

2. 簡単なプラグインを作成

次に簡単な処理を実行するプラグインを作成していきます.

SWCプラグインはあらかじめいくつものメソッドを提供してくれており,その中から自分の実装したい関数を記述し,中身を実装します.
メソッドはこちらにまとめられていて,現在は283種類提供されています.

今回はその中からvisit_mut_var_decl(変数定義についてTransformできる)メソッドを定義し,実装します.
プラグインは変数名をすべてスネークケースに変換するという内容で作りたいと思います.

実装

実装は以下のようにしました.

ソースコード
lib.rs
use convert_case::{Case, Casing};
use swc_core::ecma::{
    ast::{Pat, Program, VarDecl},
    transforms::testing::test,
    visit::{as_folder, FoldWith, VisitMut},
};
use swc_core::plugin::{plugin_transform, proxies::TransformPluginProgramMetadata};

pub struct TransformVisitor;

/**
 * Convert to snake-case
 */
fn convert_to_snake_case(s: String) -> String {
    return s.to_case(Case::Snake).to_lowercase();
}

impl VisitMut for TransformVisitor {
    fn visit_mut_var_decl(&mut self, n: &mut VarDecl) {
        let decls = &mut n.decls;

        for decl in decls.iter_mut() {
            let name = &mut decl.name;

            if let Pat::Ident(binding_ident) = name {
                let id = &mut binding_ident.id;
                id.sym = convert_to_snake_case(id.sym.to_string()).into();
            }
        }
    }
    // Implement necessary visit_mut_* methods for actual custom transform.
    // A comprehensive list of possible visitor methods can be found here:
    // https://rustdoc.swc.rs/swc_ecma_visit/trait.VisitMut.html
}

#[plugin_transform]
pub fn process_transform(program: Program, _metadata: TransformPluginProgramMetadata) -> Program {
    program.fold_with(&mut as_folder(TransformVisitor))
}

test!(
    Default::default(),
    |_| as_folder(TransformVisitor),
    test1,
    // Input codes
    r#"
    const yourName = "KIMINONAWA";
    "#,
    // Output codes after transformed with plugin
    r#"
    const your_name = "KIMINONAWA";
    "#
);

説明

関数の引数はあらかじめ決まっており,visit_mut_var_declselfVarDecl型であるn(命名はなんでも良い)となっております.

実装は以下の流れで進んでいきます.

  1. 変数1つずつについて
  2. 変数名を取り出し
  3. スネークケースに変更する

ソースコードではそれぞれ

  1. for decl in decls.iter_mut()
  2. let name = &mut decl.name;
  3. id.sym = convert_to_snake_case(id.sym.to_string()).into();

に対応します.

テストケース

テストではyourNameyour_nameにTransformすることを確認しています.

ソースコード
テストケース
test!(
    Default::default(),
    |_| as_folder(TransformVisitor),
    test1,
    // Input codes
    r#"
    const yourName = "KIMINONAWA";
    "#,
    // Output codes after transformed with plugin
    r#"
    const your_name = "KIMINONAWA";
    "#
);

3. Babelプラグインを読み解く

data-testid

今まではBabelのカスタムプラグインを使っていました.このプラグインは簡単に言うとJSXに特定の属性(今回はdata-testid)を追加する機能です.

data-testidとは?

data-testidはテストの際に要素を特定する目的で使用される属性です.
以下の記事で詳細に説明されています.

https://qiita.com/akameco/items/519f7e4d5442b2a9d2da

最近ではこんな記事もありました.
https://zenn.dev/tnyo43/articles/39e4caa321d0aa

プラグインの動作

既存のプラグインはこちらのカスタムプラグインを参考にしており,以下のように動作します.

before.tsx
function Div() {
  return <div />
}

const Hello = () => <div>hello</div>

function Nested() {
  return (
    <div>
      hello
      <div>world</div>
    </div>
  )
}
after.tsx
function Div() {
  return <div data-testid="Div" />
}

const Hello = () => <div data-testid="Hello">hello</div>

function Nested() {
  return (
    <div data-testid="Nested">
      hello
      <div>world</div>
    </div>
  )
}

各関数コンポーネントにdata-testidと値が追加されています.
値は関数コンポーネント名がそのまま挿入され,階層構造になっている場合は,トップの階層にのみ追加されます.

4. SWCプラグインを実装

公式ドキュメントを読む

SWCには公式ドキュメントがあり,その中にはプラグインの詳細を説明しているページもあります.
また先に紹介したVisitMutトレイトのページにもプラグインを実装する上で重要な情報がたくさん載っています.

今回はプラグインを実装する際に以下のようにドキュメントを読み進め,実装していきました.

  1. 実装したいプラグインの機能を考える
  2. ドキュメントからプラグインの機能を提供できそうな関数を探す
    今回はJSXの属性を操作するプラグインを作成したかったのでjsx_attrなどと検索し,jsx_attr_namejsx_attr_valueなどの関数がヒットしました.
    またJSXの開きタグについてTransformできるvisit_mut_jsx_opening_elementという関数もありました.
  3. visit_mut_jsx_opening_elementの引数を調べる
    ドキュメントによるとこの関数は&mut self, n: &mut JSXOpeningElementが引数でした.
  4. 引数の中身を確認しながら実装する
    引数のJSXOpeingElementは以下のようなstructになっており,ここでどんな値を受け取れるのかを確認し実装していきます.
JSXOpeningElement
pub struct JSXOpeningElement {
    pub name: JSXElementName,
    pub span: Span,
    pub attrs: Vec<JSXAttrOrSpread>,
    pub self_closing: bool,
    pub type_args: Option<Box<TsTypeParamInstantiation>>,
}

この全体的な流れを掴めばプラグインの実装を進めることができます.

ソースコード

コンポーネント名を取得する

ここでは主にvisit_mut_fn_decl(関数宣言をTransformできる)などの関数を用いてコンポーネント名(関数名)を取り出す処理などをしています.このコンポーネント名を取得するのに非常に苦労しました.これについての話は後述します.

親のJSXに属性を追加する

JSXにdata-testid属性を追加するのですが,全ての階層のJSXを対象としているわけではなく,トップ(親)の階層のみに追加したいという要件がありました.

SWCはASTを親のノードから子のノードへという順番で操作していくのでそのノードが親かどうかということはそのノードに親がいるかどうかという,そのノード自身だけを確認してわかるものではないため実装に悩みました.

僕はSWCの仕組みだけではそれを解決できなかったので,最終的にはSelfの中にis_in_childを持ち自分が親ノードであるかどうかを判定できるようにしました.

ignoreFiles

今回はNext.jsに導入するためプラグインの設定をnext.config.jsに記述します.
以下はnext.config.jsの例です.

next.config.js
swcPlugins: [
  [
    '<path to plugin>',
    {
      attrName: 'data-testid',
      ignoreFiles: ['/node_modules/'],
      ignoreComponents: [''],
    },
  ],
],

例にあるようにプラグインでは

  • attrName
  • ignoreFiles
  • ignoreComponents

の3つの設定が必須になります.
これらの設定はプラグインのエンドポイントとなるprocess_transformで受け取ります.
型を守りつつ,設定項目をもれなく受け取りたいという思いがあり,独自に実装していましたが,Next.js公式のプラグインを参考に実装し直しました.

Next.jsはバージョン12.2から公式のプラグインをいくつかサポートしています.
このあたりを参考にしました.

next.config.js
let config = serde_json::from_str::<Config>(
&data
    .get_transform_plugin_config()
    .expect("failed to get plugin config for styled-components"),
)
.expect("invalid config for styled-components");

GitHubはこちら

テストを書く

テストはswc_cliで用意されているテンプレートをそのまま使う形で記述していきました.
1つ注意することはTypeScriptを有効にすることです.
テストのConfig部分に以下のように記述しました.

TypeScriptを有効にする
swc_core::ecma::parser::Syntax::Typescript(swc_core::ecma::parser::TsConfig {
    tsx: true,
    ..Default::default()
}),

今は同じファイルに機能とテストを書いていてコードの可読性が低いので,今後これらを整備していきたいです.

5. プロジェクトに導入する

SWCのカスタムプラグインをプロジェクトに導入する場合,主に2つのやり方があると思っています.

  • プロジェクトの中でソースコードを管理する
    そのプロジェクトに特化したプラグイン&公開する予定がないならこちらでも良いのではないかと思っています.
  • プロジェクトと切り分けて開発する
    サードパーティプラグインとして公開することも視野に入れている場合はこちらの方が良いと思います.

それぞれについてどのようなフローになるかを簡単に説明します.

プロジェクトの中に作成する

プロジェクト内でカスタムプラグインを作成する場合,任意のディレクトリを切って(swc_plugins/など)開発していくイメージになると思います.

この方法ではプロジェクトのドメインに特化したプラグインを作成することが想定され,そのときに在籍しているメンバーが開発・保守をする形になります.
またドメイン特化のため,「公開しなくても良い」or「公開しない方が良い」という場合が多いと思います.

開発の際にできる工夫としてswc_cliでテンプレートプラグインを作成したときにできるpackage.jsonにスクリプトを追加しビルドから生成されたWASMファイルの移動までを行うことが挙げられます.(生成されたWASMファイルはignoreされるので移動させる必要がある)

package.json(スクリプト例)
"scripts": {
  "build:swc-plugins": "cargo wasm-build && cp ./target/wasm32-wasi/debug/<プラグインの名前>.wasm ./../../"
}

プロジェクトと切り分けて作成する(ライブラリとして公開する)

一方でプロジェクトと切り分けてプラグインを開発することもできます.

こちらは汎用性の高い(様々なプロジェクトで使用できる)プラグインなどの開発と想定されます.
プロジェクト外のメンバーも容易に開発に参加することができ,チーム内外の意見を取り入れることができます.

また最初からこの方法で開発を進めておくといざサードパーティライブラリとして公開しようと思ったときもすばやく公開することができます.

今回はプロジェクト内に作成

今回はPoCタスクということもあり前者で進めました.しかし今後サードパーティとして公開したいという気持ちなのでその際には新たにプロジェクトを切り分けて作成しようと思っています.

つまったところ

プリセットプラグイン

SWCプラグインとは直接関係ないのですが,プロジェクトではBabelのカスタムプラグインの他にNext.jsが用意しているpresetのnext/babelを使用していました.
今回BabelからSWCへ完全に移行したかったので現在使用しているプラグインのすべてについてそれぞれ代替となるものに置き換える必要があり,この対応が結構大変でした.

preset next/babel

Next.js includes the next/babel preset to your app, which includes everything needed to compile React applications and server-side code. But if you want to extend the default Babel configs, it's also possible.

以下のNext.jsのソースコード内にあるように

preset.ts
type NextBabelPresetOptions = {
  'preset-env'?: any
  'preset-react'?: any
  'class-properties'?: any
  'transform-runtime'?: any
  'styled-jsx'?: StyledJsxBabelOptions
  'preset-typescript'?: any
}

どうやら6つほどpresetsのプラグインとして用意されていることがわかりました.
そして同時にSWCの公式ドキュメントにMigrating from Babelというページを見つけ,この内容を参照すれば移行できるのではないかとあたりをつけました.

こちらのコミットを見てみるとstyled-jsxを除く他5つのプラグインすべてについてSWCに置き換えることができると記載してあります.
(styled-jsxについてはプロジェクト使用していないので今回は省略します)
ただドキュメントが非公開になり,現時点ではその移行方法が明記されている場所を見つけることができませんでした.

そのため今回は実際のソースコードを参照しつつ(このあたり),それに対応する設定をし,動作確認を行いながら対応しました.

JavaScriptの関数定義

JavaScriptには関数の定義方法がいくつかあります.

関数宣言

関数定義などとも呼ばれます.

関数宣言は以下のように記述され,プリミティブな引数は値渡しで関数に渡されます.

function_declarations.js
function square(number) {
  return number * number;
}

square(10); // 100

関数式

関数式は関数名を省略(無名)して定義することができます.
関数式はコールバック関数のようにある関数を別の関数の引数として渡す時に便利です.

function_expressions.js
const square = function(number) { return number * number }

square(10); // 100

アロー関数

アロー関数は常に無名関数であり,関数式と比較してより短くかけることが特徴の1つです.

arrow_functions.js
const square = (number) => {
  return number * number
}
square(10); // 100

// returnを省略
const square = (number) => number * number
square(10); // 100

これらには違いがあるものの,すべてJSXを返す関数つまりコンポーネントとして振る舞うことができます.
どの関数定義で記述されていたとしても正しく動作することを保証するためにプラグインではそれらすべてに対応する必要があります.

そのため1つずつチェックしながらきちんと定義を追えているか確認するのが意外と大変でした.

swc vs next-swc

Next.jsでSWCプラグインを作るときに必ず知っておくべきなのがNext.jsが採用しているSWCは本家のSWCとは違うということです.

https://www.wantedly.com/companies/wantedly/post_articles/386129

2つの違いは詳細に把握しているわけではありませんが,Next.jsはSWCを独自に実装しています.(もちろん本家SWCをライブラリとして使用している)
このことを常に頭に入れておくことはドキュメントを読む上でとても大切です.

詳細はこちら

swc_core version

以下はSWCのContributorsの1人であるKwonさんがコメントしていた内容です.

next-swc needs some time to keep up swc's upstream changes. Currently, next-swc does not incorporate swc_core as same as swc does, in result not able to handshake with plugins using swc_core. We expect to attemp to bump up next-swc to use swc_core soon, but that'll be on the canary release instead of public releases.

https://github.com/vercel/next.js/issues/39702#issuecomment-1221478050

上記コメントではNext.jsでのnext-swcは本家SWCとは異なりswc_coreのサポートに時間が必要とあります.

コメントの時点(2022/08/21)ではnext-swcはswc_coreを取り入れていないとありますが,Next.js公式ドキュメントによるとNext.js 12.2からSWCの実験的サポートが開始しました.
これによりバージョンの制約はありますが,Next.jsでもswc_coreが使用できるようになりました.

現在のバージョン

参考までに現時点ではswc_core0.28.10まで対応しているようです.https://github.com/vercel/next.js/commit/3f2fef19bcc455fbf5c281da325fd81fabffb9bf

成果物

以上の手順で作成したプラグインが以下になります.
https://github.com/fujiyamaorange/swc-test-plugin

おわりに

長い記事でしたが,ここまで読んでくださりありがとうございました.
SWCプラグインに興味を持っている方や今実際に開発している人の助けになれば嬉しいです.

謝辞

まずこのタスクを任せていただいたチームメンバーの方ありがとうございました!
そして一番近くでサポートしてくださったメンターの方にも感謝です!ありがとうございました!

今後

今後はライブラリとして公開できるようにリファクタリング,機能改善などを進めていきたいと思います.

またこれを機に他のSWCプラグインを作成するのも面白そうだなと思ったので,もしプロジェクトで必要になれば率先して作成しようと思いました!

バージョン

> cargo --version
cargo 1.66.0 (d65d197ad 2022-11-15)

> node --version
v18.12.0

> next.js
v12.3.1

参考

Discussion