TypeScriptで実現する型安全な多言語対応(Angularを例に)
フロントエンドエンジニアの今村です。
この記事はAngular Advent Calendar 2018 11日目の記事で、テーマは多言語対応です。
Angular Advent CalendarなのでAngularの話を中心に書きますが、手法としてはAngularに限らず、ひろく(TypeScriptで書かれた)Webフロントエンド全般の多言語対応に適用可能なものです。というのも、私は以前にも似たような記事をQiitaに投稿していて、その時はReactをやっていました。
この記事ではQiitaに投稿した方法を少し改良したやり方をご紹介しますが、TypeScriptの型に甘えて多言語対応を実現したいという気持ちは一貫しています。
TL;DR
辞書ファイルをTypeScriptで書きたいという思いのもと、Angularの標準のi18n機能やその他のライブラリに頼らずに実装した多言語対応を紹介します。
Demo: https://stackblitz.com/edit/angular-rwuzwv
Source Code: https://github.com/kimamula/ts-i18n-angular
1. 辞書ファイルの用意
// 英語
// translate/dictionary/en.ts
export const dictionary = {
greeting: (name: string) => `Hello ${name}!`,
title: 'translate example'
};
// 型定義
// translate/dictionary/index.ts
import { dictionary } from './en';
export type Dictionary = typeof dictionary;
export type Lang = 'en' | 'de' | 'ja';
// 日本語
// translate/dictionary/ja.ts
import { Dictionary } from '.';
export const dictionary: Dictionary = {
greeting: (name: string) => `こんにちは ${name}!`,
title: 'translate 例'
};
(デモではドイツ語も用意しましたが省略)
2. 辞書ファイルのロード
// translate/translate.service.ts
@Injectable({
providedIn: 'root'
})
export class TranslateService {
// lang$ に対して next() するとその度に言語が切り替わる
readonly lang$ = new ReplaySubject<Lang>(1);
readonly dictionary$ = this.lang$.pipe(
distinctUntilChanged(),
// 言語の切り替え時に辞書ファイルを dynamic import
switchMap<Lang, Dictionary>(lang => import(`./dictionary/${lang}`)
.then(({ dictionary }) => dictionary))
);
readonly availableLanguages: ReadonlyArray<Lang> = ['en', 'ja', 'de'];
constructor() {
// デフォルトではブラウザの言語を使う
const index = this.availableLanguages.indexOf(navigator.language as any);
this.lang$.next(this.availableLanguages[index] || 'en');
}
}
dynamic importを使うため、tsconfig.json
で"module": "esnext"
を指定する必要があります。この結果辞書ファイルの内容は言語ごとに独立したchunkに分離され、メインのchunkには含まれなくなります。必要な時に必要な辞書ファイルだけをロードするため、効率がよくなります(ただし、初回ロード時の注意点については後述します)。
3. 辞書の利用
TranslateService
のdictionary$
をどこかでsubscribe()
し、それをComponentに渡していきます。説明するほどのことでもありませんが、Componentでは次のように使います。
@Component({
selector: 'app-hello',
template: `
<h1>{{ dictionary.title }}</h1>
<p>{{ dictionary.greeting('Andreas') }}</p>
`
})
export class HelloComponent {
@Input() dictionary!: Dictionary;
}
Angularの一般的な多言語対応
Angularでは公式のi18n機能の他に、ngx-translateやangular-l10nが用いられることが多いようです。これら3つの機能の大まかな比較は、angular-l10nのREADMEに記載の表を見ると分かりやすいかと思います。
https://github.com/robisim74/angular-l10n#angular-i18n-solutions より
ここでFile formatsの行を見ると、JSONやXLIFF、XMB/XTBといったi18n規格が使われていることが分かります。これはAngularに限らず、一般にJavaScriptのi18n系のライブラリは同じような状況だと思います。
たとえばngx-translateでは辞書ファイルを以下のようにJSONで書きます。
{
"HELLO": "hello {{value}}"
}
この場合、valueは翻訳時に渡す変数ですが、型は宣言できませんし、変数名をtypoしたりそもそも変数を渡し忘れたりしてもコンパイルエラーにはなりません。
そこで、辞書ファイルをTypeScriptで書いて型安全なものとしたいというのが、今回のモチベーションです。
なお、標準機能があるにもかかわらず、ngx-translateやangular-l10nなどのライブラリが使われているということは、標準機能に不満を感じる開発者が少なからずいるということです。現状のAngular標準のi18n機能への主な不満は以下のようなものと考えられます。
- Angularのtemplateの中でしか使えない(通常のTypeScriptのコードの中で使えない)
- 言語ごとにバンドルを生成する必要がある
- 言語の切り替えの際にはページリロードが必要
- 辞書ファイルとしてJSONがサポートされていない
これらについては、Angularの新しいrendererであるivyが導入される際に、i18n機能も更新が予定されており、下記のissueで議論が進められています。
https://github.com/angular/angular/issues/16477
コメントが非常に多いのですべて追うのは大変ですが、issueの本文が逐次更新されているため、それを読むだけでも概要は掴めます。上記1, 2についてはivy導入後は解決する見込み(※)であり、3についてもサポートしたいというコメントがあります。
※: 2に関しては言語切り替え時にアプリのリロードは必要になってしまうとのことです。ただし、「アプリのリロード」とは「ページのリロード」ではなく、クライアントサイドでアプリを1からマウントし直すことを意味します。
この方法のメリット
1. 型安全
TypeScriptの型の恩恵が受けられます。辞書ファイルのキーや、渡す変数の名前や型に誤りがあった場合は、コンパイルエラーになります。
当然、これに付随してIDEによる補完やリファクタリングのサポートもバッチリです。
2. 柔軟
辞書ファイルはTypeScriptのコードなので、わりとなんでもできます。文言の生成のために渡す引数や生成結果も、文字列でも数値でもオブジェクトでも配列でも正規表現でも関数でもTemplateRef
でも、なんでも使えます。
3. シンプル
TypeScript以外に依存するものがなく、新たに何かを覚える必要もありません。シンプルすぎるため、単数形と複数形の切り替えなどのi18nの基本的な機能が備わっていませんが、柔軟性のメリットを生かして適当なライブラリを導入して解決可能です。
この方法のデメリット
TypeScript以外の環境との互換性がなくなることが最大のデメリットになると思われます。
JSONなどを使っている場合は、他言語でも使い回せる他、BabelEditのようなツールのサポートを受けることもできます。
辞書ファイルは非エンジニアに直接編集してもらう、といった運用も、TypeScriptを採用すると難しくなってくるかもしれません。
初回ロードの時間を短縮するために
TL;DRでごく素朴にdynamic importするやり方を紹介しましたが、これをそのまま動かすと、
- HTMLのロード
- メインのJSの取得
- メインのJSが読み込まれてその中でdynamic importが実行される
- 辞書ファイルのJSの取得
- 辞書ファイルのJSが読み込まれて文言が適用される
という手順を経なければ文言が画面に適用されません。これではdynamic importの分、文言の適用が遅れてしまいます。
これを回避する手段を考えてみます。
全言語の辞書ファイルをメインのバンドルに含める(SSR、CSR)
たとえばTL;DRのtranslate/translate.service.ts
で、
import './dictionary/en';
import './dictionary/ja';
import './dictionary/de';
と書いてしまえば、辞書ファイルがメインのJSに含まれるようになり、dynamic importのところでサーバーへのJSのリクエストが発生しなくなります。
非常にシンプルな方法ですが、言うまでもなくメインのJSの肥大化がデメリットになります。対応言語の数が数ヶ国語に止まるなら、それほど気にする必要はないかもしれませんが、数十ヶ国語以上に対応しなければならないなら、あまり現実的ではないかもしれません。
Transfer Stateの仕組みを利用する(SSR)
[2019/12/18 追記]
この方法は以下に記述するとおり気を使わなければならない部分が多いのと、Function()
を使うためコンテンツセキュリティポリシー(CSP)を有効にしたい場合に使えないといった欠点があるため、お勧めできません。
SSRをしているなら、Transfer Stateで初回ロード用の辞書ファイルの情報をHTMLに埋め込む方法が使えます。
Angular以外のSSRでも同様の仕組みがあると思いますが、サーバーサイドで初期化した情報をHTMLに埋め込んだ状態でクライアントサイドに渡すことで、クライアントサイドで初期化処理を繰り返す必要がなくなります(初回のdynamic importをスキップできる)。
注意しなければならないのは、ドキュメントにも書いてあるとおり、Transfer StateではサーバーサイドでJSON.stringify
による状態のシリアライズ、クライアントサイドでJSON.parse
による状態のデシリアライズが行われることです。
たとえば、
{
dictionary: {
greeting: (name: string) => `Hello ${name}!`,
title: 'translate example';
}
}
をJSON.stringify
すると
'{"dictionary":{"title":"translate example"}}'
になってしまいます。つまり、辞書ファイルのうち、関数で実装した部分の情報が失われます。これを避けるには、辞書全体を関数として実装し、
export const dictionary = () => ({
greeting: (name: string) => `Hello ${name}!`,
title: 'translate example'
});
サーバーサイドでシリアライズする際にFucntion.prototype.toString()
を噛ませます。また、クライアントサイドでデシリアライズする際には、Function()
を使います。
さらに、辞書ファイルが外部のモジュールに依存するような実装になっている場合も注意が必要で、
import foo from 'foo';
export const dictionary = () => ({
greeting: (name: string) => `Hello ${name}! ${foo()}`,
title: 'translate example'
});
このfoo
はtoString()
した後の文字列表現では何者か分からなくなってしまうため、このようなものは関数の引数にしてあげる必要があります。
import _foo from 'foo';
export const dictionary = (foo: typeof _foo) => ({
greeting: (name: string) => `Hello ${name}! ${foo()}`,
title: 'translate example'
});
このようにしておけば、クライアントサイドでデシリアライズした後で適切な引数を渡すことで辞書を復元できます。
なお、実行時のサーバーサイドのコードがES2015以降の形式である場合、サーバーサイドでシリアライズしたものをクライアントサイドでデシリアライズすると、一部のブラウザでエラーとなる可能性があります。その場合は辞書ファイルだけはES5にトランスパイルするなど、追加で一手間必要になってしまいそうです。
サーバーサイドで言語ごとに異なるindex.htmlを返す(SSR、CSR)
サーバーサイドで言語の判定を行い、その言語に対応した辞書ファイルのJSに対応するscriptタグをHTMLに埋め込んだ状態で返してあげる方法です。この方法がもっとも安全かつパフォーマンスにも優れるでしょう。
終わりに
個人的にはこの方法はかなり気に入っているのですが、ネット検索しても同じようなことをやっている人があまりいなさそうなので若干不安を感じています。ご意見、ご感想などいただければ嬉しいです。
また、弊社ではエンジニア(フロントエンド、サーバーサイド、機械学習)、Webデザイナーを絶賛募集中なので、そういったご連絡も大歓迎です。
明日のAngular Advent Calendarは@kpondaさんです。
その他の記事
Other Articles
関連職種
Recruit