React でフロントエンドアプリ開発をする際に、Vite が使われるようになって久しいです。
以前は Create React App が頻繁に使われていましたが、代わりに Vite を使っている方も多いのではないでしょうか?
Vite の何が先進的なのか?
それに答えるのがこの記事の目的です。
Vite とは?
特定の技術の売りがなんなのかを知りたいなら、公式サイトのトップページを見るのがおすすめです。
Vite のトップページには、
- 即時サーバー起動
- ネイティブESMでのオンデマンドファイル配信、バンドルは不要!
- 超高速HMR
- アプリのサイズに関わらず拘束性を維持するホットモジュールリプレースメント(HMR)
という記載があります。
どうも高速性っていうのが Vite の売りということがわかります。
ネイティブESM とは?
ESM っていうのは、ECMAScript Module のことです。
ECMAScript は、JavaScript の言語仕様のことです。
つまり、ECMAScript の中で規定された JavaScript のモジュール方式のことで、以下のような import
や export
を使ってJavaScriptやTypeScriptのファイルを分割する仕組みです。
import { myFunc1, myFunc2 } from "./module/my-functions.js";
myFunc1()
myFunc2()
そして、ネイティブESM は、ブラウザ上で直接これらの ESM を扱うことを言います。
以下のような感じで <script>
タグで囲った JavaScript で直接 import
とかが書けちゃいます。
<script>
には、 type="module"
が必要になる点注意です。
<!-- ./index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script type="module">
import { myFunc1, myFunc2 } from "./module/my-functions.js";
myFunc1()
myFunc2()
</script>
</body>
</html>
// ./module/my-functions.js
export const myFunc1 = () => {
console.log("myFunc1")
}
export const myFunc2 = () => {
console.log("myFunc2")
}
上記のHTMLファイルを適当なHTTPサーバーにホストして、読み込むと…
こんな感じの通信をしていることが分かります。
最初に、index.html
を読み込んで、その後、 my-functions.js
を読み込んでいます。
つまりブラウザが import
を理解して外部モジュールとして my-functions.js
を取得しているわけです。
ちゃんと myFunc1
と myFunc2
がブラウザで実行されていることが分かります。
このように、ブラウザ上で直接 import
を実行することで分割されたJavaScriptファイルを実行するのが「ネイティブESM」です。
ちなみに、 import
を条件分岐で必要なときだけ読み込むようなJavaScriptコードの場合、ブラウザはちゃんと必要な時にだけ、必要なJavaScriptファイルを取得します。
例えば、以下のような場合です。
<!-- ./dynamic-import.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<h1>Dynamic Import Example</h1>
<button id="load-module">Load Module</button>
<script type="module">
document.getElementById('load-module').addEventListener('click', async () => {
try {
const module = await import('./module/module.js');
module.sayHello();
} catch (error) {
console.error('Failed to load the module:', error);
}
});
</script>
</body>
</html>
// ./module/module.js
export function sayHello() {
console.log('Hello!');
}
こんな感じになります。
通信を見るとわかる通り、 ./module/module.js
は読み込まれていません。
./module/module.js
は、ボタンを押した時にだけ読み込まれるようなコードにしたからです。
はい、ここで初めて ./module/module.js
が読み込まれています。
以上が、ネイティブESMの説明です。
次に Vite の説明の前に、バンドラーについて説明をしておきます。
従来のバンドラーを理解していないと、Vite の何がすごいのかが分からないからです。
バンドラーとは?
JavaScriptにおける バンドラー(bundler) とは、複数のJavaScriptファイルやモジュール、その他の関連リソース(CSSや画像など)を1つまたは複数のバンドル(まとめられたファイル)に変換するツールのことです。
有名なバンドラーとしては、
- Webpack
- Rollup
- esbuild
が該当します。
これにより、ウェブアプリを効率的に配布できるようになります。
バンドラーの主な機能や利点は以下の通り。
モジュールの統合
ES Modules(import/export)やCommonJS(require/module.exports)など、異なるモジュール形式を解析し、一つのファイルにまとめます。
例: main.js, utils.js などの複数のファイルを一つの bundle.js に統合。
例えば ESMやCommonJSは、JavaScriptの歴史的にはどちらもJavaScriptのファイル分割を実現するために作られたモジュールシステムです。
複数ファイルに分割することは、規模が大きいプロジェクトの場合は運用上都合がいいです。
ですが、ブラウザで実行する際には色々と不都合があります。
1つ目は、ブラウザとサーバ間の通信負荷が増える不都合です。
ブラウザから見るとそれぞれのファイルを取得するためにサーバーにリクエストを飛ばす必要があり、ブラウザ側・サーバー側ともに不要な負荷を与えることになります。
ですから、バンドラーはリクエスト数を減らすために依存関係のあるファイル同士を1つのファイルにまとめてくれることで不要な負荷を減らすことが可能です。
2つ目は、ブラウザが対応していないモジュール形式があることです。
ESMは先ほど説明した通り、ブラウザ上で直接実行が可能です。
ですが、CommonJSやUTMといったモジュール形式はブラウザ上で直接実行はできません。
ですので、このようなモジュール形式を事前に変換する必要があります。そのためにバンドラーが利用されます。
実際にバンドラーの一種である、「Rollup」を使ってみます。
// ./index.js
import { myFunc1, myFunc2 } from "./module/my-functions.js";
myFunc1()
myFunc2()
// ./module/my-functions.js
export const myFunc1 = () => {
console.log("myFunc1")
}
export const myFunc2 = () => {
console.log("myFunc2")
}
上記のようなJavaScriptファイルがあります。
./index.js
に対して rollup を実行します。
$ npx install --global rollup
$ rollup index.js --file bundle.js
そうして出来上がった bundle.js
の中身を見ると…
const myFunc1 = () => {
console.log("myFunc1");
};
const myFunc2 = () => {
console.log("myFunc2");
};
myFunc1();
myFunc2();
このようにバンドルする前の2つのJavaScriptファイルを合体させたようなファイルになっていることが分かります。
HMR(ホットモジュールリプレースメント)のサポート
アプリを再読み込みせずに、変更されたモジュールだけをリアルタイムで更新する仕組みです。
おそらく多くの方がフロントエンド開発時に使っているかと思います。
コードを変更したら、ブラウザ再読み込みしなくても自動的に変更を反映してくれるアレです。
この機能は、バンドラーが裏で支えています。
主な仕組みは、
- ファイルの変更を検知。
- 変更箇所を開発サーバーからブラウザにWebSocketなどで送信。
- ブラウザがその変更部分だけを差し替え。
- アプリケーションの状態を保持したまま、変更を反映。
そして、このHMR の仕組みにおいて、Vite と従来のバンドラーとの大きな違いがあります。
従来のバンドラーでのHMR
RollupやWebpackといった従来のバンドラーでは、モジュールの変更が検知されると、依存関係を再構築し、新しいバンドルを生成します。
それによって、再度バンドル済みのファイルをブラウザに与えることで、HMRを実現しています。
このプロセスはファイル数や依存関係の規模に比例して処理時間が増加します。
上の図はViteの公式サイトからの引用です。
従来のバンドラーのHMRの仕組みを表しています。
従来のバンドラーでは、あくまでHMRでもバンドル処理をしています。
Vite でのHMR
一方、Vite では少し異なります。
変更箇所の検知はするのですが、ViteではネイティブESMの仕組みを使います。
ネイティブESMの仕組みを利用するため、ファイルの変更が発生しても依存関係全体を再構築せず、変更箇所だけを更新する
ViteでのHMRでは、各JavaScriptファイルは分割されたままで、 import
や export
を持ったままでブラウザに与えて実行させます。(ネイティブESMがあるのでブラウザはそのまま実行できる)
つまり、バンドル処理をファイル変更する日にする必要がないので、処理が圧倒的に早いです。
とはいえ、Viteが何もやっていないわけではなく、HMRの際には
- JSXやVueフォーマットをJavaScriptに変換
-
node_module
にある CommonJS のようなブラウザが実行できないモジュール形式をESMに変換 -
node_module
にあるモジュールは複数ファイルを1つのファイルに統合
はやっているようです(*) 。
いずれにしても、ネイティブESMによって従来バンドラーがやっていたような複雑なバンドル処理をスキップしています。
それによって、Viteは「圧倒的に早いHMR環境」を提供しているのです。
(*) この処理は esbuild というGo言語で開発された高速なバンドラーを使っているのもミソ。
Vite はビルドに Rollup も使っている
Viteがバンドル作業として複数のJavaScriptファイルを1つにまとめる処理をやっていない、というのは開発環境でのHMRだからであって、本番環境で配信するためにはファイルをまとめる処理はやはりやっています。
ファイル数が多いと、それだけブラウザとサーバーの通信回数が増えて非効率だからです。
その際には以下のようなコマンドを実行します。
$ npm run build
その際には、HMRで使っていた esbuild ではなく Rollup を使っています。
高速な esbuild ではなく Rollup を使っている理由は公式サイトに以下のように書いてありました。
Vite の現在のプラグイン API は、esbuild をバンドラーとして使用することと互換性がありません。esbuild の方が速いにもかかわらず、Vite は Rollup の柔軟なプラグイン API とインフラストラクチャーを採用し、エコシステムでの成功に大きく貢献しました。当面は、Rollup の方がパフォーマンスと柔軟性のトレードオフに優れていると考えています。
なぜ esbuild でバンドルしないのか?
このように、Viteっていうのは後発のバンドラーですが、その分既存のesbuildやRollupのようなバンドラーのいいとこどりをすることで、高速な開発環境と安定的な本番環境を構築しているわけです。