大統一 Node ツールチェイン Rome の野望 現状の実装
つい先日 beta リリースされたフロントエンドのツールチェインの Rome について、その思想とコードを読んだ結果の現状について。
この記事は公式ドキュメント以外にもソースを読んで得られた undocumented な部分も含んでいるので、すぐ古くなる。その前提で読むように。
問題の認識とその解決手段
フロントエンドの最適化は実行前のプリプロセスに、エコシステムの開発リソースの多くが当てられている。Node のツールチェインが発達するにつれて、自前の パーサ+AST 定義を持つ実装が増えていった歴史がある。
- acorn(estree)
- babel
- prettier
- typescript
- terser
それぞれのツールの生成する AST はそのツールの都合で微妙に/もしくは大幅に定義がずれている。typescript に至っては完全に別物。これは仕方ないところがあって、解析が主の acorn と、AST を書き換えるのが主で拡張性が高い babel、IDE が主でエディタ上で入力途中の壊れたソースを相手にする TypeScript では、要求されるスペックが違う。
(一応 AST 定義の大本営は、元 mozilla Parser API の estree であるという認識は共有されている)
そのため、それぞれのツールチェインでは、お互いに AST を共有せず、自前のパーサで、コードをパースして、場合によっては sourcemap を引き継ぐ必要がある。
その結果、現代的な JavaScript プロジェクトでは、 jest(jest-transformer), webpack(acorn+loader), prettier, eslint(acorn+parser 拡張) といった実装がパーサーを個別に抱えており、本来ならファイルごとに一回で済んでいるはずのパースが、ツールの実行回数分だけパース処理が発生する。
問題解決の手段
rome 開発チームはこの状態が問題だとしていて、統一的なツールチェインがあれば、一回の実行パスでコードのパース、解析、コンパイルが済むはずだ、というのが基本的な発想にある。すべての抽象構文木はローマに通じる、というわけ。
rome は既存のエコシステムを否定して、自前の AST で全部を実装するという野望を抱えている。そして、次のようなツールを置き換える対象としている。
- eslint(linter)
- prettier(formatter)
- webpack(bundler)
- typescript(parser + syntax plugin)
- 公式に言及されていないがおそらく
- terser(optimizer)
- jest(test-runner)
そのためにトークナイザ・パーサは typescript のように壊れた入力途中のコードを相手にできるように設計され、format のために AST 内に元ソースを含み、sourcemap のためのユーティリティを備えている。
AST の解析は一回だけで、それは analyze 時に linter を通り、コンパイルステップでは AST 内の raw code 変形してから出力される。
typescript も置き換える対象としていて、これは typescript compiler、 typescript-eslint のことだと思われるが、typescript の型検査の部分までも置き換えようとしているかは、そこは読み取れなかった。ただ、sebmck のことだしそのぐらい考えてそうな気がする。
CLI としては、IDE のための LSP が最初から実装され、デーモンとして起動して、バックグラウンドのワーカーでインクリメンタルに解析を行う、といった設計になっている。
手元で動かしてみる
雑に試すだけなら簡単
npm install -g rome
mkdir myrome; cd myrome;
rome init;
// edit index.ts
rome check
ドキュメントには載ってないが、(実は昔は載っていた) rome run index.ts
での実行 や rome compile index.ts
で単体ファイルのコンパイル、rome test
で *.test.ts
の実行がある。rome parse index.ts
で AST がとれる。
LSP を叩く vscode extension もあるが、今回はスキップ。
rome compile --bundle index.ts
でバンドルができるように見えるが、これは webpack のバンドルなどと違って、単体で実行できる形式にならない。
import foo from "./foo";
const x: number = 111;
console.log(x.toString(), foo);
この出力は
const ___R$proj$index_ts = {};
const ___R$$priv$proj$index_ts$x = 111;
console.log(___R$$priv$proj$index_ts$x.toString(), ___R$proj$foo_ts$default);
となる。
ファイルを結合しないのがミソで、最終的に concat してファイルを個別にグローバル名前空間で接続することを前提にしている。これによって大きなファイル IO を発生させずに済み、ファイル個別にインクリメンタルにビルドができるようになる。
グローバル変数空間で結合するのは、ちょっと前のツールに先祖返りしているように見えるが、これは最終的に optimizer で未使用コードを削除する前提なのだろう。結果的に treeshake の DCE と同じ程度には最適化される。
この変形形式は同じく sebmck のプロジェクトだった、実行パス最適化コンパイラの prepack で見たことがある。将来的には prepack と同じ最適化を入れようとしているのではないだろうか。prepack 自体は頓挫したが…
facebook/prepack: A JavaScript bundle optimizer.
実装を読む
まず驚くのは、typescript の型定義以外の依存がない。コード自体はプロジェクト内にすべて含まれている。
コード読む限りは、現状 linter, formatter, compiler の基盤部分まではできているという感じ。lint ルールはそこまで多くない。
モジュール一覧をみることで雰囲気をつかめる
❯ tree -L 1 internal/
internal/
├── ast
├── ast-utils
├── cli
├── cli-diagnostics
├── cli-environment
├── cli-flags
├── cli-layout
├── cli-reporter
├── codec-js-manifest
├── codec-js-regexp
├── codec-json
├── codec-semver
├── codec-source-map
├── codec-spdx-license
├── codec-tar
├── codec-url
├── codec-websocket
├── commit-parser
├── compiler
├── consume
├── core
├── css-parser
├── diagnostics
├── events
├── formatter
├── fs
├── html-parser
├── js-analysis
├── js-ast-utils
├── js-parser
├── js-parser-utils
├── markdown-parser
├── markup
├── markup-syntax-highlight
├── messages
├── node
├── ob1
├── parser-core
├── path
├── path-match
├── pretty-format
├── project
├── string-charcodes
├── string-diff
├── string-escape
├── string-utils
├── test-helpers
├── typescript-helpers
├── v8
├── vcs
├── virtual-packages
└── web-ui
core
,cli
,cli-*
: CLI のワーカー、デーモンの実装ast
,ast-utils
: 汎用的な AST 実装。これは JS に限らない。parser-core
: 汎用的なパーサ実装js-parser
,js-parser-utils
: JS のパーサ実装。TypeScript パーサもこの中に含まれる。*-parser
: css, html, markdown がある。codec-*
: core が通信する時のパーサ実装。parser の小さい単位という感じがするformatter
: コード整形。AST の内部トークンを生成し直していた。compiler
: コード生成部compiler/transforms/compile
: 単体ファイルのコンパイルcompiler/transforms/compileForBundle
: バンドル処理のコンパイラ
test-helpers
: たぶんテストランナー周りv8
: node の inspactor 周りのラッパー
だいたい見ての通りだが、面白いのが v8 モジュールで、v8 の inspector からコード実行カバレッジを取っている。
js-parser のテストケースで、babel から持ってきたテストケースを全部通していたのが偉い。
感想
大統一ツールチェインというのはいかにも破綻しそうなプロジェクトだが、それなりに今までの積み重ねと実現性がある。
今は Node のツールチェインに求められる役割が安定してきた時代であり、多くは TypeScript というベストプラクティスに集約されようとしている。統一 AST による整形は、実は既に prettier の内部で行われている。なにより、babel をやり遂げた sebmck がメインで動いているというのが、本来の無理筋なプロジェクトに説得力をもたせている。
追記: prettier は独自 AST をもたず、内部で多言語の AST をラップする機能があるだけだった。
一時期は sebmck の facebook 退職で facebook incubator であったプロジェクトの存続が疑われたが、最近はよりコミットが加速していた。
とはいえ、使えるかというと微妙で、一部の linter しかないのが現状。コード生成する rome compile
が安定してドキュメンテーションされたら、小さな環境で使えるかもしれない、といった程度。現状 dynamic import が変換されてないので、そのへんもうまいこと扱う必要がある。
js の bundler ができてきたところで、 webpack の yak shaving な loader を一つずつ実現する必要がある。それはこれからといった感じ。
今年の二月の記事だが、 preact の developit 氏の記事は今でも有効で、参考になるので、気になっている人は一読するのをおすすめする。とくにこの記事では触れなかった jsx の最適化について詳しい。
Rome, a new JavaScript Toolchain
node を作って deno でそれを否定するライアンダールと似たような、sebmck の babel 作っておいて rome でそれを否定する仕草を rome からは感じる。
node エコシステムが安定してきた今、その問題点も明らかになってるという時代なので、フルスクラッチでやり直そうというのがトレンドなのかもしれない。