Skip to content

UIコンポーネントライブラリにおける i18n の考え方

2024-08-24


補足

事業会社においてデザインシステムの一部として UI コンポーネントライブラリを作ることはよくあることだが、冗長になるためこの記事ではその UI コンポーネントライブラリを「デザインシステム」と表現する。

はじめに

社内用に作られているデザインシステムでは、特定の組織のユースケースに最適化されて作られることが多い。そして、そのコンポーネントを国際化対応をしたいことは往々にしてある。

今回は、特定の組織におけるデザインシステムの i18n の実装方法の 1 つについて書いていく。

デザインシステムにおける i18n の勘所

プロダクトで i18n をする際、一般的には json で言語の定数を記述し、それにライブラリを使って型をつけて hooks 経由で呼び出して使うことが多い。
全てのテキストを抜け漏れなく定義し、それぞれの言語で表現するのであればそれは妥当な方法である。

しかし、デザインシステムではそこが少し異なる。UI コンポーネントライブラリなので、基本的に props 経由でテキストが差し込まれる。差し込む場合、そのテキストに関してはプロダクト側で管理・保持してほしい。
ではどのようなときにデザインシステムの i18n 対応が必要になるのか。それは以下の 2 つのケースである。

  1. optional かつ初期値が定義されている props
  2. デザインシステム内で保持している固定値

それぞれについてもう少し抽象度を落として書く。

optional かつ初期値が定義されている props

1 つ目は optional かつ初期値が定義されている props である。
今自分がパッと思いついた内容だと、Input コンポーネントの placeholder がそれにあたる。

const Input = ({
  placeholder = "テキストを入力してください", // <- こういうの
  ...rest
}) => {
  return <input placeholder={placeholder} {...rest} />;
};
1
2
3
4
5
6

placeholder のような、初期値をデザインシステム側で持っておきつつ、言語によってそれを切り替えたい場合には、デザインシステム側で i18n 対応が必要になる。
このようなケースの場合、props が渡されたらそれを優先しつつ、渡されなかったらデザインシステム側で保持している言語に応じたテキストを使うように実装することが多い。

デザインシステム内で保持している固定値

これは比較的わかりやすい。
例えば Calendar コンポーネントが保持している曜日の定数。これは基本的にはプロダクト側から差し込むことはない。
また、プロダクトの説明テキストをデザインシステムで持っている場合も同様にこのケースに当たる。

このような固定値においても、デザインシステム側で日本語と英語(と任意の言語)の key-value を持っておきたくなる。

実装

大枠としては、各言語の定数を定義しつつ、それを React context で global で持ち回る、そしてそれを各コンポーネントで hooks 経由で取得するという流れになる。

定数とインターフェイスの定義

まずはじめに言語の定数とその型定義をする。
先ほどの例で placeholder を使ったので、ここでもそれを使う。

// 型定義
import { InputProps } from "/path/to/Input";

export type I18n = {
  components: {
    Input: {
      defaultProps: Pick<InputProps, "placeholder">;
    };
  };
};
1
2
3
4
5
6
7
8
9
10
// 各言語の定数定義
export const en: I18n = {
  components: {
    Input: {
      defaultProps: {
        placeholder: "Please enter text",
      },
    },
  },
} as const;

export const ja: I18n = {
  components: {
    Input: {
      defaultProps: {
        placeholder: "テキストを入力してください",
      },
    },
  },
} as const;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

React context の定義

次に context と provider の定義を行う。
ここでは言語を切り替えるための、locale という props を持たせる。これはプロダクト側からもらう値。

import * as React from "react";

export type I18nProviderProps = {
  locale?: "ja" | "en";
  children?: React.ReactNode;
};

export const I18nContext = createContext<I18nProviderProps>({
  locale: "ja",
});

export const I18nProvider: React.FunctionComponent<I18nProviderProps> = ({
  locale = "ja",
  children,
}) => {
  return (
    <I18nContext.Provider value={{ locale }}>{children}</I18nContext.Provider>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

hooks の定義

次に、各コンポーネントで呼び出す hooks を定義する。

hooks では props の全てのオブジェクトを取得し、それに対して定数が見つかったらそれを返すようにする。
選択中の言語に関しては、context から取得する。

また、ジェネリクスを使って props を定義することで、固定値に対しても型安全にアクセスすることができる。

import { PropsWithChildren, useContext } from "react";

import { ComponentName, locales } from "./constants";
import { I18nContext } from "./context";

export const useI18n = <T>({
  props,
  name,
}: {
  props: PropsWithChildren<T>; // ジェネリクスにすることで型の拡張を呼び出し側で制御することを想定
  name: ComponentName; // コンポーネントの名前、keyof locales[locale].components とかにしておくといいかも
}): T => {
  const { locale: localeProp } = useContext(I18nContext);
  if (localeProp === undefined) {
    return props;
  }

  // 言語の定数を取得
  const locale = locales[localeProp].components;
  const result = { ...props };
  const defaultProps = locale[name].defaultProps;

  // 受け取った props に対して、デフォルト値が存在しない場合に限り、選択中の言語のデフォルト値を代入する
  for (const key in defaultProps) {
    const _key = key as keyof typeof defaultProps;
    if (result[_key] === undefined) {
      result[_key] = defaultProps[_key];
    }
  }

  // 値をセットして返す
  return result;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

呼び出し

最後に、各コンポーネントで hooks を呼び出す。

import { useI18n } from "/path/to/useI18n";

const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  const { placeholder, ...rest } = useI18n({ props, name: "Input" });

  return <input ref={ref} placeholder={placeholder} {...rest} />;
});
1
2
3
4
5
6
7

hooks のインターフェイスに型を指定すれば、props 経由で取得しないような固定値についても、型がついている状態でアクセスすることができる。

import { useI18n } from "/path/to/useI18n";

type FooComponentProps = {
  baz: string;
};

const FooComponent = forwardRef<HTMLDivElement, FooComponentProps>(
  (props, ref) => {
    // bar は props に存在しないが、このように型を指定することで言語定数に存在していれば useI18n の戻り値で取得することができる
    const { bar } = useI18n<FooComponentProps & { bar?: string }>({
      props,
      name: "FooComponent",
    });

    return <div ref={ref}>{bar}</div>;
  }
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

使う側

使う側は、I18nProvider の locale prop を使って言語を切り替えることができる。
言語の切り替えはプロダクト側で行う想定。

import { I18nProvider } from "oreore-design-system";

const App = () => {
  const [locale, setLocale] = useState<"ja" | "en">("ja");

  // ...処理

  return (
    <I18nProvider locale={locale}>
      <ApplicationRoot />
    </I18nProvider>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13

おわりに

デザインシステムのように、言語定数の数がプロダクトよりも少なく、かつ基本的に文字等は props から値を受け取ることが前提の実装においては、ライブラリ等を使うよりはこのように小さくシンプルな実装がオーバーエンジニアリングになりにくく有効である。
こういうの書いてると eslint plugin とか書いておきたくなるなぁ。