まえがき
普段私はReact.jsでWebアプリを開発しているのだが、ふとサーバーサイドレンダリングで表示させてみたいと思い、Qiitaで調べた。Next.jsというまさにこれだというフレームワークを見つけ、勉強し始めた。
私がNext.jsで開発できるようになるまでに、**これってどうすれば?**と思う箇所が多かったので、振り返りも込めてまとめることにした。
対象者は、React.jsを理解していてサーバーサイドレンダリングで表示してみたいかたです。
1章 Hello World
まずはじめにHello Worldを表示させるため、Next.js公式サイトに飛び、ドキュメントを2、3分読んだ。
そして手を動かしはじめた。
next
とreact
とreact-dom
をインストールし、package.jsonにnpm run dev
のスクリプトを追記、pagesフォルダを作成し、その中にindex.jsを作った。
export default () => <div>Hello World</div>
余裕だった、私は完全に天狗になっていた。
「これreact-redux
とかインストールして、connect
で挟めば、redux
導入もすぐできるんじゃね?」
こんなことを心の中で囁いていた。
次に、ルーティングについて学んだ。
pagesフォルダに新しくabout.jsを作ってやればlocalhost:3000/aboutで表示できるとのことだったのでやってみた。
export default () => <div>about page</div>
「おぉ、React.jsでルーティングしようとすると、複雑でライブラリによってはversionが変わると動かないという不安定さがあったが、Next.jsはシンプルでいいね」
なんて呟いていた。
2章 React.jsで使っていたライブラリの導入
とりあえず、普段よく使っていたredux
とmaterial-ui
とredux-saga
を導入してみようと思い、調べ始めた。
「ん?_app.jsってなんだ?_document.jsなにそれ?」
そう、謎ファイルのとの出会いだった。Next.jsは普通にライブラリインストールして、React.jsのようにしてやれば導入できますというような代物ではなかったのだ。
import App, { Container } from 'next/app'
import React from 'react'
import withReduxStore from '../lib/with-redux-store'
import { Provider } from 'react-redux'
class MyApp extends App {
render () {
const { Component, pageProps, reduxStore } = this.props
return (
<Container>
<Provider store={reduxStore}>
<Component {...pageProps} />
</Provider>
</Container>
)
}
}
export default withReduxStore(MyApp)
with-redux-storeをつくっていたり、pageProps
を使っていたりとわけがわからなかった。
どうやらページ表示時はじめに実行されるものならしい。詳しくは「Next.jsの_app.jsと_document.js」の記事がよかったので読んでみて下さい。
どうやらライブラリを導入したい場合、この**_app.js**を書き換えることで使えるようになるみたいだ。
そして大体有名なライブラリはNext.jsのgithubのexamplesに書き換え方のサンプルが落ちていることに気がついた。
redux
もmaterial-ui
もredux-saga
もサンプルが落ちていた。
このサンプルを真似て、_app.jsを作成し手を加えていくとredux
を導入することができた。
※material-ui
の導入もできたがここでは割愛する。
redux導入
redux
、react-redux
をインストール
pagesフォルダに**_app.js**を作成
import App, { Container } from 'next/app'
import React from 'react'
import withReduxStore from '../lib/with-redux-store'
import { Provider } from 'react-redux'
class MyApp extends App {
render () {
const { Component, pageProps, reduxStore } = this.props
return (
<Container>
<Provider store={reduxStore}>
<Component {...pageProps} />
</Provider>
</Container>
)
}
}
export default withReduxStore(MyApp)
新しくlibフォルダを作成し、reduxを使えるようにラップするwith-redux-store.jsを作成した。
import React from 'react'
import { initializeStore } from '../store'
const isServer = typeof window === 'undefined'
const __NEXT_REDUX_STORE__ = '__NEXT_REDUX_STORE__'
function getOrCreateStore (initialState) {
// Always make a new store if server, otherwise state is shared between requests
if (isServer) {
return initializeStore(initialState)
}
// Create store if unavailable on the client and set it on the window object
if (!window[__NEXT_REDUX_STORE__]) {
window[__NEXT_REDUX_STORE__] = initializeStore(initialState)
}
return window[__NEXT_REDUX_STORE__]
}
export default App => {
return class AppWithRedux extends React.Component {
static async getInitialProps (appContext) {
// Get or Create the store with `undefined` as initialState
// This allows you to set a custom default initialState
const reduxStore = getOrCreateStore()
// Provide the store to getInitialProps of pages
appContext.ctx.reduxStore = reduxStore
let appProps = {}
if (typeof App.getInitialProps === 'function') {
appProps = await App.getInitialProps(appContext)
}
return {
...appProps,
initialReduxState: reduxStore.getState()
}
}
constructor (props) {
super(props)
this.reduxStore = getOrCreateStore(props.initialReduxState)
}
render () {
return <App {...this.props} reduxStore={this.reduxStore} />
}
}
}
store.jsを作成した。
今回はtext
とcount
のキーで値を保持させた。
PLUS
とSET_TEXT
アクションを定義し、text
とcount
の値を変更できるようにした。
import { createStore } from 'redux'
const initialState = {
text: '',
count: 0,
}
export const actionTypes = {
SET_TEXT: 'SET_TEXT',
PLUS: 'PLUS',
}
// REDUCERS
export const reducer = (state = initialState, action) => {
switch (action.type) {
case actionTypes.SET_TEXT:
return Object.assign({}, state, {
text: action.payload,
})
case actionTypes.PLUS:
return Object.assign({}, state, {
count: state.count + 1
})
default:
return state
}
}
// ACTIONS
export const SetTextAction = (text) => {
return { type: actionTypes.SET_TEXT, payload: text }
}
export const PlusAction = () => {
return{ type: actionTypes.PLUS }
}
export function initializeStore (initialState = initialState) {
return createStore(
reducer,
initialState,
)
}
index.jsとabout.jsを書き換えた。
import React from 'react'
import { connect } from 'react-redux'
import Link from 'next/link'
import { PlusAction, SetTextAction } from '../store'
class Index extends React.Component {
static getInitialProps ({ reduxStore, req }) {
const isServer = !!req
if (isServer) reduxStore.dispatch(SetTextAction('hello world'))
return {}
}
render () {
return (
<div>
<h1>index page</h1>
<p>text: {this.props.text}</p>
<p>count: {this.props.count}</p>
<button onClick={this.props.handlePlus}>plus</button>
<button onClick={() => this.props.handleSetText('Index')}>set Index</button>
<Link href="/about"><a>go to about page</a></Link>
</div>
)
}
}
const mapDispatchToProps = (dispatch) => {
return {
handlePlus: () => {
dispatch(PlusAction())
},
handleSetText: (text) => {
dispatch(SetTextAction(text))
}
}
}
const mapStateToProps = (state) => {
return state
}
export default connect(mapStateToProps, mapDispatchToProps)(Index)
import React from 'react'
import { connect } from 'react-redux'
import { PlusAction, SetTextAction } from '../store'
class About extends React.Component {
render () {
return (
<div>
<h1>about page</h1>
<p>text: {this.props.text}</p>
<p>count: {this.props.count}</p>
<button onClick={this.props.handlePlus}>plus</button>
<button onClick={() => this.props.handleSetText('About')}>set About</button>
</div>
)
}
}
const mapDispatchToProps = (dispatch) => {
return {
handlePlus: () => {
dispatch(PlusAction())
},
handleSetText: (text) => {
dispatch(SetTextAction(text))
}
}
}
const mapStateToProps = (state) => {
return state
}
export default connect(mapStateToProps, mapDispatchToProps)(About)
Next.jsはどうやらはじめにサーバーサイドレンダリングで表示され、そこから先はクライアント側の処理だけで表示されるSPAに切り替わるらしい。
getInitialProps
はページが表示されるときに、一度だけ実行されるらしい。
はじめはサーバー側で実行され、そこから先ページ遷移した場合はクライアント側で実行される万能な関数だ。
もちろんリロードすればサーバー側で実行される。
index.jsではhello world
の文字をtext
にいれるアクションSetTextAction
をdispatch
している。またreq
の値でサーバー側の処理かクライアント側の処理か判定もできた。
class Index extends React.Component {
static getInitialProps ({ reduxStore, req }) {
const isServer = !!req
if (isServer) reduxStore.dispatch(SetTextAction('hello world'))
return {}
}
render () {
// 省略
}
}
「なんか、getInitialProps
が使えるようになったことで少しだけNext.jsがどういうものなのか理解が深まった気がした。」
3章 Web APIってどうやって使う?
WebAPIの使用はとても簡単だった。
Next.js公式サイトのlearnのFetching Data for Pagesにて解説してあった。
もうすでにあなたは知ってる、getInitialProps
でリクエストを投げればいい。
まず、リクエストを投げるisomorphic-unfetch
をインストール。
index.jsを書き直した。
import React from 'react'
import { connect } from 'react-redux'
import fetch from 'isomorphic-unfetch'
import Link from 'next/link'
import { PlusAction, SetTextAction } from '../store'
class Index extends React.Component {
static async getInitialProps ({ reduxStore, req }) {
const isServer = !!req
if (isServer) reduxStore.dispatch(SetTextAction('hello world'))
const endpoint = 'https://qiita.com/api/v2/users/kousaku-maron/items?page=1&per_page=100'
const res = await fetch(endpoint)
const data = await res.json()
const result = data.map(record => record.title)
return {
result: result,
}
}
render () {
return (
<div>
<h1>index page</h1>
<p>text: {this.props.text}</p>
<p>count: {this.props.count}</p>
{this.props.result.map(title => (
<p key={title}>{title}</p>
))}
<button onClick={this.props.handlePlus}>plus</button>
<button onClick={() => this.props.handleSetText('Index')}>set Index</button>
<Link href="/about"><a>go to about page</a></Link>
</div>
)
}
}
const mapDispatchToProps = (dispatch) => {
return {
handlePlus: () => {
dispatch(PlusAction())
},
handleSetText: (text) => {
dispatch(SetTextAction(text))
}
}
}
const mapStateToProps = (state) => {
return state
}
export default connect(mapStateToProps, mapDispatchToProps)(Index)
getInitialProps
をasync
にし、isomorphic-unfetch
でリクエストを投げている。返り値はthis.props
に入るので利用する。
※今回使用しているAPIはQiita APIです。
static async getInitialProps ({ reduxStore, req }) {
const isServer = !!req
if (isServer) reduxStore.dispatch(SetTextAction('hello world'))
const endpoint = 'https://qiita.com/api/v2/users/kousaku-maron/items?page=1&per_page=100'
const res = await fetch(endpoint)
const data = await res.json()
const result = data.map(record => record.title)
return {
result: result,
}
}
4章 localhost/:idなどのルーティングはどうやる?
こちらもNext.js公式サイトのlearnのServer Side Support for Clean URLsにて解説してあった。
express
を利用して実現するらしい。
express
をインストール。
server.jsを作成し、ルーティングの設定をした。
const express = require('express')
const next = require('next')
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare()
.then(() => {
const server = express()
server.get('/p/:id', (req, res) => {
const actualPage = '/about'
const queryParams = { id: req.params.id }
app.render(req, res, actualPage, queryParams)
})
server.get('*', (req, res) => {
return handle(req, res)
})
server.listen(3000, (err) => {
if (err) throw err
console.log('> Ready on http://localhost:3000')
})
})
.catch((ex) => {
console.error(ex.stack)
process.exit(1)
})
同時にpackage.jsonを書き換えた。
"scripts": {
"dev": "node server.js",
"build": "next build",
"start": "NODE_ENV=production node server.js",
},
index.jsでは、SPAによる遷移でabout.jsのページに遷移する。
server.jsはサーバーサイドレンダリングでの処理のため、クライアント側でもクエリを取得できるように、
Link
の属性である、href
にはクエリを含む本来のルーティングを、as
には表示するルーティングを記載しなければならない。
// 省略
class Index extends React.Component {
// 省略
render () {
return (
<div>
<h1>index page</h1>
<p>text: {this.props.text}</p>
<p>count: {this.props.count}</p>
{this.props.result.map(title => (
<p key={title}>{title}</p>
))}
<button onClick={this.props.handlePlus}>plus</button>
<button onClick={() => this.props.handleSetText('Index')}>set Index</button>
<Link as="/p/1" href="/about?id=1"><a>go to about page</a></Link>
</div>
)
}
}
// 省略
export default connect(mapStateToProps, mapDispatchToProps)(Index)
about.jsでクエリを取得し表示させてみる。クエリの取得にはwithRouter
を使用する。
import React from 'react'
import { withRouter } from 'next/router'
import { connect } from 'react-redux'
import { PlusAction, SetTextAction } from '../store'
class About extends React.Component {
render () {
return (
<div>
<h1>about page</h1>
<p>text: {this.props.text}</p>
<p>count: {this.props.count}</p>
<p>id: {this.props.router.query.id}</p>
<button onClick={this.props.handlePlus}>plus</button>
<button onClick={() => this.props.handleSetText('About')}>set About</button>
</div>
)
}
}
// 省略
export default connect(mapStateToProps, mapDispatchToProps)(withRouter(About))
これで、サーバーから直接アクセスしても、index.jsからSPAでアクセスしてもidの取得が可能になった。
「サーバー側での処理とクライアント側での処理を意識しながら開発をしていかないとダメなんだなぁと実感した。」
あとがき
これでカスタムサーバー、ライブラリの導入ができるようになったのでNext.jsでWebアプリを作れるようになったんじゃないでしょうか。React.jsを触ったことがあるのであれば一度Next.jsを触ってみるのもいいかもしれませんね。
サンプルコードをgithubに公開しておきました。