🌐

i18n with zero deps

2023/10/21に公開

Web Appのi18n対応のためにデカいライブラリを入れなくても、サクッとJSの機能だけで実現できるよという紹介です。
今回紹介するものは以下の stackblitz からも確認できます。

https://stackblitz.com/edit/stackblitz-starters-hobpey?embed=1&file=src%2Flocales%2Fuse-locale.ts

想定するユースケース

例えばReactでWeb Appを実装する際、こんな感じで辞書から言語別で文言を引いてきて、表示できるようなカスタムフックがあると便利です。

// 辞書: { ja: { GREETING: "こんにちは。" } }

const { t } = useLocale("ja");
return <div>{t.GREETING}</div> // <div>こんにちは。</div>

ただ、これだけだとあらかじめ辞書に用意した文言しか表示できないので、最低限の機能として渡した値を埋め込むような関数も用意したくなります。
そこで以下のように、第一引数に辞書のkeyを、第二引数に埋め込みたいプロパティを受け取る関数を実装してみます。

// 辞書: { ja: { GREETING_WITH_SUFFIX: 'こんにちは。${"suffix"}' } }

const { m } = useLocale();
return (
  <div>
    {m("GREETING_WITH_SUFFIX", { values: { suffix: "いい天気ですね。" } })}
  </div>
); // <div>こんにちは。いい天気ですね。</div>

実装

辞書から文言を引いてくる実装はこれだけで完了します。簡単なので紹介程度に留めます。

// 辞書
const dicts = {
  ja: {
    GREETING: 'こんにちは。',
    GREETING_WITH_SUFFIX: 'こんにちは。${"suffix"}',
  },
  // ~~~
} as const;
import dicts from './dicts';

type Lang = keyof typeof dicts;

// Reactの外からも呼び出せるように分離しています。
export function getTranslation(lang: Lang) {
  const t = dicts[lang];
  return { t, m };
}

export const useLocale = (lang: Lang = 'ja') => {
  return { ...getTranslation(lang) };
};

langの部分は適宜navigator.languageなどでユーザーの設定言語見て、useLocale に渡すようにしてください。

動的な文言

ここから本題で、動的に文言の一部を変えられるような関数を作成します。先に全体の実装から紹介します。

import dicts from './dicts';

type Lang = keyof typeof dicts;

function template(strings: TemplateStringsArray, ...keys: string[]) {
  return function (dict: Record<string, string>) {
    const result = [strings[0]];
    keys.forEach((key, i) => {
      const value = dict[key];
      result.push(value, strings[i + 1]);
    });
    return result.join('');
  };
}

export function getTranslation(lang: Lang) {
  const t = dicts[lang];
  function m(
    key: keyof typeof t,
    options?: { values: Record<string, string> }
  ) {
    const value = t[key];
    const closure = Function(`return (t) => t\`${value}\``)()(template);
    const res = closure(options?.values || {});
    return res;
  }
  return { t, m };
}

export const useLocale = (lang: Lang = 'ja') => {
  return { ...getTranslation(lang) };
};

今回実装するものは Tagged Template Literal(MDN) を用いています。styledやlitなどに触れたことのある方には馴染みのあるものかもしれません。
今一度紹介すると、関数名`文字列` の形式で、文字列に埋め込んだ変数を、関数名に相当する関数で操作できる仕組みです。MDNに掲載されている一例を引用します。

const person = "Mike";
const age = 28;
myTag`That ${person} is a ${age}.`; // That Mike is a youngster.

// ※ 99 歳より上だと 'centenarian' になる
const person = "Mike";
const age = 100;
myTag`That ${person} is a ${age}.`; // That Mike is a centenarian.

myTag は以下になります。

function myTag(strings, personExp, ageExp) {
  const str0 = strings[0]; // "That "
  const str1 = strings[1]; // " is a "
  const str2 = strings[2]; // "."

  const ageStr = ageExp > 99 ? "centenarian" : "youngster";

  // We can even return a string built using a template literal
  return `${str0}${personExp}${str1}${ageStr}${str2}`;
}

埋め込まれた変数ごとに区切られた文字列の配列が第一引数に、それ以降は残余引数として埋め込まれた変数が得られます。

i18n対応にこれを用いる場合、myTagの引数部分をvalueに持つプロパティからなるオブジェクト(あるいはJSON)を作成すれば良いように思われます。
しかし、存在しない変数を埋め込んでしまうと実行できません。

// 辞書
const dicts = {
  ja: {
    GREETING: 'こんにちは。',
    GREETING_WITH_SUFFIX: `こんにちは。${suffix}`, // error
  },
  // ~~~
} as const;

変数部分は何かしらの文字列として、それを後ほど値に置換する処理を挟むのが望ましいです。

// 辞書
const dicts = {
  ja: {
    GREETING: 'こんにちは。',
    GREETING_WITH_SUFFIX: 'こんにちは。${"suffix"}',
  },
  // ~~~
} as const;

では以下のような template 関数を考えてみます。

function template(strings: TemplateStringsArray, ...keys: string[]) {
  return function (dict: Record<string, string>) {
    const result = [strings[0]];
    keys.forEach((key, i) => {
      const value = dict[key];
      result.push(value, strings[i + 1]);
    });
    return result.join('');
  };
}

Tagged Template Literalは文字列以外を返すこともできます。そこで、埋め込みたい値を引数で渡すことができる関数を返すようにします。

template`${"greeting"}, This is ${"name"}.`({ greeting: "Hello", name: "John" });
// Hello, This is John.

この場合、templateの引数は strings: ["", ", This is ", "", ".", raws: [/* 略 */]], keys: ["name"] です。
templateの返す関数では、stringsの1つ目の要素でresultを初期化しています。stringsには少なくとも1つ文字列が入るので、resultは文字列の配列になります。
これに対してkeysを順に回し、値をpushしていけば ["Hello", ", This is ", "John", "."]のように配列が組み上がるので、joinを使えば完成形が得られます。

汎用化時のハマりどころ

では、完成した template 関数を汎用化するために、以下のようにラップします。m の第一引数に指定したkeyによって、出力を変化させられます。

function m(
  key: keyof typeof t,
  options?: { values: Record<string, string> }
) {
  const value = t[key];
  const closure = template`${value}`;
  const res = closure(options?.values || {});
  return res;
}

と思いきや、うまく出力できません。

m("GREETING", { greeting: "Hello", name: "John" });
// ''

templateへの文字列の渡し方に問題があり、 文字列自体を変数として渡すことはできません。

代わりに Function を用いて解決を試みます。

function m(
  key: keyof typeof t,
  options?: { values: Record<string, string> }
) {
  const value = t[key];
  const closure = Function(`return (t) => t\`${value}\``)()(template);
  const res = closure(options?.values || {});
  return res;
}

Function は引数に受け取った文字列をJSとして評価できます。
これを用いて Function(`return (t) => t\`${value}\``)()(template) のように、 functionBody として関数を返却すると、HoCのようなものが作成できます。
戻り値の関数が引数として Tagged Temlate Literal を受け取って実行するように書けば、文字列を展開することなく引数に渡せます。

より手っ取り早く実装できるものとして eval を連想するかもしれませんが、今回は用いていません。理由はいくつかありますが、1つ挙げるとJS/TSを用いていると往々にしてソースコードのminifyを行います。
例えば、i18n対応部分だけ切り出してライブラリにする際のminifyで関数名が変化してしまい、evalでは実行できなくなるので Function を採用しています。

あとは、m をフック経由で呼び出せるようにすれば、冒頭で紹介したようにReactに組み込んで、文言の一部を変化させながら表示できます。
もちろん、getTranslation のようにフックではない関数に一度分離すれば、Reactの外(SSRのサーバーサイドなど)でも使用できます。

export function getTranslation(lang: Lang) {
  const t = dicts[lang];
  function m(
    key: keyof typeof t,
    options?: { values: Record<string, string> }
  ) {
    const value = t[key];
    const closure = Function(`return (t) => t\`${value}\``)()(template);
    const res = closure(options?.values || {});
    return res;
  }
  return { t, m };
}

export const useLocale = (lang: Lang = 'ja') => {
  return { ...getTranslation(lang) };
};

以上、数行程度の実装で求める最低限のi18n対応ができるようになりました。

より良い方法があるよという方はぜひ筆者までご教示ください。
ご精読ありがとうございました。

Discussion