はじめに
通常Webサイトを作るときは複数ページ構成になることが多いと思います。
Reactを使って、複数ページを行き来する場合はreact-routerなるものを使うのが一番近道だと思います。
例によってフロントを始めた時に、こんなドキュメントがあればハマらなかっただろうな…と思うことの備忘です。
いつものように、よくわからなければ公式ドキュメントとエラーメッセージをよく読むのが解決への近道です。
前提条件
https://qiita.com/IgnorantCoder/items/88f13569cbf0a1c5eaa1
が終わってる。つまりreduxとreact-reduxは使ったことがある前提です。
そもそもreact-routerとは
React routerとはなにかですが、公式を見に行けば説明が書いてあります。
Components are the heart of React's powerful, declarative programming model. React Router is a collection of navigational components that compose declaratively with your application. Whether you want to have bookmarkable URLs for your web app or a composable way to navigate in React Native, React Router works wherever React is rendering--so take your pick!
React Router: Declarative Routing for React.js (https://reacttraining.com/react-router/)
英語は嫌いなので、日本語に直すと、
コンポーネントこそがReactの強力な宣言的プログラミングの本質です。React Routerとは宣言的なナビゲーションコンポーネントを集めたものです。ウェブアプリとしてブックマーク可能なURLを持ちたいときや、React Nativeでナビゲーションする場合でも、Reactでレンダリングされてる限りReact Routerは動きます、これでやっとけ!
要するにこれを使えばいい感じでReactでつくったページたちをURLでルーティングできますよ的なことですね。
とりあえず作ってみる
今回以下の構成で作ってみます。
- /と/fooと/barの3ページ
- すべてのページに全ページへのリンクが張ってあるが、今表示中のページだけリンクになってない
- ページの状態をreduxで管理する
必要なモジュールのインストール
基本的には最新版で書いていきます。
# 環境っぽいもの
npm install --save-dev html-webpack-plugin ts-loader tslint-config-airbnb tslint-loader typescript webpack webpack-cli webpack-serve connect-history-api-fallback koa-connect
# 今回使うライブラリ全部
npm install --save react react-dom react-redux react-router react-router-dom connected-react-router redux
# と、その型定義ファイル
npm install --save-dev @types/react @types/react-dom @types/react-redux @types/react-router @types/react-router-dom
いつもの環境設定ファイル
webpack.config.jsとtsconfig.jsonとtslint.jsonですね。
webpackの設定ファイルでは、今回はwebpack-serveが立ち上がるように記述しておきます。
ただ、実はwebpackの設定ファイルは、このままでは問題があるので、あとで修正します。
const path = require('path');
const htmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: './src/index.tsx',
devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.tsx?$/,
enforce: 'pre',
loader: 'tslint-loader',
exclude: [
/node_modules/
],
options: {
emitErrors: true,
}
},
{
test: /\.tsx?$/,
loader: 'ts-loader',
exclude: [
/node_modules/
],
options: {
configFile: 'tsconfig.json',
}
}
]
},
resolve: {
extensions: [ '.tsx', '.ts', '.js' ]
},
output: {
filename: 'static/js/bundle.js',
path: path.resolve(__dirname, 'dist')
},
server: {
content: path.resolve(__dirname, 'dist'),
port: 3000,
},
plugins: [
new htmlWebpackPlugin({
template: "index.html"
})
]
};
{
"compilerOptions": {
"sourceMap": true,
"noImplicitAny": true,
"module": "es6",
"target": "es5",
"jsx": "react",
"lib": ["ES2018", "dom"],
"moduleResolution": "node",
"removeComments": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strictFunctionTypes": false
}
}
{
"extends": "tslint-config-airbnb",
"rules": {
"no-boolean-literal-compare": false,
"ter-indent": [true, 4]
}
}
一筆書きしてみる
サンプルコードは筆書きしたら一発でビルドも通る程度でしたので、大した内容ではありません。
ナビゲーションバーは、Routerの状態から現在のpathをとってLinkかただの文字列かを決定するだけの共通表示コンポーネントです。
根っこのコンポーネントではConnectedRouterにcreateBrowserHistoryで作ったhistoryをぶちこんで、routerMiddlewareを適用します。
reduxを扱う部分では、reducerとstateにRouter用の領域を設けるだけです。
import * as React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { RootState } from '../modules';
type OwnProps = { // NavBarに実際表示する情報は外から与える
data: {
display: string;
path: string;
}[];
};
const mapStateToProps = (state: RootState, ownProps: OwnProps) => {
return {
items: ownProps.data.map((e) => {
return {
display: e.display,
to: e.path,
disabled: state.router.location == null
? false
: state.router.location.pathname === e.path, // stateからrouterの状態がとれる
};
}),
};
};
type Props = {
items: {
display: string;
to: string;
disabled: boolean;
}[];
};
const component: React.SFC<Props> = (props: Props) => {
return (
<ul>
{
props.items.map((e) => {
return (
<li key={e.display}>
{
e.disabled
? e.display
: <Link to={e.to}>{e.display}</Link>
}
</li>
);
})
}
</ul>
);
};
export default connect(mapStateToProps)(component);
import { RouterState } from 'connected-react-router';
import { combineReducers } from 'redux';
export type RootState = { // 必要なら当然自分用のstateも追加してOK
router: RouterState, // Router用の領域
};
export const rootReducer = combineReducers({ // 必要に応じて自分用のreducer追加
} as any);
import * as React from 'react';
import { applyMiddleware, createStore } from 'redux';
import { Provider } from 'react-redux';
import { Route, Switch } from 'react-router';
import { ConnectedRouter, routerMiddleware } from 'connected-react-router';
import createBrowserHistory from 'history/createBrowserHistory';
import { rootReducer } from './modules';
import Home from './pages/Home';
import Foo from './pages/Foo';
import Bar from './pages/Bar';
import NavBar from './containers/NavBar';
const history = createBrowserHistory(); // Browser historyをとって
const store = createStore(
connectRouter(history)(rootReducer),
applyMiddleware(routerMiddleware(history))); // router用のmiddlewareを適用しておく
const component: React.SFC = () => {
// ConnectedRouterは1エレメントしかとれないのでRoute群はdivで囲っておく
// Switchで囲っておくと呼ばれたやつだけrenderingする
// 共通コンポーネントはSwitchの外側に置いとけば良い
return (
<Provider store={store}>
<ConnectedRouter history={history}>
<div>
<NavBar
data={[
{ display: 'to home', path: '/' },
{ display: 'to foo', path: '/foo' },
{ display: 'to bar', path: '/bar' },
]}
/>
<Switch>
<Route exact path={'/'} component={Home}/>
<Route exact path={'/foo'} component={Foo}/>
<Route exact path={'/bar'} component={Bar}/>
</Switch>
</div>
</ConnectedRouter>
</Provider>
);
};
export default component;
その他に追加したソースはシンプルにComponentを並べてるだけです。
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(
<App/>,
document.getElementById('root'));
import * as React from 'react';
const component: React.SFC = () => {
return (
<div>
This is Home.
</div>
);
};
export default component;
import * as React from 'react';
const component: React.SFC = () => {
return (
<div>
This is Foo.
</div>
);
};
export default component;
import * as React from 'react';
const component: React.SFC = () => {
return (
<div>
This is Bar.
</div>
);
};
export default component;
<!DOCTYPE html>
<html>
<head>
<title>Router sample</title>
<meta charset="utf-8">
</head>
<body>
<div id="root"></div>
</body>
</html>
完成したが…?
これでビルドすれば完成です。
webpack-serve --config webpack.config.js
で立ち上がります。リンクをクリックしてもうまくページが遷移できると思います。
しかし、アドレスバーに直接 http://localhost:3000/foo などを入力するとCannnot GET /fooと表示されて、うまくいかないはずです。
これは考えてみると当たり前で、 http://localhost:3000/ にアクセスするとhttp://localhost:3000/index.html が読み込まれ、その中でロードしているbundle.jsがレンダリングというなか、いきなり http://localhost:3000/foo に飛ばれた場合は http://localhost:3000/ にフォールバックしてあげる必要があるわけです。
そこでwebpack.config.jsをそのように書き換えます。
const path = require('path');
const htmlWebpackPlugin = require('html-webpack-plugin');
const history = require('connect-history-api-fallback');
const convert = require('koa-connect');
module.exports = {
mode: 'development',
entry: './src/index.tsx',
devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.tsx?$/,
enforce: 'pre',
loader: 'tslint-loader',
exclude: [
/node_modules/
],
options: {
emitErrors: true,
}
},
{
test: /\.tsx?$/,
loader: 'ts-loader',
exclude: [
/node_modules/
],
options: {
configFile: 'tsconfig.json',
}
}
]
},
resolve: {
extensions: [ '.tsx', '.ts', '.js' ]
},
output: {
filename: 'static/js/bundle.js',
path: path.resolve(__dirname, 'dist')
},
serve: {
content: path.resolve(__dirname, 'dist'),
port: 3000,
add: (app, middleware, options) => {
app.use(convert(history({ index: '/' })));
}
}
plugins: [
new htmlWebpackPlugin({
template: "index.html"
})
]
};
これで、OKです。
これはAWSなどにデプロイする場合も同じで、404なときにindex.htmlを返してあげるような設定をしましょう。
できあがったソース群はこちら
https://github.com/IgnorantCoder/typescript-react-redux-router-sample
まとめ
Reduxさえ理解していればreact-router自体は記述量も少なく、難しいこともあまりないため導入はとても簡単ですが、バージョンアップの度にインターフェースが破壊的に変わるようで、調べると古い記述も多いです。(この記事も正しいかやや不安)
ただ、現状これ以外に有効な選択肢もなさそうですので、とりあえずバージョンを固定して導入してみるのが良いと思います。