レベルエンター山本大のブログ

面白いプログラミング教育を若い人たちに

BLOCKVROCKリファレンス目次はこちら

Typescriptで国際化対応する方法を試行錯誤したので、viteつかってハンズオンできるように残す

f:id:iad_otomamay:20220207221801g:plain
国際化対応の完成図

Typescriptを使った開発で、国際化対応(多国語へのコンテンツ変換)に対応し、できるだけ開発体験をよくするための方法を考えてみました。

この記事では、typescriptの環境を作るためにviteでバニラtypescript(フレームワークを使わないシンプルなtypescript)のプロジェクトを作ってみることもやってみます。

僕は、自分のサービスで国際化対応するのは初めてではなく、なんどか試みたことがあるのですが開発体験がものすごく下がることが辛くて、一度挫折しました。

その課題を解決する方法を模索していて、ある程度納得のいく環境ができたので記事にします。

国際化対応のつらさ(課題)

当初はi18nextというパッケージでen.jsonとかja.jsonなどのリソースファイルを作ってやってましたが、以下のような辛さがありました。

  • ソース中を文字列などでgrepした時にリソースファイルに飛んでしまう
  • リソースファイルと本体のソースコードを行ったり来たりする際に迷子になる
  • 別言語での書き方を参考にしたい時に、リソースファイルが別れているのでいちいち探すのがつらい

どんな形が自分には理想的か?

開発の規模や国際化対応の規模によって、全然話が変わってくるのですが、僕の場合とりあえず日・ひらがな・英ぐらいで対応できたらいいだけなので小規模前提です。

  • 国際化の必要な文字リソースはプロジェクトのあらゆる場所に頻繁に出てくるので、1文字でも記述量を減らしたいし補完で書きたい。
  • リソースファイルの定義では、各言語が別ファイルにあるのではなく同一語句はまとめて定義したい。
  • リソースファイルは、機能群毎に分割したい。
  • IDEの機能でリソースファイルにすぐに飛びたい。
  • Typescriptの補完が効くのがベストだけれど、そのために書くべきことが増えるのはだめ。

ということで試行錯誤した結果、以下のような書き方に落ち着きました。

  • 利用する側
const title = translate.to("こんにちは世界", "title");

app.innerHTML = `<h1>${title}</h1>`;

#to()の第1引数に、日本語でのリソースを記述することによって、ソースの可読性やgrepした時の課題を解決します。

#to()の第2引数は、リソースのキーを記述します。これは定義されたリソースファイルのプロパティ名で補完されます。(後述しますがtypescript のkeyofがミソです)

translateオブジェクトの参照を辿れば、リソースファイルへはすぐに辿り着けるようにしました。

書き方を変えて以下のようにHTMLやVueテンプレートなどに埋め込むことも想定します。

translateを短くtに置き換えておくとテンプレートの中でも邪魔にならないですね。

  • テンプレートに文字列を埋め込む
const t = translate;

app.innerHTML = `<h1>${t.to("こんにちは世界", "title")}</h1>`;

そしてリソース定義側は以下のように同じリソース名に対する翻訳は、並べて記述します。

  • リソース定義
import { Translation } from "./Translation";

class TopResource extends Translation {
  title = {
    en: "Hello world!",
    cn: "你好世界!",
  };
 foo = {
   en: "Bar",
   cn: "婆",
 }
}
export const topResource = new TopResource();

translateオブジェクトは機能単位で分割可能で、Translationクラスを継承すればOKです。

リソースが増えても、toの第2引数はリソースのプロパティ(titleやfoo)で補完でき、型チェックも効いてくれます!

ではそんな環境を作っていきましょう

ハンズオン開始!viteプロジェクトを作成

vite は最近出てきたフロントエンドのツールで、ややこしかったビルド環境の構築を瞬時にやってくれるすごいやつです。 React、VueといったフレームワークだけではなくバニラなTypescriptの環境も作ってくれます(ちゃんとimportなどを解決してくれます)

以下のコマンドを実行します。

npm init vite@latest

そうすると対話式で、プロジェクトのScaffoldをつくてくれます。 まずはプロジェクト名、今回は「localization」と名づけました。

Project name: › localization

次に利用するフレームワークですが、ここは「vanilla」つまりフレームワークなしを選択しました。選択肢が広くていいですね。

? Select a framework: › - Use arrow-keys. Return to submit.
❯   vanilla
  vue
  react
  preact
  lit
  svelte

次は、JSかTSか。「vanilla-ts」を選びました。

? Select a variant: › - Use arrow-keys. Return to submit.
vanilla
❯   vanilla-ts

「Done. 」

Scaffolding project in /Users/dai/projects/2022/localization...

Done. Now run:

出来上がったプロジェクトのtreeを見てみると以下のような感じです。

.
├── favicon.svg
├── index.html
├── package.json
├── src
│   ├── main.ts
│   ├── style.css
│   └── vite-env.d.ts
└── tsconfig.json

node_modulesがないのでnpm installを実行しておきます。

これだけで、VanillaなTypescript環境が出来上がりました。

package.jsonにあるdev のscriptをnpm runで実行しましょう。

npm run dev

ブラウザで「http://localhost:3000」にアクセスします。

f:id:iad_otomamay:20220207214947p:plain
Vite初期画面

VanillaなTypescriptをまともに作ろうと思うと、これまではどうしてもWebPackやらが必要になっていたのでありがたいです。

以降では、国際化対応のコードを記載していきます。

国際化対応のコードを記載

まずはi18next関連のライブラリをインストールします。

npm install i18next i18next-browser-languagedetector

次にリソースファイルです。

  • TopResource.ts(新規)
import { Translation } from "./Translation";

class TopResource extends Translation {
  title = {
    en: "Hello world!",
    cn: "你好世界!",
  };
 foo = {
   en: "Bar",
   cn: "婆",
 }
}
export const topResource = new TopResource();

次のクラスが翻訳を実施するクラスです。

  • Translation.ts(新規)
import i18next from "i18next";
import i18nextBrowserLanguageDetector from "i18next-browser-languagedetector";

/**
 * 対応言語を設定
 */
const Languages = ["en", "ja", "cn"] as const;
type LanguageTypes = typeof Languages[number];

/**
 * リソースファイルの親クラス
 */
export class Translation {
  constructor() {
    i18next.use(i18nextBrowserLanguageDetector).init();
  }

  // 対応する言語かを判定
  isAvailable = (name: string): name is LanguageTypes => {
    return Languages.some((value) => value === name);
  };

  /**
   * 現在選択されている言語を返す、リストになければenを返す
   */
  get language(): LanguageTypes {
    return this.isAvailable(i18next.language) ? i18next.language : "en";
  }

  set language(lang: LanguageTypes) {
    i18next.changeLanguage(lang).catch();
  }

  /**
   * リソースの翻訳を実施する
   * @param defaultJp 日本語のリソース文字列
   * @param key リソースを探すキーワード(リソースファイルのプロパティ名に一致)
   * @returns 翻訳された文字列
   */
  to(defaultJp: string, key: keyof this) {
    if (this.language === "ja") return defaultJp;
    const keyObject = this[key] as any;
    return keyObject[this.language] as string;
  }
}

ポイントは、toのメソッドです。

to(defaultJp: string, key: keyof this) 

引数keyの型がkeyof thisとなっており、継承サブクラスであるResourceクラスのプロパティ名をstring literal union 型にしています。

初めて使ったキーワードですが、なかなか感動!

これでkeyの指定をするために""などの文字列のクオーテーションを書くだけでリソースファイルからキーの一覧が出てきます。

そのほかのテクニックとしては、対応言語をstring literal union typeで指定しつつ、その内容も値として使いたい(string literal unionに含まれるかをチェックしたい)のでas constとtypeofをつかってstring literal unionを作っています。

画面を操作するmain.tsでは、各言語に変換できるボタンを実装しておきます。 宣言的にはかけないので頑張ります。

  • main.ts (修正)
import "./style.css";
import { topResource } from "./TopResource";

const app = document.querySelector<HTMLDivElement>("#app")!;
const t = topResource;

app.innerHTML = `
  <h1 id="title">${t.to("こんにちは世界", "title")}</h1>
  <button id="ja" class="lang"  >Japanese</button>
  <button id="en" class="lang"  >English</button>
  <button id="cn"  class="lang"  >Chinese</button>
`;

document.querySelectorAll(".lang").forEach((b) => {
  b.addEventListener("click", () => {
    t.language = b.id as "en" | "cn" | "ja";
    const title = t.to("こんにちは世界", "title");
    document.getElementById("title")!.innerText = title;
  });
});

できました。

f:id:iad_otomamay:20220207221801g:plain
国際化対応の完成図