Expo + React Native のプロジェクトでi18n-jsを使って多言語化対応

スマホアプリを作るなら、世界のマーケットを攻めた方がコストパフォーマンスが良いでしょう。日本のスマホユーザーだけを相手にするより、世界中に向けたほうが当然マーケットが大きいからです。

世界に向けてアプリを出す場合、多言語化対応は面倒ですが必須となります。

expo-localization を使ってアプリの言語をローカライズ(i18n対応)する

はじめに expo-localization をインストールします。

shell
npx expo install expo-localization

国際化対応するためのファイルを作成します。

shell
mkdir -p functions/i18n
shell
touch functions/i18n/i18n.ts

i18n.ts に以下のように書いてみましょう。

i18n.ts
import { getLocales } from 'expo-localization';

export const deviceLanguage = getLocales()[0].languageCode;
console.log('deviceLanguage', deviceLanguage);  /ja と表示される

端末の言語情報が取得できます。日本語の場合は ja です。

以下のドキュメントが参考になります。

i18n-jsで翻訳する

i18n パッケージをインストールする。

shell
npx expo install i18n-js

i18n.ts を以下のように編集すると、「こんにちは」というログが出力されます。

i18n.ts
import { getLocales } from 'expo-localization';
import { I18n } from 'i18n-js';

// Set the key-value pairs for the different languages you want to support.
export const i18n = new I18n({
  en: { welcome: 'Hello' },
  ja: { welcome: 'こんにちは' },
});

// Set the locale once at the beginning of your app.
export const deviceLanguage = getLocales()[0].languageCode;
i18n.locale = deviceLanguage;

console.log(i18n.t('welcome'));

Localization.locale は ja-US(言語 + 地域) のような文字列になる

上の例では languageCode を使っていますが、 公式ドキュメントには Localization.locale で言語設定を取得するような例があります。 localeだと ja-US のように、言語 + 地域の情報が取得されてしまいます。

i18n.ts
import * as Localization from 'expo-localization';
import { I18n } from 'i18n-js';

// Set the key-value pairs for the different languages you want to support.
const i18n = new I18n({
  en: { welcome: 'Hello' },
  ja: { welcome: 'こんにちは' },
});

// Set the locale once at the beginning of your app.
export const deviceLanguage = Localization.locale;
i18n.locale = deviceLanguage;

console.log(i18n.t('welcome'));

上の例だと LOG [missing "ja-US.welcome" translation] というエラーが出ます。なぜなら、 jaja-US がマッチしないからです。

fallback language を設定する

設定されていない言語が端末に設定されていた場合、デフォルトで設定した言語にするような設定ができます。

以下の例では、あえて ja を設定ファイルから消しています。シミュレーターの言語は ja ですが、設定ファイルにないので、翻訳できません。

そんなときに、 デフォルトで設定したen で翻訳してもらえれば助かります。最終的に以下の i18n.ts を用意すれば良さそうです。

i18n.ts
import { getLocales } from 'expo-localization';
import { I18n } from 'i18n-js';

export const i18n = new I18n({
  en: { welcome: 'Hello' },
  ze: { welcome: 'こんにちは' },
});

i18n.locale = getLocales()[0].languageCode;

i18n.enableFallback = true;
i18n.defaultLocale = 'en';

コンポーネント側で利用する

以下のように {i18n.t('welcome')} とすればOKです。

Sample.tsx
<Text style={{ fontFamily: 'VarelaRound_400Regular', fontSize: 50 }}>
        {i18n.t('welcome')}
</Text>

i18n.t に変数を渡して、動的に翻訳メッセージを変更する方法

「◯◯を削除しますか?」の 「◯◯」 の部分に変数で動的に値を指定したいケースもあるでしょう。以下のようにします。

i18n.tsx
`${i18n.t('deleteAlertDescriptionWithName', { name: `${repeatTask.name}` })}`

i18n.t の第2引数に変数を渡せばOKです。

翻訳を定義しているオブジェクトには以下のようにします。

i18n.json
deleteAlertDescriptionWithName: 'Is it okay to delete {{name}}?',

{{}} で渡す変数名を囲ってあげればOKです。

Unable to resolve “make-plural” from “node_modules/i18n-js/dist/import/Pluralization.js”

shell
iOS Bundling failed 
Unable to resolve "make-plural" from "node_modules/i18n-js/dist/import/Pluralization.js"

2023年8月に突然、上記のようなエラーが出ましたが、 i18n-js のバージョンを 4.3.0 から 4.2.0 にダウングレードしたら直りました。

package.json
"i18n-js": "4.2.0"

ダウングレードせずに解決するには、 metro.config.js を以下のように修正すればOKです。

metro.config.js
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require('expo/metro-config');

/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname, {
  // [Web-only]: Enables CSS support in Metro.
  isCSSEnabled: true,
});

// Adds support for `mjs` files
config.resolver.sourceExts.push('mjs');

module.exports = config;

その他、国際化に必要そうな設定まとめ

数字のカンマの区切り方や $ , ¥ などの違いも国際化対応には必要です。日付の表示の仕方も異なるので、それぞれフォーマッタがあったほうがいいです。

i18n.ts
import { getLocales } from 'expo-localization';
import {I18n} from "i18n-js";

export const i18n = new I18n({
  en: { welcome: 'Hello' },
  ja: { welcome: 'こんにちは' },
});

export const languageTag = getLocales()[0].languageTag;
export const languageCode = getLocales()[0].languageCode;
export const numericFormatter = new Intl.NumberFormat(languageTag);
export const dateTimeFormatter = new Intl.DateTimeFormat(languageTag);

export const currencyCode = getLocales()[0].currencyCode;
export const currencyFormatter = new Intl.NumberFormat(languageTag, {
  style: 'currency',
  currency: currencyCode ?? 'USD',
});

export const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;

i18n.locale = languageCode;
i18n.enableFallback = true;
i18n.defaultLocale = 'en';

i18n の定義に型を設定してちょっと安全につかう

言語間で翻訳漏れを防ぐために、型を設定します。

コピーしました!

i18nTypes.ts
export interface TextDefinition {
  common: {
    save: string;
    selectDate: string;
  };
}

export interface Translations {
  en: TextDefinition;
  ja: TextDefinition;
}

翻訳ファイルを作成します。 自分は mkdir functions/i18n/translatios 以下に各言語の翻訳ファイルを作成しています。

コピーしました!

shell
touch functions/i18n/translatios/en.ts
touch functions/i18n/translatios/ja.ts
touch functions/i18n/translatios/index.ts

各ファイルに以下のように書きます。

コピーしました!

各ファイル.ts
// ja.ts

import { type TextDefinition } from '@/functions/i18n/i18nTypes';

export const ja: TextDefinition = {
  common: {
    save: '保存',
    selectDate: '日付を選択',
  },
};

// en.ts

import { type TextDefinition } from '@/functions/i18n/i18nTypes';

export const en: TextDefinition = {
  common: {
    save: '保存',
    selectDate: '日付を選択',
  },
};

// index.ts
import { type Translations } from '@/functions/i18n/i18nTypes';
import { ja } from '@/functions/i18n/translations/ja';
import { en } from '@/functions/i18n/translations/en';

export const translations: Translations = {
  ja,
  en,
};

functions/i18n/i18n.ts に以下のように記載します。

コピーしました!

i18n.ts
import { getLocales, getCalendars } from 'expo-localization';
import { I18n } from 'i18n-js';
import { translations } from '@/functions/i18n/translations';

export const i18n = new I18n();
export const deviceLanguage = getLocales()[0]?.languageCode;
const { timeZone } = getCalendars()[0];
export const deviceTimeZone = timeZone ?? 'Asia/Tokyo';

const fallbackLanguage = 'ja';

i18n.locale = deviceLanguage ?? fallbackLanguage;

// ここで型付きの翻訳ファイルを設定
i18n.translations = translations;

i18n のキーにも型を設定して、i18nをラップしてキーの打ち間違いを防ぐ

コピーしました!

i18nType.ts
export interface TextDefinition {
  common: {
    complete: string;
  };
}

export interface Translations {
  en: TextDefinition;
  ja: TextDefinition;
}

type DotNotation<T, Prefix extends string = ''> = {
  [K in Extract<keyof T, string>]: T[K] extends object
    ? DotNotation<T[K], `${Prefix}${K}.`>
    : `${Prefix}${K}`;
}[Extract<keyof T, string>];

export type TranslationKeys = DotNotation<TextDefinition>;

i18n.ts に i18n をラップした translate 関数を作ります。

コピーしました!

i18n.ts
import { getLocales, getCalendars } from 'expo-localization';
import { I18n, type TranslateOptions } from 'i18n-js';
import { translations } from '@/functions/i18n/translations';
import { type TranslationKeys } from '@/functions/i18n/i18nTypes';

export const i18n = new I18n();
export const deviceLanguage = getLocales()[0]?.languageCode;
const { timeZone } = getCalendars()[0];
export const deviceTimeZone = timeZone ?? 'Asia/Tokyo';

const fallbackLanguage = 'ja';

i18n.locale = deviceLanguage ?? fallbackLanguage;

i18n.translations = translations;

export function translate(key: TranslationKeys, options?: TranslateOptions): string {
  return i18n.translate(key, options);
}

使う側は以下のように translate 関数を呼び出して使います。

コピーしました!

sample.tsx
<Text>{translate('common.complete')}</Text>

ここの common.complete の部分が予測変換で出てくるし、型チェックしてくれるので、キーの部分(i18n の Scope)を打ち間違えることもなくなりました。