mizdra's blog

ぽよぐらみんぐ

Next.js で言語ごとに異なるアセット画像を埋め込む

多言語対応している Web アプリで、あらかじめファイルに書き出しておいた画像 (アセット) が言語別にあって、それをページに埋め込むにはどうするか、という話題です。基本は言語ごとに別々の画像を出すことないと思いますが、たまーにあるんですよね。例えばGoogle Play のバッジが言語ごとに違うので、これを言語ごとに出し分けたいとか。

色々方法があるので、それをいくつか紹介します。

方法1. import 文を言語の数だけ書く

シンプルにやるなら、言語の数だけ import 文を書いて、next/image<Image> コンポーネントで読み込むコードを書くかと思います。

// pages/index.ts
import Image from 'next/image';
import GooglePlayJaJP from '../assets/ja-JP/google-play.png';
import GooglePlayEnUS from '../assets/en-US/google-play.png';
import GooglePlayZhCN from '../assets/zh-CN/google-play.png';
import GooglePlayDeDE from '../assets/de-DE/google-play.png';
import GooglePlayItIT from '../assets/it-IT/google-play.png';
// ...

const GooglePlayMap = {
  'ja-JP': GooglePlayJaJP,
  'en-US': GooglePlayEnUS,
  'zh-CN': GooglePlayZhCN,
  'de-DE': GooglePlayDeDE,
  'it-IT': GooglePlayItIT,
  // ...
} as const

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <Image src={GooglePlayMap[lang]} alt="Google Play" />
    </div>
  );
}

Next.js way に沿っている一方で、言語の数に比例してコード量が増えるのがイマイチですね。

余談: import 文で読み込むと cache-busting パターン対応をやってくれる

import 文を使って画像を import すると、フレームワークのビルドツールが GooglePlay 変数にアセットへのパスを割り当て、<Image src="/assets/google-play.c53d93.png" alt="Google Play /> というコードに変換します (c53d93は content hash)。加えてアセットも <ビルド成果物の出力先ディレクトリ>/google-play./logo.c53d93.png へとコピーします。要は cache-busting パターンに対応した変換をやってくれる訳です。

方法2. public ディレクトリと img タグを使う

Next.js には public ディレクトリという static なファイル置き場があります。例えば public/assets/ja-JP/google-play.png にアセットを置くと、/assets/ja-JP/google-play.png という URL で配信されます。

これを利用すると、言語ごとに異なる画像を全部 public ディレクトリに置き、それを img タグで参照する、というアプローチが取れます。

// pages/index.ts

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <img src={`/assets/${lang}/google-play.png`} alt="Google Play" />
    </div>
  );
}

ただこのように書くと、画像の URL が /assets/ja-JP/google-play.png, /assets/en-US/google-play.png, ... などに固定されてしまいます。これは HTTPキャッシュを使用して不要なネットワーク要求を防ぐ のようにアセットを Cache-Control: max-age=31536000 で長期キャッシュしている場合に問題になります。もしそのように長期キャッシュしている場合、画像が新しい内容に置き換わっても、古いキャッシュがブラウザから使われ続けてしまうため、ユーザからは古い画像が見えたままになってしまいます。

そのため、もしこのアプローチを採用するなら、古いキャッシュが使われないよう工夫しなければいけません。例えば Cache-Control: no-cache で毎回キャッシュが古くなっていないか検証するよう要求するとかですね。

あるいは (雑ですが) アセットの URL の末尾に git の commit hash をつけるとかでも良いと思います。デプロイする度に変わる値なので、デプロイするごとにキャッシュが無効になってしまいますが、まあそれは仕方ないということで。ただ、git の commit hash を環境変数から参照できるようにする仕組みの整備はちょっと面倒だと思います。

// pages/index.ts

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <img src={`/assets/${lang}/google-play.png?${process.env.GIT_COMMIT_HASH}`} alt="Google Play" />
    </div>
  );
}

方法3. import 文を自動生成する

やっぱり Next.js のお作法に則って import 文を使った方法で書きたい、cache-busting パターンにも自動で対応させたい、という人向けの方法です。import 文を自動生成する CLI ツールを作りましょう。

// scripts/generate-i18n-asset-map.ts

// 言語ごとに異なるアセットの map を生成するツール。

const assetPatterns = [
  // 言語ごとに異なるアセットが増えたら、ここに追加してください。
  // プロジェクトのルートディレクトリからの相対パスで書くこと。
  'assets/[lang]/google-play.png',
]
const langs = ['ja-JP', 'en-US', 'zh-CN', 'de-DE', 'it-IT']

import {parse, resolve} from 'path'
import {mkdir, rm, writeFile} from 'fs/promises'

import {pascalCase} from 'change-case'


const rootDir = resolve(__dirname, '..')
const distDir = resolve(rootDir, '__generated__', 'i18n-asset-map')

async function generateI18nAssetMap(assetPattern: string) {
  // name は`'./assets/[lang]/google-play.png'` から `'google-play'` のみ取り出した文字列。
  const {name} = parse(assetPattern)
  const distFilename = resolve(distDir, name + '.ts')

  const importStatements = langs.map(lang => {
    const path = assetPattern.replace('[lang]', lang)
    const identifier = pascalCase(name + lang)
    return `import ${identifier} from '../../${path}'`
  })
  const importMapProps = langs.map(lang => {
    const key = lang
    const value = pascalCase(name + lang)
    return `  '${key}': ${value},`
  })
  const importMapName = pascalCase(name + 'Map')
  const content = `
${importStatements.join('\n')}

const ${importMapName} = {
${importMapProps.join('\n')}
} as const
export default ${importMapName}
  `.trim()

  await writeFile(distFilename, content)
}

// map ファイルの出力先は `__generated__/i18n-asset-map/<拡張子を除いたファイル名>.ts` なので、
// ファイル名が同じ assetPattern が複数あると、同じ場所に出力されてしまう。
// それを防ぐために、assetPattern のファイル名が被っていないかチェックする。
function validateAssetPatterns() {
  const visitedNames = new Set<string>()
  for (const assetPattern of assetPatterns) {
    const {name} = parse(assetPattern)
    if (visitedNames.has(name)) {
      console.error(
        `${name} という名前のファイルの assetPattern が複数検知されました。ファイル名は assetPattern ごとに被らないようにしてください。`
      )
      process.exit(1)
    }
    visitedNames.add(name)
  }
}

async function main() {
  validateAssetPatterns()

  await rm(distDir, {recursive: true, force: true}) // 古いファイルを削除
  await mkdir(distDir, {recursive: true})
  for (const assetPattern of assetPatterns) {
    await generateI18nAssetMap(assetPattern)
  }
}

main().catch(console.error)

これを ts-node scripts/generate-i18n-asset-map.ts で実行すると、以下のようなファイルが生成されます。

// ui/__generated__/i18n-asset-map/google-play.ts
import GooglePlayjaJp from '../../assets/ja-JP/google-play.png'
import GooglePlayenUs from '../../assets/en-US/google-play.png'
import GooglePlayzhCn from '../../assets/zh-CN/google-play.png'
import GooglePlaydeDe from '../../assets/de-DE/google-play.png'
import GooglePlayitIt from '../../assets/it-IT/google-play.png'

const GooglePlayMap = {
  'ja-JP': GooglePlayjaJp,
  'en-US': GooglePlayenUs,
  'zh-CN': GooglePlayzhCn,
  'de-DE': GooglePlaydeDe,
  'it-IT': GooglePlayitIt,
} as const
export default GooglePlayMap

こういう感じで使えます。

// pages/index.ts
import Image from 'next/image';
import GooglePlayMap from '../__generated__/i18n-asset-map/google-play';

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <Image src={GooglePlayMap[lang]} alt="Google Play" />
    </div>
  );
}

<Image> がそのまま使えて、かつ cache-busting パターン対応もやってくれます。良いですね。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.