🥕

【vite】Remix SPAモードにおけるconfig.defineの扱い

2024/08/20に公開

メモがてらなので結構雑書きです

概要

config() {
    define: {
        "window.__CONFIG__": secretsExcludedConfig,
    },
}

と書いたとき、

  • SSRモードではオブジェクトに対して直接書き込みにいくため、windowオブジェクトに__CONFIG__ が生える。
  • SPAモード(静的出力)では、ビルド時に対象文字列と完全一致する箇所を検索し、そのチャンクの最初でdefineした値を定数宣言した後、対象文字列をこの定数に置き換える。そのため、windowオブジェクトに__CONFIG__が生えるわけではない。
    • 定数宣言させるチャンクはその分だけムダにサイズが大きくなるので、たとえ小さな定数だとしても共通モジュールに逃がして読み込みを一度で済ませるのがよさそう

内容

Remix SPA modeのセットアップ中に、

  • 全repositoryにまたがるconfigを管理するファイルから値を取得して使いたい
  • 静的生成する場合configファイルごとimportすると全部見えちゃうので、フロントエンド側に出したくない値をフィルタしたうえでconfigを露出させたい

という需要が生まれたので、ビルド時にフィルタした値をwindow.__CONFIG__ などのグローバルな箇所に貼り付けるやり方を調べていた。

調べていくと、ViteからESbuild由来のdefineを使って変数を置き換える、というアプローチが有効そうなことが判明。
https://esbuild.github.io/api/#define

以下のように書いてみたところ、うまく動いた。

// plugins/expose-configs
/**
 * フロントエンドに露出させるconfigを定義する
 */
export default function exposeConfigs(): PluginOption {
  return {
    name: "expose-configs",
    config() {
      // SSRモード(DEV環境 & SPAのビルド時)はglobalThis.__CONFIG__を経由して参照
      // CSRモード(静的出力後のSPA)はwindow.__CONFIG__ を経由して参照
      globalThis.__CONFIG__ = secretsExcludedConfig;
      return {
        define: {
          "window.__CONFIG__": secretsExcludedConfig,
        },
      };
    },
  };
}
// vite.config.ts

export default defineConfig({
  build: {
    sourcemap: true,
  },
  ...,
  plugins: [
    ..., 
    exposeConfigs(),
  ],
});
// app/utils/config.ts

const CLIENT_CONST = { ... } // このrepository上でのみ使用するconfig

/**
 * configをまとめてapp側で使用する
 */
// SSR時&ビルド時はglobalThisを、CSR時はwindow.__CONFIG__を使用する
const config: Config | Record<string, never> = globalThis.__CONFIG__ || window.__CONFIG__ || {};
const appConfig = _merge({}, config, { configsDefinedInClientApp });

export type AppConfig = typeof appConfig;
export default appConfig;

実際に使ってみたところ不思議な挙動があったので、そのメモ。

SSR時とSPA(静的出力)時で挙動が違う

SSR時: オブジェクトを直接書き変える

SSRモードにおいては、既存オブジェクトに値を代入することでこの挙動を実現していた。

この処理を実際にブラウザのdevtoolから見に行く。
devtools > Sources > Top/localhost:{port}/node_modules/vite/dist/client/env.mjs具体的には以下のような形で文字列をパースし、挿入していることがわかる。

const defines = {
    "window.__CONFIG__": {
        ...
    }
};

Object.keys(defines).forEach((key)=>{
    const segments = key.split(".");
    let target = context;
    for (let i = 0; i < segments.length; i++) {
        const segment = segments[i];
        if (i === segments.length - 1) {
            target[segment] = defines[key];
        } else {
            target = target[segment] || (target[segment] = {});
        }
    }
}

余談だが、key.split(".") からも分かる通り、文字列内のドットを使って掘っていく仕組みのため、これ以外の方法ではうまく動かない。
window[__CONFIG__] に書き換えたらwindowオブジェクトにアクセスできなくなった。

SPA時: 対象文字列が出現するチャンクごとに置換処理を入れる

ビルド後の成果物をpython3 -m http.server かなにかで適当に立ち上げ、ブラウザからdevtoolで中身を見に行く。
devtools > Sources > Top.localhost:{port}

// assets/_index-DV6rnG1k.js

var UT = {
    CONFIG: {
        ...
    },
};

// 中略

const cT = DT
  , GT = globalThis.__CONFIG__ || UT || {} // "window.__CONFIG__" 自体が定数UTに置換される
  , lT = sT({}, GT, {
    CLIENT_CONST: cT
})
  , YT = lT, ...
function HT() { // CONFIGを実際に使用するコンポーネント
    return console.log(YT.CONFIG),
    a.jsxs("div", {
        ...
    })
}

というように、windowの中身を書き換えにいくSSR時の挙動と違って、window.__CONFIG__という文字列自体を置き換えていることがわかる。
当然、この状態ではwindow.__CONFIG__ = undefined になっていた。

対象文字列を複数箇所で登場させるか共通モジュールにまとめるかでファイルサイズが変わる

対象文字列を複数箇所で登場させる

試しに2つのページでwindow.__CONFIG__ を登場させる。

localhost:{port}

localhost:{port}/sample-page

両ページで定数定義がなされている。

共通モジュールにまとめる

localhost:{port}

localhost:{port}/sample-page

2つ目の読み込み時、定数定義がなされていない。

ファイルサイズの比較

前者

後者

サイズに差があるのがわかる。
lazy loadされていればユーザー体験的には大きな影響は出なさそうではあるものの、多くのページで使うような、サイズの大きいconfig系の設定を各ページ読み込みで設定してしまうと、全体で見てもバカにならないサイズになってしまうので気をつけたいところ。

Discussion