Redux 服務(wù)端渲染

2021-09-16 10:07 更新

服務(wù)端渲染

服務(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 框架,做法也是類似的。

服務(wù)端使用 Redux

當(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ā)送到客戶端,需要以下步驟:

  • 為每次請(qǐng)求創(chuàng)建全新的 Redux store 實(shí)例;
  • 按需 dispatch 一些 action;
  • 從 store 中取出 state;
  • 把 state 一同返回給客戶端。

在客戶端,使用服務(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。

安裝依賴庫(kù)

本例會(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ù)端開發(fā)

下面是服務(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);

處理請(qǐng)求

第一件要做的事情就是對(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));
}

注入初始組件的 HTML 和 State

服務(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。

客戶端開發(fā)

客戶端代碼非常直觀。只需要從 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。

準(zhǔn)備初始 State

因?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。

處理 Request 參數(shù)

服務(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__ 變量。

獲取異步 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í)。

安全注意事項(xiàng)

因?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)

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)