🗼

現実の Babel プラグインを SWC プラグインに移行する

2022/03/20に公開

最近の SWC は Rust でプラグインを書くことができます。

https://twitter.com/swc_rs/status/1492454606118752257

先日、現実の Babel プラグインを SWC プラグインに移植して実際に使ってみたので、それについて書き残します。

(SWC のプラグインの仕様は変わっていく可能性が高いので、この記事や参照先のコードはあんまり参考にしすぎないように...)

移行対象の Babel プラグイン

今回の移行対象の Babel プラグインは、主に React 用の状態管理ライブラリである Valtio に実装されている useProxy という Hooks です。

https://github.com/pmndrs/valtio#useproxy-macro

この Hooks は babel-plugin-macros で実装されており、ビルド時に別の Hooks に展開されます。今回はこの挙動を SWC プラグインをとして再現します。

useProxy

Valtio はシンプル(見かけ上はシンプル、実際にはシンプル/イージーでいえばイージーかも)な Proxy ベースの状態管理ライブラリで、次のようにして使います。

import { proxy, useSnapshot } from 'valtio'

const state = proxy({ count: 0 })

function Counter() {
  const snap = useSnapshot(state)
  return (
    <div>
      {snap.count}
      <button onClick={() => ++state.count}>+1</button>
    </div>
  )
}

これは簡単なカウンターアプリの例です。proxy で状態を作成し、useSnapshot でその state をコンポーネント内からリアクティブに参照できます。

しかし、これだと状態の読み取りと更新で異なる変数を扱うことになり、やや直感的ではありません。上記の例では状態を読み取るときには snap.count を見る必要がありますが、更新するときには state.count を見る必要があります。

そこで useProxy の出番です。useProxy は次のように使います。

import { proxy } from "valtio";
import { useProxy } from 'valtio/macro'

const state = proxy({ count: 0 })

const Component = () => {
  useProxy(state)
  return (
    <div>
      {state.count}
      <button onClick={() => ++state.count}>+1</button>
    </div>
  )
}

これは useSnapshot を使ったカウンターアプリと同じように振る舞います。useProxy の引数に state を渡すと、state という一つの変数で状態の読み取りも更新もできるようになるのです。

useProxy は babel-plugin-macros で実装されていて、ビルド時に次のような useSnapshot を使った形に展開されます。

import { useSnapshot } from 'valtio'

const Component = () => {
  const snap = useSnapshot(state)
  return (
    <div>
      {snap.count}
      <button onClick={() => ++state.count}>+1</button>
    </div>
  )
}

ちなみに移行対象に useProxy を選んだのは規模が小さくて現実的だったからです。

SWC プラグインを書く

書きました。

https://github.com/sosukesuzuki/swc-plugin-valtio

メインの実装は https://github.com/sosukesuzuki/swc-plugin-valtio/blob/main/src/lib.rs にあります。

免許合宿にいく前の日の夜に荷造りをやりたくなさすぎて書いたのでコードは汚く、特に if let をネストしまくる部分については後輩から「お前それはさすがにありえないよ?」と言われました。

次のようなステップで useProxy を実現できます。

  • import { useProxy } from "valtio/macro"; を探して消す。そして import { useSnapshot } from "valtio"; を追加する。
  • useProxy(state)const snap = useSnapshot(state); に置き換える。
  • snap を参照している箇所を state への参照に置き換える。

SWC のコアのトランスフォーマーと同じようなノリでそれぞれのステップを実装する感じです。

ただやはり Babel プラグインと比べてヘルパーが少ないため書きにくいです。@babel/template 相当のものがなかったので AST を構成する struct と enum を真心をこめて手書きする必要がありました。

また、Babel のトラバーサーの場合は変更したいノードにビジットしたあとに path を使ってその親をたどったりできるんですが、SWC の場合はそういうことはできないので、変更したいノードの親ノードの情報が必要な場合親ノードにビジットして子供を探す必要があります。これがかなり面倒くさい。Babel プラグインのノリで適当に書き始めると、「あっ実はこれこいつの親にビジットしないといけなかった!書き直し!」みたいなことによくなります。

実際に書く必要に迫られた場合 SWC のコアに存在するトランスフォーマーを見ると参考になると思います。特に swc_ecma_transforms_compat とかだと各 ES のバージョンごとにまとまっているので見やすい気がします。

SWC プラグインは WebAssembly にコンパイルして使います。で、生成された wasm ファイルだけを package.jsonfiles に指定して npm で配布すればよいみたいです。

僕が作った SWC プラグインは npm install swc-plugin-valtio で入ります。

SWC プラグインを使う

例を用意しました。

https://github.com/sosukesuzuki/valtio-swc-example

Valtio を使ったカウンターアプリを webpack でバンドルして動かします。バンドルするときに swc-loader を使います。

.swcrc はこんな感じです。最初の方は構文や JSX の変換の指定ですね。最後のプラグインがあります。swc-plugin-valtio はオプションを受け取りませんが、そのような場合にはオプションとして空オブジェクトを渡す必要がありました。

{
  "jsc": {
    "parser": {
      "syntax": "ecmascript",
      "jsx": true
    },
    "transform": {
      "react": {
        "runtime": "automatic"
      }
    },
    "experimental": {
      "plugins": [["swc-plugin-valtio", {}]]
    }
  }
}

あとは Babel と使う場合と変わらずアプリケーションを書いたら動きました。

おわりに

SWC プラグインに足りないのはヘルパーとドキュメントだと思います。

Discussion