🌏

【RT/TS】多言語対応について

2025/01/27に公開

新シリーズ始動

React Tech/Typescript Scraps (略してRT/TS) の記念すべき第一回へようこそ!今回は、最近react-i18nextと一緒に使っている便利なパターンをご紹介します。ローカライズ以外にも役立つテクニックなので、どうぞ最後までお見逃しなく!

このシリーズはどんな人向け?

このシリーズは、実践的な知識を身につけたいあなたのためのものです。難しい理論はできるだけ省き、サクッと読めるように心がけています。

このシリーズで扱うこと

このシリーズでは、実践的な例を通して、以下の内容を扱っていきます。

  • プログラミングパターン
  • 設計
  • TypeScriptの型付けTips
  • 便利ワザ & 裏技

これらの記事を通して、ReactとTypeScriptへの理解を深め、プロとしての実力をアップすることを目指します。

毎回、「おっ、これは使える!」と思えるような発見があるはず。ぜひ、お楽しみください!

必要な前提知識

この記事をスムーズに理解するには、以下の知識があると役立ちます。

コンセプト

多言語を導入する際、多くの場合、以下のようなコード変更が発生します。

<div className="my-class">
  { translate("page.title") }
</div>

ここでtranslateは、キー ("page.title") に基づいて対応する翻訳をローカライズ辞書 (JSONファイル) から取得するヘルパー関数です。詳しい実装はここでは省略しますが、通常はuseTranslationsのようなフックからエクスポートされ、内部でターゲット言語を決定します。

つまり、典型的なボイラープレートコードは以下のようになります。

const { translate } = useTranslations()

return (
  <div className="my-class">
    { translate("page.title") }
  </div>
)

しかし、このコードをもっとシンプルで分かりやすくできるとしたらどうでしょう? 例えば、このように。

return (
  <translated.div className="my-class">
    page.title
  </translated.div>
)

興味が湧いてきましたか? それでは、これがどのように実現されているのか、詳しく見ていきましょう。

実装

Higher-Order Component

このパターンの要となるのは、Higher-Order Component (HOC) です。

今回のHOCは、翻訳が必要なコンポーネントを引数として受け取り、自動翻訳機能を組み込んだコンポーネントを返します。

export const Translated = (Component) =>
  function TranslatedComponent({ children, ...props }) {
    const { translate } = useTranslations()
    return (
      <Component {...props}>
        { translate(children) }
      </Component>
    )
  }

この実装では、Component には翻訳対象となる文字列型の children が1つだけ存在すると仮定しています。必要に応じて、より柔軟なコンポーネントにすることもできますが、まずは型定義について理解しましょう。

ぜひ、このHOCの型定義にご自身で挑戦してみてください。TypeScriptの可能性について、多くのことを学べる良い練習になるはずです。

もし、型定義に詰まってしまっても大丈夫! この記事を読み進めていけば、解決策が見つかるはずです。


では、完成した実装を見てみましょう。
念のためジェネリクスの参照はこちらです。

import { ComponentProps, ElementType } from 'react'
import { JSX } from 'react/jsx-runtime'
import { useTranslations } from '@/hooks/useTranslations'

export const Translated = <
  Element extends ElementType,
  Props extends Omit<ComponentProps<Element>, 'children'> & { children: string | undefined },
>(
  Component: Element,
) =>
  function TranslatedComponent({ children, ...props }: Props) {
    const { translate } = useTranslations()
    return (
      <Component {...(props as JSX.LibraryManagedAttributes<Element, Props>)}>
        {translate(children)}
      </Component>
    )
  }

(JSX.LibraryManagedAttributesを使わずに実現する方法があるかもしれませんが、今のところ私にはわかりません)

さらなる進化

いよいよ仕上げです! children に文字列だけでなく、関数を渡せるようにHOCを強化してみましょう。

<translated.div>
  {(translate) => `${translate("price")}: ${number}${translate("currency")}`}
</translated.div>

このステップを成功させるには、レンダープロップスについて理解しておく必要があります。

ここでも、まずはご自身で実装に挑戦してみてから、解答を見てください。


HOCの最終版は以下のようになります。

import { ComponentProps, ElementType } from 'react'
import { JSX } from 'react/jsx-runtime'
import { useTranslations } from '@/hooks/useTranslations'

type TranslatedChildren =
  | string
  | undefined
  | ((translate: ReturnType<typeof useTranslations>['translate']) => string)

export const Translated = <
  Element extends ElementType,
  Props extends Omit<ComponentProps<Element>, 'children'> & {
    children: TranslatedChildren
  },
>(
  Component: Element,
) =>
  function TranslatedComponent({ children, ...props }: Props) {
    const { translate } = useTranslations()
    const isString = typeof children === 'string'

    return (
      <Component {...(props as JSX.LibraryManagedAttributes<Element, Props>)}>
        {children && (isString ? translate(children) : children(translate))}
      </Component>
    )
  }

これで、childrenTranslatedChildren 型に一致する任意のコンポーネントに翻訳機能を追加できるようになりました。

例えば、UIライブラリの一般的な <Button/> コンポーネントを使用する場合、以下のように記述できます。

import { Button } from "@/components/ui"
import { Translated } from "@/i18n/translated"

const TranslatedButton = Translated(Button)

function Page() {
  // コードは省略
  return (
    <section>
      <TranslatedButton variant="solid" color="success">
        actions.confirm
      </TranslatedButton>
    </section>
  )
}

シンタックス

では、<translated.div>key</translated.div>のようなシンタックスはどのように実現するのでしょうか? 実は、見た目よりも簡単です。

Reactにおけるタグ (コンポーネント) とは、関数のことです。ということは、translated.div も関数 (コンポーネント) になります。

つまり、以下のように記述することができます。

export const translated = {
  div: Translated('div'),
  span: Translated('span'),
  p: Translated('p'),
  label: Translated('label'),
  button: Translated('button'),
  th: Translated('th'),
  td: Translated('td'),
  h1: Translated('h1'),
  h2: Translated('h2'),
  h3: Translated('h3'),
  h4: Translated('h4'),
}

標準的なHTMLタグの場合、タグ名を表す文字列をコンポーネントとして使用できます。便利ですよね!

あとは、このオブジェクトをインポートして、関数をタグとして呼び出すだけです。

import { translated } from "@/i18n/translated"

function DetailsPage() {
  // その他のコード
  return (
    <main>
      <translated.h1>page.title</translated.h1>
      <translated.h2>page.subtitle</translated.h2>
      <translated.p>page.description</translated.p>
    </main>
  )
}

まとめ

今回の記事では、コンポーネントをラップして新しい機能を追加する方法を学びました。この方法では、元のコンポーネントのpropsに干渉することなく、機能を拡張することができます。さらに、適切な型定義によって、コンポーネントが持つpropsを明確に示すことができます。

このアプローチは、ローカライズ以外にも応用できます。例えば、アニメーションライブラリとして有名なFramer Motion (現在はMotion) では、このメカニズムを使って、複雑なアニメーションをシンプルかつ迅速に実装しています。

<motion.div
  initial={{ x: "100%" }}
  animate={{ x: "calc(100vw - 50%)" }}
/>

今回の記事はいかがでしたか? 新しい発見があれば幸いです。

記事では実装の詳細をすべて解説しているわけではありませんので、ご不明な点があれば、お気軽にコメントをお寄せください。

それでは、次の記事でお会いしましょう!

Happy coding!

Sun* Developers

Discussion