クモのようにコツコツと

フロントエンドエンジニア イイダリョウの技術ブログ。略称「クモコツ」

【React】React RouterでSPAのルーティング設定(下層ページの404対策も)

Reactの続きです。以前作成したReact + CSS in JS + TypeScript環境にReact Routerを使ってルーティング設定をして複数ページを遷移できるSPAにします。helmetを使ってコンテンツだけでなくheadタグも動的に変更。さらに下層ページに直接アクセスすると404になる対策も行いました。それではいきましょう!

【目次】

※参考:前回記事
【React】ピュアなTypeScriptモジュールを追加してみる(Reactとメタ言語の比較-6) - クモのようにコツコツと

※参考:Reactを習得するためにやったことまとめ
qiita.com

前回のおさらい

以前作成したフロントエンドスターターキット(EJS + Sass(SCSS) + TypeScirpt)環境のコンテンツをReact + CSS in JS + TypeScript環境で再現。

https://cdn-ak.f.st-hatena.com/images/fotolife/i/idr_zz/20210212/20210212063628.jpg

※参考:【React】ピュアなTypeScriptモジュールを追加してみる(Reactとメタ言語の比較-6) - クモのようにコツコツと

SPAで複数ページ構成を再現したい

EJSの時は「index.ejs」と「other.ejs」というようにページごとにEJSファイルを作っていた。

f:id:idr_zz:20210228142304j:plain

※参考:【gulp】メタ言語(EJS、Sass(SCSS)、TypeScript)を同時コンパイルする! - クモのようにコツコツと


ではこれをReactで再現するにはどうするか。

いま、HTMLページ単位としては「index.html」があり、その中の#rootタグでReactの仮想DOM(JSX)をレンダリングしている。

  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!-- 中略 -->
  </body>

同じような感じで「other.html」を作って、その中のタグにotherページに表示したい仮想DOMをレンダリングさせるのも手だとは思う。


しかしここはあえて「index.html」のシングルページの中で複数ページのUXを再現するルーティング設定をやってみたい。(以前ExpressとJSフレームワークの違いについて調べていたときに知った仕組み)

※参考:ExpressとJSフレームワーク(React、Vue、Angularなど)との関係について調べたこと - クモのようにコツコツと

ルーティングを実現するとページに表示されるコンテンツだけでなくURLのパスも変更し、閲覧履歴(History API)も制御できる。つまり、ブラウザの戻るボタン、進むボタンでページが切り替わる。

※参考:History API - Web API | MDN

react-routerをインストール

Reactのルーティング設定は「react-router」を使うのが定番のようだ。

※参考:React Router: Declarative Routing for React.js

まず、react-router-domパッケージをインストールする。

$ npm install react-router-dom

TypeScript環境のため@typesも必要(これを入れないとエラーになった)。

$ npm i @types/react-router-dom

package.json確認

  "dependencies": {
    // 中略
    "@types/react-router-dom": "^5.1.7",
    // 中略
    "react-router-dom": "^5.2.0",
    // 中略
  },

「react-router-dom」とその@typesが追加された!

「data.json」に別ページの内容を追加

otherページに表示したいコンテンツを「data.json」に追加する。

以前はmainキーに下記のtitletextを設定していた。

 "main": {
            "title":"タイトルです",
            "text": "テキストです。テキストです。テキストですったらテキストです。"
            }

mainの内容をメインページとわかる内容に変更し、さらにotherキー(別ページに表示したい内容)を追加

"main": {
            "title":"メインページです",
            "text": "メインページのテキストです。テキストですったらテキストです。"
        },
        "other": {
            "title":"別ページです",
            "text": "別ページのテキストです。テキストですったらテキストです。"
        },

APPコンポーネントでheadタグのルーティング設定

react-routerのBrowserRouter、Switch、Routeをインポート

次にコンポーネントのファイルにreact-routerをインポートする。ドキュメントを参考に設定してみる。

※参考:React Router: Declarative Routing for React.js

Appコンポーネント(App.tsx)の冒頭

import {
  BrowserRouter as Router,
  Switch,
  Route,
} from 'react-router-dom';
  • react-router-domからBrowserRouterSwitchRouteをインストールする

ドキュメントではLinkもインストールしているが、LinkはメニューのリンクボタンでHeaderコンポーネント(Header.tsx)で使うため、ここではインストースしない。

メインページ、別ページのtitle、descriptionを設定

App.tsxで設定したheadタグのtitleとdescriptionの値

const titleText = Data.data.header.title;
const descriptionText = Data.data.header.text;

以前はheaderキーの値を読みこんでいた。


先ほど「data.json」設定したmainキーとotherキーに変更する

const mainTitle = Data.data.main.title;
const mainDescription = Data.data.main.text;
const otherTitle = Data.data.other.title;
const otherDescription = Data.data.other.text;

GitHub Pages用にルートパスを設定

homeUrlでルートパスの設定

const homeUrl = process.env.PUBLIC_URL;

ドキュメントにはこの設定はないがGitHub Pagesのように「/hoge」などの下層ディレクトリをルートにしたいときにこの設定が必要だった。

※参考:Specifying the starting url path in npm start · Issue #2959 · facebook/create-react-app · GitHub

※参考:create-react-appでreact-router - JavaScriptだけでWebサイト開発

headタグのルーティング設定

以前、Appコンポーネントには「helmet」でheadタグ値を設定した。

    <div className="App">
        <Helmet
            title={ titleText }
            meta={[
                { name: 'description', content: descriptionText }
            ]}
         />
        <Header />
        <Main />
        <Footer />
    </div>

※参考:【React】react-helmetでheadタグの中身を動的に打ち替える(Reactとメタ言語の比較-2) - クモのようにコツコツと


ここにルーティング設定を追加

    <div className="App">
      <Router>
        <Switch>
          <Route exact path={ homeUrl + "/" }>
            <Helmet
              title={ mainTitle }
              meta={[
                  { name: 'description', content: mainDescription }
              ]}
            />
          </Route>
          <Route path={ homeUrl + "/other" }>
            <Helmet
              title={ otherTitle }
              meta={[
                  { name: 'description', content: otherDescription }
              ]}
            />
          </Route>
        </Switch>
        <Header />
        <Main />
        <Footer />
      </Router>
    </div>
  • ルーティング設定したい全体をRouterコンポーネントで囲う(全体で一つのため入れ子にしているHeaderMainFooterコンポーネントも含む)
  • Routerの中にSwitchを入れてルーティン設定を書く。
  • Switchの中に複数のRouteコンポーネントでルーティンしたい中身を個別に書く
  • 1つ目のRouteにメインページ(/)で表示したいtitle、descriptionの内容を設定
  • 2つ目のRouteに別ページ(/other)で表示したいtitle、descriptionの内容を設定

Routeの中でpath属性でルーティングのパスを設定。ドキュメントでは//hogeのみだが、今回はGitHub Pagesの下層ディレクトリをルートにしたいため、先ほどのhomeUrlを足している。

1つ目のRouteにあるexact属性はパスが完全一致の時のみにルーティングする設定

※参考:React Routerのexactとは何か - Qiita

Headerコンポーネントでメニューを設定

Linkをインポート

Headerコンポーネント(Header.tsx)でメニューボタンを設定する。

先ほどAppコンポーネントではインポートしなかったLinkをここでインストールする。

import { Link } from 'react-router-dom';

ルートパスを設定

ここでもGitHub Pages用にルートパスを設定する

const homeUrl = process.env.PUBLIC_URL;

メニューのCSS設定

メニューリンクのCSS設定(navタグ内のspanタグ)

const HeaderTag = styled.header`
  // 中略
  & nav span {
    padding-right: 0.5em;
  }
`;

Linkでメニューボタンを設定

Linkでメニューボタンのリンクを設定する。

    <HeaderTag>
        <h2>{ title }</h2>
        <p dangerouslySetInnerHTML={{ __html: text }}></p>
          <nav>
            <span>MENU:</span>
            <span><Link to={ homeUrl + "/" }>Home</Link></span>
            <span><Link to={ homeUrl + "/other" }>Other</Link></span>
          </nav>
    </HeaderTag>
  • Navタグにメニューを設定
  • spanタグの中にLinkコンポーネント、to属性でリンクパスを設定

ドキュメントにはないがここでもGitHub Pages向けにhomeUrlでルートパスの設定を追加している。

Mainコンポーネントでコンテンツのルーティング設定

SwitchとRouteをインポート

次はMainコンポーネント(Main.tsx)でコンテンツのルーティング設定

ここではSwitchとRouteを使用するのでインポートする

import {
  Switch,
  Route
} from 'react-router-dom';

メインページ、別ページのコンテンツを読み込む

先ほどのAppコンポーネントと同じく、JSONファイルからメインページ、別ページのコンテンツを読み込む

const mainTitle = Data.data.main.title;
const mainText = Data.data.main.text;
const otherTitle = Data.data.other.title;
const otherText = Data.data.other.text;

ルートパスを設定

ここでもGitHub Pages向けにルートを設定する

const homeUrl = process.env.PUBLIC_URL;

コンテンツのルーティング設定

変更前のMainコンポーネント

    <main>
      <SectionTag>
          <h1>{ title }</h1>
          <p dangerouslySetInnerHTML={{ __html: text }}></p>
          <Inner />
      </SectionTag>
    </main>

メインページに表示するコンテンツが単独で設定されている


ルーティング設定を追加

  <main>
      <SectionTag>
        <Switch>
          <Route exact path={ homeUrl + "/" }>
            <h1>{ mainTitle }</h1>
            <p dangerouslySetInnerHTML={{ __html: mainText }}></p>
            <Inner />
          </Route>
          <Route path={ homeUrl + "/other" }>
            <h1>{ otherTitle }</h1>
            <p dangerouslySetInnerHTML={{ __html: otherText }}></p>
          </Route>
        </Switch>
      </SectionTag>
    </main>
  • Switchの中に複数のRouteでルーティング設定
  • 1つ目のRouteにメインページ(/)で表示したいtitle、descriptionの内容を設定
  • 2つ目のRouteに別ページ(/other)で表示したいtitle、descriptionの内容を設定

ここでもGitHub Pages向けにhomeUrlでルートパスの設定を追加している。

ブラウザ動作確認(半分成功!)

Reactをローカルで起動(ドキドキ…)

$ nom srart

ブラウザにReactが立ち上がった!ヘッダーにボタンが増えて、テキストも「メインページです」になっている♪ f:id:idr_zz:20210228155935j:plain


メニューボタン「Other」を押すと、おお!別ページになった!! f:id:idr_zz:20210228160057j:plain

URLが「/other」のパスになり、タブを見るとtitleもちゃんと「別ページです」になってる♪


メニューボタンで「Main」を押すとメインページになる。 f:id:idr_zz:20210228161007j:plain

また、ブラウザの「戻る」ボタンでもメインページに戻れる。ブラウザの閲覧履歴が変わっていることがわかる。


しかし、このままだと若干の懸念もあるので、この状態ではまだ「半分成功」くらいの状態…

JSXレンダリングのたびに関数を実行したい

TypeScriptモジュールの関数がページ読み込み時に1回しか実行されない

先ほどのメニューで別ページから戻ったメインページをよく見ると「JS(文字列)」のテキストのカギカッコの中身が空欄になっている。 f:id:idr_zz:20210228161007j:plain

ここはTypeScriptモジュールで「こんにちは、りあくと」という文字を追加したい場所。


TypeScriptのhelloモジュールはDOMContentLoaded(ページのDOM読み込み終了時)に発火する。

const hello = (): void => {
    document.addEventListener('DOMContentLoaded', () => {
        // 中略(処理)
    });
}

※参考:Window: DOMContentLoaded イベント - Web API | MDN

Innerコンポーネント(Inner.tsx)でhelloモジュールをインポートし

import { hello } from './modules/hello/hello';

hello()関数を実行

// modules
hello();

しかしこのままだとページの読み込み時にhello()が1回実行されるだけで、以降のルーティングでメインページを表示した時には実行されず、カギカッコが空欄になってしまう。

ページ読み込み時ではなく仮想DOMレンダリング時に関数を毎回実行したい

下記の記事などを読んでいるうちに、自分がやりたいのはDOM読み込み時(DOMContentLoaded)ではなくReactのレンダリング時に関数を毎回実行したいんだなとわかってきた。

※参考:お前らのReactは遅い - Qiita

関数の処理はReactで生成した仮想DOMと紐づいているため、その仮想DOMが生成されたあとのタイミングに実行したいとなると、クラスコンポーネントではcomponentDidMount、関数コンポーネントではフックを使えば良さそう。

フックのuseEffectを使うとレンダリング以外の処理を副作用として実行できる。

※参考:【React】フックとjson-serverをFetch APIで連携(Reactライフサイクルの理解) - クモのようにコツコツと

フック(useEffect)で関数実行を設定

まずhelloモジュールにDOMContentLoadedのイベント設定をコメントアウトして、たんなる処理の実行内容だけを書く。

const hello = (): void => {
    // document.addEventListener('DOMContentLoaded', () => {
        // 省略(処理)
    // });
}

実行のタイミングはフック(useEffect)で設定する


Innerコンポーネント(Inner.tsx)の中にuseEffect()を追加し、その中でHello()関数を実行する

function Inner() {

  useEffect(() => {
    hello();
  });

  return (
    // 中略(JSX設定)
  );
}

ブラウザ起動(関数が実行された!)

ブラウザを確認、メインページからメニュー「Other」ボタンを押す f:id:idr_zz:20210228155935j:plain

別ページが開く。ここからまたメニュー「Main」ボタンを押すと f:id:idr_zz:20210228160057j:plain

やた!今度はルーティングで切り替え時も関数が実行され「こんにちは、りあくと。」が表示されてる♪ f:id:idr_zz:20210228155935j:plain

ただ、これだけだとまだ完全解決ではなく、もう一つ懸念が残っているのです。。

下層ページを直接開くと404になる問題

下層ページを直接開く or リロードで404

これまでの状態で、ルーティングによるページ切り替え自体に成功はしている。が、もう一つ問題がある。

メインページからメニューボタンでページを切り替える分には問題ないが、GitHub Pagesで直接別ページのURL(/other)を開くと404になった。。 f:id:idr_zz:20210228164957j:plain


また、別ページの状態でブラウザをリロードした場合も同様に404になる。

メインページからメニュー「Other」ボタンを押すと f:id:idr_zz:20210228173111j:plain

別ページが開く。ここでページをリロードすると… f:id:idr_zz:20210228173121j:plain

別ページが404になる。。 f:id:idr_zz:20210228164957j:plain


メインページ(/)は「index.html」をアクセスするので問題ないが、別ページ(/other)にアクセスした場合は「/other/index.html」を探しに行って(実際には存在しない仮想のディレクトリ&ファイルのため「そんなページないよ(404)」となるようだった。

※参考:Reactでgh-pagesにデプロイしたとき、直接URLを踏むと404が返る問題への対応 - Qiita

※参考:getting 404 for links with create-react-app deployed to github pages - Stack Overflow


解決策をいろいろ調べていて、下記の2つの方法をミックスする方法を取った。

/otherでアクセスしたとき404.htmlに転送されるので、そこからindex.htmlにリダイレクトさせる。この時、アクセスURLをSessionStrageに保存する。

※参考:Angular アプリを GitHub Pages に公開する際、ルーティングによる 404 を回避する - Neo's World

index.htmlを開いた時に、history.replaceState()で下層ページへのリダイレクトを行う(location.replace()だと無限ループになった!焦った!!)。

※参考:GitHub Pages で React Router を使った SPA サイトを動かす方法|まくろぐ

リダイレクトの判定は事例のようなURLのパラメータではなく先ほどのSessionStrageの値の有無で行った。


なお、同じような404の事象はNetlifyでも起こる模様なのでここでも使えるかも。

※参考:NetlifyにデプロイしたSPAがPage Not Foundを出してしまう問題 - code-log

404.html作成(index.htmlへの転送、SessionStrage保存)

404.htmlを作成し、ここにscriptタグで処理を書く

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>404</title>
    <script>
        sessionStorage.setItem('path', location.pathname);
        location.replace('./');
    </script>
</head>
  • sessionStoragesetItem()で値を保存。キー名はpathで値はlocation.pathname(URLのディレクトリ以下の部分)
  • location.replace()でindex.html(./)にリダイレクト

※参考:Location - Web API | MDN

index.htmlに下層ページへのリダイレクト設定を追加

 <head>
    <!-- 中略 -->
    <script>
      const path = sessionStorage.getItem('path');
      console.log(path);
      if (path && path === '/react-from-meta-lang/other') {
        sessionStorage.removeItem('path');
        window.history.replaceState(null, null, './other');
      }
    </script>
  </head>
  • 変数pathsessionStoragegetItem()pathキーの値を取得
  • もしpathが存在し、かつ値が/react-from-meta-lang/otherならば
    sessionStorageremoveItem()pathの値を削除(リセット)
    history.replaceState()で別ページ./otherにリダイレクト

先ほどのsessionStoragepathキーがあって、かつその値が別ページ(/other)の場合のみリダイレクトを実行する(これがないとすべてのアクセスがotherにリダイレクトされる)

また、removeItem()sessionStorageの値をリセット、これもしないと一度でも/otherにアクセスするとセッション(ブラウザやタブを閉じる)の間はすべてのアクセスがotherにリダイレクトされる。

また、リダイレクト処理を先ほどの404.htmlと同じlocation.replace()で書いたところ、index.htmlと404.htmlの間を高速無限ループでリダイレクトしまくって焦った!!

history.replaceState()はブラウザの履歴は変えずにページのURLのみを置換する方法

※参考:History.replaceState() - Web API | MDN

ブラウザ確認(404がなくなった!)

GitHub Pagesで最初から別ページ(/other)を開いてみると f:id:idr_zz:20210228173121j:plain おお!やった!今度はちゃんと別ページが表示される♪

※参考:https://ryo-i.github.io/react-from-meta-lang/other


メインページからメニュー「Other」ボタンを押すと f:id:idr_zz:20210228173111j:plain

別ページが開く。ここでページをリロードすると… f:id:idr_zz:20210228173121j:plain

おお、今度は大丈夫だ!別ページがそのまま表示されている♪ f:id:idr_zz:20210228173121j:plain

※参考:Reactとメタ言語の比較


Dev-toolsでheadタブのソースを確認

「Sources」パネルは初期値になっている(「helmetで打ち替えテストだよ〜ん」) f:id:idr_zz:20210228173916j:plain

「Elements」パネルはメインページは「メインページ」に! f:id:idr_zz:20210228174035j:plain

「Elements」パネルは別ページは「別ページ」に! f:id:idr_zz:20210228174135j:plain


コード(GitHiub)

※参考:GitHub - ryo-i/react-from-meta-lang at 89128c55409503a287730c6d33bd0d214c4e850e

プレビュー(GigHub Pages)

※参考:Reactとメタ言語の比較

最後に

ということで、ついにSPAでルーティング設定を行い、複数ページを遷移しているようなUXを実現できました!

自分はもともとSPA(シングルページアプリケーション)って、単一ページの中の処理はページ全体をリロードせずに変更部分のみを変更できる、という意味だと思ってたんですが、ルーティングによって複数ページを遷移しているようなUX体験ができることを知って、前からやってみたいと思ってましたー。

実際にやってみるとコンテンツだけでなくURLパス、headタグ、閲覧履歴も変わって複数ページと変わらない使い心地、しかもページのリロードもないから軽快な動きに感じられました。

まあ、あまりにたくさんのページをSPAだけで設定すると最初の読み込み時間が長くなるという問題も起こりそうなので、その場合はNext.jsなどでMPA(マルチページアプリケーション)構成にした方がいいのかもしれません。

ここらへんはNext.jsの進化も早くていろいろな作り方の選択肢がありそうです。

※参考:Next.js 4年目の知見:SSRはもう古い、VercelにAPIサーバを置くな - Qiita

とはいえ、少ないページ構成のSPAならばReactだけで簡潔できそうなので、いったんこれまでの知見をもってミニアプリを作るフェーズに移りたいと考えています。そしてサーバサイドについてはまずはFirebaseのDBにFecth APIでCRUD操作する方法を取ってみようかと思います。

それではまた!


※参考:Reactを習得するためにやったことまとめ
qiita.com