サーバーサイドレンダリング
サーバーサイドレンダリングの最も一般的なユースケースは、ユーザー(または検索エンジンクローラー)が最初にアプリをリクエストしたときの初期レンダリングを処理することです。サーバーがリクエストを受け取ると、必要なコンポーネントをHTML文字列にレンダリングし、それをクライアントへのレスポンスとして送信します。その時点から、クライアントがレンダリングの役割を引き継ぎます。
以下の例ではReactを使用しますが、サーバーでレンダリングできる他のビューフレームワークでも同じ手法を使用できます。
サーバーでのRedux
Reduxをサーバーサイドレンダリングで使用する場合、アプリの状態もレスポンスに含める必要があります。これにより、クライアントはそれを初期状態として使用できます。これは、HTMLを生成する前にデータをプリロードする場合、クライアントもこのデータにアクセスできるようにすることが重要です。そうでなければ、クライアントで生成されたマークアップはサーバー側のマークアップと一致せず、クライアントはデータを再度ロードする必要があります。
データをクライアントに送信するには、次の手順が必要です。
- すべてのリクエストで新しいReduxストアインスタンスを作成します。
- 必要に応じていくつかのアクションをディスパッチします。
- ストアから状態を取り出します。
- そして、状態をクライアントに渡します。
クライアント側では、新しいReduxストアが作成され、サーバーから提供された状態を使用して初期化されます。サーバー側でのReduxの唯一の役割は、アプリの初期状態を提供することです。
セットアップ
次のレシピでは、サーバーサイドレンダリングのセットアップ方法について説明します。簡素なカウンターアプリをガイドとして使用し、サーバーがリクエストに基づいて事前に状態をレンダリングする方法を示します。
パッケージのインストール
この例では、シンプルなWebサーバーとしてExpressを使用します。また、ReduxのReactバインディングもインストールする必要があります。これは、Reduxにはデフォルトで含まれていません。
npm install express react-redux
サーバーサイド
以下は、サーバーサイドの概略です。Expressミドルウェアをapp.useを使用して設定し、サーバーに届くすべてのリクエストを処理します。Expressまたはミドルウェアに慣れていない場合は、handleRender関数がサーバーがリクエストを受け取るたびに呼び出されることを知っておいてください。
さらに、最新のJSとJSX構文を使用しているので、Babel(Babelを使用したNodeサーバーの例を参照)とReactプリセットを使用してコンパイルする必要があります。
server.js
import path from 'path'
import Express from 'express'
import React from 'react'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import counterApp from './reducers'
import App from './containers/App'
const app = Express()
const port = 3000
//Serve static files
app.use('/static', Express.static('static'))
// This is fired every time the server side receives a request
app.use(handleRender)
// We are going to fill these out in the sections to follow
function handleRender(req, res) {
/* ... */
}
function renderFullPage(html, preloadedState) {
/* ... */
}
app.listen(port)
リクエストの処理
各リクエストで最初に必要なことは、新しいReduxストアインスタンスを作成することです。このストアインスタンスの唯一の目的は、アプリケーションの初期状態を提供することです。
レンダリング時に、ルートコンポーネントである<App />
を<Provider>
でラップして、"Redux Fundamentals" Part 5: UI and Reactで見たように、コンポーネントツリー内のすべてのコンポーネントでストアを使用できるようにします。
サーバーサイドレンダリングの重要なステップは、クライアントサイドに送信する前に、コンポーネントの初期HTMLをレンダリングすることです。これを行うには、ReactDOMServer.renderToString()を使用します。
store.getState()
を使用して、Reduxストアから初期状態を取得します。これはrenderFullPage
関数でどのように渡されるかを確認します。
import { renderToString } from 'react-dom/server'
function handleRender(req, res) {
// Create a new Redux store instance
const store = createStore(counterApp)
// Render the component to a string
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
)
// Grab the initial state from our Redux store
const preloadedState = store.getState()
// Send the rendered page back to the client
res.send(renderFullPage(html, preloadedState))
}
初期コンポーネントHTMLと状態の挿入
サーバーサイドでの最後のステップは、初期コンポーネントHTMLと初期状態をテンプレートに挿入して、クライアントサイドでレンダリングすることです。状態を渡すには、<script>
タグを追加して、preloadedState
をwindow.__PRELOADED_STATE__
にアタッチします。
preloadedState
は、その後、window.__PRELOADED_STATE__
にアクセスすることでクライアント側で使用できます。
スクリプトタグを使用して、クライアントサイドアプリケーションのバンドルファイルも含まれています。これは、バンドルツールがクライアントのエントリポイントに対して提供する出力です。静的ファイルまたはホットリローディング開発サーバーへのURLのいずれかです。
function renderFullPage(html, preloadedState) {
return `
<!doctype html>
<html>
<head>
<title>Redux Universal Example</title>
</head>
<body>
<div id="root">${html}</div>
<script>
// WARNING: See the following for security issues around embedding JSON in HTML:
// https://redux.dokyumento.jp/usage/server-rendering#security-considerations
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(
/</g,
'\\u003c'
)}
</script>
<script src="/static/bundle.js"></script>
</body>
</html>
`
}
クライアントサイド
クライアントサイドは非常に簡単です。window.__PRELOADED_STATE__
から初期状態を取得し、初期状態としてcreateStore()
関数に渡すだけです。
新しいクライアントファイルを見てみましょう
client.js
import React from 'react'
import { hydrate } from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import App from './containers/App'
import counterApp from './reducers'
// Create Redux store with state injected by the server
const store = createStore(counterApp, window.__PRELOADED_STATE__)
// Allow the passed state to be garbage-collected
delete window.__PRELOADED_STATE__
hydrate(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
お好みのビルドツール(Webpack、Browserifyなど)を設定して、バンドルファイルをstatic/bundle.js
にコンパイルできます。
ページがロードされると、バンドルファイルが起動され、ReactDOM.hydrate()
はサーバーでレンダリングされたHTMLを再利用します。これにより、新しく起動されたReactインスタンスは、サーバーで使用された仮想DOMに接続されます。Reduxストアの初期状態が同じであり、すべてのビューコンポーネントに同じコードを使用しているため、結果として同じ実際のDOMが生成されます。
以上です!サーバーサイドレンダリングを実装するために必要なのはこれだけです。
しかし、結果は非常に単純です。基本的に、動的なコードから静的なビューをレンダリングします。次に、レンダリングされたビューを動的にできるように、初期状態を動的に構築する必要があります。
window.__PRELOADED_STATE__
をcreateStore
に直接渡し、プリロードされた状態への追加の参照(例:const preloadedState = window.__PRELOADED_STATE__
)を作成しないことをお勧めします。これにより、ガベージコレクションが可能になります。
初期状態の準備
クライアント側は継続的なコードを実行するため、空の初期状態から開始し、必要な状態をオンデマンドで、時間をかけて取得できます。サーバー側では、レンダリングは同期であり、ビューをレンダリングするチャンスは一度だけです。リクエスト中に初期状態をコンパイルする必要があります。これにより、入力に反応し、外部状態(APIやデータベースなどからの状態)を取得する必要があります。
リクエストパラメータの処理
サーバーサイドコードの唯一の入力は、ブラウザでアプリのページを読み込むときに作成されたリクエストです。起動時にサーバーを構成することもできます(開発環境と本番環境で実行する場合など)が、その構成は静的です。
リクエストには、リクエストされたURLに関する情報(クエリパラメータを含む)が含まれており、React Routerなどを使用する場合に役立ちます。また、Cookieや承認などの入力を含むヘッダー、またはPOST本文データを含むこともあります。クエリパラメータに基づいて初期カウンタの状態を設定する方法を見てみましょう。
server.js
import qs from 'qs' // Add this at the top of the file
import { renderToString } from 'react-dom/server'
function handleRender(req, res) {
// Read the counter from the request, if provided
const params = qs.parse(req.query)
const counter = parseInt(params.counter, 10) || 0
// Compile an initial state
let preloadedState = { counter }
// Create a new Redux store instance
const store = createStore(counterApp, preloadedState)
// Render the component to a string
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
)
// Grab the initial state from our Redux store
const finalState = store.getState()
// Send the rendered page back to the client
res.send(renderFullPage(html, finalState))
}
このコードは、サーバーミドルウェアに渡されたExpressのRequest
オブジェクトから読み取ります。パラメーターは数値に解析され、初期状態に設定されます。ブラウザでhttp://localhost:3000/?counter=100にアクセスすると、カウンターは100から始まります。レンダリングされたHTMLでは、カウンターが100として出力され、__PRELOADED_STATE__
変数にカウンターが設定されていることがわかります。
非同期状態の取得
サーバーサイドレンダリングで最もよくある問題は、非同期で取得される状態の処理です。サーバーでのレンダリングは本質的に同期処理であるため、非同期フェッチを同期操作にマップする必要があります。
これを行う最も簡単な方法は、同期コードにコールバックを返すことです。この場合、レスポンスオブジェクトを参照し、レンダリングされたHTMLをクライアントに送り返す関数になります。心配しないでください、思っているほど難しくありません。
例として、カウンターの初期値を含む外部データストア(Counter As A Service、またはCaaS)があると想像してみましょう。それらにモックコールを行い、結果から初期状態を構築します。APIコールの構築から始めましょう。
api/counter.js
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min)) + min
}
export function fetchCounter(callback) {
setTimeout(() => {
callback(getRandomInt(1, 100))
}, 500)
}
繰り返しますが、これは単なるモックAPIであるため、setTimeout
を使用して、応答までに500ミリ秒かかるネットワークリクエストをシミュレートします(実際のAPIではこれよりもはるかに高速になります)。非同期的に乱数を返すコールバックを渡します。PromiseベースのAPIクライアントを使用している場合は、then
ハンドラーでこのコールバックを発行します。
サーバー側では、既存のコードをfetchCounter
でラップし、コールバックで結果を受け取ります。
server.js
// Add this to our imports
import { fetchCounter } from './api/counter'
import { renderToString } from 'react-dom/server'
function handleRender(req, res) {
// Query our mock API asynchronously
fetchCounter(apiResult => {
// Read the counter from the request, if provided
const params = qs.parse(req.query)
const counter = parseInt(params.counter, 10) || apiResult || 0
// Compile an initial state
let preloadedState = { counter }
// Create a new Redux store instance
const store = createStore(counterApp, preloadedState)
// Render the component to a string
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
)
// Grab the initial state from our Redux store
const finalState = store.getState()
// Send the rendered page back to the client
res.send(renderFullPage(html, finalState))
})
}
コールバック内でres.send()
を呼び出すため、サーバーは接続を開いたままにし、そのコールバックが実行されるまでデータを送信しません。新しいAPIコールの結果、各サーバーリクエストに500ミリ秒の遅延が追加されていることに気付くでしょう。より高度な使用方法では、不正な応答やタイムアウトなど、APIのエラーを適切に処理します。
セキュリティに関する考慮事項
ユーザー生成コンテンツ(UGC)と入力に依存するコードを導入したため、アプリケーションの攻撃対象領域が増加しました。クロスサイトスクリプティング(XSS)攻撃やコードインジェクションなどを防ぐために、入力内容が適切にサニタイズされていることを確認することが、あらゆるアプリケーションにとって重要です。
この例では、セキュリティに基本的なアプローチを取っています。リクエストからパラメーターを取得するときに、counter
パラメーターにparseInt
を使用して、この値が数値であることを確認します。これをしないと、リクエストにスクリプトタグを提供することで、レンダリングされたHTMLに危険なデータが簡単に侵入する可能性があります。それは次のようなものになります。?counter=</script><script>doSomethingBad();</script>
単純な例では、入力を数値に変換することで十分なセキュリティが確保されます。自由形式のテキストなど、より複雑な入力を処理する場合は、xss-filtersなどの適切なサニタイズ関数を使用して、その入力を処理する必要があります。
さらに、状態出力をサニタイズすることで、セキュリティレイヤーを追加できます。JSON.stringify
はスクリプトインジェクションの対象となる可能性があります。これに対抗するには、JSON文字列からHTMLタグやその他の危険な文字を削除できます。これは、文字列に対する単純なテキスト置換(例:JSON.stringify(state).replace(/</g, '\\u003c')
)またはserialize-javascriptなどのより高度なライブラリを使用して行うことができます。
次のステップ
Promiseやthunkなどの非同期プリミティブを使用してReduxで非同期フローを表現する方法の詳細については、Redux Fundamentals Part 6: Async Logic and Data Fetchingを参照してください。そこで学ぶことはすべて、ユニバーサルレンダリングにも適用できることを覚えておいてください。
React Routerのようなものを使用している場合は、データ取得の依存関係をルートハンドラーコンポーネントの静的なfetchData()
メソッドとして表現することもできます。それらはthunkを返す可能性があるため、handleRender
関数はルートをルートハンドラーコンポーネントクラスに一致させ、それぞれについてfetchData()
の結果をディスパッチし、Promiseが解決された後にのみレンダリングできます。このように、異なるルートに必要な特定のAPI呼び出しは、ルートハンドラーコンポーネントの定義と共存します。クライアント側でも同じテクニックを使用して、データがロードされるまでルーターがページを切り替えるのを防ぐことができます。