【vite】Remix SPAモードにおけるconfig.defineの扱い
メモがてらなので結構雑書きです
概要
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
を使って変数を置き換える、というアプローチが有効そうなことが判明。
以下のように書いてみたところ、うまく動いた。
// 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