🌏

Next.jsでi18nをやるんだが、言語ごとの翻訳ファイルを作りたくない

2022/08/07に公開

TL;DR

こんな感じの、翻訳テキストがIDごとに1箇所にまとまったファイルでi18n対応してみました。

translationTexts.js
export default {
	// オブジェクトのkeyがテキストID的に働く
	person: {
		// IDの入れ子可能
		name: {
			en: 'Name',
			ja: '名前'
		},
		// 各言語で同じテキストを使う場合は言語コードを指定しないでもOK
		hp: 'HP',
		// 入れ子の階層は任意
		status: {
			power: {
				en: 'power',
				ja: '力'
			},
			speed: {
				en: 'speed',
				ja: '素早さ'
			}
		},
		// 関数でもOK
		money: {
			en: (num:number) => `$ ${num}`,
			ja: (num:number) => `${num}`,
		},
		// 配列を使用可能
		parts: [{
			en: 'head',
			ja: '頭'
		},{
			en: 'body',
			ja: '体'
		}],
	}
}

背景

Nextjs i18n とかでググるとたくさんの有益情報がHitするんですが、そのどれもが
「それでは、言語ごとの翻訳ファイルを作っていきましょう」
みたいな感じでした。

なんとなくそっちの方がちゃんとしてるのはわかる気がするんですが、僕みたいに一人で小規模のものを作ってる場合には、こっちの構造の方が見通し良くて好きだな〜と思っての蛮行です。

手順

サブパスルーティングを設定

next.config.js
module.exports = {
// ...中略
  i18n: {
    locales: ["en", "ja"],
    defaultLocale: "ja",
  },
}

これで、
https://example.com/およびhttps://example.com/jaではja版のページを
https://example.com/enではen版のページを表示する準備が整いました。

翻訳ファイルを作成

記事冒頭のやつです。再掲します。

translationTexts.js
export default {
	// オブジェクトのkeyがテキストID的に働く
	person: {
		// IDの入れ子可能
		name: {
			en: 'Name',
			ja: '名前'
		},
		// 各言語で同じテキストを使う場合は言語コードを指定しないでもOK
		hp: 'HP',
		// 入れ子の階層は任意
		status: {
			power: {
				en: 'power',
				ja: '力'
			},
			speed: {
				en: 'speed',
				ja: '素早さ'
			}
		},
		// 関数でもOK
		money: {
			en: (num:number) => `$ ${num}`,
			ja: (num:number) => `${num}`,
		},
		// 配列を使用可能
		parts: [{
			en: 'head',
			ja: '頭'
		},{
			en: 'body',
			ja: '体'
		}],
	}
}

useTranslations hookを作成

useTranslations.ts
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import texts from "../constants/translationTexts"

export const useTranslations = () => {
	const { locale } = useRouter()

	const result = useMemo<{[key:string]:any}>(() => {
		const t = locale || 'ja'
		// localeが一致するデータを再帰的にpick
		const cherryPick = (obj: {[key:string]:any}|any[], key:string):{}|[] => {
			if(Array.isArray(obj)) {
				return obj.map(o => cherryPick(o, key))
			}
			else if(typeof obj === 'object') {
				if(key in obj) return obj[key]
				return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, cherryPick(v, key)]))
			}
			return obj
		}
		return cherryPick(texts, t)
	}, [locale])
	return { locale, t:result };
}

使用

import { useTranslations } from 'useTranslations'

const Person = () => {
  const { t:{person} } = useTranslations()
  
  return (<>
    <p>{person.name}</p>
    <p>{person.hp}</p>
    <p>{person.status.power}</p>
    <p>{person.status.speed}</p>
    <p>{person.money(100)}</p>
    <ul>{person.parts.map((t:string)=>(<li>{t}</li>))}</ul>
  </>)
}
export default Person

おまけ

言語切り替えリンク

import { useRouter } from 'next/router';

const LanguageSelector = () => {
	const router = useRouter()
	return (<>
		<Link href={router.asPath} locale='ja'>JP</Link>
		<span> | </span>
		<Link href={router.asPath} locale='en'>EN</Link>
	</>)
}
export default LanguageSelector

Discussion