跳至主要內容

伺服器端渲染

伺服器端渲染最常見的用例是處理使用者(或搜尋引擎爬蟲)首次要求我們的應用程式時的初始渲染。當伺服器收到要求時,它會將所需的元件渲染成 HTML 字串,然後將其作為回應傳送給客戶端。從那時起,客戶端便接手渲染工作。

我們將在以下範例中使用 React,但相同的技術可與其他可以在伺服器上渲染的檢視架構搭配使用。

伺服器上的 Redux

在伺服器渲染中使用 Redux 時,我們還必須在回應中傳送應用程式的狀態,以便客戶端可以使用它作為初始狀態。這很重要,因為如果我們在產生 HTML 之前預先載入任何資料,我們希望客戶端也能存取這些資料。否則,在客戶端產生的標記不會與伺服器標記相符,而且客戶端必須重新載入資料。

若要將資料傳送至用戶端,我們需要

  • 在每個要求中建立一個新的 Redux 儲存體執行個體;
  • 選擇性地傳送一些動作;
  • 從儲存體中提取狀態;
  • 然後將狀態傳遞給用戶端。

在用戶端,將建立一個新的 Redux 儲存體,並使用從伺服器提供的狀態進行初始化。Redux 在伺服器端的唯一工作是提供我們應用程式的初始狀態

設定

在以下範例中,我們將探討如何設定伺服器端呈現。我們將使用簡化的 計數器應用程式 作為指南,並展示伺服器如何根據要求預先呈現狀態。

安裝套件

對於此範例,我們將使用 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 儲存體執行個體。此儲存體執行個體的唯一目的是提供我們應用程式的初始狀態。

在呈現時,我們將在 <Provider> 內封裝我們的根組件 <App />,以使所有組件在組件樹中都能使用儲存體,如同我們在 「Redux 基礎」第 5 部分:UI 和 React 中所見。

伺服器端呈現中的關鍵步驟是在將初始 HTML 傳送到用戶端之前呈現我們組件的初始 HTML。為此,我們使用 ReactDOMServer.renderToString()

接著,我們使用 store.getState() 從 Redux store 取得初始狀態。我們將會在 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__

然後,我們可以透過存取 window.__PRELOADED_STATE__ 在用戶端取得 preloadedState

我們也透過一個 script 標籤包含用戶端應用程式的套件檔案。這會是你套件工具為用戶端進入點提供的任何輸出。它可能是靜態檔案或熱更新開發伺服器的 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.dev.org.tw/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 store 有相同的初始狀態,而且我們為所有檢視元件使用了相同的程式碼,因此結果會是相同的真實 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 物件中讀取。參數會被解析成數字,然後設定在初始狀態中。如果您在瀏覽器中拜訪 https://127.0.0.1:3000/?counter=100,您會看到計數器從 100 開始。在渲染的 HTML 中,您會看到計數器輸出為 100,而 __PRELOADED_STATE__ 變數中設定了計數器。

非同步狀態擷取

伺服器端渲染最常見的問題是處理非同步傳入的狀態。在伺服器上渲染本質上是同步的,因此有必要將任何非同步擷取對應到同步操作。

執行此操作最簡單的方法是將一些回呼傳遞回您的同步程式碼。在這種情況下,這將是一個會參考回應物件並將我們的渲染 HTML 傳送回用戶端的函式。別擔心,這並不難,聽起來可能比較難。

對於我們的範例,我們將想像有一個外部資料儲存,其中包含計數器的初始值(計數器即服務,或 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

後續步驟

您可能想閱讀 Redux 基礎第 6 部分:非同步邏輯和資料擷取,以進一步了解如何使用非同步原語(例如 Promise 和 thunk)在 Redux 中表達非同步流程。請記住,您在那裡學到的任何東西也可以應用於通用渲染。

如果您使用類似 React Router 的東西,您可能還想將您的資料擷取相依性表示為路由處理元件上的靜態 fetchData() 方法。它們可能會傳回 thunk,以便您的 handleRender 函式可以將路由與路由處理元件類別配對,為它們中的每一個傳送 fetchData() 結果,並僅在 Promise 解析後才進行渲染。這樣,不同路由所需的特定 API 呼叫就會與路由處理元件定義放在一起。您還可以在客戶端使用相同的技術,以防止路由器在資料載入之前切換頁面。