Re: 僕らを縛る Node.js という呪いについて - あるいはなぜ TypeScript 以外が真っ当な選択肢にならなかったか
https://d.potato4d.me/entry/20220405-nodejs/ へのアンサーソング。
プログラミング言語としての JavaScript の話をする。
2010年頃、Python 2 でプログラミングを学習した自分にとっては Node.js + CoffeeScript が Better Python だった。
CoffeeScript は当時の JS(ES3~5) に足りない機能を補ってくれて、Python と同じく空白制御のオフサイドルールなのが気に入った。見た目が少しだけ Ruby っぽいので当時全盛だった Rails の人間に訴求するにも有利だった。
Node.js のモジュールシステムである Commonjs は Python と同じくファイルスコープが明示的で、 Ruby + Rails で感じた名前空間の暗黙の発散が起こりづらく、また npm がツールとして rubygems より洗練されていたように感じた。後発の強みを十分に生かした。
CoffeeScript は Railsに採用されるなどの普及後に仕様が硬直し、その後トレンドに乗れずに退場したが、そこで示された思想は ES2015 の ESM と TypeScript に継承されている。
「JavaScript の主戦場は AST パイプラインである」
その主張をする Node/フロントエンド陣営と、 そこにコストを掛けたくない片手間JS(ES5)水準で文化が大きく分離した。
とはいえ、 長期に使われた汎用言語は負債と向き合うことになるわけで、Node も例外ではない。 Python 2/3 がそうだったように。
Node 12 から段階的に npm の ESM 対応が始まっているが、 commonjs で構築されたエコシステムは痛みを伴う対応が必要で、 npm は今まさに混沌の時代である。
元々の node.js は個々人の開発者が作った小さなミドルウェアの集積で大きな機能を実現するものだった。その代表格が express で、ほとんどコアと呼べるものはなく、ミドルウェアパイプラインと表現したほうが実体に即している。
ベストプラクティスが定まらない時代は、それが柔軟性として有利に働いていた。昔は npm とフロントエンドが分離されていたので、 webpack のようなビルドステップも、 Commonjs/ESM 両対応なども必要なかった。
今は、そのような時代ではない。ベストプラクティスの集約・収斂の時代であって、それは TypeScript ファースト, Node/ブラウザ相互利用を前提としたユニバーサルな設計, Next.js のようなスタックの隠蔽、 マイクロサービスを背景とするモノレポによる分割統治に進んでいる。
今後は、政情不安によるサプライチェーンアタックの現実的なリスクの高まりによって、ライブラリ間の依存はより減っていく方向に進むだろう。
Deno が使いやすく見えるのは node の負の遺産を引き継がないからで、普及した場合は遅かれ早かれ同じ道を辿るだろう。 また、仮に TypeScript 以外の現実的な言語の選択肢が出現した場合、Deno の優位は消える。
過去に作られた npm のライブラリ郡とどう向き合うか。
映画レディ・プレイヤーワンのジェームス・ハリデイのモデルにもなったであろう node の OSS スター substack (同名) は活動を大幅に縮小していて、彼が作った大量のミドルウェア郡はおそらく今後更新されないように思える。
現状、個人で活動している OSS スターは現状だと sindresorhus や egoist あたりだろうが、やはり今のトレンドは低依存かつ企業の強いバックアップが付いてるものになる。 Facebook の React/Yarn, Microsoft の TypeScript/VSCode, Vercel の Next.js/Turbo, 大量のスポンサーが付いてる Evan You の Vue/Vite…
現状、モダンな JS のプロジェクトで苦労するのは必ずといっていいほど webpack, eslint, jest, typescript(tsconfig.json) の設定の組み合わせである。これはおそらく、フロントエンドエキスパートは各人がそれぞれのプリセットを持っていて(お互いにパクリつつ)、場合によっては噛み合わない。
個人的にはこういうタイミングでツールごと捨てる選択肢をとっている。最近はこうなっている。
- webpack => vite
- jest => vitest
- prettier => deno fmt
- eslint => deno lint
- typescript(解析以外) => esbuild
とはいえこれも間接的に Evan You と Ryan Dahl のプリセットを借用してるだけとも言える。
この状況に不満を覚えた Babel/Yarn の sebmck が Rome という大統一ツールチェインを開発しようとしてるが、リアーキテクチャを繰り返したり、共同創業者が転職してしまったりで、進捗に不安を覚える。
ここでよく聞くのが 「Wasm でフロントエンドを置き換えるから JS は将来的に捨てられる」という話である。
Wasm について話す前に、ここで思い出すのが8年ぐらい前の「AltJS でJSを駆逐する」みたいな話がどうなったか、という話をしよう。
各コンパイラのJSバックエンドでJSを置き換えていく実装が一時期流行った。
自分も頑張って色々と調査したが、結局使い物になると判断したのは TypeScript だけだった。これは言語の表現能力の話ではなく、 node/npm/ブラウザのエコシステムに対するその言語の「態度」が問題だったと思う。
あえて他の言語で書いて JS をターゲットにコンパイルする、という世界観だと、基本的に JS 側のエコシステムが尊重されないことが多かった。この結果、commonjs/ESM ではないところでグローバル変数が勝手に生やされたり、場合によっては標準の挙動を書き換えられたりする。
JSから呼べたところで、その言語のランタイムがラップしてるインターフェースを「剥がして」使う必要がある。 js_of_ocaml や Scala.js のリスト構造体に JS からアクセスするには、カリー化されたものを何度も何度も剥ぐ必要があった。これが大変だった。
逆に AltJS から JS オブジェクトに型をつけるのにも困難を伴い、よくあるのは静的型付の言語で、JSバインディングの部分に型をつけないと動き出さない、というもので、現実的に採用不可能な事が多かった。
パフォーマンス面でも、各 AltJS は、小さなコードでもその実行環境特有の大きめのランタイムコードが付随することが多く、ライブラリに使うとその回数だけのしかかってくる。
じゃあ、ある程度のパフォーマンスの劣化を許容して、 AltJS で徹頭徹尾書く、という選択肢はアリだったのかというと…あまり話を聞かない。話を聞かないのは当然で、これも「node.js に対する態度」の話であって、JS コミュニティに方向が向いてないので、あまり伝わってこない。
これらの結果、「話を聞かないし、ライブラリにも使えない」という話だと、ノウハウの蓄積で存在してる Node/フロントエンドの世界からは、存在しないのと同義になっていた。
結果、漸進的型付で段階的に導入でき、 JS のスーパーセットである TypeScript のみが生き残った。他も生きているのかもしれないが、ノウハウが合流しないので、結果として「レガシー」になりつつあるのではないか。
で、wasm はどうなのかというと、 個人的には「フロントエンドを wasm メインで書く選択肢が普通になる世界」 は、「10年後に 30% ぐらい」ぐらいの確度で見ている。現時点でそこにキャリアを全ベットすることはできない。その詳細については次の記事で書いた。
WebAssembly の GC Proposal とは何か / どこに向かおうとしてるのか
簡単に要約すると、 現状の命令セットが低水準すぎてビルドサイズが爆発するし、複雑な仕様の策定は時間がかかるし本当に入るかわからない、入ったところで他の言語のランタイムがこれで軽量になるかも不明。やりたければ Rust no_std を使いこなす必要がある」 という感じ。
wasm のモジュールシステムは ESM と似ていて JS エコシステムに組み込むことが可能だし、実際 ESM Binding の話も進んでるが、ビルドサイズの問題で Rust 以外のものはエコシステムに組み込むのは難しい。フロントエンドツールチェインの今の課題感は、巨大な動的ページやSPAを構築するバンドルサイズで、ここに依存の数だけ累積でのしかかってくる。
自分もこういう問題と戦っていた。
wasm-pack で regex を使う時のビルドサイズとパフォーマンスの調査
で、現実に「TypeScriptに不満はあるか」というと 「他人が書くものを使う分には不満だが、自分が書く言語としてはそこそこ満足」という水準に落ち着いている。
TypeScript が理想の言語かと言うと全くそうではない。 typeof null => object が修正されるわけでもないし、 var や with が消えるわけでもない。 Error はそのままだし、 null と undefined の使い分けは面倒だし、 class はいろんな言語の最大公約数的な仕様で使い辛いし、 危険なオブジェクトへのアクセスに制約を掛けられるわけでもない。 enum は制約が多い上に非標準、複雑な推論に違反した際のエラーメッセージは結構不親切。 ESM 対応が雑。本体は namespace で書かれてて Treeshake できない。言語上の不備は不健全なアップキャストで解決しろ。 tsconfig.json 次第で安全性が変わるが、断片的に渡されたコードからはわからない。
あくまで型があると思いこんでるだけで JavaScript であってランタイムの振る舞いに責任を持つのは書き手である。自分は訓練の結果まあ確度が高い保証ができてるとして、他人のコードを型が通ってるから信頼できるかというと、できない。そういう言語であると思う。熟練度がそのまま安定性に直結する。
使いこなせばほとんど型の宣言なしに推論だけで自然な振る舞いをするコードを提供できる。ただ、そのテクニックを覚えるために、いろんなOSSのライブラリの実装を見たり勉強する必要があり、ほとんどの書き手にそれを期待することはできない。
だから、よく練られた TypeScript の型を提供してあるライブラリをみんなが使うのが一番安定したTSの使い方で、それが機能しているのが React 界隈で、機能してないのが xxx みたいな気持ちがある。
というわけで、TypeScript がよくて、 npm が混乱期で、Deno は負債をまだ被っておらず、 wasm は特定ユースケースを除いて選択肢にない、という現状です。
Discussion