Ext JS の多言語化について

2016/02/11 に Sencha の公式ブログに Internationalization & Localization with Sencha Ext JS という記事が掲載されました。

僕自身 Ext JS での多言語化については、いろいろと苦労をしてきましたが、これはなかなかにエレガントな解決方法を提唱していると思ったので、記事の内容を日本語で紹介したいと思います。単に翻訳ではなく、僕の言葉で解説したいと思います。

multilingual Photo credit: quinn.anya via Visual hunt / CC BY-SA

多言語化といいますと、日本語以外でのサービスの提供といった全世界的なことを思い浮かべますが、多言語化を可能にする仕組みは、表示する文字列とシステムの分離を前提とするので、別な用途でも意味を持ちます。 例えば、多くの顧客に対して提供しているサービスで、顧客毎に表現を一部変更しなければならない、というような場合。いかがです?実際に多くある場面ではありませんか? 多言語化の仕組みを取り入れていれば、対象顧客の言語ファイルの文言をちょいちょいっと変えるだけで対応できます。

  • Ext JS における多言語化について
  • 自分のアプリを多言語対応に
  • リテラルを特定のクラスに集める
  • エレガントな解決方法

まず、Ext JS での多言語化の仕組みについて説明します。 Ext JS の多言語化といってもそこには二つの観点があります。

  1. Ext JS フレームワーク内の多言語化
  2. 自分のアプリケーションの多言語化

Ext JS フレームワークの多言語対応

Ext JS には標準で 40カ国語以上の言語ファイルがあります。Ext JS 6 の場合だと ext/classic/locale/override の下に各言語のロケールファイルが格納されています。 (ただ modern ツールキットにはないですね。自前でやるしかないのか)

ロケールファイルをアプリケーションに適用するには、app.jsonrequiresext-locale パッケージを追加して、locale ディレクティブに言語をを指定します。

[js] "requires": [ "ext-locale" ] "locale": "ja" [/js]

これで、フレームワークのメッセージが綺麗に日本語化されます。 この方法で、Sencha Cmd でビルドした際には、ロケールファイルはビルドの中に統合されます。 複数の言語のサイトを用意する場合は、locale の指定を変えたビルドを必要な言語の分だけ用意することになります。 ビルドを一つにしてロケールを切り替えるために、ビルドとは別にロケールを適用したい場合は、index.html の中の script タグで言語ファイルを読み込むという方法もあります。 そのあたりは 元記事 を参照してください。

いずれにしても、メイン画面が描画される前にロケールを読み込まなければなりません。 表示された後では遅いですからね。

ロケールファイルを読み込むとその言語に対応するという仕組みは、どのように動作しているかと言いますと。 Ext JS フレームワークの「オーバーライド」という機能を使っています。 「オーバーライド」とは、Ext JS のクラスの属性などを「後から」変更できる仕組みです。 [Ext.Define](http://docs.sencha.com/extjs/6.0/6.0.1-classic/#!/api/Ext-method-define) の項の後半に解説があります。 Ext JS における多言語対応は、基本的にこの仕組みを使って実現されています。

例えば、次は日本語のページングツールバーの部分です。

[js] Ext.define("Ext.locale.ja.toolbar.Paging", { override: "Ext.PagingToolbar", beforePageText: "ページ", afterPageText: "/ {0}", firstText: "最初のページ", prevText: "前のページ", nextText: "次のページ", lastText: "最後のページ", refreshText: "更新", displayMsg: "{2} 件中 {0} – {1} を表示", emptyMsg: ‘表示するデータがありません。’ }); [/js]

これは、Ext.PagingToolbar をオーバーライドして、beforePageText 以下のプロパティを上書きしています。 こうして、プロパティを上書きして変更してしまうことで、表示言語に合わせるわけです。 ですから、もともとのクラスの作りも表示文字列をプロパティにセットして、オーバーライドできるように作る必要があります。

自分のアプリを多言語対応に

上記の作法に従えば、自分のアプリも多言語対応にさせるために、表示文字列をプロパティにして、ロケールファイルをロードすればオーバーライドされて切り替えられるようにすればOKということになります。 まずそれをやってみましょう。

多言語化の課程を順番に見ていきましょう。 次のようなコンポーネントがあります。

Code-1
[js] Ext.define(‘MyApp.view.window.MyWindow’, { extend: ‘Ext.window.Window’, title: ‘確認’, bodyPadding: 16, width: 400, items: [{ xtype: ‘displayfield’, value: ‘次の中で、人間はどれでしょう?’ }, { xtype: ‘radiogroup’, columns: 1, items: [{ boxLabel: ‘パズー’ },{ boxLabel: ‘トトロ’ },{ boxLabel: ‘オーム’ }] }], buttons: [{ text: ‘回答する’ }, { text: ‘わからない’ }] }); [/js]

いろいろな改装でリテラルが使われています。title プロパティはそのままオーバーライドできますね。でも他のプロパティはどうでしょうか?下位のコンポーネントにあるので、このままでは、一つひとつのリテラルだけをオーバーライドすることはできません。ですから、これをクラスのプロパティにしてオーバーライドできるようにする必要があります。

クラスのボディで次のようにプロパティに値をセットします。

[js] questionText: ‘次の中で、人間はどれでしょう?’ answers: [ ‘パズー’, ‘トトロ’, ‘オーム’ ], okButtonText: ‘回答する’, cancelButtonText: ‘わからない’, [/js]

これを個々のコンポーネントにセットするんですが、クラス定義時に上記のプロパティをセットすることは不可能です。

[js] Ext.define(‘MyApp.view.window.MyWindow’, { extend: ‘Ext.window.Window’, title: ‘確認’, questionText: ‘次の中で、人間はどれでしょう?’ : : items: [{ xtype: ‘displayfield’, value: this.questionText // こんなことはできない }, { : : : }] }); [/js]

上記のようにやっても、この時点での this は、MyApp.view.window.MyWindow クラスではありませんから。 この場合は、Ext JS のフレームワークにあるコンポーネントがそうしているように、 initComponent を使った初期化が必要になります。

[js] Ext.define(‘MyApp.view.window.MyWindow’, { extend: ‘Ext.window.Window’, title: ‘確認’, bodyPadding: 16, width: 400, // プロパティで定義 questionText: ‘次の中で、人間はどれでしょう?’, answers: [ ‘パズー’, ‘トトロ’, ‘オーム’ ], okButtonText: ‘回答する’, cancelButtonText: ‘わからない’, initComponent: function() { me = this; // 定義したプロパティを使う為には定義本体ではなく // クラスの初期化時にセットする必要がある Ext.apply(me, { items: [{ xtype: ‘displayfield’, value: me.questionText }, { xtype: ‘radiogroup’, columns: 1, items: [{ boxLabel: me.answers[0] },{ boxLabel: me.answers[1] },{ boxLabel: me.answers[2] }] }], buttons: [{ text: me.okButtonText }, { text: me.cancelButtonText }] }); me.callParent(arguments); } }); [/js]

これでなんとかなりました。 しかし文字列リテラルをクラスのプロパティにするという以外は特別なことをしているわけではないのに、わざわざ initComponent の中で定義しなければならないのは面倒な感じがします。

また、そもそもクラスのプロパティをオーバーライドするという方法ですと、オーバーライドの対象が多くなりすぎる嫌いがあります。アプリケーションはフレームワーク自体に較べるとメッセージやキャプションの量がとても多くなりますし、アプリケーションの各所で同じメッセージが使われることも多くあります。例えば「検索」とか「キャンセル」というようなキャプションは、いろんな画面で使われるでしょう。この方法だと、クラスにそれらの単語が出現する度に、それらを定義しなくてはなりません。言語ファイルの大きさはどんどん大きくなるし、キャプション自体の管理も大変です。

リテラルを特定のクラスに集める

綺麗にやるためには、メッセージやキャプションをもつクラスを作って、そこで定義されているメッセージを各クラスで使うようにする、というのがいい方法です。そうすれば、そのクラスのメッセージを翻訳すれば、他の言語のファイルも作れますし、サービスで顧客毎に文言を少々変更するといった対応も、そのクラスをさしかえることで簡単に対応できます。 定義はクラスでなくても、単なる JavaScript オブジェクトでもいいですよね。

[js] Ext.define(‘MyApp.Captions’, { singleton: true, confirmTitle: ‘確認’, questionText: ‘次の中で、人間はどれでしょう?’, answers: [ ‘パズー’, ‘トトロ’, ‘オーム’ ], okButtonText: ‘回答する’, cancelButtonText: ‘わからない’ }); [/js]

ここではシングルトンで定義してみました。 ここで定義した値をクラスの方で使えばいいのです。 で、つぎのようにしたくなりますよね。

Code-2
[js] Ext.define(‘MyApp.view.window.MyWindow’, { extend: ‘Ext.window.Window’, title: MyApp.Captions.confirmTitle, bodyPadding: 16, width :400, items: [{ xtype: ‘displayfield’, value: MyApp.Captions.questionText }, { xtype: ‘radiogroup’, columns: 1, items: [{ boxLabel: MyApp.Captions.answers[0] },{ boxLabel: MyApp.Captions.answers[1] },{ boxLabel: MyApp.Captions.answers[2] }] }], buttons: [{ text: MyApp.Captions.okButtonText }, { text: MyApp.Captions.cancelButtonText }] }); [/js]

title などのプロパティにシングルトンで定義したキャプションをセットしています。 これは、特定の条件を満たす場合には動作します。 条件とは、このクラスのソースが読み込まれる時に、すでに MyApp.Captions クラスがインスタンス化されていることです。 つまりクラスのインスタンスが作られるときではなく、クラス定義がロードされるとき、つまりブラウザがこの JavaScript ファイルを読み込むときにすでに MyApp.Captions クラスのシングルトンオブジェクトが出来上がっていなければならないということです。 これはなかなかうまくいきません。開発環境の Ext.Loader での読み込みでうまく行っていても、ビルドするとだめだったり。 MyApp.Captions クラスが出来上がっていないと盛大に JS エラーが吐き出されます。 ですから次のようにやっぱり initComponent の世話になることになります。

[js] Ext.define(‘MyApp.view.window.MyWindow’, { extend: ‘Ext.window.Window’, initComponent: function() { me = this; Ext.apply(me, { title: MyApp.Captions.confirmTitle, bodyPadding: 16, width :400, items: [{ xtype: ‘displayfield’, value: MyApp.Captions.questionText }, { xtype: ‘radiogroup’, columns: 1, items: [{ boxLabel: MyApp.Captions.answers[0] },{ boxLabel: MyApp.Captions.answers[1] },{ boxLabel: MyApp.Captions.answers[2] }] }], buttons: [{ text: MyApp.Captions.okButtonText }, { text: MyApp.Captions.cancelButtonText }] }); me.callParent(arguments); } }); [/js]

僕は基本的にはこの方法でメッセージを一箇所に集めるように記述していました。 僕は以前のバージョンの Ext JS から使っていますから、initComponent のこうした使い方には慣れていますが、それでもできることなら、Code-2 のような書き方をしたい、と思っていました。 それの解決方法として、 でも参照されている Learn from Saki: Localization of Ext Applications という記事ではアンダースコア関数を用意するという方法を提案しています。

[js] MyApp.currentLocale = ‘de’; Ext.define(‘MyApp.store.Locales’,{ extend:’Ext.data.Store’ ,storeId:’locales’ }); _ = function(textId) { var store = Ext.getStore(‘locales’) ,rec = store.findRecord(‘textId’, textId) ; return rec ? rec.get(MyApp.currentLocale) : text; }; Ext.define(‘MyApp.view.MyPanel’,{ extend:’Ext.panel.Panel’ ,title:_(‘title’) }); [/js]

わりとクールは方法だと思います。が、この方法でも、クラスが読み込まれる前にこのアンダースコア関数が定義されていなければならないという点は変わりありません。 元記事 ではよりエレガントな方法を提案しています。

エレガントな解決方法

やっと、今回の Blog の内容にたどりつきました。

今回の Blog での解決方法は二つの手法を合わせて使っています。一つ目は、ここでも述べた「リテラルを集めたクラス(またはオブジェクト)を作る」ということです。記事では、そのクラスを実際に差し替える手法についても言及されています。 もう一つは、その定義をクラスに適用する方法です。これがなかなかにエレガントなやり方になっています。

一つ目から見ていきましょう。 元記事 ではリテラルの定義はシングルトンになっています。入れ替えるロケールファイルは、オーバーライド定義でも単なる JavaScript オブジェクトでもかまいません。それを実際に入れ替えるには、Application の launch メソッドで、入れ替え処理を記述します。

[js] Ext.define (‘Jnesis.Application’, { launch: function () { Ext.Ajax.request({ url: ‘get-localization’, params:{locale:’fr’}, callback: function (options, success, response) { var data = Ext.decode(response.responseText, true); Ext.override(Jnesis.Labels, data); Ext.create(‘Jnesis.view.main.Main’); } }); } }); [/js]

これは、get-localization にアクセスすると JavaScript の Object としてロケール情報が帰ってくるというものですね。取得した情報で Jenesis.Label というリテラル定義のシングルトンをオーバーライドしています。元記事では、ロケールファイルがオーバーライドの場合の方法も記述されています。

もう一つは、リテラルの定義情報をどのようにしてクラスに適用するか、というところです。 ここでは、クラス定義の際にリテラルを定義したクラスから取得したい値に関しては、localized というオブジェクトの中に記述するという方法をとっています。 その方法を、これまでの例に適用してみると次のようになります。

[js] Ext.define(‘MyApp.view.window.MyWindow’, { extend: ‘Ext.window.Window’, title: MyApp.Captions.confirmTitle, bodyPadding: 16, width :400, items: [{ xtype: ‘displayfield’, localized: { value: `MyApp.Captions.questionText` } }, { xtype: ‘radiogroup’, columns: 1, items: [{ localized: { boxLabel: ‘MyApp.Captions.answers[1]’ } },{ localized: { boxLabel: ‘MyApp.Captions.answers[2]’ } },{ localized: { boxLabel: ‘MyApp.Captions.answers[3]’ } }] }], buttons: [{ localized: { text: ‘MyApp.Captions.okButtonText’ } }, { localized: { text: ‘MyApp.Captions.cancelButtonText’ } }] }); [/js]

ロケールによって切り替えたいプロパティを localized というオブジェクトで囲っています。 そしてその中にリテラルを定義したクラスのプロパティを「文字列で」指定しています。 この手法は、Ext JS クラスシステムのクラス名指定と同じ仕組みですね。 文字列で指定していますから、このクラスが定義される時点でまだ MyApp.Captions が出来上がっていなくてもエラーになる事はありません。 (この例だと、localized が沢山あっていささかうざいですが…)

でも、このままではプロパティに値がセットされることはありませんので、それを実現するコードが必要になります。 文字列で指定されたプロパティにリテラル定義クラスで定義された値を適用するためのコードが次になります。

元記事 からそのまま引用しています。

[js] Ext.define(‘overrides.localized.Component’, { override: ‘Ext.Component’, initComponent: function() { var me = this, localized = me.localized, value; if (Ext.isObject(localized)) { for (var prop in localized) { value = localized[prop]; if (value) { me[prop] = eval(value); } } } me.callParent(arguments); } }); [/js]

すべてのコンポーネントに対するオーバーライドです。 localized プロパティ内のプロパティをすべて、文字列を評価した結果で置き換えています。 これにより、リテラル定義をセットするわけですね。 これは、なかなかにエレガントな仕組みだとおもいます。 多言語化や沢山の顧客を相手に細かな文言を微調整しなければならない場合にはうってつけの解決方法ではないでしょうか。