この記事は新歓ブログリレー2023 37日目(4/14)の記事です。
こんにちは、19B/22Mの翠(sappi_red)です。SysAd班で活動していました。Viteのチームメンバーだったりもします。
この記事ではViteでの開発中のSSR対応の仕組みがどう実装されているか、どうしてそうなっているかについて紹介します。
前提知識
特筆すべき点のみを取り上げたざっくりとした説明です。
Webフロントエンド
Web上のサービス・アプリケーションのユーザー側に近い開発領域のことを指します。「ボタンを押したときにどうなるか」や「ページ内のどこに何が表示されるか」をプログラムするなど、主にユーザーの端末上で動作するプログラムの部分を指します。
Vite
Webフロントエンド開発において使われているツールの一つです。フロントエンドのプログラムを構成するファイルを各実行環境(ブラウザ、Node.jsなど)が効率よく利用・実行できるように処理をします。処理の内容の例としては、TypeScriptなど別の言語をJavaScriptに変換するトランスパイルや、多数のファイルを少数のファイルにまとめるバンドル(bundle)があります。Viteの特徴としては、バンドルを開発中には行わず、プログラムの配布前にのみ行うことです。
SSR (Server Side Rendering)
ユーザーの端末のブラウザ上で実行される処理のうち、ユーザーに送信する前にサーバー側で実行可能な処理をサーバー側で行っておく、という仕組みをSSRと呼びます。これを行う主な利点は、ユーザーの端末でページが表示されるまでの時間を短縮できることです。
厳密な話をするとViteのSSR対応に関してはSSR対応というよりサーバーサイドへの対応であって、本来のSSRを指していません。本来のSSRは、ブラウザ側で行っていた「HTMLの構築」と「JS側との変数の紐づけ処理」のうち、「HTMLの構築」のみサーバー側で予め行っておき、ブラウザでは「JS側との変数の紐づけ」のみを行うというものです。最後のまとめでちょっと触れるアーキテクチャは、ブラウザで「JS側との変数の紐づけ」すら行わないため、SSRとは少し異なります。
HMR (Hot Module Replacement)
コードを変更した際に影響のある部分だけを局所的に再実行する技術をHMRと呼びます。対照的に、コードを変更した際に全体を再実行する場合はlive reloadやhot reloadと呼ばれます。HMRでは再実行前の変数の値が保持されることが多いのに対して、live reloadでは全体が再実行されるため値が保持されません。
開発中のSSR対応の難しさ
例えば、下のように/src/foo.mjs
と/src/bar.mjs
があるとしましょう。
/src/foo.mjs
export const foo = 'foo' console.log('from foo:', foo)
/src/bar.mjs
import { foo } from './foo.mjs' console.log('from bar:', foo)
この/src/bar.mjs
をnode /src/bar.mjs
で実行すると、
from foo: foo
from bar: foo
と出力されます。
ここで、const transform = content => content.replace("'from foo'", "'from foo2'")
という変換で出力を下のようになるように変えたいとします。
from foo2: foo
from bar: foo
ここで難しいのが、依存関係にあるファイルをすべて読まない限り、どのファイルの変換が必要かがわからない点です。例えば、ここでは/src/bar.mjs
が実行対象になっていますが、'from foo'
という文字列を含んだ/src/foo.mjs
が読み込まれているので、実行前に変換が必要です。
開発時にもバンドルを行うのであれば、バンドルをする際に変換を一緒に行って、代わりにバンドルしたファイルを実行することで実現できます。しかし、Viteは開発時にバンドルを行わないので、そのような手段を取れません。
そのため、import
によってファイルが読み込まれるタイミングで変換を行う必要がありますが、通常はファイルが読み込まれるタイミングで処理を加えることはできません。
Viteでの実装方法
さて、通常行えないと言ったimport
時の変換ですが、いくつか実装方法があります。この節ではViteで利用しているものを紹介します。
Viteでの実装の根幹をなすのはssrTransform
と呼ばれる関数です。この関数はimport
やexport
を特定の名前の関数呼び出しやオブジェクトへの代入に書き換えます。
例えば、先述のfoo.mjs
とbar.mjs
をこの関数で変換すると、それぞれ以下のようになります。
const foo = 'foo';
Object.defineProperty(__vite_ssr_exports__, "foo", { enumerable: true, configurable: true, get(){ return foo }});
console.log('foo.mjs:', foo);
const __vite_ssr_import_0__ = await __vite_ssr_import__("/src/foo.mjs");
console.log('bar.mjs:', __vite_ssr_import_0__.foo);
import
は__vite_ssr_import__
の関数呼び出し、export
は__vite_ssr_exports__
への代入に書き換わりました。
こうすることでこの__vite_ssr_import__
内に行いたい処理を加えることで、あたかもimport
時に処理が行われているような挙動が実現されます。
しかし、このままでは__vite_ssr_import__
が定義されていなく動作しません。そこで登場するのがssrLoadModule
という関数です。この関数は、__vite_ssr_import__
などを適切に定義しながら、渡されたファイルを実行します。ちょっと複雑ですが簡略化すると以下のような処理をしています。
const ssrLoadModule = (filePath: string) => {
// ファイルの読み込み
const originalCode = load(filePath)
// 読み込んだコードの変換 (例: TypeScriptをJavaScriptにする、fooをfoo2に置換する)
const code = transform(originalCode)
// 先述のssrTransformの処理
const ssrCode = ssrTransform(code)
// モジュールオブジェクト
const ssrModule = {
[Symbol.toStringTag]: 'Module',
}
const ssrImport = (path) => {
// filePathからpathを解決する
const absPath = resolve(filePath, path)
return ssrLoadModule(absPath)
}
// 変換したコードの実行
const initModule = new AsyncFunction(
'global',
'__vite_ssr_exports__',
'__vite_ssr_import__',
ssrCode // 変換したコード
)
await initModule(
global,
ssrModule,
ssrImport,
)
return ssrModule
}
ここでAsyncFunction
は非同期関数のコンストラクタです。例えばfoo.mjs
の変換結果でいうと、「変換したコードの実行」の部分は以下のような処理になります。
const initModule = async (global, __vite_ssr_exports__, __vite_ssr_import__) => {
const foo = 'foo';
Object.defineProperty(__vite_ssr_exports__, "foo", { enumerable: true, configurable: true, get(){ return foo }});
console.log('from foo2:', foo);
}
await initModule(
global,
ssrModule,
ssrImport,
)
このようにimport
の書き換えとAsyncFunction
コンストラクタの利用によって実現されています。
ほかの実装方法
Vite内部では利用していませんが、ほかの実装方法もあります。
Node.js loaders
import
時に行う処理を変える方法としてloadersというものをNode.jsが提供しています (require
に関してはNode.jsの内部的な関数を書き換えると実現できます。)。ts-node
やtsx
やtsm
などTypeScriptをそのまま実行できる感じにするパッケージはこれを利用していることが多いです。
loadersのresolve
フックとload
フックを利用すると同じようなことが実現できます (Node.jsのドキュメントにある参考例)。
この仕組みはVitestでも一部使われています。外部化した(=Viteでの処理をスキップした)モジュールについて、Vitestのdeps.registerNodeLoader
オプションを有効化すると、モジュール解決だけはViteで行うようになります。この際に、前述したloadersが利用されます。
また、ViteのssrTransform
とssrLoadModule
の代わりにloadersを活用する試みがvaviteというツールで実装されています (Node ESM loader #17 - cyco130/vavite)。
loadersを使うメリットは、import
の書き換えが不要なことでパフォーマンスが向上する点とNode.jsでのsourcemap対応の恩恵を得られる点です。しかし、loadersはNode.jsでしか利用できずdenoやbunでの対応がされるかは不明瞭であり、Viteの方法に比べると柔軟性に劣ります。さらに、新しいファイルを読み込みなおしたときに前のファイルがメモリから消えないので、メモリリークしてしまいます。
メモリリークが回避できない理由
JavaScriptではimport
したファイルは同じインスタンスを指すので、メモリから消してしまうと、その実現ができないからだと考えています。
例えば、下のコードで2回目のimport('./foo.mjs')
の前に./foo.mjs
の情報をメモリから消してしまうと最後の行での出力が100
ではなくundefined
になってしまいます。
const { foo } = await import('./foo.mjs')
console.log(foo.obj.v) // undefinedが返るとする
foo.obj.v = 100
await new Promise(resolve => setTimeout(resolve, 1000)) // 1秒待つ
const { foo: foo2 } = await import('./foo.mjs')
console.log(foo2.obj.v) // 100 が返る
モジュールの情報を消すような関数があれば実現できそうですが、セマンティックスが難しくなるので、実装されないでしょう。
node:vm
の利用
Viteでの実装ではAsyncFunctionのコンストラクタを利用しましたが、その代わりにnode:vm
のrunInThisContext
関数を利用することもできます。
この方法はvite-node
で利用されています。vite-node
はVitestとNuxtで利用されています。
なお、現状、node:vm
はdenoとbunにサポートされていません(denolang/deno#13239、Node.js - Ecosystem | Bun Docs)。
今後について
現在ViteのSSRの開発時はHMR APIが実装されていません(vitejs/vite#7887)。一方でvite-nodeではHMR APIが実装されています。また、Viteとvite-nodeの両方で似た機能を持つのはメンテナンスの観点から好ましくありません。
これらの点からvite-nodeをViteに取りこもうという動きがあります(vitejs/vite#12165)。そこそこ変更が大きいこととVitestからの要求もあるためまだどうなるかはわかりませんが、うまくいけばVite 5ではマージされると思います。
まとめ
Islands ArchitectureやReactのServer Componentsなどブラウザでの処理を減らすようなアーキテクチャが近年注目されており、サーバー側での処理の重要性が増しています。ViteのSSR対応に関しても改善を続けていきたいです。
この辺で気になることがあったら、ぼくが居たら相談会で雑に聞いてもらっても大丈夫です (月木の午後はたぶん居ます)。あとはtraPに入ってtraQの#random/sodanで投稿するのもありです。
明日の担当は @mehm8128 さんです。お楽しみに!