OSSTech株式会社

SvelteKit はどのようにコンパイルしているのか?

2023-11-24 - 森本 哲也

フロントエンドの技術選定 によって Svelte と SvelteKit を使って管理画面を作ることに決めました。Unicorn Cloud ID Manager (以下UCIDM) の管理画面を SvelteKit を使って開発しています。

UCIDM のフロントエンドのアーキテクチャ

BFF (Backend For Frontend) サーバーに Node.js を配置し、サーバーサイドレンダリングを採用したフロントエンドを提供しています。マイクロサービスでは一般的なアーキテクチャだと思います。

エンドユーザー (ブラウザ) は BFF サーバーに対して認証を行い、BFF サーバーが UCIDM API サーバーと認証します。エンドユーザーは透過的に UCIDM API サーバーが返す情報を参照できますが、直接 UCIDM API とは通信できません。これはセキュリティを担保する上での考慮であったり、将来 UCIDM API 以外にもバックエンドサービスを提供するような場合においても、BFF サーバーがその差異を吸収できるというメリットがあります。そして、この構成をとるためには SvelteKit のサーバーサイドレンダリングの機能が必要となります。

カスタムテンプレート

エンドユーザーがフロントエンドの画面構成、レイアウト、テーマなどをちょっとカスタマイズしたいという要件があります。SvelteKit アプリケーションのどこにフックすべきかを検討しています。

シンプルな実装

もっとも簡単なフックポイントとしては、エンドユーザーが編集した HTML をサーバーサイドから返し、ブラウザ側でそのまま流用してレンダリングする方法です。Svelte では {@html …} というテンプレート構文のタグがあり、このタグを使うと任意の内容を表示できます。

<div class="blog-post">
    <h1>{post.title}</h1>
    {@html post.content}
</div>

一方でこのタグには注意事項があります。任意の HTML を挿入できるトレードオフとしてセキュリティ上の懸念があります。ドキュメントには次の注記があります。

Svelte は HTML を注入する前に式をサニタイズしません。データが信頼できないソースから来る場合、それをサニタイズする必要があります。

安全のため、スクリプトを実行させたくない場合は Content Security Policy を設定できます。次のように svelte.config.js に追加することでエンドユーザーが編集した HTML のスクリプトは実行できないように制御できます。

/** @type {import('@sveltejs/kit').Config} */
const config = {
	kit: {
		csp: {
			directives: {
				'script-src': ['self']
			}
		}
	}
};

より安全に、より柔軟に、より簡単にカスタムテンプレートを実装することはできないだろうか?というのが本稿の出発点です。

コンポーネントはどのようにコンパイルされるのか?

そもそも SvelteKit はどのように .svelte ファイルやスクリプトファイルをコンパイルしているのか。SvelteKit のドキュメントを読んでいてもこの背景はほとんどわかりません。アプリケーションのビルド には次のようにあります。

SvelteKit アプリのビルドは2段階で行われ、どちらも vite build (通常は npm run build) を実行したときに行われる。

まず Vite は、サーバーコード、ブラウザコード、サービスワーカー (ある場合) の最適化されたプロダクションビルドを作成します。必要に応じて、この段階で プリレンダリング が実行されます。

次にアダプターがこのプロダクションビルドを目的の環境向けチューニングします。

Vite というビルドツールを使っていると説明されているのでそこでやっているのだろうと推測できます。APPENDIX Integrations に紹介程度にコンパイラのプリプロセスや関連する情報へのリンクがありますが、具体的にどうやってコンパイルしているのか (おそらく) どこにも書いていません。もう少し核心の情報が svelte/compiler の冒頭に書いてあります。

通常、Svelte コンパイラと直接やり取りすることはありません。その代わり、バンドラープラグイン(bundler plugin)を使ってビルドシステムにインテグレートします。Svelte チームが最も推奨している、また注力もしているバンドラープラグインは vite-plugin-svelte です。SvelteKit フレームワークは vite-plugin-svelte を活用し、アプリケーションをビルドするためのセットアップと、Svelte コンポーネントライブラリをパッケージングするツール を提供しています。Svelte Society には、Rollup や Webpack などのツール向けの その他のバンドラープラグイン のリストがあります。

どうやらコンパイルは vite-plugin-svelte という Vite のプラグインが行っているようにみえます。

SvelteKit のデモアプリケーションを調べる

前の節でドキュメントに書いてあった内容をソースコードから追いかけてみましょう。調査を始めたとき、本当はソースコードから読み始めて、kit のコードのどこにもコンパイラを呼び出しているコードはないなとわかっていました。

ここで簡単に my-kit-app というディレクトリに SvelteKit のデモアプリケーションを作ってみましょう。

$ npm create svelte@latest my-kit-app
# ◇  Which Svelte app template?
# │  SvelteKit demo app
# │
# ◇  Add type checking with TypeScript?
# │  Yes, using TypeScript syntax
# │
# ◇  Select additional options (use arrow keys/space bar)
# │  none
$ cd my-kit-app
$ npm install

vite.config.ts を確認するとプラグインを sveltekit() という関数を呼び出して設定していることがわかります。

import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
	plugins: [sveltekit()]
});

該当のソースコードを調べると vite-plugin-svelte の svelte() 関数にオプションを設定していることが伺えます。

import { svelte } from '@sveltejs/vite-plugin-svelte';

export async function sveltekit() {
    ...
	return [...svelte(vite_plugin_svelte_options), ...kit({ svelte_config })];
}

この時点ではよくわからないまま、次のように vite の開発サーバーを起動してみます。

$ npm run dev

vite の開発サーバーが起動して、ブラウザがその開発サーバーへリクエストしたときに vite-plugin-svelte が透過的に .svelte ファイルやスクリプトなどのファイルをコンパイルしてくれます。次の節ではもう少しその詳細を調べていきます。

vite-plugin-svelte のコードを読む

SvelteKit でコンポーネントのコンパイルは vite-plugin-svelte が行っています。vite-plugin-svelte の svelte() 関数の中身をみてみましょう。後述しますが、これは Vite のプラグイン API を実装しています。インポート文や load()transform() API のところで svelte のコンパイルをしているコードもみえます。ようやく svelte のコンパイラが呼ばれている場所にたどり着きました。

import { createCompileSvelte } from './utils/compile.js';
...
import * as svelteCompiler from 'svelte/compiler';

export function svelte(inlineOptions) {
    ...
    const plugins = [
        {
            name: 'vite-plugin-svelte',
            enforce: 'pre',
            api,
            async config(config, configEnv) {...},
            async configResolved(config) {...},
            async buildStart() {...},
            configureServer(server) {...},
            async load(id, opts) {...},
            async resolveId(importee, importer, opts) {...},
            async transform(code, id, opts) {...},
            handleHotUpdate(ctx) {...},
            async buildEnd() {...}
        }
    ];
    ...
	return plugins;
}

先ほどのデモアプリケーションでその振る舞いを検証してみましょう。通常は npm run dev で vite コマンドを呼び出すところを、デバッグのために直接 vite コマンドを次のように呼び出します。

$ ./node_modules/.bin/vite dev --debug
...
  vite:vite-plugin-svelte
...

たくさん設定のデバッグログが表示されています。vite:vite-plugin-svelte という prefix で出力されているのが vite-plugin-svelte の、Vite のプラグイン API が呼ばれているログです。ここで vite の開発サーバーにブラウザでアクセスしてみましょう。デフォルトでは http://localhost:5173/ になります。

適当に抜き出してみましたが、次のようなログを確認できます。それぞれのプラグイン API を呼び出したデバッグログです。このときに .svelte ファイルやスクリプトなどがコンパイルされています。

...
  vite:vite-plugin-svelte transform returns compiled js for path/to/my-kit-app/src/routes/Counter.svelte +11ms
  vite:vite-plugin-svelte transform returns compiled js for path/to/my-kit-app/src/routes/+page.svelte +8ms
  vite:vite-plugin-svelte load returns css for path/to/my-kit-app/src/routes/+page.svelte +1ms
  vite:vite-plugin-svelte setting cssHash s-Cmt25qOMERl7 for /src/routes/Header.svelte +1ms
  vite:vite-plugin-svelte load returns css for path/to/my-kit-app/src/routes/Header.svelte +0ms
...

プラグインの作成の Tipsvite-plugin-inspect を使ったデバッグ方法についても紹介されています。やってみましょう。

vite-plugin-inspect をインストールして vite.config.ts に設定を追加します。

$ npm i -D vite-plugin-inspect

次のように設定します。

import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import Inspect from 'vite-plugin-inspect'

export default defineConfig({
    plugins: [
        Inspect(),
        sveltekit(),
    ]
});

次のように __inspect というエンドポイントが追加されていることがわかります。

$ ./node_modules/.bin/vite dev --debug
...
  ➜  Inspect: http://localhost:5174/__inspect/
...

ブラウザで直接このエンドポイントにアクセスしてみましょう。vite-plugin-svelte プラグインにより、コンパイルされていない状態だと一覧には何も表示されません。http://localhost:5174/ のトップページにアクセスした後でもう一度 /__inspect/ のエンドポイントをリロードしてみましょう。いくつかのコンパイルされたコンポーネントが表示されます。

例えば、Header.svelte を選択すると、コンポーネントのコードがどのようなスクリプトのコードに変換されたかがわかります。

vite:import-analysis ではインポートパスを絶対パスに置き換えているのがわかります。

通常の開発においてコンポーネントのソースと変換されたスクリプトのコードを見比べることはないと思います。知っておくと、コンポーネントが意図した振る舞いをしないときに調査するデバッグツールの1つとして使えるかもしれません。

vite build のデバッグログをみる

せっかくの機会なので vite の本番向けビルドもデバッグモードで実行してみます。開発サーバーとオプションは同じです。

$ ./node_modules/.bin/vite build --debug

ここでも vite:vite-plugin-svelte が実行されたときのログを確認できます。これで開発サーバーでも本番向けビルドでも .svelte ファイルやスクリプトなどのコンパイルは vite-plugin-svelte が行っていることを確認できました。

Vite とは何か

前の節では vite-plugin-svelte によって .svelte ファイルやスクリプトなどをコンパイルしてくれることを確認しました。コンパイルのエントリーポイントは次の2つになります。

  • vite の開発サーバーにリクエストしたとき
  • vite build で本番向けビルドしたとき

後者はコマンドラインからのバッチ処理なのでまだイメージできますが、前者のリクエスト時にコンパイルするというのは、どうしてそんなことができるのでしょうか?という疑問が生じます。この節ではその仕組みを提供している Vite について理解を深めてみます。

Vite を使う理由 から読み進めます。

ES モジュールがブラウザで利用できるようになるまで、開発者はモジュール化された JavaScript を生成するネイティブの仕組みを持っていませんでした。これは、私たちが「バンドル」のコンセプトに慣れ親しんでいる理由でもあります: すなわち、ブラウザで実行可能なようにソースモジュールをクロール、処理し、連結するツールを使用しています。

ECMAScript 2015 で追加された Modules のことを Vite のドキュメントでは「ES モジュール」と記載しています。ES モジュールの実装として JavaScript モジュール があります。ES モジュールが追加される前と後でフロントエンド開発の仕組みが大きく変わったようです。具体的にはブラウザが直接 JavaScript のモジュールをインポートできるようになりました。

例えば、次のように <script> タグに type="module" と指定して ES モジュールのインポートができます。

<!doctype html>
<html lang="en">
  <body>
    <div id="app"></div>
    <script type="module">
      import { myapp } from '/src/main.ts';
      myapp();
    </script>
  </body>
</html>

シンプルに ES モジュールを読み込むこともできます。

<!doctype html>
<html lang="en">
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

一見しただけだと、これまでのスクリプト読み込みと何が違うのかということが モジュールの通常のスクリプトのその他の違い にあります。大きな違いの1つを引用します。

最後ですが重要なこととして明らかにしておきますが、モジュールの機能は単独のスクリプトのスコープにインポートされます。つまり、インポートされた機能はグローバルスコープから利用することはできません。それゆえ、インポートされた機能はインポートしたスクリプトの内部からしかアクセスできず、例えば JavaScript コンソールからはアクセスできません。文法エラーは開発者ツール上に表示されますが、使えることを期待するデバッグ技術の中には使えないものがあるでしょう。

ES モジュールは独自のスコープをもっていて名前空間を限定できるとあります。つまり、グローバルの名前空間を汚染しないことで、名前の競合が発生しにくくなり、アプリケーションの開発を容易にします。

ES モジュールが追加される前まではバンドルツールと呼ばれるものがありました。フロントエンドのアプリケーション全体の JavaScript のファイル群をビルドして1つのファイルに統合することを「バンドル」と呼び、バンドルツールを使うことで内部の依存関係やデプロイの制御をしてきました。しかし、フロントエンドのアプリケーションが肥大化するにつれて、JavaScript のファイル群が数千といった規模まで大きくなり、バンドルする待ち時間が長くなってきました。Vite のドキュメントによると、大規模なプロジェクトでは数分かかることもあると書かれています。

そして、開発サーバーにおいてもバンドルするには、アプリケーション全体をビルドしないといけないことから、スクリプトの小さな修正であっても、バンドル全体を再構築してウェブページを再読込する必要がありました。Vite のドキュメントから次の図を引用します。

Bundle based dev serverentry···routeroutemodulemodulemodulemodule···BundleServerready

ES モジュールの仕組みを使って Vite はこの問題を解決します。比較のための図から紹介します。この図は特定のエンドポイントにアクセスしたときに ES モジュールを読み込むことを表現しています。

Native ESM based dev serverentry···routeroutemodulemodulemodulemodule···ServerreadyDynamic import(code split point)HTTP request

Vite のデモアプリケーションを調べる

ドキュメントを読むだけではよくわからないので実際にデモアプリケーションを動かしてみましょう。

$ npm create vite@latest 
# ✔ Project name: … my-vite-app
# ✔ Select a framework: › Svelte
# ✔ Select a variant: › TypeScript
$ cd my-vite-app/
$ npm install

カレントディレクトリに index.html があります。中身をみると次のように main.ts というファイル名の ES モジュールを読み込んでいます。

<script type="module" src="/src/main.ts"></script>

これを踏まえて vite の開発サーバーを起動してデモアプリケーションが起動します。

$ npm run dev

Google Chrome のデベロッパーツールで通信ログを確認すると vite の開発サーバーの /src/main.ts というエンドポイントにリクエストしていることがわかります。

そして、vite の開発サーバーは main.ts というファイル名の ES モジュールを返します。この main.ts のコンテンツは JavaScript としても有効なコードにみえます。そして、app.css や App.svelte をインポートしていて、それらのコンテンツもブラウザで取得できていることを通信ログから確認できます。この状態だと main.ts のインポート時か個々のコンポーネントのインポート時かのタイミングがわからないので次のように index.html を書き換えます。

<script type="module">
  import '/src/app.css'
  import App from '/src/App.svelte'
  const app = new App({
    target: document.getElementById('app'),
  })
</script>

vite の開発サーバーを再起動します。

$ npm run dev

先ほどと同様にデモアプリケーションのホーム画面が確認できると思います。

今度はデベロッパーツールの通信ログで App.svelte のレスポンスを確認してみましょう。

ブラウザは css や .svelte ファイルを直接インポートはできません。App.svelte のソースコードの内容と実際にレスポンスで取得している内容は異なります。つまり、ES モジュールをインポートするタイミングで vite-plugin-svelte がフックして App.svelte ファイルが JavaScript のコードにコンパイルされていることがわかります。

次は本番向けビルドも試してみます。dist ディレクトリ配下にビルドした成果物が作られていることがわかります。

$ npm run build
...
dist/index.html                 0.46 kB │ gzip: 0.30 kB
dist/assets/index-w6hcz12F.css  1.27 kB │ gzip: 0.64 kB
dist/assets/index-Tjxc2dTA.js   8.12 kB │ gzip: 3.66 kB
✓ built in 152ms

dist/index.html ファイルの中身をみるとコンパイル済みの JavaScript ファイルを読み込むように置き換わっていることを確認できます。

<script type="module" crossorigin src="/assets/index-Tjxc2dTA.js"></script>

Vite とこれまでのバンドルツールとの違い

これまでのバンドルツールと vite の開発サーバーの違いをまとめます。

前の節で振る舞いを確認したように、あるエンドポイントにリクエストして ES モジュールをインポートするタイミングで vite の開発サーバーはその ES モジュール (とさらに依存するモジュール) を生成します。それがエンドポイントやページ単位で独立していれば、生成するファイル群を減らせるため、開発サーバーの起動や修正の反映するためのビルドを高速にします。

そして Vite を使ったアプリケーション開発も、なるべくエンドポイント単位で独立したモジュールを生成するような形に変わってきています。実際のアプリケーションはさまざまな機能や要件、最適化も関わってくるため、このようなシンプルな構成ではないでしょうが、原理として理解しておくとよいように思います。

Vite プラグインの API

vite-plugin-svelte の個々のフックポイント、load()transform() といった API に svelte のコンパイラの処理が実装されていることを前の節で確認しました。より詳細を調査するときは次のドキュメントを読み解いていく必要があります。Vite は Rollup というバンドルツールを参考にしているようで、プラグイン API などはまず Rollup のプラグインドキュメントを読むように書いてあります。

この中身をみていくと、いまやりたいことから離れていってしまいます。一旦、そういう仕組みがあると知っておいて次の考察へ進みます。

Vite アプリケーションにおけるカスタムテンプレート実装の考察

本題です。なぜ SvelteKit のコンパイルの仕組みを調査しようとしたかというとカスタムテンプレートの機能をどうやって実現するかの設計に活かすためでした。

これまでの調査により、SvelteKit のコンポーネントのコンパイル機能は vite-plugin-svelte という Vite のプラグインが提供していて、Vite が提供するネイティブ ES モジュールを用いたアプリケーション開発のアーキテクチャに沿って実装されたものでした。仮にカスタムテンプレートを SvelteKit のコンポーネントとして実現するのであれば、Vite を介してビルドする必要があります。

そのヒントになることが バックエンドとの統合 というドキュメントにあります。このドキュメントは従来のバックエンド (Rails のようなフルスタックフレームワークなど) のテンプレートと、Vite アプリケーション (Svelte で実装したコンポーネントなど) をどのように連携するかについて説明しています。それぞれのフレームワークやシステム向けにその連携のためのツールも Awesome Vite に列挙されています。

開発向けには直接 vite の開発サーバーへリクエストするように、本番向けには manifest.json ファイルを介して適切な ES モジュール/アセットのパスやオプションなどを設定すればよいようです。この仕組みを応用すれば、提供したい機能をもった SvelteKit アプリと、その SvelteKit アプリをコンポーネントの1つとして扱う CMS またはデザイナーのようなアプリケーションを Vite アプリケーションとして実装すればよいのではないか?という展望が描けます。

manifest.json のサンプル。

{
  "main.js": {
    "file": "assets/main.4889e940.js",
    "src": "main.js",
    "isEntry": true,
    "dynamicImports": ["views/foo.js"],
    "css": ["assets/main.b82dbe22.css"],
    "assets": ["assets/asset.0ab0f9cd.png"]
  },
  "views/foo.js": {
    "file": "assets/foo.869aea0d.js",
    "src": "views/foo.js",
    "isDynamicEntry": true,
    "imports": ["_shared.83069a53.js"]
  },
  "_shared.83069a53.js": {
    "file": "assets/shared.83069a53.js"
  }
}

個々のフロントエンドアプリケーションを1つの機能とみなし、それらを任意に組み合わせて開発できる状態を目指す マイクロフロントエンド という概念があるそうです。UI コンポーネントをモジュール化して再利用することの、もう少しアプリケーション寄りの考え方にもみえます。カスタムテンプレートという、エンドユーザーがデザインやレイアウトを自由に定義できるという要件はマイクロフロントエンドの考え方とも相性がよいのかもしれません。

まとめ

SvelteKit のコンパイルの仕組みを理解できたので今回の調査は一区切りとします。本稿で調査したことをまとめてみます。

  • SvelteKit アプリケーションとは Svelte コンポーネントを Vite でビルドするアプリケーション
    • 実際に Svelte コンパイラを呼び出しているのは vite-plugin-svelte が実装している
    • Svelte コンポーネントや SvelteKit アプリケーションの開発時にビルドの詳細を意識することはない
      • 透過的にコンパイルされるため、ツールの設定ぐらいしかない
  • Vite はブラウザが ES モジュールを扱えることを前提としたビルドツール
    • (いまは考えなくてよいと思うが) ES モジュールをサポートしていないブラウザでは使えない
      • SvelteKit アプリケーションも動作しない
    • プラグイン API は Rollup を参考に機能拡張して実装されている
    • 従来のバックエンドと連携する仕組みも備えている

Svelte コンポーネントデザイナーのような Vite アプリケーションを実際に作れるのかどうか、私自身、やってみないとまだわからないことがたくさんあります。また次回をご期待ください。

リファレンス