まえおき
この記事はReact Advent Calendar 2017の 2 日目になるはずでしたが 25 日目が空いていたので代わりに 25 日目です。~~いずれにせよ遅刻なのですが……~~
本文中のサンプルコードはgithub.com/euxn23/react-ssr-sample にあります。ビルド済みなので npm install
してそのまま動きます
ServerSideRendering とは
言葉の通りに読めばサーバ側でレンダリングすることであるが、フロントエンドの文脈では、本来ブラウザ上で構築される DOM をサーバ側で生成してから返すことで、
- イニシャルビューが早くなる
- パフォーマンスが向上する
- SEO 対策になる(クローラが JS を解釈するか定かではないため)
というメリットを得るための手法のことを指す
React での SSR をやってみるにあたって、サーバサイドの経験がないと、どのように動くのかイメージが湧かないという話を聞いたので、まず通常のサーバサイドでのテンプレートエンジンでのレンダリングの話をし、それから React での SSR の話をする
1. テンプレートエンジンによるサーバサイドレンダリング
※ サーバサイドの話がわかる人はこの章は読み飛ばして問題なさそうです
古くから(要出典)ある方式であり、わかりやすい言語でいうと PHP がまさにそれである
他にも erb や ejs、複雑なものだと haml や jade(pug) 等もある
比較対象が React による SSR のため、ベース部分を共通して Express.js にするため、ejs を使う
使用する node.js のバージョンは 8 以降を推奨する
Express.js + ejs で HTML を動的生成する
まず非常にシンプルに、 HTML をテキストで返すコードを、 Express.js を用いてサーバサイドの技術で実装する
1'use strict'
2
3const express = require('express')
4const app = express()
5
6app.get('/', (req, res) => {
7 res.send('<html><h1>Hello World</h1></html>')
8})
9
10app.listen(3000)
このサーバを起動しブラウザから localhost:3000
にアクセスすると、 h1 で Hello World
が表示される
これだけではつまらないので、ejs テンプレートエンジンを使用して、別途用意してある view ファイルを render する
1'use strict'
2
3const express = require('express')
4const app = express()
5
6app.set('views', __dirname + '/views')
7app.set('view engine', 'ejs')
8
9app.get('/', (req, res) => {
10 res.render('sample01-2')
11})
12
13app.listen(3000)
1<html>
2 <h1>Hello World</h1>
3</html>
テンプレートエンジンを使う手始めとして、Hello World の文字列を渡して render する
1'use strict'
2
3const express = require('express')
4const app = express()
5
6app.set('views', __dirname + '/views')
7app.set('view engine', 'ejs')
8
9app.get('/', (req, res) => {
10 res.render('sample02', {message: 'Hello World'})
11})
12
13app.listen(3000)
1<html>
2 <h1><%= message %></h1>
3</html>
localhost:3000?name=euxn23
のようにクエリを付けるとそれがサーバ側で処理される
1'use strict'
2
3const express = require('express')
4const app = express()
5
6app.set('views', __dirname + '/views')
7app.set('view engine', 'ejs')
8
9app.get('/', (req, res) => {
10 res.render('sample03', {name: req.query.name})
11})
12
13app.listen(3000)
1<html>
2 <h1>Hello, <%= name %></h1>
3</html>
外部 API コールをサーバ側で行う場合、その API が重い場合はそれの解決を待ってから render されるため、レスポンスが遅くなる
今回は、2 秒ほど待ってレスポンスを返す API を用意したのでそちらを叩く
1'use strict'
2
3const express = require('express')
4const fetch = require('isomorphic-fetch')
5const app = express()
6
7
8app.set('views', __dirname + '/views')
9app.set('view engine', 'ejs')
10
11app.get('/', async (req, res) => {
12 // ちゃんとやる場合はサニタイズしてから渡す
13 const response = await fetch('https://euxn-lazy-api.herokuapp.com/')
14 const json = await response.json()
15 res.render('sample04', {spentMs: json.spentMs})
16})
17
18app.listen(3000)
1<html>
2 <h1>Lazy api! <%= spentMs %> ms had spent.</h1>
3</html>
このような観点からみると、ブラウザでレンダリングする場合のメリットとして、遅延を伴う API コールを待たずともイニシャルビューが返ることがあげられる
とはいえ、HTML を汲みあげる速度は大抵の場合サーバの方が早い(ようにチューニングすべきっぽい)ことが、サーバ側でレンダリングするメリットとなる
最初にあげた通り、React で SSR をするメリットはこの辺りだろう
2. React.js の Component を使い HTML を動的生成する
まずは、上記のように、React の Component を単なるテンプレートとして使用する例から実装する
1'use strict'
2
3const express = require('express')
4const React = require('react')
5const { renderToString } = require('react-dom/server')
6const MyComponent = require('./views/sample01')
7
8const app = express()
9
10const htmlify = rootComponent => (
11 `
12 <html>
13 <div id="app">${rootComponent}</div>
14 </html>
15 `
16)
17
18app.get('/', (req, res) => {
19 const prerenderedContent = renderToString(React.createElement(MyComponent))
20 const prerenderedHtml = htmlify(prerenderedContent)
21 res.send(prerenderedHtml)
22})
23
24app.listen(3000)
node では JSX の解釈ができないため、 <MyComponent />
という記述はせず、 React.createElement(MyComponent)
を使う
1'use strict'
2
3const React = require('react')
4const { Component } = React
5
6module.exports = class MyComponent extends Component {
7 render() {
8 return (
9 <div>
10 <h1>Hello World</h1>
11 </div>
12 )
13 }
14}
同様に Component は Babel を通して以下のように JSX のない形式にする。もしくははじめからこちらのように書く
(node-jsx は古いし、babel-node はプロダクションで使えるものではないので、基本的には事前にビルドするか、JSX 無しの記述を行う)
1'use strict';
2
3const React = require('react');
4const { Component } = React;
5
6module.exports = class MyComponent extends Component {
7 render() {
8 return React.createElement(
9 'div',
10 null,
11 React.createElement(
12 'h1',
13 null,
14 'Hello World'
15 )
16 );
17 }
18};
上記サーバ側のコードの、 react-dom/server#renderToString
により、Component を DOM 構造の文字列へと変換する
この際、この後ブラウザ上の React が仮想 DOM を認識して動作するよう、DOM には React 用の key が振られている
まず Express で HTML として返すために HTML タグでくくり、React の影響範囲であることを示す DOM 以下に renderToString した Component を埋め込む
今回はレンダリング後の挙動はないため、レンダリング後のブラウザ側の React との連携は後述する
上記 Express で行った実装を React で行うと以下のようになる
1'use strict'
2
3const express = require('express')
4const React = require('react')
5const { renderToString } = require('react-dom/server')
6const MyComponent = require('./views/sample02')
7
8const app = express()
9
10const htmlify = rootComponent => (
11 `
12 <html>
13 <div id="app">${rootComponent}</div>
14 </html>
15 `
16)
17
18app.get('/', (req, res) => {
19 const prerenderedContent = renderToString(React.createElement(MyComponent, {message: 'Hello World'}))
20 const prerenderedHtml = htmlify(prerenderedContent)
21 res.send(prerenderedHtml)
22})
23
24app.listen(3000)
1'use strict'
2
3const React = require('react')
4const { Component } = React
5
6module.exports = class MyComponent extends Component {
7 render() {
8 return (
9 <div>
10 <h1>{this.props.message}</h1>
11 </div>
12 )
13 }
14}
変数は props として渡す
1'use strict'
2
3const express = require('express')
4const React = require('react')
5const { renderToString } = require('react-dom/server')
6const MyComponent = require('./views/sample03')
7
8const app = express()
9
10const htmlify = rootComponent => (
11 `
12 <html>
13 <div id="app">${rootComponent}</div>
14 </html>
15 `
16)
17
18app.get('/', (req, res) => {
19 const prerenderedContent = renderToString(React.createElement(MyComponent, {name: req.query.name || 'World'}))
20 const prerenderedHtml = htmlify(prerenderedContent)
21 res.send(prerenderedHtml)
22})
23
24app.listen(3000)
1'use strict'
2
3const React = require('react')
4const { Component } = React
5
6module.exports = class MyComponent extends Component {
7 render() {
8 return (
9 <div>
10 <h1>Hello, {this.props.name}</h1>
11 </div>
12 )
13 }
14}
クエリパラメータも同様にサーバ側で処理し props にして渡す
3. React SSR と非同期処理
外部 API コールによるデータ処理をサーバ側で行いレンダリングしたいというケースも SSR ではあると考えられる
そのようなケースは以下のように、サーバ側でデータを取得し、グローバル変数 DEFAULT_PROPS
に格納されるように HTML をレンダリングし、ブラウザ側のコードではこのグローバル変数を取得して Component に Props として渡す、という処理をする
ブラウザでロード時に同一データでレンダリングが走りますが、DOM ツリーに変更はないので実際の DOM 再描画は発生しない
具体的な実装は以下の通り
app04.jsx はフロントエンドのエントリーポイントとなる。Webpack や Parcel 等で依存ファイルを含んで views/dist/entry04.js
にビルドする
1'use strict'
2
3const express = require('express')
4const fetch = require('isomorphic-fetch')
5const React = require('react')
6const { renderToString } = require('react-dom/server')
7const path = require('path')
8const MyComponent = require('./views/sample04')
9
10const app = express()
11app.use(express.static(path.join(__dirname, './views/dist')))
12
13const htmlify = (rootComponent, defaultProps) => (
14 `
15 <html>
16 <div id="app">${rootComponent}</div>
17 <script>window.DEFAULT_PROPS = ${JSON.stringify(defaultProps)}</script>
18 <script src="entry04.js"></script>
19 </html>
20 `
21)
22
23app.get('/', async (req, res) => {
24 const response = await fetch('http://euxn-lazy-api.herokuapp.com')
25 const json = await response.json()
26 const defaultProps = { spentMs: json.spentMs }
27 const prerenderedContent = renderToString(React.createElement(MyComponent, defaultProps))
28 res.send(htmlify(prerenderedContent, defaultProps))
29})
30
31app.listen(3000)
1'use strict'
2
3const React = require('react')
4const { Component } = React
5
6module.exports = class MyComponent extends Component {
7 constructor(props) {
8 super(props)
9 this.state = { count: 1 }
10 }
11
12 render() {
13 return (
14 <div>
15 <h1>Lazy api{'!'.repeat(this.state.count)} {this.props.spentMs} ms has spent.</h1>
16 <button onClick={() => this.setState({count: this.state.count+1})}>💢</button>
17 </div>
18 )
19 }
20}
1'use strict'
2
3import React from 'react'
4import ReactDOM from 'react-dom'
5import MyComponent from './sample04'
6
7const defaultProps = window.DEFAULT_PROPS
8
9ReactDOM.render(React.createElement(MyComponent, defaultProps), document.querySelector('#app'))
レスポンスに時間のかかる API コールを行うと、上の Express の例と同様にレンダリングの完了を待ってからレスポンスが返るため遅くなる
その間はイニシャルビューが返らないため、ここで実装されている React のカウンターも操作することができない
このような重い API のみフロントエンドでリクエストしようと以下のようにすると問題が発生する
1'use strict'
2
3const express = require('express')
4const React = require('react')
5const { renderToString } = require('react-dom/server')
6const path = require('path')
7const MyComponent = require('./views/sample05')
8const { initialState } = MyComponent
9
10const app = express()
11app.use(express.static(path.join(__dirname, './views/dist')))
12
13const htmlify = rootComponent => (
14 `
15 <html>
16 <div id="app">${rootComponent}</div>
17 <script src="entry05.js"></script>
18 </html>
19 `
20)
21
22app.get('/', (req, res) => {
23 const prerenderedContent = renderToString(React.createElement(MyComponent, { spentMs: 0 }))
24 res.send(htmlify(prerenderedContent))
25})
26
27app.listen(3000)
1'use strict'
2
3const React = require('react')
4const { Component } = React
5
6module.exports = class MyComponent extends Component {
7 constructor(props) {
8 super(props)
9 this.state = { count: 1 }
10 }
11
12 render() {
13 return (
14 <div>
15 <h1>Lazy API, but async{'♥️'.repeat(this.state.count)} {this.props.spentMs ? `${this.props.spentMs} ms has spent.` : 'Now loading...'}</h1>
16 <button onClick={() => this.setState({count: this.state.count+1})}>♥️</button>
17 </div>
18 )
19 }
20}
1'use strict'
2
3import React from 'react'
4import ReactDOM from 'react-dom'
5import MyComponent from './sample05'
6
7const main = async () => {
8 const response = await fetch('https://euxn-lazy-api.herokuapp.com')
9 const json = await response.json()
10 ReactDOM.render(React.createElement(MyComponent, json), document.querySelector('#app'))
11}
12
13main()
実際に触ってみて欲しいのだが、最初の API の取得が完了し ReactDOM#render
が走るまで React は有効になっていないので、ボタン操作が反応しない
これを解決するために、一旦ブラウザ側でサーバ側と同じデータ = 空の State でレンダリングし、非同期通信が完了した後に再度 ReactDOM#render
を走らせる
1'use strict'
2
3const express = require('express')
4const React = require('react')
5const { renderToString } = require('react-dom/server')
6const path = require('path')
7const MyComponent = require('./views/sample06')
8
9const app = express()
10app.use(express.static(path.join(__dirname, './views/dist')))
11
12const htmlify = (rootComponent, defaultProps) => (
13 `
14 <html>
15 <div id="app">${rootComponent}</div>
16 <script>window.DEFAULT_PROPS = ${JSON.stringify(defaultProps)}</script>
17 <script src="entry06.js"></script>
18 </html>
19 `
20)
21
22app.get('/', (req, res) => {
23 const defaultProps = { spentMs: 0 }
24 const prerenderedContent = renderToString(React.createElement(MyComponent, defaultProps))
25 res.send(htmlify(prerenderedContent, defaultProps))
26})
27
28app.listen(3000)
1'use strict'
2
3const React = require('react')
4const { Component } = React
5
6module.exports = class MyComponent extends Component {
7 constructor(props) {
8 super(props)
9 this.state = { count: 1 }
10 }
11
12 render() {
13 return (
14 <div>
15 <h1>Lazy API, but async{'♥️'.repeat(this.state.count)} {this.props.spentMs ? `${this.props.spentMs} ms has spent.` : 'Now loading...'}</h1>
16 <button onClick={() => this.setState({count: this.state.count+1})}>♥️</button>
17 </div>
18 )
19 }
20}
1'use strict'
2
3import React from 'react'
4import ReactDOM from 'react-dom'
5import MyComponent from './sample06'
6
7const lazyApiCallAndRerender = async () => {
8 const response = await fetch('https://euxn-lazy-api.herokuapp.com')
9 const json = await response.json()
10 ReactDOM.render(React.createElement(MyComponent, json), document.querySelector('#app'))
11}
12
13const defaultProps = window.DEFAULT_PROPS
14ReactDOM.render(React.createElement(MyComponent, defaultProps), document.querySelector('#app'))
15lazyApiCallAndRerender()
4. 簡単な SSR アプリを作る
例として Twitter の検索結果を表示するアプリを SSR で作る
イニシャルビューで直近 200 件のデータをレンダリングするため、このようなケースでは SSR によってパフォーマンス向上が期待できる
実装は以下の通り。主に sample04 と sapmle06 で紹介した方法を用いた
動作するものはこちら
実際にコードを動かす際には、各自 Twitter Developer Platform から認証キーを取得し、環境変数にセットしてから実行する必要がある
(余談だが、 direnv が便利)
1'use strict'
2
3const express = require('express')
4const fetch = require('isomorphic-fetch')
5const React = require('react')
6const { renderToString } = require('react-dom/server')
7const path = require('path')
8const Twit = require('twit')
9const MyComponent = require('./views/sample-twitter')
10const { initialState } = MyComponent
11
12const app = express()
13app.use(express.static(path.join(__dirname, './views/dist')))
14
15const { consumer_key, consumer_secret, access_token, access_token_secret } = process.env
16const client = new Twit({ consumer_key, consumer_secret, access_token, access_token_secret })
17
18const htmlify = (rootComponent, defaultProps) => (
19 `
20 <html>
21 <div id="app">${rootComponent}</div>
22 <script>window.DEFAULT_PROPS = ${JSON.stringify(defaultProps)}</script>
23 <script src="entry-twitter.js"></script>
24 </html>
25 `
26)
27
28app.get('/', async (req, res) => {
29 const response = await fetch('http://localhost:3000/tweets')
30 const tweets = await response.json()
31 const prerenderedContent = renderToString(React.createElement(MyComponent, { tweets }))
32 res.send(htmlify(prerenderedContent, { tweets }))
33})
34
35app.get('/tweets', async (req, res) => {
36 const data = await client.get('search/tweets', {q: 'react', count: 200, since_id: req.query.since_id})
37 const tweets = data.data.statuses.filter(s => `${s.id}` !== req.query.since_id)
38 res.json(tweets)
39})
40
41app.listen(3000)
1'use strict'
2
3const React = require('react')
4const { Component } = React
5
6const Tweet = ({ user, text}, idx) => (
7 <tr key={idx}>
8 <th>@{user.screen_name}</th>
9 <th>{text}</th>
10 </tr>
11)
12
13module.exports = class MyComponent extends Component {
14 constructor(props) {
15 super(props)
16 }
17
18 render() {
19 console.log('render')
20 return (
21 <table border="1">
22 <tr>
23 <th>Screen Name</th>
24 <th>Tweet</th>
25 </tr>
26 { this.props.tweets.map(Tweet) }
27 </table>
28 )
29 }
30}
1'use strict'
2
3import React from 'react'
4import ReactDOM from 'react-dom'
5import fetch from 'isomorphic-fetch'
6import MyComponent from './sample-twitter'
7
8let tweets = window.DEFAULT_PROPS.tweets
9
10const appendLatestTweet = async () => {
11 const response = await fetch(`http://localhost:3000/tweets?since_id=${tweets[0].id}`)
12 const nextTweets = await response.json()
13 tweets = [...nextTweets, ...tweets]
14 ReactDOM.render(React.createElement(MyComponent, { tweets }), document.querySelector('#app'))
15}
16
17setInterval(appendLatestTweet, 20000)
18
19ReactDOM.render(React.createElement(MyComponent, { tweets }), document.querySelector('#app'))
5. より高度な SSR アプリを作るために
上記では Express から始まり React 単体での SSR を簡単に説明した
Redux を使う場合は、 Store をグローバル変数に配置しサーバからクライアントに渡す、という手法で実装できる
ReactRouter を使う場合はルーティングまわりが複雑になるが、ある程度は ReactRouter が提供する機能で実装できる(が、むずい)
Redux/ReactRouter での SSR については以下が参考になる