LoginSignup
29
20

More than 5 years have passed since last update.

TypeScript を使ったシンプルで型安全な i18n 実装

Last updated at Posted at 2016-08-20

最近 Node.js な Web アプリケーションの多言語対応について調べる機会があったので、自分なりの考えをまとめておく。
結論としては、特にライブラリを使わず、自前の実装で片付けてしまうことにした。
誰でも思いつきそうな至極単純な発想なのだけれど、調べた限りこういう情報が出てこなかったので、記事にすることで誰かのお役に立てれば幸い。

既存ライブラリに対する不満: 静的型付を実現できない1

TypeScript ユーザーにとっては、以下のようなものがコンパイル時ではなく、 実行時のエラーになるのは耐え難い。

  • message の key の typo
  • message の引数の typo, 過不足, 型違い
  • 異なる言語間で message の定義に一貫性がない

ピンと来ない人向けに、それなりに人気のありそうな i18next という package の記法を例として書いておく。

[参考] i18next の記法

辞書ファイル (JSON):

{
  "en": {
    "translation": {
      "key1": "value of key1 in en",
      "interpolation": "{{what}} is {{how}}",
      "dateFormat": "The current date is {{date, MM/DD/YYYY}}",
      "keyWithCount": "{{count}} item",
      "keyWithCount_plural": "{{count}} items"
    }
  },
  "ja": {
    "translation": {
      "key1": "日本語の鍵1の値",
      "interpolation": "{{what}} は {{how}} です",
      "dateFormat": "今日は {{date, YYYY/MM/DD}} です",
      "keyWithCount": "{{count}} 個のアイテム"
    }
  }
}

dateFormat のところは、 format 用の関数の実装を別途用意しなければならないが、割愛している (詳細は http://i18next.com/translate/formatting/ )。

利用方法:

i18next.t('key1'); // -> value of key1 in en
i18next.t('interpolation', { what: 'i18next', how: 'great' }); // -> i18next is great
i18next.t('dateFormat', { date: new Date() }); // -> The current date is 08/20/2016
i18next.t('keyWithCount', { count: 0 }); // -> 0 items
i18next.t('keyWithCount', { count: 1 }); // -> 1 item
i18next.t('keyWithCount', { count: 2 }); // -> 2 items

細かい説明は省くが、この使い方を見れば、上で挙げたようなものがコンパイルエラーにならないことはご理解いただけると思う。

辞書を JSON で定義するのが間違い、 TypeScript で書こう

arrow 関数と string template literal を使えば、 JSON と同等の可読性を持った辞書ファイルを TypeScript で記述できる。

export const messages = {
  key1: 'value of key1 in en',
  interpolation: (what: string, how: string) => `${what} is ${how}`,
  dateFormat: (date: Date) => `The current date is ${formatDate(date, 'MM/DD/YYYY')}`,
  keyWithCount: (count: number) => `${count} item${count === 1 ? '' : 's'}`
};

function formatDate(date: Date, format: string): string {
  // format 実装
}

言語に応じた利用辞書の解決

ライブラリを使う場合、このあたりは簡単な方法が提供されているはずだが、ここを自前で実装する必要がある。
しかし、大した手間ではない。

辞書ファイルの配置

src
  |
  |- messages
       |
       |- index.ts // 後述
       |
       |- en.ts // 英語の辞書ファイル
       |
       |- ja.ts // 日本語の辞書ファイル
       |
       |- // その他の対応言語の辞書ファイル...

server side

src/messages/index.ts を以下のように書いておき、言語に応じた辞書を動的に解決できるようにしておく。

import * as path from 'path';
import * as glob from 'glob';
import { messages as en } from './en';

export const messages = en;

export namespace Server {
  // src/messages 下に存在する (index.ts 以外の) ファイルが対応言語を表す
  export const acceptableLanguages = glob.sync(`${__dirname}/*.js`)
    .map((file) => path.basename(file, '.js'))
    .filter((language) => language !== 'index');

  // それぞれの言語を require, キャッシュしておく
  const map = acceptableLanguages.reduce((acc, language) => {
    acc[language] = require(`./${language}`).messages;
    return acc;
  }, {} as {[language: string]: typeof messages});

  /**
   * 指定された言語に対応する辞書を返す
   */
  export function messagesOf(language: string): typeof messages {
    return map[language];
  }
}

すると、以下のようにして request の言語に対応する辞書を取得できる (Express の req.acceptsLanguages を使った例)。

import * as express from 'express';
import { Server } from './messages';

const
  app = express(),
  DEFAULT_LANGUAGE = 'en';

app.get('/', (req: express.Request, res: express.Response) => {
  const
    language = (req.acceptsLanguages(Server.acceptableLanguages) || DEFAULT_LANGUAGE) as string,
    messages = Server.messagesOf(language);

  // ...
});

client side

webpack.NormalModuleReplacementPlugin を利用して、 require('./path/to/messages') がビルド時に対応言語ごとに require('./path/to/messages/{language}') として扱われるようにする。

// webpack.config.js
const webpack = require('webpack');
const { Server } = require('./src/messages');

module.exports = Server.acceptableLanguages.map((language) => ({
  entry: './src/client.js',
  output: {
    path: './build',
    filename: `bundle_${language}.js` // 言語ごとにバンドルした JS を出力
  },
  plugins: [
    new webpack.NormalModuleReplacementPlugin(/^\..*\/messages$/, (result) => result.request += `/${language}`)
  ]
}));

server side の template で、 request の言語に応じたバンドル済み JS をアサインする。

const language = (req.acceptsLanguages(Server.acceptableLanguages) || DEFAULT_LANGUAGE) as string;

// ...

<script type='text/javascript' charSet='utf-8' src={`bundle_${language}.js`}></script>

こうしておくと、 client side の TS からは、単に以下のように書くだけで必要な辞書を取得できる。

// 一応説明しておくと、下記は import { messages } from './path/to/messages/index'; と同じ意味に解釈されるので、
// コンパイル時は src/messages/index.ts に定義した messages の型に基づいた型チェックが行われる
import { messages } from './path/to/messages';

server side rendering

server side rendering を行う場合、 component のコードは server side でも client side でも動くことになるため、 component の中から直接辞書を取得してはならない (server side と client side で辞書の解決方法が異なるため)。

そこで、辞書を引数にして、最上位の component を返すような関数を定義する。

// React の場合
import * as React from 'react';
import { messages } from './messages';

export const createApp = (_messages: typeof messages) =>
  class extends React.Component<{ name?: string; unread?: number; }, void> {
    render(): JSX.Element {
      return <div>
        <h1>{_messages.title}</h1>        <p>{_messages.greeting(this.props.name)}</p>        <p>{_messages.unreadNotification(this.props.unread)}</p>      </div>;    }
  }
;

server side, client side, それぞれのやり方で取得した辞書をこの関数に渡すことで、最上位の component を取得すればよい。
子 component からは、最上位の component から props や context (または利用する view のライブラリに応じた何らかの機構) によって伝播された辞書を使うようにする。

動作する例

上記の内容を実装したものを、以下の repository に上げた


  1. 私の調査が甘いだけかもしれないので、実現できるものをご存知の方がいたら教えてください 

29
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
20