前提
個人開発で「資格みっけ」という診断アプリを作っている。Expo / React Native + TypeScript で書いて、iOS版はEAS Build、Web版はViteでビルドしてGitHub Pagesに置いている。コードベースは1つ。
このアプリは資格スクールの紹介リンクを載せている。A8.netのアフィリエイトリンクで、アプリ用とWeb用でメディア登録が別になっている。同じスクールでもリンクのURLパラメータが違う。
ここが厄介だった。A8.netは、登録したメディア以外でリンクを使うことを認めていない。Web用のページにアプリ用のリンクが出ていたら規約違反になる。成果が計上されないどころか、アカウントごと止まる可能性がある。
つまり「たまにバグって違うリンクが出る」が許されない。ゼロにしないといけない。
最初の設計と、その問題
素直に書くとこうなる。
// affiliate.ts
export const APP_AFFILIATE_URLS: Record<string, string> = {
'care-shoninsha': 'https://px.a8.net/.../APP_MEDIA_PARAMS',
'food-creator': 'https://px.a8.net/.../APP_MEDIA_PARAMS',
};
export const WEB_AFFILIATE_URLS: Record<string, string> = {
'care-shoninsha': 'https://px.a8.net/.../WEB_MEDIA_PARAMS',
'food-creator': 'https://px.a8.net/.../WEB_MEDIA_PARAMS',
};
export function getAffiliateUrl(id: string, platform: 'app' | 'web') {
const map = platform === 'web' ? WEB_AFFILIATE_URLS : APP_AFFILIATE_URLS;
return map[id];
}
一見問題なさそうに見える。プラットフォームで分岐しているし、Web版では WEB_AFFILIATE_URLS しか参照されない。
ただ、これはViteでビルドしたときに APP_AFFILIATE_URLS の中身が成果物に残る。
理由は2つある。
1つ目。map[id] が動的アクセスになっている。バンドラはどのキーが使われるか静的に判断できないので、オブジェクト全体を残す。
2つ目。分岐の条件が関数の引数になっている。呼び出し側を全部たどって「webしか渡らない」と証明できないと、両方のブランチが残る。
書き方を工夫すれば消える構成もある。モジュールトップレベルの定数を define で置換して、参照を1本に潰す、といったやり方はある。
ただ、そこを狙いにいくのはやめた。消えるかどうかがバンドラの最適化に依存する時点でアウトだと判断した。Viteのバージョンを上げるたび、importを1本足すたびに、成果物を目視で確認し続けるのは現実的じゃない。
変えた設計
3つに分けた。
1. データ本体からURLを追い出す
資格マスタの licenses.ts には、URLを一切書かないルールにした。持つのは id と表示用の情報だけ。
// licenses.ts
export const licenses = [
{
id: 'care-shoninsha', // このidは絶対に変えない
name: '介護職員初任者研修',
category: 'welfare',
// URLは持たない
},
];
id はアフィリエイトURLマップ、公式サイトURLマップ、すべてのキーになる。ここを変えると全部が黙って壊れるので、変更禁止としてドキュメントに残してある。
2. メディアごとにファイルを分けて、相互参照を禁止する
src/data/
licenses.ts … URLを持たない資格マスタ
affiliate-app.ts … アプリ用リンクのみ
affiliate-web.ts … Web用リンクのみ
official-urls.ts … 公式サイトURL(アフィリエイトなし)
affiliate-app.ts と affiliate-web.ts は、お互いを絶対にimportしない。共通化したくなるユーティリティがあっても、片方がもう片方を経由して引っ張られる経路を作らない。
エントリポイントで分ける。Web側のエントリから affiliate-app.ts への参照経路が存在しなければ、そもそもバンドルに入りようがない。「最適化で消える」ではなく「最初から入らない」に持っていく。
ESLintでも縛った。
{
"overrides": [
{
"files": ["src/web/**/*.ts", "src/web/**/*.tsx"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [{
"group": ["**/affiliate-app*"],
"message": "Web側からアプリ用アフィリエイトモジュールは参照禁止"
}]
}]
}
}
]
}
3. 表示ロジックにURLを持ち込まない
「この資格は象タイプにだけ出す」といった出し分けが必要になったとき、URLマップを見に行く実装にしがちだった。そこで表示条件だけを持つモジュールを別に切った。
// promotions.ts
type Promotion = {
id: string;
types: 'all' | string[]; // 'all' か、対象の診断タイプ配列
};
export function isPromotionForType(promo: Promotion, type: string): boolean {
return promo.types === 'all' || promo.types.includes(type);
}
このファイルはURLマップを一切importしない。判定と、リンク解決を、依存関係のレベルで切り離す。
最後の砦:ビルド成果物を検査する
設計とLintで防いでも、それは「そう書いたつもり」でしかない。最終的には出来上がったJSを見るのが一番確実だった。
デプロイ前に成果物を走査して、アプリ用リンクの識別子が含まれていたらビルドを落とす。
// scripts/check-web-links.ts
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
const DIST = path.join(process.cwd(), 'dist');
// 実際のIDは伏せているが、アプリ用リンクにだけ現れる文字列を指定する
const APP_ONLY_MARKERS = ['XXXXXX+XXXXXX'];
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
return statSync(full).isDirectory() ? walk(full) : [full];
});
}
const hits: string[] = [];
for (const file of walk(DIST)) {
if (!/\.(js|html|css)$/.test(file)) continue;
const content = readFileSync(file, 'utf8');
for (const marker of APP_ONLY_MARKERS) {
if (content.includes(marker)) {
hits.push(`${path.relative(DIST, file)}: ${marker}`);
}
}
}
if (hits.length > 0) {
console.error('アプリ用リンクがWeb成果物に混入しています:');
hits.forEach((h) => console.error(` - ${h}`));
process.exit(1);
}
console.log('check-web-links: OK');
package.json でデプロイの前段に噛ませる。
{
"scripts": {
"build:web": "vite build",
"check:web-links": "tsx scripts/check-web-links.ts",
"predeploy:web": "npm run build:web && npm run check:web-links",
"deploy:web": "node scripts/deploy.mjs"
}
}
これで、設計を崩す変更が入っても、デプロイの手前で止まる。
小さい話だが、パス結合は全部 path.join にしている。開発環境がWindows / PowerShellなので、'dist' + '/' + name みたいな書き方をすると後で踏む。
まとめ
- Viteは「importしたモジュールは基本入る」と考えたほうが安全。動的なオブジェクトアクセスと、引数による分岐は、tree-shakingが効かない典型パターン
- 消えることを期待するより、参照経路を作らないほうが確実
- 規約違反や金銭が絡む部分は、レビューや注意力ではなく、ビルドを落とす仕組みで守る
- 成果物そのものを検査する工程を1つ入れておくと、設計が崩れたときに気づける
同じような「同一コードベースで出し分ける、でも混ぜたら困るものがある」ケースの参考になれば。