blog.euxn.me

React での ServerSideRendering 入門

2017-12-25 Mon.

まえおき

この記事は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 を用いてサーバサイドの技術で実装する

sample01.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 する

sample01-2.js
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)
views/sample01-2.ejs
1<html>
2 <h1>Hello World</h1>
3</html>

テンプレートエンジンを使う手始めとして、Hello World の文字列を渡して render する

sample02.js
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)
views/sample02.ejs
1<html>
2 <h1><%= message %></h1>
3</html>

localhost:3000?name=euxn23 のようにクエリを付けるとそれがサーバ側で処理される

sample03.js
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)
views/sample03.ejs
1<html>
2 <h1>Hello, <%= name %></h1>
3</html>

外部 API コールをサーバ側で行う場合、その API が重い場合はそれの解決を待ってから render されるため、レスポンスが遅くなる

今回は、2 秒ほど待ってレスポンスを返す API を用意したのでそちらを叩く

sample04.js
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)
views/sample04.ejs
1<html>
2 <h1>Lazy api! <%= spentMs %> ms had spent.</h1>
3</html>

このような観点からみると、ブラウザでレンダリングする場合のメリットとして、遅延を伴う API コールを待たずともイニシャルビューが返ることがあげられる

とはいえ、HTML を汲みあげる速度は大抵の場合サーバの方が早い(ようにチューニングすべきっぽい)ことが、サーバ側でレンダリングするメリットとなる

最初にあげた通り、React で SSR をするメリットはこの辺りだろう

2. React.js の Component を使い HTML を動的生成する

まずは、上記のように、React の Component を単なるテンプレートとして使用する例から実装する

sample01.js
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) を使う

sample01.jsx
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 無しの記述を行う)

views/sample01.js
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 で行うと以下のようになる

sample02.js
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)
views/sample02.jsx
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 として渡す

sample03.js
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)
views/sample03.jsx
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 にビルドする

sample04.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)
views/sample04.jsx
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}
views/entry04.jsx
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 のみフロントエンドでリクエストしようと以下のようにすると問題が発生する

sample05.js
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)
views/sample05.js
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}
views/entry05.jsx
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 を走らせる

sample06.js
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)
views/sample06.jsx
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}
views/entry06.jsx
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 が便利)

sample-twitter.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 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)
views/sample-twitter.jsx
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}
views/entry-twitter.jsx
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 については以下が参考になる