服務(wù)端渲染一個(gè)很常見的場(chǎng)景是當(dāng)用戶(或搜索引擎爬蟲)第一次請(qǐng)求頁(yè)面時(shí),用它來(lái)做初始渲染。當(dāng)服務(wù)器接收到請(qǐng)求后,它把需要的組件渲染成 HTML 字符串,然后把它返回給客戶端(這里統(tǒng)指瀏覽器)。之后,客戶端會(huì)接手渲染控制權(quán)。
下面我們使用 React 來(lái)做示例,對(duì)于支持服務(wù)端渲染的其它 view 框架,做法也是類似的。
當(dāng)在服務(wù)器使用 Redux 渲染時(shí),一定要在響應(yīng)中包含應(yīng)用的 state,這樣客戶端可以把它作為初始 state。這點(diǎn)至關(guān)重要,因?yàn)槿绻谏?HTML 前預(yù)加載了數(shù)據(jù),我們希望客戶端也能訪問這些數(shù)據(jù)。否則,客戶端生成的 HTML 與服務(wù)器端返回的 HTML 就會(huì)不匹配,客戶端還需要重新加載數(shù)據(jù)。
把數(shù)據(jù)發(fā)送到客戶端,需要以下步驟:
在客戶端,使用服務(wù)器返回的 state 創(chuàng)建并初始化一個(gè)全新的 Redux store。
Redux 在服務(wù)端惟一要做的事情就是,提供應(yīng)用所需的初始 state。
下面來(lái)介紹如何配置服務(wù)端渲染。使用極簡(jiǎn)的 Counter 計(jì)數(shù)器應(yīng)用 來(lái)做示例,介紹如何根據(jù)請(qǐng)求在服務(wù)端提前渲染 state。
本例會(huì)使用 Express 來(lái)做小型的 web 服務(wù)器。引入 serve-static middleware 來(lái)處理靜態(tài)文件,稍后有代碼。
還需要安裝 Redux 對(duì) React 的綁定庫(kù),Redux 默認(rèn)并不包含。
npm install --save express serve-static react-redux
下面是服務(wù)端代碼大概的樣子。使用 app.use 掛載 Express middleware 處理所有請(qǐng)求。serve-static
middleware 以同樣的方式處理來(lái)自客戶端的 javascript 文件請(qǐng)求。如果你還不熟悉 Express 或者 middleware,只需要了解每次服務(wù)器收到請(qǐng)求時(shí)都會(huì)調(diào)用 handleRender 函數(shù)。
生產(chǎn)環(huán)境使用須知
在生產(chǎn)環(huán)境中,最好使用類似 nigix 這樣的服務(wù)器來(lái)處理靜態(tài)文件請(qǐng)求,只使用 Node 處理應(yīng)用請(qǐng)求。雖然這個(gè)話題已經(jīng)超出本教程討論范疇。
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;
// 使用這個(gè) middleware 處理 dist 目錄下的靜態(tài)文件請(qǐng)求
app.use(require('serve-static')(path.join(__dirname, 'dist')));
// 每當(dāng)收到請(qǐng)求時(shí)都會(huì)觸發(fā)
app.use(handleRender);
// 接下來(lái)會(huì)補(bǔ)充這部分代碼
function handleRender(req, res) { /* ... */ }
function renderFullPage(html, initialState) { /* ... */ }
app.listen(port);
第一件要做的事情就是對(duì)每個(gè)請(qǐng)求創(chuàng)建一個(gè)新的 Redux store 實(shí)例。這個(gè) store 惟一作用是提供應(yīng)用初始的 state。
渲染時(shí),使用 <Provider>
來(lái)包住根組件 <App />
,以此來(lái)讓組件樹中所有組件都能訪問到 store,就像之前的搭配 React 教程講的那樣。
服務(wù)端渲染最關(guān)鍵的一步是在發(fā)送響應(yīng)前渲染初始的 HTML。這就要使用 React.renderToString().
然后使用 store.getState()
從 store 得到初始 state。renderFullPage
函數(shù)會(huì)介紹接下來(lái)如何傳遞。
function handleRender(req, res) {
// 創(chuàng)建新的 Redux store 實(shí)例
const store = createStore(counterApp);
// 把組件渲染成字符串
const html = React.renderToString(
<Provider store={store}>
{() => <App />}
</Provider>
);
// 從 store 中獲得 state
const initialState = store.getState();
// 把渲染后的頁(yè)面內(nèi)容發(fā)送給客戶端
res.send(renderFullPage(html, initialState));
}
服務(wù)端最后一步就是把初始組件的 HTML 和初始 state 注入到客戶端能夠渲染的模板中。如何傳遞 state 呢,我們添加一個(gè) <script>
標(biāo)簽來(lái)把 initialState
賦給 window.__INITIAL_STATE__
。
客戶端可以通過(guò) window.__INITIAL_STATE__
獲取 initialState
。
同時(shí)使用 script 標(biāo)簽來(lái)引入打包后的 js bundle 文件。之前引入的 serve-static
middleware 會(huì)處理它的請(qǐng)求。下面是代碼。
function renderFullPage(html, initialState) {
return `
<!doctype html>
<html>
<head>
<title>Redux Universal Example</title>
</head>
<body>
<div id="app">${html}</div>
<script>
window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};
</script>
<script src="https://atts.w3cschool.cn/attachments/image/cimg/bundle.js"></script>
</body>
</html>
`;
}
字符串插值語(yǔ)法須知
上面的示例使用了 ES6 的模板字符串語(yǔ)法。它支持多行字符串和字符串插補(bǔ)特性,但需要支持 ES6。如果要在 Node 端使用 ES6,參考 Babel require hook 文檔。你也可以繼續(xù)使用 ES5。
客戶端代碼非常直觀。只需要從 window.__INITIAL_STATE__
得到初始 state,并傳給 createStore()
函數(shù)即可。
代碼如下:
client.js
import React from 'react';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import App from './containers/App';
import counterApp from './reducers';
// 通過(guò)服務(wù)端注入的全局變量得到初始 state
const initialState = window.__INITIAL_STATE__;
// 使用初始 state 創(chuàng)建 Redux store
const store = createStore(counterApp, initialState);
React.render(
<Provider store={store}>
{() => <App />}
</Provider>,
document.getElementById('root')
);
你可以選擇自己喜歡的打包工具(Webpack, Browserify 或其它)來(lái)編譯并打包文件到 dist/bundle.js
。
當(dāng)頁(yè)面加載時(shí),打包后的 js 會(huì)啟動(dòng),并調(diào)用 React.render()
,然后會(huì)與服務(wù)端渲染的 HTML 的 data-react-id
屬性做關(guān)聯(lián)。這會(huì)把新生成的 React 實(shí)例與服務(wù)端的虛擬 DOM 連接起來(lái)。因?yàn)橥瑯邮褂昧藖?lái)自 Redux store 的初始 state,并且 view 組件代碼是一樣的,結(jié)果就是我們得到了相同的 DOM。
就是這樣!這就是實(shí)現(xiàn)服務(wù)端渲染的所有步驟。
但這樣做還是比較原始的。只會(huì)用動(dòng)態(tài)代碼渲染一個(gè)靜態(tài)的 View。下一步要做的是動(dòng)態(tài)創(chuàng)建初始 state 支持動(dòng)態(tài)渲染 view。
因?yàn)榭蛻舳酥皇菆?zhí)行收到的代碼,剛開始的初始 state 可能是空的,然后根據(jù)需要獲取 state。在服務(wù)端,渲染是同步執(zhí)行的而且我們只有一次渲染 view 的機(jī)會(huì)。在收到請(qǐng)求時(shí),可能需要根據(jù)請(qǐng)求參數(shù)或者外部 state(如訪問 API 或者數(shù)據(jù)庫(kù)),計(jì)算后得到初始 state。
服務(wù)端收到的惟一輸入是來(lái)自瀏覽器的請(qǐng)求。在服務(wù)器啟動(dòng)時(shí)可能需要做一些配置(如運(yùn)行在開發(fā)環(huán)境還是生產(chǎn)環(huán)境),但這些配置是靜態(tài)的。
請(qǐng)求會(huì)包含 URL 請(qǐng)求相關(guān)信息,包括請(qǐng)求參數(shù),它們對(duì)于做 React Router 路由時(shí)可能會(huì)有用。也可能在請(qǐng)求頭里包含 cookies,鑒權(quán)信息或者 POST 內(nèi)容數(shù)據(jù)。下面演示如何基于請(qǐng)求參數(shù)來(lái)得到初始 state。
server.js
import qs from 'qs'; // 添加到文件開頭
function handleRender(req, res) {
// 如果存在的話,從 request 讀取 counter
const params = qs.parse(req.query);
const counter = parseInt(params.counter) || 0;
// 得到初始 state
let initialState = { counter };
// 創(chuàng)建新的 Redux store 實(shí)例
const store = createStore(counterApp, initialState);
// 把組件渲染成字符串
const html = React.renderToString(
<Provider store={store}>
{() => <App />}
</Provider>
);
// 從 Redux store 得到初始 state
const finalState = store.getState();
// 把渲染后的頁(yè)面發(fā)給客戶端
res.send(renderFullPage(html, finalState));
}
上面的代碼首先訪問 Express 的 Request
對(duì)象。把參數(shù)轉(zhuǎn)成數(shù)字,然后設(shè)置到初始 state 中。如果你在瀏覽器中訪問 http://localhost:3000/?counter=100,你會(huì)看到計(jì)數(shù)器從 100 開始。在渲染后的 HTML 中,你會(huì)看到計(jì)數(shù)顯示 100 同時(shí)設(shè)置進(jìn)了 __INITIAL_STATE__
變量。
服務(wù)端渲染常用的場(chǎng)景是處理異步 state。因?yàn)榉?wù)端渲染天生是同步的,因此異步的數(shù)據(jù)獲取操作對(duì)應(yīng)到同步操作非常重要。
最簡(jiǎn)單的做法是往同步代碼里傳遞一些回調(diào)函數(shù)。在這個(gè)回調(diào)函數(shù)里引用響應(yīng)對(duì)象,把渲染后的 HTML 發(fā)給客戶端。不要擔(dān)心,并沒有想像中那么難。
本例中,我們假設(shè)有一個(gè)外部數(shù)據(jù)源提供計(jì)算器的初始值(所謂的把計(jì)算作為一種服務(wù))。我們會(huì)模擬一個(gè)請(qǐng)求并使用結(jié)果創(chuàng)建初始 state。API 請(qǐng)求代碼如下:
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);
}
再次說(shuō)明一下,這只是一個(gè)模擬的 API,我們使用 setTimeout
模擬一個(gè)需要 500 毫秒的請(qǐng)求(實(shí)現(xiàn)項(xiàng)目中 API 請(qǐng)求一般會(huì)更快)。傳入一個(gè)回調(diào)函數(shù),它異步返回一個(gè)隨機(jī)數(shù)字。如果你使用了基于 Promise 的 API 工具,那么要把回調(diào)函數(shù)放到 then
中。
在服務(wù)端,把代碼使用 fetchCounter
包起來(lái),在回調(diào)函數(shù)里拿到結(jié)果:
server.js
// Add this to our imports
import { fetchCounter } from './api/counter';
function handleRender(req, res) {
// 異步請(qǐng)求模擬的 API
fetchCounter(apiResult => {
// 如果存在的話,從 request 讀取 counter
const params = qs.parse(req.query);
const counter = parseInt(params.counter) || apiResult || 0;
// 得到初始 state
let initialState = { counter };
// 創(chuàng)建新的 Redux store 實(shí)例
const store = createStore(counterApp, initialState);
// 把組件渲染成字符串
const html = React.renderToString(
<Provider store={store}>
{() => <App />}
</Provider>
);
// 從 Redux store 得到初始 state
const finalState = store.getState();
// 把渲染后的頁(yè)面發(fā)給客戶端
res.send(renderFullPage(html, finalState));
});
}
因?yàn)樵诨卣{(diào)中使用了 res.send()
,服務(wù)器會(huì)保護(hù)連接打開并在回調(diào)函數(shù)執(zhí)行前不發(fā)送任何數(shù)據(jù)。你會(huì)發(fā)現(xiàn)每個(gè)請(qǐng)求都有 500ms 的延時(shí)。更高級(jí)的用法會(huì)包括對(duì) API 請(qǐng)求出錯(cuò)進(jìn)行處理,比如錯(cuò)誤的請(qǐng)求或者超時(shí)。
因?yàn)槲覀兇a中很多是基于用戶生成內(nèi)容(UGC)和輸入的,不知不覺中,提高了應(yīng)用可能受攻擊區(qū)域。任何應(yīng)用都應(yīng)該對(duì)用戶輸入做安全處理以避免跨站腳本攻擊(XSS)或者代碼注入。
我們的示例中,只對(duì)安全做基本處理。當(dāng)從請(qǐng)求中拿參數(shù)時(shí),對(duì) counter
參數(shù)使用 parseInt
把它轉(zhuǎn)成數(shù)字。如果不這樣做,當(dāng) request 中有 script 標(biāo)簽時(shí),很容易在渲染的 HTML 中生成危險(xiǎn)代碼。就像這樣的:?counter=</script><script>doSomethingBad();</script>
在我們極簡(jiǎn)的示例中,把輸入轉(zhuǎn)成數(shù)字已經(jīng)比較安全。如果處理更復(fù)雜的輸入,比如自定義格式的文本,你應(yīng)該用安全函數(shù)處理輸入,比如 validator.js。
此外,可能添加額外的安全層來(lái)對(duì)產(chǎn)生的 state 進(jìn)行消毒。JSON.stringify
可能會(huì)造成 script 注入。鑒于此,你需要清洗 JSON 字符串中的 HTML 標(biāo)簽和其它危險(xiǎn)的字符??赡芡ㄟ^(guò)字符串替換或者使用復(fù)雜的庫(kù)如 serialize-javascript 處理。
你還可以參考 異步 Actions 學(xué)習(xí)更多使用 Promise 和 thunk 這些異步元素來(lái)表示異步數(shù)據(jù)流的方法。記住,那里學(xué)到的任何內(nèi)容都可以用于同構(gòu)渲染。
如果你使用了 React Router,你可能還需要在路由處理組件中使用靜態(tài)的 fetchData()
方法來(lái)獲取依賴的數(shù)據(jù)。它可能返回 異步 action,以便你的 handleRender
函數(shù)可以匹配到對(duì)應(yīng)的組件類,對(duì)它們均 dispatch fetchData()
的結(jié)果,在 Promise 解決后才渲染。這樣不同路由需要調(diào)用的 API 請(qǐng)求都并置于路由處理組件了。在客戶端,你也可以使用同樣技術(shù)來(lái)避免在切換頁(yè)面時(shí),當(dāng)數(shù)據(jù)還沒有加載完成前執(zhí)行路由。(Revision needed)
更多建議: