feature image

2023年4月14日 | ブログ記事

Viteでの開発中のSSR対応の仕組み

この記事は新歓ブログリレー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/bar.mjsnode /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と呼ばれる関数です。この関数はimportexportを特定の名前の関数呼び出しやオブジェクトへの代入に書き換えます。

例えば、先述のfoo.mjsbar.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-nodetsxtsmなどTypeScriptをそのまま実行できる感じにするパッケージはこれを利用していることが多いです。

loadersのresolveフックとloadフックを利用すると同じようなことが実現できます (Node.jsのドキュメントにある参考例)。

この仕組みはVitestでも一部使われています。外部化した(=Viteでの処理をスキップした)モジュールについて、Vitestのdeps.registerNodeLoaderオプションを有効化すると、モジュール解決だけはViteで行うようになります。この際に、前述したloadersが利用されます。

また、ViteのssrTransformssrLoadModuleの代わりにloadersを活用する試みがvaviteというツールで実装されています (Node ESM loader #17 - cyco130/vavite)。

loadersを使うメリットは、importの書き換えが不要なことでパフォーマンスが向上する点とNode.jsでのsourcemap対応の恩恵を得られる点です。しかし、loadersはNode.jsでしか利用できずdenobunでの対応がされるかは不明瞭であり、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:vmrunInThisContext関数を利用することもできます。

この方法はvite-nodeで利用されています。vite-nodeはVitestとNuxtで利用されています。

なお、現状、node:vmはdenoとbunにサポートされていません(denolang/deno#13239Node.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 ArchitectureReactのServer Componentsなどブラウザでの処理を減らすようなアーキテクチャが近年注目されており、サーバー側での処理の重要性が増しています。ViteのSSR対応に関しても改善を続けていきたいです。

この辺で気になることがあったら、ぼくが居たら相談会で雑に聞いてもらっても大丈夫です (月木の午後はたぶん居ます)。あとはtraPに入ってtraQの#random/sodanで投稿するのもありです。


明日の担当は @mehm8128 さんです。お楽しみに!

sappi_red icon
この記事を書いた人
sappi_red

19B/22M。SysAd班。 JavaScript書いたりTypeScript書いたりGo書いたりRust書いたり…

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2023年4月17日
ポケモンを飼いたい夢を叶える
tqk icon tqk
2023年12月11日
DIGI-CON HACKATHON 2023『Mikage』
toshi00 icon toshi00
2023年4月25日
【驚愕】作曲4年目だった男が大学3年間ゲームサウンドに関わった末路...【ゲームサウンドのお仕事について】
tenya icon tenya
2023年3月20日
traPグラフィック班の活動紹介(Ver.2023)
NABE icon NABE
2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記