Frontend & i18n

【フロントエンド多言語化】Vanilla JSとi18nextで実現するSPA/PWAの多言語(i18n)対応と動的テキスト管理

ReactやVueに依存せず、Vanilla JSベースのブラウザゲームにおいて軽量なi18nアーキテクチャを構築。PWAのインストール促すダイアログや動的生成通知を含めて完全多言語化した開発知見をまとめました。

1. 背景:グローバル展開とフレームワークレスの挑戦

『Doodle Fighter』は「描いた絵がAIによって戦士化する」という非言語的な面白さを持つWebゲームです。海外アクセスが増加する中で、ランディングページだけでなくゲーム画面、設定モーダル、さらにはPWA(Progressive Web App)の各種通知ダイアログを含めた包括的な多言語対応(i18n)が必須となりました。

一方で、本作はパフォーマンスと読み込み速度を最優先するため、ReactやVue等の大型SPAフレームワークを使用せず、軽量なVanilla JS構造で構築しています。本記事では、Vanilla JS環境で静的DOM・動的DOMの双方を漏れなく多言語化する手法について解説します。

2. i18nアーキテクチャ設計とi18nextの統合

多言語マネージャー(i18n.js)を作成し、非同期でJSON形式の言語リソース(ja.json / en.json)を取得してキャッシュ・適用する設計を採用しました。

国際化モジュール (frontend/static/js/i18n.js)

// 国際化モジュール (i18n.js)
let currentLang = localStorage.getItem('app_lang') || 
    ((navigator.language || 'ja').startsWith('ja') ? 'ja' : 'en');

const resources = {};

async function loadLocale(lang) {
    if (resources[lang]) return resources[lang];
    try {
        const res = await fetch(`/static/locales/${lang}.json?v=${Date.now()}`);
        if (res.ok) {
            resources[lang] = await res.json();
            return resources[lang];
        }
    } catch (e) {
        console.error(`Failed to load locale for ${lang}:`, e);
    }
    return null;
}

export function t(key, defaultVal = '') {
    const lang = window.i18next ? window.i18next.language : currentLang;
    if (resources[lang] && resources[lang][key]) {
        return resources[lang][key];
    }
    return defaultVal || key;
}
window.t = t;

export function updateUI() {
    const lang = window.i18next ? window.i18next.language : currentLang;
    document.querySelectorAll('[data-i18n]').forEach(el => {
        const key = el.getAttribute('data-i18n');
        if (resources[lang] && resources[lang][key]) {
            el.textContent = resources[lang][key];
        }
    });
}

HTML上の静的要素には data-i18n="キー名" 属性を付与することで、言語変更時に updateUI() を一度呼ぶだけで一元的にテキストが差し替わります。

3. 動的生成UI(PWAインストール促すダイアログ等)でのハマり所と解決策

開発中に直面した課題が、JavaScriptで動的に生成される要素(PWAのインストール促進スナックバーやアプリアップデート案内)の文言が一部日本語のままハードコードされていた点です。

問題のコード(修正前)

// app.js (修正前:タイトルと説明文が日本語固定)
function showInstallPromotion() {
    const snackbar = document.createElement('div');
    snackbar.innerHTML = `
        <h4 class="font-bold">アプリとしてインストール</h4>
        <p class="text-xs">ホーム画面に追加すると、全画面でサクサク遊べるようになります!</p>
        <button id="pwa-install-btn">${t('pwa_install_btn', 'インストールする')}</button>
    `;
    document.body.appendChild(snackbar);
}

解決策:t() と data-i18n のダブル指定

JavaScriptテンプレート文字列内で ${t('キー', 'デフォルト')} を用いて生成時の言語を反映させつつ、後から言語切替が行われた場合にも対応できるよう data-i18n 属性も同時付与しました。

// app.js (修正後:多言語化対応済み)
function showInstallPromotion() {
    if (document.getElementById('pwa-install-snackbar')) return;

    const snackbar = document.createElement('div');
    snackbar.id = 'pwa-install-snackbar';
    snackbar.innerHTML = `
        <div class="flex items-start justify-between gap-3">
            <div class="flex-grow">
                <h4 class="font-bold" data-i18n="pwa_install_title">${t('pwa_install_title', 'アプリとしてインストール')}</h4>
                <p class="text-xs" data-i18n="pwa_install_desc">
                    ${t('pwa_install_desc', 'ホーム画面に追加すると、全画面でサクサク遊べるようになります!')}
                </p>
            </div>
        </div>
        <button id="pwa-install-btn" data-i18n="pwa_install_btn">
            ${t('pwa_install_btn', 'インストールする')}
        </button>
    `;
    document.body.appendChild(snackbar);
}

4. 更新告知文言の汎用化設計

また、「アプリアイコン・設定の更新」のように特定の過去変更に依拠していた告知文言を、特定のリリースに依存しない汎用的な文言(アプリの更新のお知らせ / App Update Available)へと統一しました。これにより、継続的なバージョンアップのたびに文言を書き換えるコード変更コストを削減できます。

5. 将来の機能追加・拡張における多言語化漏れを防ぐ自動テスト(Playwright)

個人開発で継続的に機能開発を進める中で、新たなUIやモーダルを追加した際に「多言語キーの登録を忘れて日本語が露出する」「言語切替時に動的コンポーネントの表示が崩れる」といった回帰バグ(デグレ)の発生が懸念されます。

こうした今後の機能拡張時における抜け漏れを自動で未然に防ぐため、Headlessブラウザテストツールである Playwright を導入し、E2Eテストによる品質ガードレールを構築しました。

Playwright E2E テストコード (tests/e2e/i18n-pwa.spec.js)

// tests/e2e/i18n-pwa.spec.js
import { test, expect } from '@playwright/test';

test('PWA install prompt translates correctly in English environment', async ({ page }) => {
    // 1. 英語ロケール状態をセットしてページへアクセス
    await page.addInitScript(() => {
        localStorage.setItem('app_lang', 'en');
    });
    await page.goto('/play.html');

    // 2. beforeinstallprompt イベントをブラウザ上で偽装発火
    await page.evaluate(() => {
        const event = new Event('beforeinstallprompt');
        window.dispatchEvent(event);
    });

    // 3. PWAスナックバーの出現待機と多言語キーのテキスト検証
    const snackbar = page.locator('#pwa-install-snackbar');
    await expect(snackbar).toBeVisible({ timeout: 5000 });
    
    // タイトル・ボタンテキストが英語辞書通りにレンダリングされているか検証
    await expect(snackbar.locator('[data-i18n="pwa_install_title"]')).toHaveText('Install as an App');
    await expect(snackbar.locator('#pwa-install-btn')).toHaveText('Install Now');
});

test('Dynamic language selector updates UI in real-time', async ({ page }) => {
    await page.goto('/');
    
    // 言語セレクトボックスで 'en' を選択
    await page.selectOption('#language-select', 'en');

    // 静的データ属性要素の自動置換をチェック
    await expect(page.locator('[data-i18n="hero_title"]')).toHaveText('Your Doodle Turns into a Warrior.');
});

Playwrightを用いることで、PWA特有の beforeinstallprompt イベントのテストやロケール切り替え時のDOM再描画アサーションを自動化。将来的な新機能の追加時にも、辞書の定義漏れやハードコードの先祖返りを確実に検知して防止できる体制を確立しました。

6. まとめ

フレームワーク非依存のVanilla JS開発においても、t() ヘルパーと data-i18n 宣言的属性を組み合わせることで、静的テキストと動的UIの両方を高い保守性で多言語化できます。

さらに Playwright によるE2E自動テストを導入したことで、今後の機能拡張時にも翻訳漏れや表示崩れの心配なく、安心してスピード感のある個人開発を継続できる基盤が整いました。