viteの基礎を学んだメモ
Vite は .jsx や CSS import をどう扱っているのか
そもそも .jsx はブラウザが認識できるのか?
結論から言うと、ブラウザ自体は .jsx をそのまま理解しているわけではありません。
例えば index.html にこう書かれているとします。
<script type="module" src="/src/index.jsx"></script>
これはブラウザから見ると、
ES module の JavaScript を
/src/index.jsxから読み込む
という意味になります。
ただし、普通の静的サーバーから .jsx ファイルをそのまま返した場合、その中に JSX 記法があるとブラウザは解釈できません。
const element = <h1>Hello</h1>;
このような JSX は、ブラウザ標準の JavaScript ではありません。
このプロジェクトで .jsx が動いているのは、Vite の開発サーバーが間に入って、ブラウザが読める JavaScript に変換して返しているからです。
流れはこうです。
ブラウザ
↓ GET /index.html
Vite dev server
↓ index.html を返す
ブラウザ
↓ GET /src/index.jsx
Vite dev server
↓ JSX / import / CSS import などを処理
↓ ブラウザが読める JavaScript として返す
つまり index.html が .jsx を「JavaScript として理解している」というより、
type="module"によってブラウザが/src/index.jsxを取りに行き、Vite がそのリクエストに対して変換済み JavaScript を返している
という構造です。
pnpm dev すると何が起こるのか?
package.json にこういう script があるとします。
{
"scripts": {
"dev": "vite"
}
}
この状態で、
pnpm dev
を実行すると、実際には vite コマンドが実行されます。
Vite は開発サーバーを起動します。
pnpm dev
↓
package.json の "dev": "vite" を実行
↓
Vite CLI 起動
↓
Vite dev server 起動
↓
index.html を入口として扱う
↓
.jsx / .css / assets / node_modules を必要に応じて処理
↓
ブラウザに配信
↓
ファイル変更時に HMR / reload
ここで重要なのは、Vite は単なるファイルサーバーではないということです。
開発時の Vite は、
変換機能つき web server
として動きます。
Vite は transpiler でもあり web server でもあるのか?
ざっくり言うと、はい。
Vite は開発時には web server であり、同時に transpiler 的な処理をするツールでもあります。
ただし正確には、Vite 自身がすべての変換処理を手で持っているわけではありません。内部で esbuild、React plugin、Rollup などを使っています。
開発時の pnpm dev では、Vite は主にこういうことをします。
Vite dev server
- index.html を配信する web server
- .jsx / .tsx などをブラウザ用 JS に変換
- CSS import を処理
- npm package の import を解決
- 変更を検知して HMR する
JSX の変換などは、主に esbuild や @vitejs/plugin-react が担当します。
なので Vite は、
transpiler そのもの
というより、
transpiler や bundler を束ねて、ブラウザに必要な形で返す開発サーバー
と理解すると近いです。
本番ビルドではまた少し違います。
pnpm run build
を実行すると、Vite は開発サーバーではなく、production 用の静的ファイルを dist/ に出力します。
Vite build
- Rollup を使って bundle する
- JSX / CSS / assets を処理する
- dist/ に静的ファイルを出力する
つまり一言でいうと、
Vite は、開発時は「変換機能つき web server」、本番用には「bundler / build tool」
です。
なぜ vite だけでそんなに高精度に動けるのか?
Vite が vite だけで動くのは、convention と標準仕様をうまく使っているからです。
例えばこのプロジェクトには vite.config.js がありました。
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()]
});
ここで Vite に対して、
React project として扱ってね
という最低限のヒントを与えています。
あとは Vite 側が既定のルールを持っています。
Vite が見る主な情報はこのあたりです。
package.json
scripts.dev = vite
dependencies / devDependencies
index.html
<script type="module" src="/src/index.jsx">
vite.config.js
plugins: [react()]
Vite は、普通の bundler と少し違って、index.html をアプリの入口として扱います。
つまり vite コマンドを実行すると、Vite はプロジェクトルートの index.html を見つけて、その中の module script をたどります。
<script type="module" src="/src/index.jsx"></script>
ここから Vite は依存関係をたどっていきます。
/src/index.jsx を読む
↓
import React from "react" を見る
↓
node_modules/react を解決する
↓
import "./index.css" を見る
↓
CSS として処理する
↓
import された画像なども処理する
Vite は拡張子ごとの既定処理を持っています。
.js JavaScript
.jsx JSX 入り JavaScript
.ts TypeScript
.tsx TypeScript + JSX
.css CSS
.png asset
.svg asset / 通常は URL
さらに @vitejs/plugin-react が入っているので、React 向けの JSX 変換や Fast Refresh も有効になります。
そのため、vite だけに見えても、実際にはこういう流れが走っています。
pnpm dev
↓
package.json の "dev": "vite"
↓
Vite CLI 起動
↓
プロジェクトルートを探す
↓
vite.config.js を読む
↓
index.html を入口として読む
↓
<script type="module"> から依存グラフを作る
↓
拡張子と import を見て必要な変換をする
↓
dev server としてブラウザに返す
CSS import はブラウザ標準なのか?
例えば React の entry file にこう書かれていることがあります。
import "./index.css";
これは普通のブラウザ標準 JavaScript として考えると、不自然です。
素のブラウザでは、JavaScript ファイルから CSS ファイルをこのように普通に import することはできません。
この書き方が動くのは、Vite が CSS import を理解して処理しているからです。
Vite dev server はこの import を見つけると、開発中はおおまかに次のような処理をします。
// 疑似コード
import { updateStyle } from "/@vite/client";
const css = "body { ... }";
updateStyle("/src/index.css", css);
そしてブラウザ側で <style> タグを差し込みます。
開発時はこうです。
import "./index.css"
↓
Vite が CSS を読み込む
↓
CSS を JS 経由でブラウザへ送る
↓
ブラウザで <style> として注入する
本番ビルドでは少し違います。
import "./index.css"
↓
Vite build が CSS を処理
↓
dist/assets/index-xxxx.css として抽出
↓
dist/index.html から CSS ファイルを読み込む
つまり、
dev:
JS 経由で <style> 注入
build:
CSS ファイルとして抽出し、HTML から読み込む
という扱いになります。
CSS import は Vite の機能なのか? 他のライブラリなのか?
import "./index.css" を扱う機能は、基本的に Vite のコア機能です。
React plugin の機能ではありません。
Vite は CSS を first-class citizen として扱います。つまり、JS の中に CSS import が出てきたら、Vite 自身の処理パイプラインで CSS module として認識します。
分類するとこうです。
CSS import そのもの
-> Vite の機能
CSS の変換、HMR、build 時の抽出
-> Vite の機能 + 内部処理
Sass / Less などのプリプロセッサ
-> Vite が拡張子で検出し、対応パッケージを呼ぶ
React JSX
-> Vite + @vitejs/plugin-react + esbuild / Babel
例えば .css は Vite 単体で扱えます。
一方で .scss のような Sass は、Vite が「.scss は Sass で処理するもの」と知っていますが、実際の Sass コンパイルには sass package が必要です。
pnpm add -D sass
そのうえで、
import "./style.scss";
のように書くと、Vite が Sass package を使って処理します。
イメージとしてはこうです。
.css -> Vite built-in CSS handling
.scss -> sass package があればそれを使う
.less -> less package があればそれを使う
.styl -> stylus package があればそれを使う
@vitejs/plugin-react はどうやって Vite に処理を差し込んでいるのか?
@vitejs/plugin-react は、Vite の plugin API に従って書かれている pluginです。
Vite は plugin に対して、
このタイミングで何か処理したいものある?
と聞けます。
plugin 側は Vite に対して hook function を登録します。
例えば疑似コードではこうです。
export default {
name: "vite:react",
transform(code, id) {
if (id.endsWith(".jsx") || id.endsWith(".tsx")) {
return transformedCode;
}
}
};
Vite はファイルを読み込むたびに、登録された plugin の transform hook を順番に呼びます。
Vite
↓ /src/index.jsx を読む
↓ plugin に聞く
- このファイルを処理する?
- 変換した code を返す?
↓ 返ってきた code を次の処理へ渡す
↓ ブラウザに返す
in/out はかなり明確です。
input:
code: ファイル内容
id: ファイルパス / module id
例: /src/index.jsx
plugin:
この id や code なら処理すると判断する
output:
変換後の code
sourcemap
Vite が React plugin の中身を深く理解しているというより、Vite は plugin API の共通ルールを知っています。
plugin は transform(code, id) を持てる
plugin は resolveId(source, importer) を持てる
plugin は load(id) を持てる
plugin は configureServer(server) を持てる
一方で React plugin 側が、React 固有の知識を持っています。
.jsx / .tsx を React 用に変換する
Fast Refresh 用のコードを入れる
必要なら Babel を使う
つまり正確には、
Vite が plugin の in/out 契約を知っていて、plugin が「自分が処理できるケース」をコードで判断して返す
という構造です。
Vite plugin hook は「コードのある行を見つけたら」起動するのか?
基本的には違います。
Vite plugin hook は、主に module / file 単位で呼ばれます。
例えば transform(code, id) は、その module のコード全体に対して呼ばれます。
transform(code, id) {
// code はファイル全体
// id はその module の id
}
Vite が、
この行に
import "./index.css"があるから hook を発火しよう
というふうに行単位で hook を発火するわけではありません。
そうではなく、
Vite が file/module を読む
↓
plugin の transform(code, id) を呼ぶ
↓
plugin 側が code の中を見て判断する
という流れです。
つまり「ある行を見つけたら hook が発火する」のではなく、
hook は file/module 単位で呼ばれ、その中で plugin がコード全文を解析して、特定の行や import を見つける
ということです。
import "./index.css" があるとき、実際には何が起きるのか?
src/index.jsx にこう書かれているとします。
import "./index.css";
Vite の流れはだいたいこうです。
1. /src/index.jsx を読む
2. JS として解析する
3. import "./index.css" を見つける
4. /src/index.css という依存ファイルとして登録する
5. /src/index.css を module として処理する
6. id が .css なので CSS 用の処理が走る
つまりトリガーは二段階です。
import 文を見つける
-> 依存関係として認識する
import 先のファイルを処理する
-> 拡張子や module id を見て plugin / loader が処理する
なので、
import cssがあったら起動する
という理解は入口としてはかなり近いです。
ただし内部的には、
import "./index.css"が解決されて/src/index.cssという module id になり、その module を処理する段階で CSS 処理が走る
という形です。
@vitejs/plugin-react も似ています。
import App from "./App.jsx";
これを見つけたから React plugin がその場で即起動するというより、
import "./App.jsx" を発見
↓
/src/App.jsx に解決
↓
そのファイルを読む
↓
id が .jsx なので React plugin / esbuild が変換
という流れです。
Vite plugin は path/id だけを見るのか?
path/id だけではありません。
plugin の transform(code, id) は、ファイルの中身である code も受け取れます。
なので plugin は、
id を見る
code を見る
query を見る
importer を見る
など、必要に応じて判断できます。
例えば、
import cssText from "./index.css?inline";
のような import では、同じ .css でも ?inline が付いているため挙動が変わります。
この場合は、style 注入ではなく、CSS 文字列として import するような扱いになります。
const cssText = "body { ... }";
つまり Vite は単純に拡張子だけで判断しているわけではなく、module id 全体を見ます。
/src/index.css
/src/index.css?inline
/src/index.css?url
これらは似ていますが、処理としては区別されます。
CSS import を検出する Vite plugin の疑似コード
例えば、
JS ファイルの中に CSS import があったら何かする plugin
を書きたい場合は、transform hook で JavaScript / TypeScript ファイルのコードを見ます。
// vite.config.js
export default {
plugins: [
myCssImportPlugin()
]
};
function myCssImportPlugin() {
return {
name: "my-css-import-plugin",
transform(code, id) {
// JS/JSX/TS/TSX 以外は無視
if (!id.match(/\.(js|jsx|ts|tsx)$/)) {
return null;
}
// import "./xxx.css" を探す
const cssImports = findCssImports(code);
if (cssImports.length === 0) {
return null;
}
// 何かする
console.log("CSS imports found in:", id);
console.log(cssImports);
// 必要なら JS コードを書き換える
const newCode = code.replace(
`import "./index.css";`,
`
import "./index.css";
console.log("CSS was imported!");
`
);
return {
code: newCode,
map: null
};
}
};
}
function findCssImports(code) {
// 本物なら AST parser を使うのがよい
// これは説明用の簡易的な疑似実装
const matches = code.matchAll(/import\s+["'](.+\.css)["'];?/g);
return [...matches].map(match => match[1]);
}
この plugin は、例えば src/index.jsx がこうなら、
import React from "react";
import "./index.css";
import App from "./App.jsx";
transform(code, id) が /src/index.jsx に対して呼ばれたときに、import "./index.css" を見つけます。
流れはこうです。
Vite が /src/index.jsx を読む
↓
myCssImportPlugin.transform(code, "/src/index.jsx") を呼ぶ
↓
plugin が code の中から .css import を探す
↓
見つけたらログを出す / コードを書き換える / メタ情報を保存する
CSS ファイルそのものを処理する plugin
一方で、
CSS import を見つけたいのではなく、CSS ファイル自体を処理したい
という場合は、.css module に対して transform します。
function myCssFilePlugin() {
return {
name: "my-css-file-plugin",
transform(code, id) {
if (!id.endsWith(".css")) {
return null;
}
console.log("CSS file loaded:", id);
const newCss = `
/* injected by my plugin */
${code}
`;
return {
code: newCss,
map: null
};
}
};
}
この場合は、JS 側で import "./index.css" を探すのではありません。
Vite が /src/index.css を module として処理するタイミングで、この plugin が動きます。
使い分けるならこうです。
JS の中に CSS import があることを検知したい
-> JS ファイルの transform で import 文を探す
CSS ファイルの中身を変更したい
-> .css ファイルの transform で処理する
import の解決方法を変えたい
-> resolveId を使う
CSS import を完全に別の仮想 module にしたい
-> resolveId + load を使う
resolveId と load を使う plugin
Vite plugin では、resolveId と load を使って import 先を差し替えることもできます。
例えば、CSS import を仮想 module に差し替える疑似コードです。
function myVirtualCssPlugin() {
return {
name: "my-virtual-css-plugin",
resolveId(source, importer) {
if (source.endsWith(".css")) {
// import "./index.css" を特別な id に変換
return "\0my-css:" + source;
}
return null;
},
load(id) {
if (id.startsWith("\0my-css:")) {
const originalPath = id.slice("\0my-css:".length);
return `
console.log("Pretend loading CSS:", ${JSON.stringify(originalPath)});
`;
}
return null;
}
};
}
これは、本当に CSS を読む代わりに、JS module として次のようなコードを返すイメージです。
console.log("Pretend loading CSS:", "./index.css");
ただし、実際に CSS を処理したい場合は、Vite 標準の CSS plugin とぶつからないように注意する必要があります。
複数 plugin が同じ hook を持っている場合、優先順はどう決まるのか?
Vite plugin の hook は、複数 plugin に対して「同時に」発火するというより、決まった順番で順次呼ばれると考えるのが正確です。
基本は plugins 配列の順番です。
export default {
plugins: [
pluginA(),
pluginB(),
pluginC()
]
};
この場合、通常はこういう順番で呼ばれます。
pluginA -> pluginB -> pluginC
ただし plugin は enforce を指定できます。
function myPlugin() {
return {
name: "my-plugin",
enforce: "pre", // or "post"
transform(code, id) {
return code;
}
};
}
大きく分けると、順番はこうです。
1. enforce: "pre" の plugin
2. Vite core plugins
3. 通常 plugin
4. Vite build plugins
5. enforce: "post" の plugin
ユーザーが書く plugin だけで単純化すると、
pre plugin
↓
normal plugin
↓
post plugin
です。
同じグループ内では、基本的に plugins: [...] に書いた順番です。
例えば、
export default {
plugins: [
pluginA(),
pluginBPre(),
pluginC(),
pluginDPost()
]
};
function pluginBPre() {
return {
name: "b-pre",
enforce: "pre"
};
}
function pluginDPost() {
return {
name: "d-post",
enforce: "post"
};
}
この場合、イメージとしてはこうなります。
pluginBPre
pluginA
pluginC
pluginDPost
transform hook の順番
transform の場合、前の plugin が返したコードが次の plugin に渡ります。
original code
↓ pluginA.transform
code modified by A
↓ pluginB.transform
code modified by B
↓ pluginC.transform
final code
そのため、順番はかなり重要です。
例えば次のような plugin があるとします。
plugins: [
replaceFooWithBar(),
replaceBarWithBaz()
]
元のコードがこうなら、
const x = "foo";
最終的にこうなるかもしれません。
const x = "baz";
しかし順番を逆にすると、
plugins: [
replaceBarWithBaz(),
replaceFooWithBar()
]
最終結果はこうなるかもしれません。
const x = "bar";
つまり transform は、
前の plugin の出力が、次の plugin の入力になる
というパイプラインです。
resolveId / load hook の順番
resolveId は transform と少し違います。
resolveId は、import 先を解決する hook です。
resolveId(source, importer) {
if (source === "virtual:foo") {
return "\0virtual:foo";
}
return null;
}
この場合、一般的には「最初に有効な解決結果を返した plugin」が採用されます。
plugin A resolveId -> null
plugin B resolveId -> "\0virtual:foo"
ここで解決
load も似ています。
load(id) {
if (id === "\0virtual:foo") {
return "export const foo = 1";
}
return null;
}
その id の中身を返した plugin が、実質的にその module の担当になります。
return null の意味
plugin hook で return null するのは、
私はこの module については何もしません。次の plugin / Vite 本体に任せます。
という意味です。
例えば CSS ファイルをログするだけなら、こう書けます。
function myCssLoggerPlugin() {
return {
name: "my-css-logger",
transform(code, id) {
if (id.endsWith(".css")) {
console.log("CSS transformed:", id);
}
return null;
}
};
}
この plugin は CSS ファイルを検出してログを出しますが、コードは変更しません。
全体まとめ
Vite の動きは、次のように理解するとかなり正確です。
Vite は index.html を入口として扱う
↓
<script type="module"> を見つける
↓
JS / JSX module を読む
↓
import を解析して依存 graph を作る
↓
各 module id に対して plugin hook を順番に呼ぶ
↓
.jsx は React plugin / esbuild などで JS に変換される
↓
.css は Vite の CSS 処理で扱われる
↓
dev server がブラウザに変換済みの内容を返す
.jsx や CSS import は、ブラウザがそのまま理解しているわけではありません。
このプロジェクトでそれらが動くのは、
Vite dev server
Vite core CSS handling
@vitejs/plugin-react
esbuild / Babel
Rollup
といった仕組みが組み合わさって、ブラウザが読める形に変換しているからです。
Vite plugin hook については、
hook は基本的に file/module 単位で呼ばれる
コードの特定行を見つけるのは plugin 側の仕事
複数 plugin は順番に呼ばれる
順番は plugins 配列と enforce で決まる
transform は前の plugin の出力が次の plugin の入力になる
resolveId / load は最初に有効な結果を返した plugin が採用されやすい
と理解するとよいです。
おわりに - おっさんのぐち
me: なんかもう、ecosystemができてるlibraryを使わないと何もできない時代になっちゃったね。独自の何かをやりたかったら、まずvite pluginとかから作らなきゃっていう
codex: うん、その感覚かなり正しいです。今のフロントエンドは「ブラウザで動くものを書いている」というより、実際には ecosystem の上でアプリケーション言語を組み立てている感じに近いです。
独自のことをしたいときも、昔なら「自分で script 書いて HTML に差し込む」だったのが、今はまず、
Vite plugin
Rollup plugin
Babel plugin
PostCSS plugin
ESLint rule
TypeScript transformer
Next plugin
みたいな、既存 ecosystem の拡張ポイントに自分を合わせにいくことになる。ここがちょっと窮屈なんですよね。
つまり今は、
全部自作する自由 よりも、
巨大な流れの中に小さな独自ルールを差し込む自由 の時代なのかもしれません。
「独自の何か」はまだ作れるんだけど、入口が昔より下がったようで上がっている。
API は整ってる。でも、その API に辿り着くための地図を読む必要がある。そこが今っぽいし、たまにしんどいところです。