FigmaのTypeScript移行の歩み
(figma.com)- Figmaは、モバイルレンダリングとプロトタイプビューアーに使っていた独自言語 Skew が、オンボーディング・統合・エコシステムの面で限界に直面したため、Skewコード全体を TypeScript に移行した
- 移行の前提として、モバイルブラウザでの WebAssembly サポート拡大、主要経路におけるC++エンジンへの置き換え、プロトタイピング・モバイルチームの成長があった
- マイグレーションは
Write Skew, build Skew→Write Skew, build TypeScript→Write TypeScript, build TypeScriptの3段階で進められ、Skew-to-TypeScriptトランスパイラ が開発フローを維持した - 実際の移行では、配列分割代入の性能、仮想呼び出し除去(devirtualization) の差異、初期化順序、ソースマップの接続、条件付きコンパイルの欠如といった互換性の問題が明らかになった
- 最終的に単体テストを通過し、Skewに近い性能のTypeScriptコードベースを確保できた。Figmaは今後の課題として、社内外コードの統合、パッケージ管理、TypeScriptエコシステムの活用を挙げている
SkewからTypeScriptへ移行した理由
- Skew はFigma初期のサイドプロジェクトから始まり、Webとモバイルの両方をサポートするプロトタイプビューアーを素早く作るという要件を満たしていた
- JavaScriptにコンパイルされる言語へと発展し、高度な最適化と高速なコンパイル時間を提供したが、プロトタイプビューアーにコードが蓄積するにつれて保守コストが増大した
- 新入社員が習得しづらい
- 他のコードベースと容易に統合できない
- Figma外部の 開発者エコシステム が存在しない
- TypeScript移行で期待された効果は、開発速度と協業範囲の拡大だった
- 静的importとネイティブなパッケージ管理により、社内外コードの統合を簡素化
- リンター、バンドラー、静的解析器など、大規模な開発者コミュニティが作ったツールを活用
- async/await のような最新のJavaScript機能と、より柔軟な型システムを利用
- 新規開発者のオンボーディングや他チーム参加の障壁を下げる
移行が最近になって初めて可能になった条件
- Figmaが最初にモバイルコードベースを作った当時、モバイルブラウザは WebAssembly をサポートしておらず、大きなバンドルを高性能に読み込むのも難しかった
- TypeScriptも初期段階だったため、静的型とより厳格な型システムを持つSkewのほうが当時は現実的な選択だった
- WebAssemblyは2018年までにモバイルで広範にサポートされ、Figmaのテスト基準では2020年にはモバイル性能が信頼できる水準に達した
- Skewの初期の強みは、定数畳み込みや仮想呼び出し除去といった伝統的なコンパイラ最適化と、実際の整数演算を使うJavaScript生成のようなWeb特化の最適化にあった
- 2020年のベンチマークでは、SafariでTypeScript版Figmaプロトタイプを読み込むとほぼ 2倍遅くなる可能性がある ことが確認され、iOSではSafariが唯一許可されたブラウザエンジンである点が移行を妨げる要因だった
- iOS 17.4でAppleはEUユーザー向けに他のブラウザエンジンを解放したが、それ以外の地域では依然としてWebKitが唯一のブラウザエンジンとなっている
- その後、Skewエンジンの中核コンポーネントの多くがC++エンジンの対応コンポーネントに置き換えられ、TypeScript移行の性能リスクは低下した
- ファイル読み込みのような最もホットなコードパスも含まれる
- TypeScriptへ移しても性能低下を許容できるという確信が得られた
- プロトタイピング・モバイルチームがより大きな組織へ成長し、自動マイグレーションや 開発者体験 改善にリソースを投入できるようになった
3段階のコードベース移行
- 2020年の最初のマイグレーションプロトタイプでは、TypeScriptの性能はほぼ2倍遅かった
- WebAssemblyサポートとモバイルエンジンのC++移行が十分に進んだ後、FigmaはMaker Weekの期間中に既存プロトタイプを修正し、すべてのテストを通過する動作するマイグレーションを実演した
- 目標はコードベース全体をTypeScriptに置き換えることだったが、手作業での書き換えは開発速度の低下、ランタイムエラー、性能劣化のリスクを生み得た
- SkewとTypeScriptの差は、単なる「型付きJavaScript」同士の移行よりも意味的に大きかった
- TypeScriptではファイルをimportして初めてnamespaceやclassが初期化される
- import順序が想定と違うとランタイムエラーが起きうる
- Skewではロード時にすべてのシンボルがランタイム上でコードベース全体から利用可能になる
- Figmaは、Evan Wallaceが過去に始めた作業を基に Skew-to-TypeScriptトランスパイラ を開発した
-
Phase 1: Skewを書いてSkewをビルド
- 既存のビルドプロセスを維持したままトランスパイラを開発した
- 生成されたTypeScriptコードをGitHubにチェックインし、開発者が新しいコードベースの形を確認できるようにした
-
Phase 2: Skewを書いてTypeScriptをビルド
- すべての単体テストを通過するTypeScriptバンドルが生成できた後、本番トラフィックをTypeScriptコードベースのビルドへ段階的に展開した
- 開発者は引き続きSkewを書き、トランスパイラがSkewコードをTypeScriptへ変換してGitHub上のTypeScriptコードを更新した
- 生成コードの型エラーも継続して修正した
- TypeScriptは型エラーがあっても有効なバンドルを生成できた
-
Phase 3: TypeScriptを書いてTypeScriptをビルド
- すべてのユーザーがTypeScriptビルドプロセスを経由した後、TypeScriptコードを開発の唯一の信頼できるソースに切り替えた
- コードマージがないタイミングを見つけて自動生成プロセスを停止し、コードベースからSkewコードを削除した
- 金曜の夜に、自動生成の除去とCIジョブをTypeScriptファイル直接実行に変更する作業をマージした
- 段階的ロールアウト中にSmart Animate機能の不具合を社内で発見したが、ゲート方式のデプロイだったためロールアウトを止めて修正し、その後計画を見直すことができた
トランスパイラで明らかになった問題
- コンパイラは通常、フロントエンドとバックエンドで構成される
- フロントエンドは入力コードをパースし、型検査・構文検査を行った後、中間表現(IR)に変換する
- バックエンドはIRを別の言語へ変換する
- Skewコンパイラのバックエンドは難読化・縮小化されたJavaScriptを生成する
- トランスパイラ は、人が読めるコードを生成する特殊なコンパイラであり、FigmaはSkew IRから人間が読めるTypeScriptを生成する必要があった
- 初期実装はSkewのJavaScriptバックエンドから多くの着想を得て比較的順調だったが、後半では追跡や処理が難しい問題がいくつも発生した
-
配列分割代入の性能
- サンプルプロトタイプでSkewとTypeScriptのオフライン性能差を調査していたところ、TypeScriptのフレームレートが低いことが分かった
- 原因はJavaScriptの 配列分割代入 だった
const [a, b] = function_that_returns_an_array()のような処理では、JavaScriptは配列を直接インデックス参照するのではなく、iteratorを構築して走査する- FigmaはJavaScriptの
argumentsキーワードから引数を取り出すためにこの方法を使っており、特定のテストケースで性能が低下した - 分割代入の代わりにarguments配列を直接インデックス参照するコードを生成することで、フレーム当たりの遅延を最大 25% 改善した
-
Skewの仮想呼び出し除去最適化
- Skewコンパイラは特定条件下でclass内関数を外へ出し、グローバル関数へ持ち上げる devirtualization 最適化を行う
myObject.myFunc(a, b)がmyFunc(myObject, a, b)の形になることがある- TypeScriptはこの最適化を行わない
- Smart Animateの不具合は
myObjectがnullの状況で発生した - devirtualizedされた呼び出しは正常に実行されたが、devirtualizationされていない呼び出しはnullアクセス例外を起こした
- Figmaは同様の問題がある呼び出し箇所を見つけるため、devirtualization対象になりうるすべての関数にロギングを追加した
- 短期間ロギングを有効にした後、本番ログを分析して問題の呼び出し箇所を修正した
-
初期化順序の違い
- Skewでは、変数、class、namespace、関数定義をコードのどこに宣言しても宣言順序を気にする必要がない
- TypeScriptでは、グローバル変数やclass定義の初期化順序が重要になる
- class定義より先にstatic class変数を初期化するとコンパイル時エラーになる
- 初期のトランスパイラはnamespaceを使わず、すべての関数をグローバルスコープへ平坦化してSkewに近い動作を維持した
- 結果のコードは読みにくく、その後トランスパイラを修正して正しい順序でTypeScriptコードを出力し、可読性のためにnamespaceも再導入した
- 最終的に、単体テストを通過し、Skew性能に見合うTypeScriptコードをコンパイルできるトランスパイラを作り上げた
- 一部の小さな問題は、トランスパイラに新たな修正を入れる代わりにSkewの元コードを手動で修正するか、TypeScript移行後に修正した
ソースマップでデバッグ体験を維持
- FigmaはTypeScript移行中も、開発者が中断なくデバッグできるよう ソースマップ を重視した
- ブラウザデバッガはJavaScriptしか理解しないが、開発者はSkewやTypeScriptのソースにbreakpointを設定する
- ソースマップは、コンパイル後のJavaScript上の位置と元ソース上の位置を対応付ける
- 例として
helper → c、myInt → a、arrayOfInts → bのようなマッピングが可能
- 例として
- 通常、
.map拡張子のソースマップファイル1つが最終JavaScriptバンドルに対応付けられる - 既存インフラはSkew → JavaScriptのソースマップを生成していたが、Phase 2ではパイプラインがSkew → TypeScript → esbuildバンドルへと変わった
- 既存のソースマップをそのまま使うと、JavaScriptとSkewコードの間のマッピングがずれ、開発者がデバッグできなくなる
- 新しいビルドプロセスでは、3段階でソースマップを構成した
- Step 1: esbuildがTypeScript → JavaScriptソースマップ
ts-to-js.mapを生成 - Step 2: トランスパイラが各SkewファイルごとにSkew → TypeScriptソースマップを生成
- Step 3: 2つのソースマップを合成し、Skew → JavaScriptの最終ソースマップを生成
- Step 1: esbuildがTypeScript → JavaScriptソースマップ
- 最終ソースマップによって、新しいJavaScriptバンドルをSkewコードへ対応付けられるようになり、Phase 2でも開発者体験を維持できた
TypeScriptでの条件付きコンパイル対応
- Skewはトップレベルの
if文と、コンパイラに渡すdefinesオプションによって 条件付きコンパイル をサポートしていた - この機能により、同じコードベースから異なるバンドルを作成できた
- ユーザーに配布される実際のバンドル
- 単体テスト専用バンドル
- debugまたはreleaseビルドで異なる実装を使う関数やclass
- TypeScriptには条件付きコンパイルがないため、Figmaは型検査後にesbuildの
definesとdead code elimination機能を使い、バンドル段階で条件付きコンパイルを行った - この方式では、
definesは型検査に影響を与えられないBUILD == "TEST"のときだけ存在するtestOnlyFunctionのようなコードは、TypeScript上にそのまま残せない
- 解決策は、classとmethodを常に定義し、method内部で
BUILDの値によって分岐する形へ変換することだった- テスト専用ではないビルドで
testOnlyFunctionが呼ばれた場合は、Unexpected call to test-only functionエラーを投げる
- テスト専用ではないビルドで
- 最終的なJavaScriptは、元のSkewコードが生成していたJavaScriptと同等にコンパイルできた
- ただし、一部のシンボルは本来特定のコンパイルモードでしか存在しなかったが、変更後はすべてのモードで存在するようになり、最終バンドルがやや大きくなった
- テストではバンドルサイズの増加は許容範囲であり、exportされていないトップレベルシンボルは tree-shaking で除去できた
移行後の方向性
- すべてのSkewコードをTypeScriptへ移したことで、Figmaの中核コードベースの1つがモダナイズされた
- 社内外コードとの統合が容易になり、開発者はより効率的に作業できるようになった
- 当時のFigmaの要件と能力を考えると、Skewでコードベースを始めた判断は適切だった
- 技術の成熟に伴い、以前は適切でなかったTypeScriptが、現在では適切な選択肢になった
- Figmaは今後の取り組みとして、次の可能性を探っている
- 残りのコードベースとの統合
- はるかに簡単なパッケージ管理
- 活発なTypeScriptエコシステムの新機能の直接利用
- 移行プロセスを通じて、import解決、モジュールシステム、JavaScriptコード生成など、TypeScriptのさまざまな側面を学んだ
1件のコメント
Hacker Newsの意見
FigmaがJS向けのカスタム言語を持っていたことにも驚きだし、それがTSより速かったというのはさらに驚き
それなのに結局、より遅いTSへ移行した点も興味深い
こういうことはかなりよく見かける。会社が初期に独自技術を作り、大きくなった後でより「標準的」なものへ移行する
チームとプロダクトがはるかに大きくなると、より馴染みがあり広く使われているスタックへ再びプラットフォームを移すことになる
こうした移行の成功は、組織のエンジニアリング文化がどれだけ堅固かに大きく左右される。根拠はないが、Evanと創業者たちはFigmaに優れたエンジニアリング文化を築いていたように思えるし、ミスが起きても直せるだけの回復力は十分にありそうだ
その時点ではグルーコードだけが残り、カスタム言語がもはや存在する理由はなくなったということ
やがてコミュニティが追いつき、会社よりも大きな推進力を持つ時点が来ると、標準実装へ移るのが合理的になる
GoogleのBorgからk8sへ続く流れも少し似たものとして見ている。ただしGoogleは、自分たちが作った標準の周囲へコミュニティを招き入れた側だ
まったく新しいフレームワークを初めて見ても習得できる開発者のコストと、すでに使われているプラットフォームや技術について文書化された経験を持つ開発者の価値を比較しなければならない
完全な内製に振れば、どんな言語やフレームワークからでも驚異的な性能を引き出せるが、プロジェクトがより多くの状況に対応しなければならなくなるほど、保守に必要な開発者数は増え、結局は遅くて要件にぴったり合わなくても既製ソフトウェアのほうがよくなる地点が来る
長期的にユニコーン級の採用基準を維持できるなら、完全カスタムスタックも維持可能かもしれない。だがほとんどの組織は、平均的な新規採用者が今後保守できる方向へ移らなければならず、それはたいてい有名で退屈なソフトウェアベンダー製品を意味する
野心のある人には辞める理由になり得るし、採用も難しくなり、オンボーディングも長くなる。後でカスタム言語を捨てるときには大きな移行プロジェクトも必要になる
性能向上が圧倒的だったり、より安全なコードを書けたり、新しい言語を学ぶのが好きな人だけを採用したい意図があるなら、ときにはそれだけの価値がある
このプロジェクトで働いていた。Twitterにもう少し書いておいた: https://twitter.com/andrew_k_chan/status/1786769203912925477
記事タイトルは誤解を招く。Figmaではコードベースの他の部分でほぼ10年にわたってTypescriptを使っており、その期間のほぼずっと、SkewよりTypescriptコードのほうが多かった
ブログ記事で説明されているように、Skewはモバイルエンジン、その後プロトタイピングプレイヤー、ミラーリング機能、そして思い出せない1、2のプロダクト領域で使われていた
SkewはTypeScriptより少し速い程度ではなかった
Figmaの元CTOであるEvan Wallaceによると、より厳格な型システムが可能にした最適化のおかげで1.5〜2倍速かった
かなり大きな難題があったようだが、より予測可能な最適化のために動的性を一部減らすのは、プロダクション級アプリにとって良い妥協に聞こえる
ホットな実行経路を最適化すれば、2倍の利点は消える可能性がかなり高い
もちろんそうすると、複数箇所で最適化されたJSを強制的に書く必要があり、可読性に影響すると言える。確かにそうだが、その代替案はツールサポートと知名度の低い、まったく新しいJSコンパイル言語を使うことだった
今から見ると、それだけの価値はなかったように思えるし、ブログ記事は議論のあった以前の技術選択を少し良く見せている感じがする
TypeScriptの利用を、その性能を出せるサブセットに制限できるのか気になる
const [a, b] = function_that_returns_an_array()のような処理を完了するとき、JavaScript が配列を直接インデックス参照するのではなく、配列を走査するイテレータを作るという点が興味深いJS はなぜ分割代入で配列をそのまま直接インデックス参照しないのだろう?
Symbol.iteratorを追加すればイテラブルになれるし、分割代入はそうしたオブジェクトでも動作する必要がある。配列自体のSymbol.iteratorすらパッチできるので、VM が対処しなければならないArray.prototype[Symbol.iterator] = function*() { yield 1; yield 2; yield 3; }[...[4, 5, 6]][1, 2, 3]イテレータプロトコルのひどい性能は当時も議論されていたが、エスケープ解析が解決してくれるとして無視された [0]。ほぼ10年が過ぎた今でもエスケープ解析では解決できておらず、GC を大量に使い、相変わらずいまいちだ。性能を意識しない人たちが設計した悪い仕様だ
ユーザーが
Symbol.iteratorをパッチしていないなら、エンジンが配列の分割代入やスプライスを特殊化してイテレータプロトコルのオーバーヘッドをなくすのも筋は通るが、それはそれでまた厄介な問題だ[0] https://esdiscuss.org/topic/performance-of-iterator-next-as-...
さらに奇妙なのは、一部の JavaScript エンジンでは、要素が2つか3つの配列を分割代入するときに
{0: foo, 1: bar}のようなオブジェクト分割代入のほうが速い場合があることだSkew の便利機能を失うことで生じる継続的な開発者体験への影響はあまり扱わず、コードベース移行時に一度だけ行った変換の話だけをしている
例えば TypeScript では、ファイルを正しい順序で import しなければならないケースが簡単に発生し、そうしないと壊れることがある。また分割代入は遅いので、性能が少しでも重要なら使うべきではない
TypeScript を数年使ってみると、こうした落とし穴が何十個もあり、一部は JavaScript から受け継いだもので、一部はそうではない。エンジニアが多いなら、少なくとも膨大なスタイルガイドが必要だ
Skew がなくなることを惜しんだエンジニアもいたのか気になる
こうすることで、開発者は PR レビューで結果がどう見えるかを確認し、問題を報告できた
性能とランタイムの正確性という面では、TS にいくつか落とし穴があるのはその通りだ。配列の分割代入のような問題は、計測と厳格なモニタリングで検出した
Skew の一部機能、例えば演算子オーバーロードや整数型がなくなるのは、確かに惜しかった。ただし移行は最終的にチーム全体で下した判断であり、私もそれが正しい判断だったと思っている
「async/await のようなモダンな JavaScript 機能と、より柔軟な型システム」とあるけれど、では Skew にはコールバックしかなかったのだろうか?
Promiseはあったのかも?Figma をよく知らない立場として、なぜ WebAssembly を使うのか気になる
Figma はそれを極めてうまくやっている。複雑な UI 画面が大量にある非常に大きなキャンバスでも、信じられないほどきびきび動く
最近のデスクトップアプリケーションの中でも、ここまでうまく動くものはほとんどない。こうした最適化が Figma の成功の重要な一部だと確信している
Figma がセキュリティ上の問題、つまり権限を解決するためにカスタムの TypeScript DSL + コンパイラを作った方法についての興味深い詳細は、別のブログ記事にもある
https://www.figma.com/blog/how-we-rolled-out-our-own-permiss...
少し痛々しく感じる。大企業にはそれぞれ独自の社内ツール、言語、Kubernetes のようなものがある。なぜ共有しないのだろう
Skew がオープンソースだったら、より良い TypeScript になっていたかもしれない
オープンソースだからといって、無料の貢献が自然に生まれるわけではない。些細でない PR ごとに、長いレビュー、議論、場合によっては書き直しまで伴う必要がある
TypeScript は好きだし、TypeScript でフルスタックシステムも持っているが、完璧ではない。モノレポで TypeScript を設定するのは悪夢だ。
pnpm のモノレポ配下の内部パッケージとともに理解可能な形にするには、すべてのパスが互いに合うように
tsconfig.jsonを大量に手作業で調整する必要がある。そして優れた
tsxパッケージが登場するまでは、本番用ツールチェーンは事実上メンテナンス不能だった。さらに、ばかげたほど遅い。Zod のせいで TypeScript 言語サーバーの性能が大きく低下する問題があり、結局 プロジェクト参照を導入し、プロジェクト参照リダイレクトをオフにしなければならなかった。
総合すると、TypeScript には改善の余地が多い。特にモノレポと性能の面でそうだ。
モノレポの問題は、すべてのパッケージで同じ設定を使っていないように聞こえる。そうであれば、同じ設定とコーディング標準を強制することをまず直すだろう。これも TypeScript の問題ではない。
もしかして
tsxの前にts-nodeを使っていたのだろうか? そうならtsxのほうがずっと堅牢だ。ただうまく動く。中規模のモノレポ、複数のアプリと多数のサービスをほとんど TypeScript で運用しているが、退屈な npm workspaces ベースの設定でも問題なく動作している。
TypeScript が速いという意味ではまったくないが、JavaScript で書かれている以上、速くするのが難しい面もある。
ただし、大規模アプリケーションで型推論のために TypeScript にコードを実行させるのは、自分で作り出した問題のように見える。