react-routerに乗りながら言語別URLに対応する

こんにちは！！！ はなくら ([@hanak1a_](https://twitter.com/hanak1a_))です！！！😈😈😈
今回は3DキャラクターSNS [VRoid Hub](https://hub.vroid.com)で行った、「react-routerに乗りながら言語別URLに対応する」方法を紹介します！

皆さん`react-router`使ってますか！？ 使ってない！！？ それはとてもイケイケですね！ react-routerはしんどいのでやめましょう！ ~~丁度いい代替のルーターライブラリはありませんが、~~Next.jsってやつに乗れれば大丈夫でしょう（知らんけど🤷‍♀️）

追記 2020/08/24 15:18 :: 代替ルーターライブラリないって言ったけど、記事を出した直後に [Rocon](https://rocon.uhyohyo.net/)というライブラリを見つけました… ざっとDocsを見た感じよさげなので、気になったら覗いてみてください

追記 2020/12/18 22:10 :: ""全て""を解決する最高のルーターライブラリ[`@fleur/froute`](https://github.com/fleur-js/froute) を開発しました！ さよならreact-router!!! 今までありがとう!!!! (いい子はNext.jsを使いましょう)

ということで僕はまだreact-routerと袂を分かてていないのでreact-routerで言語別URL対応します😛

# 言語別URL #とは

`yourwebsite.me/ja`, `yourwebsite.me/en`など、URL内に言語コードを含むことで異なる言語向けのWebサイトを展開するアレのことを、この記事では言語別URLと呼んでいます。

VRoid Hubでは、`/en/`以下のURLからアクセスした場合に英語版ページを表示する仕組みに（さっき）しました。 これは海外向けSEOのためで、海外のGoogle Botから、「へぇ… 君くんって意外と英語対応してるんだ… （無言のインデックス）」をしてもらうために実装されています。

多言語対応のために言語別URLを提供することは[Googleからも推奨されています。](https://support.google.com/webmasters/answer/182192?hl=ja)

# とりあえずルーティングをいい感じにやろうぜ

react-routerが`/en`あるなしを判断しつついい感じにルーティングを行えるかというと、VRoid Hubの環境下ではかなり微妙でした。

チュートリアル的なreact-routerの使い方のように、JSX内にルート定義をしているなら[`react-router-i18n`](https://github.com/perseids-tools/react-router-i18n)を組み合わせた対応ができるのですが、VRoid HubではSSR時のAPIコール定義をまとめたかった都合上、`react-router-config`を使ったObject notation形式のルート定義を行っています。

```tsx
const routeConfig: { [key in RouteName]: AppRouteConfig } = {
  RouteName.artworks: { // ← このsyntaxは不正だがハイライトが死ぬので…
    path: '/artwork/:id',
    exact: true,
    component: Artwork,
    preload: (match, dispatch) => [
      dispatch(fetchArtwork(match.params.id))
    ],
  },
  ...
}
```

`/en`を解釈できるようにするには、一段`/:lang`をはさんであげればよいのですが、これをやると逆に現状のURL構造を`/ja`などに変える必要が出てきます。

ここをいい感じにしようと思うと、react-routerで無理やりどうこうするより、ルーティングを自分でやってしまえという気持ちになりますね🤪

`react-router-config`は、ルートに対応するコンポーネントをレンダリングする処理を`renderRoutes(routeConfig)`という関数が行っています。この関数を自分の好きなルーティング処理に置き換えてしまえば、いい感じにルーティングできそうです。

それでは、こちらのあらかじめ調理しておいたコードをご覧ください 🕒

```tsx
// Router.ts
export const useRouteRender = () => {
  const location = useLocation();

  // `/[lang]`なURLを言語コードなしのURLに正規化する
  const normalizedPath = normalizeLocaleUrl(location.pathname);
  const match = useMemo(() => matchedRoute(routeConfig, normalizedPath), [normalizedPath]);

  const Component = match.component;
  const normalizedLocation = useMemo(
    () => ({
      ...location,
      pathname: normalizedPath,
    }),
    [location, normalizedPath],
  );

  return useMemo(
    () => ({
      renderRoutes: () => <Component match={match} location={normalizedLocation} />,
    }),
    [match, normalizedLocation],
  );
};

// App.tsx
export default () => {
  const { renderRoutes } = useRouteRender();

  return (
    <div>
      {renderRoutes()}
    </div>
  );
}
```

`matchedRoute`はreact-router-configが提供している`matchedRoutes`の「ジャストそれ！！！」なルート定義をとってきてくれる版です。配列返しません。

`normalizeLocaleUrl()`で言語コードあり・なしのURLを「言語コードなし」に正規化します。 アプリ側は言語コードなしのURLだと思い込みながら動くので、言語コード付きURLをルーター層に隠蔽することが出来ます。

Componentのpropsに`match`, `location`を渡してあげれば、react-routerと同じインターフェースを保つ事ができます。

あとはSSR時にアプリで利用する言語を設定してあげればだいたい動きました。

```tsx:server.tsx
// URLで指定された言語コードを取り出してくれるやつ
const lang = detectLangFromUrl(req.url);

reduxStore.dispatch(AppActions.setLanguage(lang));
i18n.changeLanguage(lang);
```

SSRからhydrateされたstateを元にStoreを復元→Storeに入っている言語設定を元にクライアント側でレンダリング、という流れでクライアント側は特に何もせず動きました。

クライアント側独自で言語設定を再解釈してる？ それは大変だねぇ…… がんばってね、応援してるよ…（何？？？


# URL生成も言語別URLに対応する

サイト内に点在するURL生成処理も言語別URLに対応する必要があります。
例えば、ユーザーが`/en/artworks`にいる時、そのページ内にあるサイト内リンクは全て`/en/~`形式になっていて欲しいですね。

VRoid HubのURL生成処理は全て`makePath` / `makeFullPath`という単純な関数に任されており、これらの関数から現在のURL文脈を考慮することは、お行儀の都合上出来ません。（react-router下でwindow.locationを触りたくないよね）

そこで、これらの関数をReact Hooksでラップすることで、現在のURL文脈を考慮したURLを生成するように変更しました。

```tsx
import { makePath, makeFullPath } from '昔々あるところにあったURL生成処理'

export const useUrlBuilder = () => {
  const { pathname } = useLocation();
  // pathnameから言語コード取り出してくれるマン
  const locale = detectLangFromUrl(pathname);
  const prefix = locale === Langs.En ? '/en' : '';

  return useMemo(
    () => ({
      makePath: (name: RouteName, data?: { [k: string]: string }, query?: { [k: string]: any }) =>
        `${prefix}${makePath(name, data, query)}`,
      makeFullPath: (name: RouteName, data?: { [k: string]: string }, query?: { [k: string]: any }) =>
        `${prefix}${makeFullPath(name, data, query)}`,
    }),
    [prefix],
  );
};
```

# react-routerこわれちゃった…

さて、ここまでで何事もなくいい感じに動くようになったように見えますが、こんな強引なことをやればもちろんreact-routerがイカれます。
 react-routerはブラウザに表示されてるURLを見て動こうとしますが、アプリ側は正規化済みのURLで動いているためです。

`/en/~`でアクセスされたらreact-routerは「対応パスなし」と判断しますので、`useParams`, `useLocation`などのreact-routerビルトインのHooksはアプリ側が意図した状態を返しません。 ただしこれらのHooksはそんなに難しいことはしていないので、しれっと再実装できちゃいます。

なのでやっていきましょう。

```ts:Router.ts
import { useLocation as useReactRouterLocation } from 'react-router'
import qs from 'querystring'

export const useLocation = <T extends Record<string, string | string[]> = {}>(): Location<T> => {
  const location = useReactRouterLocation();
  const normalizedPath = normalizeLocaleUrl(location.pathname)
  const match = useMemo(() => matchedRoute(routeConfig, normalizedPath), [normalizedPath]);

  return useMemo(
    () => ({
      pathname: location.pathname,
      search: location.search,
      query: Object.assign(qs.parse(location.search.slice(1)), match?.params ?? {}),
    }),
    [location.pathname, location.search, match],
  );
};

export const useParams = <Params extends { [K in keyof Params]?: string } = {}>(): { [p in keyof Params]: string } => {
  const location = useLocation();
  const match = matchedRoute(routeConfig, location.pathname);

  return match?.params ?? {};
};
```

あとはアプリ内で使われているこれらのHooksを自前実装Hooksに置き換えればおわりです！ こうして言語別URL対応ができました。 react-routerくん、""これからもよろしくな！""（悪い顔で睨みつける）

ここまでラップすると、いざ「react-routerやめよう！」となった時にも多少クッションにしやすいです。はやくNext.jsの恩恵を受けたいところですね▲


# おわり

文中のコードには`/en`以外の言語が増えたときのこと何も考えてなさそうなコードが散らばっていてもにょっとしますね。 まあ必要になったときに考えればいいので雑に書いています。 importとかも雑に書いたり書かなかったりしてるので概念だけの説明でした。

参考になるかはわからんですが、参考になれば参考にしてください。
早くreact-routerやめて脳死dynamic importキメて配信されるjs軽くしたいアルﾖ……🤷‍♀️