Reactの続きです。以前作成したReact + CSS in JS + TypeScript環境にReact Routerを使ってルーティング設定をして複数ページを遷移できるSPAにします。helmetを使ってコンテンツだけでなくheadタグも動的に変更。さらに下層ページに直接アクセスすると404になる対策も行いました。それではいきましょう!
【目次】
- 前回のおさらい
- SPAで複数ページ構成を再現したい
- react-routerをインストール
- 「data.json」に別ページの内容を追加
- APPコンポーネントでheadタグのルーティング設定
- Headerコンポーネントでメニューを設定
- Mainコンポーネントでコンテンツのルーティング設定
- ブラウザ動作確認(半分成功!)
- JSXレンダリングのたびに関数を実行したい
- 下層ページを直接開くと404になる問題
- 最後に
※参考:前回記事
【React】ピュアなTypeScriptモジュールを追加してみる(Reactとメタ言語の比較-6) - クモのようにコツコツと
※参考:Reactを習得するためにやったことまとめ
qiita.com
前回のおさらい
以前作成したフロントエンドスターターキット(EJS + Sass(SCSS) + TypeScirpt)環境のコンテンツをReact + CSS in JS + TypeScript環境で再現。
※参考:【React】ピュアなTypeScriptモジュールを追加してみる(Reactとメタ言語の比較-6) - クモのようにコツコツと
SPAで複数ページ構成を再現したい
EJSの時は「index.ejs」と「other.ejs」というようにページごとにEJSファイルを作っていた。
※参考:【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
キーに下記のtitle
とtext
を設定していた。
"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
からBrowserRouter
、Switch
、Route
をインストールする
ドキュメントでは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
コンポーネントで囲う(全体で一つのため入れ子にしているHeader
、Main
、Footer
コンポーネントも含む) 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が立ち上がった!ヘッダーにボタンが増えて、テキストも「メインページです」になっている♪
メニューボタン「Other」を押すと、おお!別ページになった!!
URLが「/other」のパスになり、タブを見るとtitleもちゃんと「別ページです」になってる♪
メニューボタンで「Main」を押すとメインページになる。
また、ブラウザの「戻る」ボタンでもメインページに戻れる。ブラウザの閲覧履歴が変わっていることがわかる。
しかし、このままだと若干の懸念もあるので、この状態ではまだ「半分成功」くらいの状態…
JSXレンダリングのたびに関数を実行したい
TypeScriptモジュールの関数がページ読み込み時に1回しか実行されない
先ほどのメニューで別ページから戻ったメインページをよく見ると「JS(文字列)」のテキストのカギカッコの中身が空欄になっている。
ここは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で生成した仮想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」ボタンを押す
別ページが開く。ここからまたメニュー「Main」ボタンを押すと
やた!今度はルーティングで切り替え時も関数が実行され「こんにちは、りあくと。」が表示されてる♪
ただ、これだけだとまだ完全解決ではなく、もう一つ懸念が残っているのです。。
下層ページを直接開くと404になる問題
下層ページを直接開く or リロードで404
これまでの状態で、ルーティングによるページ切り替え自体に成功はしている。が、もう一つ問題がある。
メインページからメニューボタンでページを切り替える分には問題ないが、GitHub Pagesで直接別ページのURL(/other)を開くと404になった。。
また、別ページの状態でブラウザをリロードした場合も同様に404になる。
メインページからメニュー「Other」ボタンを押すと
別ページが開く。ここでページをリロードすると…
別ページが404になる。。
メインページ(/)は「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>
sessionStorage
にsetItem()
で値を保存。キー名はpath
で値はlocation.pathname
(URLのディレクトリ以下の部分)location.replace()
でindex.html(./
)にリダイレクト
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>
- 変数
path
でsessionStorage
のgetItem()
でpath
キーの値を取得 - もし
path
が存在し、かつ値が/react-from-meta-lang/other
ならば
sessionStorage
のremoveItem()
でpath
の値を削除(リセット)
history.replaceState()
で別ページ./other
にリダイレクト
先ほどのsessionStorage
のpath
キーがあって、かつその値が別ページ(/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)を開いてみると おお!やった!今度はちゃんと別ページが表示される♪
※参考:https://ryo-i.github.io/react-from-meta-lang/other
メインページからメニュー「Other」ボタンを押すと
別ページが開く。ここでページをリロードすると…
おお、今度は大丈夫だ!別ページがそのまま表示されている♪
※参考:Reactとメタ言語の比較
Dev-toolsでheadタブのソースを確認
「Sources」パネルは初期値になっている(「helmetで打ち替えテストだよ〜ん」)
「Elements」パネルはメインページは「メインページ」に!
「Elements」パネルは別ページは「別ページ」に!
コード(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