Next.jsにてContextとhookで多言語対応する

13 min読了の目安(約7800字TECH技術記事

Next.jsにてContextとhookで多言語対応する

Next.js(React)で多言語対応をしたい場合、isaachinman/next-i18nextなどのライブラリはありますが、ちょっと多機能すぎて、ブラックボックスな部分が多いと感じたので、簡単に自作してみることにしました。

準備

とりあえずNext.jsの環境を用意します。

$ npx create-next-app demo --use-npm --example "<https://github.com/vercel/next-learn-starter/tree/master/learn-starter>"
$ cd demo

TypeScriptを利用するのでその設定を追加。

$ touch tsconfig.json
$ npm install --save-dev typescript @types/react @types/node

ローカルサーバでnext.jsを起動します

$ npm run dev

pages/index.tsxを最小の設定にします

import Head from 'next/head';

export default function Home() {
  return (
    <div className="container">
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <h1 className="title">
          Welcome to <a href="https://nextjs.org">Next.js!</a>
        </h1>

        <p className="description">
          Get started by editing <code>pages/index.js</code>
        </p>
      </main>
    </div>
  );
}

まずはこのように表示されます。

言語ファイルの作成

次に各言語ファイルのを作成します。

locales
├── en
│   └── common.json
└── ja
    └── common.json

common.jsonは以下のようにします。

// locales/ja/common.json
{
  "head": {
    "title": "ようこそNext.jsへ!"
  },
  "title": "ようこそ",
  "description": "編集してみましょう"
}
// locales/en/common.json
{
  "head": {
    "title": "Welcome to Next.js!"
  },
  "title": "Welcome to",
  "description": "Get started by editing"
}

ネストさせてもOKな作りにしています。

Contextの作成

画面に表示されるテキスト、現在の言語の設定、言語の設定関数をシステム全体で利用したいのでそのContextとそのProviderを作成します。

import clone from 'lodash/cloneDeep';
import merge from 'lodash/merge';
import * as React from 'react';

export type locales = "ja" | "en";

interface LocaleProviderProps {
  lang: locales;
}

interface ContextProps {
  readonly locale: locales;
  readonly localeData: {
    [key: string]: string;
  };
  readonly setLocale: (lang: locales) => void;
}

const getLocaleData = (lang: locales) => {
  let localeData = {};
  const jaData = require("../locales/ja/common.json");
  switch (lang) {
    case "en":
      localeData = merge(clone(jaData), require("../locales/en/common.json"));
      break;

    default:
      localeData = jaData;
      break;
  }
  return localeData;
};

// Contextを生成
export const LocaleContext = React.createContext<ContextProps>({
  localeData: {},
  setLocale: () => null,
  locale: "ja"
});

/**
 * LocaleProvider
 */
const LocaleProvider: React.FC<LocaleProviderProps> = ({ children, lang }) => {
  const [locale, setLocale] = React.useState<locales>(lang);
  const [localeData, setLocaleData] = React.useState(getLocaleData(locale));
  // 言語データを更新
  React.useEffect(() => {
    setLocaleData(getLocaleData(locale));
  }, [locale]);

  return (
    <LocaleContext.Provider value={{ localeData, setLocale, locale }}>
      {children}
    </LocaleContext.Provider>
  );
};
export default LocaleProvider;

言語の変更(日本語→英語) getLocaleData が呼び出されそこでjsonが読み込まれます。
lodashのmergeを使い、"en"のデータがない場合自動的に"ja"の値をフォールバックさせる仕組みにしてます。

hookの作成

Contextを呼び出すためにカスタムhookを作成します。Building Your Own Hooks を参考。

import get from 'lodash/get';
import { useContext } from 'react';

import { LocaleContext } from '../contexts/LocaleContext';

export default function useLocales() {
  const { localeData, setLocale, locale } = useContext(LocaleContext);

  function t(key: string): any {
    const data = get(localeData, key);
    if (!data) {
      console.error(`Translation '${key}' for locale not found.`);
    }
    return data || "";
  }

  return {
    t,
    locale,
    setLocale,
  };
}

利用される変数は以下の3つです

  • t ... 引数で渡された文字列のキーをjsonから検索し、値を返します。lodashのgetを使い、ドット表記でネストされたキーも引き当てることができます(tはtranslateの略です)
  • locale ... 現在の言語の設定を返します。実際は"ja" or "en" の文字列を返します。
  • setLocale ... 言語の設定を変更する際に呼び出される関数。context側のuseEffectが呼び出されます

組み込み

実際に組み込みます

pages/_app.jsx を生成しProviderを利用します

import { AppProps } from 'next/app';

import LocaleProvider from '../contexts/LocaleContext';

function App({ Component, pageProps }: AppProps) {
  return (
    <LocaleProvider lang="ja">
      <Component {...pageProps} />
    </LocaleProvider>
  );
}

export default App;

<LocaleProvider> のlang="ja"の部分でデフォルトの言語を設定します。上記では強制的にjaになってますが、ComponentDidMountやgetStaticProps等で、パスやクエリパラメータ、ブラウザのデフォルト言語や保存された値から取得する方法を取るといいです。

次に、 useLocalesを呼び出して実際の翻訳テキストを表示できるようにします。

import Head from 'next/head';

import LocaleSwitch from '../components/LocaleSwitch';
import useLocales from '../hooks/useLocales';

export default function Home() {
  const { t } = useLocales();
  return (
    <div className="container">
      <Head>
        <title>{t("head.title")}</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <h1 className="title">
          {t("title")} <a href="https://nextjs.org">Next.js!</a>
        </h1>

        <p className="description">
          {t("description")} <code>pages/index.js</code>
        </p>
      </main>
    </div>
  );
}

hookを利用することでpages以外のComponentなどどこからでも呼び出すことができます。

言語の切り替えを実装

LocaleSwitchコンポーネントを作成し、useLocalesの setLocale を呼び出します。以下のように実装しました。

import * as React from 'react';

import useLocales from '../hooks/useLocales';

interface LocaleSwitchProps {}

/**
 * LocaleSwitch
 */
const LocaleSwitch: React.FC<LocaleSwitchProps> = () => {
  const { setLocale } = useLocales();
  return (
    <div>
      <span
        className="button"
        onClick={() => {
          setLocale("ja");
        }}
      >
        日本語
      </span>
      <span> / </span>
      <span
        className="button"
        onClick={() => {
          setLocale("en");
        }}
      >
        English
      </span>
      <style jsx>{`
        .button {
          padding: 8px;
          cursor: pointer;
          color: #0070f3;
        }
      `}</style>
    </div>
  );
};
export default LocaleSwitch;

切り替えができるようになりました。

まとめ

以上の実装により、リロードなしで言語の切り替えができるようになりました。


課題点🤔

簡単に言語の切り替えを実装することはできましたが、以下の点は課題・懸念としてあり、最善ではないかもしれないですが個人的な意見を書いています。

改行を含むテキストはどうするのか

改行を含むテキストはjson上で改行文字列( \n)を書き、表示するコンポーネントのcssにて white-space: pre-wrap; もしくは pre-line を利用して改行を表現することができます。

テキスト内にリンクがある場合、どうしても分けないといけない

上記の例でもあるようなテキスト内にリンクがある場合、翻訳テキストを2つにわけないといけないです。

<h1>
{t("title")} <a href="https://nextjs.org">{t("titleNext")}</a>
</h1>

さらに日本語 < - > 英語で文法上前後が変わってしまう場合はもっと厄介で、仕方なくif文で分けるしかないのかなと思います。可能であれば、そうならないようにテキスト自体を調整する。

配列で保持したい値はどうするか

結論からいうと面倒くさがらず、個々の値を持つようにすることだと思います。

しかしながら、Reactを使っている以上、配列で記述したいということはあると思います。その場合以下のように翻訳テキストのJSONには配列を記述し、コンポーネントの方で配列として扱うことはできます。

{
  "texts": ["テキスト1", "テキスト2", "テキスト3"]
}
<ul>
  {t("texts").map((t) => (
    <li key={t}>{t}</li>
  ))}
</ul>

ただしこの場合、関数 t の戻り値が不定になるので、その点がモヤモヤします。。

日付のフォーマットが変わる場合はどうするか

これも多言語機能全般にいえることですが、今回の場合はフォーマット用のコンポーネントを作るとか、でしょうか・・?

簡単に例を書くと、

import * as React from 'react';

import useLocales from '../hooks/useLocales';

interface FormatDateProps {
  date: Date;
}

/**
 * FormatDate
 */
const FormatDate: React.FC<FormatDateProps> = ({ date }) => {
  const { locale } = useLocales();
  const y = date.getFullYear();
  const m = date.getMonth() + 1;
  const d = date.getDay() + 1;
  return <span>{locale == "en" ? `${m}/${d}/${y}` : `${y}/${m}/${d}`}</span>;
};
export default FormatDate;

もしくはmoment.jsday.jsで選択されているlocaleを元に導出するとか。

SEO的にはどうか

SEO的には例えばパスで/enや/jaを指定しそのページを表示しますが、上記ではそれができません。

試してはないですがNext.jsのRouting: Dynamic Routesを使って /pages/[lang]/index.tsxを作り、パスに言語設定を渡すことができると思います。