2023.07.10

続・Turbopack vs Vite 次世代バンドルツールの競争の今

導入

こんにちは。グループ研究開発本部 次世代システム研究室のH.Oです。前回の記事ではフロントエンドの領域で大きく注目を浴びている二つの次世代バンドルツール、TurbopackとViteを紹介しました。今回はその続編として、前半で、この3ヶ月で最も大きい動きだったと言っていいVite4.3のリリースについて、後半では実際に自分の環境で実践したTurbopack、新旧Viteの比較検証結果を紹介し、さらに詳細に立ち入って考察していきたいと思います。

結論ファースト

  • 2023年4月23日にVite v4.3がリリースされた。これによってViteのパフォーマンスの改善が実現し、TurbopackとViteの性能差はほぼなく、プロジェクトで導入するのであればViteを導入するのが自然な選択となっている。
  • Turbopack側では特にめぼしい動きが見られていない。
  • React×TypeScriptのプロジェクトにおいてHMRの実行時間をVite4.2, Vite4.3, Turbopackで比較したところ、ViteとTurbopackでは大きな差が見られ、Viteの方が実行時間が明らかに早く、コンポーネント数の増加に対しても影響が小さかった。
 

Vite 4.3のパフォーマンス改善

Viteの取り組んできたパフォーマンス改善の施策について詳細を解説します。
  • 「シンプル」「厳密」「正確」なmodule解決ロジックに改善
  • 「待ち」が発生するタスクの削減
  • HMR時のモジュール探索の効率化
  • 並列化
  • JavaScriptの最適化
主な取り組みは以上の5点にまとめられます。

「シンプル」「厳密」「正確」なmodule解決ロジックに改善

  • 依存関係のpackage.jsonを解決するために、これまではresolve packageに大きく依存していたが、これには多くの不要なロジックが含まれていた。resolve packageの使用を取りやめて直接nestされた親ディレクトリにあるpackage.jsonをチェックすることでシンプルにした。
  • Viteはmoduleを見つけるのにNode.jsのfs APIを呼び出す必要があるが、そのI/Oが高くついた。Vite4.3ではファイルの探索範囲を狭めたり、ある特定のpathについて検索をskipすることでできるだけfsを呼び出す回数を減らした。
  • 4.2ではファイルパスがディレクトリの時、モジュールを再帰的に解決していたので、繰り返し不要な計算を行なっていた。4.3では再帰的な解決をフラットにして異なるタイプのpathに適切な解決を行うようにした。このことでfsの呼び出しをキャッシュすることも容易になった。
  • キャッシュのキーとして使えるものをファイルの絶対パスだけでなく、ディレクトリパスに拡張することでnode_modules packageのデータの解決の際にボトルネックとなっていた部分を解決した
  • 本来異なるresolve contextにあるのに混同してしまっていた、rootのpackage.jsonを見つける処理と、解決したい当該ファイルに最も近いpackage.jsonを見つける処理を分離した。
  • fs.realpathSyncはfs.realpathSync.nativeより70倍遅い。これまでWindowsではrealpathSyncしか使えなかったので、Windowsでfs.realpathSync.nativeを呼び出すときにnetwork drive validationを加えることでWindowsでも高速で処理できるように変更した。

「待ち」が発生するタスクの削減

  • tsconfigのパース
    Viteサーバーは、tsまたはtsxのプリバンドリング時にtsconfigデータを必要とする。
    Vite 4.2では、サーバーが起動する前にプラグインフックconfigResolvedでtsconfigデータのパースが完了するまで待機する。ページリクエストは、tsconfigのパース待機が必要な場合でも、サーバーが準備完了していない状態でサーバーにアクセスすることがある。
    Vite 4.3では、サーバーが開始する前にtsconfigパースを初期化するが、サーバーは待機しない仕様となった。
  • ファイル処理
    Viteには多くのfs呼び出しがあり、一部は同期的に呼び出されている。この同期的なfs呼び出しはメインスレッドをブロックする可能性があったため、Vite 4.3では、これらを非同期に変更した。

HMR時のモジュール探索の効率化

C <- B <- A & D <- B <- Aという依存関係が存在するとき、Aを編集すると、HMRはAからCとAからDに伝播する。これにより、Vite 4.2ではAとBが2回更新されてしまう。Vite 4.3では、これらの探索済みモジュールをキャッシュして複数回の探索を回避している。

並列化

インポートの解析、依存関係のエクスポートの抽出、モジュールURLの解決、およびバルクオプティマイザの実行など、一部のコア機能を並列化した。

JavaScriptの最適化

  • コールバックによる*yieldの代替
    tsconfigファイルを検索およびパースに用いるtsconfckは以前、*yieldを使用して対象ディレクトリを走査していた。*yieldを用いるデメリットとしてGeneratorオブジェクトを格納するためにより多くのメモリスペースが必要であり、実行時には多くのジェネレータコンテキストの切り替えが発生することが挙げられるが、コアで*yieldをコールバックに置き換えたことで改善した。
  • startsWithとendsWithを===で代替する
    Vite 4.2では、ホットURLの先頭と末尾の’/’を確認するためにstartsWithとendsWithを使用していたが、===の方がstartsWithよりも約20%高速で、endsWithは===よりも約60%遅いことが判明したため、代替を行った。
  • 正規表現の再生成を回避する
    文字列のマッチングに用いる正規表現を単一のインスタンスとして使用することで再利用可能にした。
  • カスタムエラーの生成を中止する
    Vite 4.2で存在した、より良いDXのためにいくつかのカスタムエラーがあるが、パフォーマンスを低下させる可能性のある余分な計算やガベージコレクションを引き起こすことがあったため、Vite 4.3では、いくつかのカスタムエラーの生成を取りやめ、パフォーマンス向上のために元のエラーを直接スローするようになった
詳細はこちらのページに記されています。

パフォーマンスの検証

背景にある「パフォーマンスをめぐるTurbopack, Vite側双方の主張」については前回記事にて詳しく記載しました。ここでは、実際にVite4.2,Vite4.3,Turbopackを使用したReact×TypeScriptプロジェクトを作成し、議題となっているHMRの実行時間について検証をしてみたいと思います。

実験

目的

ViteとTurbopackのHMRのパフォーマンスを公平に比較し、性能差を評価すること。また、現実的にフロントエンド開発で主流となっているTypeScriptを使用した場合について新たに検証を行うこと。

使用機器

Macbook Pro
2.6GHz 6コア Intel Core i7

環境

Node.js v18.16.0

使用技術

Vite4.2.0
Vite4.3.9
React18.2.0
Next13
TypeScript

測定方法

Evan You氏が行った測定方法を踏襲する。Viteについては、4.2.0と4.3.9の二種類のバージョンでプロジェクトを用意する。
元はJavaScript baseで測定されているため、TypeScript baseに書き換える。

手順

npm create vite@[インストールしたいバージョン]
として、Reactの使用、TypeScript + SWCを指定してプロジェクトを作成する
(Vite4からはdefaultでSWCの使用を指定できるようになっている)
src配下にcomponentsディレクトリを切り、App.tsxの記述を消す
genFilesを作成し、プロジェクトの直下に置く。
import fs from 'node:fs'

let imports = ``
let renderCode = ``

for (let i = 0; i < 1000; i++) {
  imports += `import { Comp${i} } from './components/comp${i}.jsx'\n`
  renderCode += `<Comp${i}/>\n`
  fs.writeFileSync(
    `src/components/comp${i}.tsx`,
    `export function Comp${i}() {
    return <div>hello ${i}</div>
  }`
  )
}

const code = `
 ${imports}
export default function App() {
  return <div>
   ${renderCode}
  </div>
}
`

fs.writeFileSync('src/App.tsx', code)
これは、hello{コンポーネント番号}という文字列を出力するだけのコンポーネントを1000個作成し、App.tsxに全てimportして描画するためのファイルを作成するものである。

次にコンソールにてレンダリングされた日時を出力し、HMRにかかった時間を計測するためのwatch.jsを作成し、プロジェクト直下におく。
import { watch } from 'node:fs'

watch('src/App.tsx', (e, filename) => {
  const now = new Date();
  console.log(`${now.getSeconds()}s ${now.getMilliseconds()}ms, ${filename}, root`)
})

watch('src/components/comp0.tsx', (e, filename) => {
  const now = new Date();
  console.log(`${now.getSeconds()}s ${now.getMilliseconds()}ms, ${filename},  leaf`)
})
秒とミリ秒が必要なので、それだけを出力するようにconsole.logの中身を書き換えている。

Vite4.3.9のプロジェクトで行ったその他の対応
typescriptのversionを4.9.3に落とし、tsconfig.jsonを下記に修正。
デフォルトの設定ではtsconfig.json、Component File双方にエラーが出るため。また、両方のプロジェクトでTypeScriptの設定を揃えるため。
{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "allowJs": false,
    "skipLibCheck": true,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}こ
comp0.tsxを下記のように書き換える。これは、leaf componentのHMRが完了した時刻を表している
import { FunctionComponent } from "react"
const Comp0: FunctionComponent = () => {
const now = new Date();
const filename = 'comp0.tsx';
return<div>leaf: {`${now.getSeconds()}s ${now.getMilliseconds()}ms, ${filename}, leaf`} 1</div>
}
export default Comp0
 

App.tsxに下記を追記。これは、root componentのHMRが完了した時刻を表している
const now = newDate();
const filename = 'App.tsx';

root: ${`${now.getSeconds()}s ${now.getMilliseconds()}ms, ${filename}`}
node genFiles.jsでコンポーネントを生成し、

node watch.jsで測定開始。

comp0コンポーネントのhello0の記述を書き換えると、HMRを開始した時刻がconsoleに出力されるので、consoleに出力された時刻と、描画された時刻の差のミリ秒がHMRの実行時間となる。
7回計測し、最大値と最小値を落として平均値をとった。

測定結果

leaf componentのHMR実行時間(5回の測定値の平均)は下記のようになりました。

コンポーネント数Vite4.2Vite4.3Turbopack
1000270.2ms188.8ms 923.4ms
5000206.6ms 206.8ms 1873.4ms
10000282.6ms 362.4ms 3362.0ms
30000301.8ms 307.4ms 11994.2ms

結果から導ける結論

  • leaf componentのHMR時間については、ViteがTurbopackよりも大きく差をつけて速いという結果になった。これは使用感とも概ね一致した。
  • Turbopackはモジュール数が増加するごとに、それに比例するように実行時間が長くなっている。体感的にもモジュール数が増えるごとに、ファイルの変更から描画まで相当なタイムラグを感じた。
  • Viteはモジュール数の増加に対してHMRの実行時間が大きく変化することはなかった。

検証の課題

  • Evan You氏の実験や、公式に発表されているVite4.3の性能調査と異なり、今回はReact×TypeScriptのプロジェクトで開発を進めることを想定している。そのため、純粋なVite, Turbopackの性能と異なる要因が混在している可能性を考えなければならない。(ただし、これが双方の比較の公平性を毀損しているわけではないと考える。両者のプロジェクトの使用技術・構成がほぼ同一だからだ。)特にVite4.2とVite4.3の比較結果については、TypeScriptからJavaScriptに変換する処理や、その他、今回の環境的な要因がより大きい誤差を生じさせ、見かけの性能差を逆転させてしまったのではないかという仮説について考えなければならないだろう。
  • Viteの方は、各実行において、実行時間に大きなばらつきがあった。同じ条件で500msかかる時と、約150msで実行できる場合などがあったため、外れ値の影響を大きく受けてしまっていると考えられる。その一方でTurbopackの方は各実行において実行時間が大きくばらつくということは起きなかった。Viteにおける実行時間のばらつきの大きさの原因が何なのかは今後の調査課題としたい。

まとめ

この3ヶ月で主なニュースと言えるのは、Vite4.3のリリースだったと言えるでしょう。ViteサイドではTurbopackとの競争において処理速度が遅れているスタッツに注目して、地道な改善を重ねてきたことが発表からも見て取れました。一方のTurbopackサイドは大きな進展が報告されていません。最初のリリースではViteよりも処理速度が10倍高速なんだと謳って、センセーショナルな形で表舞台に出てきましたが、現在ではツールとしての成熟度、エコシステムの大きさ、そして何より性能差にほぼ優位な差がない(今回の検証実験ではHMRについてはViteの方が高速でした)ことからViteが圧倒的に優勢というのが現状でしょう。
一方で、ViteやTurbopackなどのバンドルツールはHMRの処理速度だけで決まるものではなく、ビルドの速度も極めて重要なスタッツです。こちらはまだ双方ともに課題がある状況なのは変わりないので、競争はむしろこれから激化していくと思われます。引き続き双方の動向を注視しつつ、積極的にプロジェクトに使用していきたいと考えています。

 

参考資料

https://ja.vitejs.dev/
https://turbo.build/pack
https://github.com/yyx990803/vite-vs-next-turbo-hmr/discussions/8
https://recruit.gmo.jp/engineer/jisedai/blog/turbopack-vs-vite/
https://sun0day.github.io/blog/vite/why-vite4_3-is-faster.html
https://vitejs.dev/blog/announcing-vite4-3.html

 

最後に

グループ研究開発本部 次世代システム研究室では、最新のテクノロジーを調査・検証しながらインターネット上の高度なアプリケーション開発を行うエンジニア・アーキテクトを募集しています。募集職種一覧 からご応募をお待ちしています。

  • Twitter
  • Facebook
  • はてなブックマークに追加

グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。

 
  • AI研究開発室
  • 大阪研究開発グループ

関連記事