基於React+Redux的SSR實現

爲何要實現服務端渲染(SSR)

總結下來有如下幾點:javascript

  1. SEO,讓搜索引擎更容易讀取頁面內容
  2. 首屏渲染速度更快(重點),無需等待js文件下載執行的過程
  3. 代碼同構,服務端和客戶端能夠共享某些代碼

今天咱們將構建一個使用Redux的簡單的React應用程序,實現服務端渲染(SSR)。該示例包括異步數據抓取,這使得任務變得更有趣。css

若是您想使用本文中討論的代碼,請查看GitHub: answer518/react-redux-ssrhtml

安裝環境

在開始編寫應用以前,須要咱們先把環境編譯/打包環境配置好,由於咱們採用的是es6語法編寫代碼。咱們須要將代碼編譯成es5代碼在瀏覽器或node環境中執行。java

咱們將用babelify轉換來使用browserifywatchify來打包咱們的客戶端代碼。對於咱們的服務器端代碼,咱們將直接使用babel-clinode

代碼結構以下:react

build
src
  ├── client
  │   └── client.js
  └── server
      └── server.js
複製代碼

咱們在package.json裏面加入如下兩個命令腳本:git

"scripts": {
    "build": "
      browserify ./src/client/client.js -o ./build/bundle.js -t babelify &&
      babel ./src/ --out-dir ./build/",
    "watch": "
      concurrently 
        \"watchify ./src/client/client.js -o ./build/bundle.js -t babelify -v\"
        \"babel ./src/ --out-dir ./build/ --watch\"
      "
}
複製代碼

concurrently庫幫助並行運行多個進程,這正是咱們在監控更改時須要的。es6

最後一個有用的命令,用於運行咱們的http服務器:github

"scripts": {
  "build": "...",
  "watch": "...",
  "start": "nodemon ./build/server/server.js"
}
複製代碼

不使用node ./build/server/server.js而使用Nodemon的緣由是,它能夠監控咱們代碼中的任何更改,並自動從新啓動服務器。這一點在開發過程會很是有用。express

開發React+Redux應用

假設服務端返回如下的數據格式:

[
        {
            "id": 4,
            "first_name": "Gates",
            "last_name": "Bill",
            "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/marcoramires/128.jpg"
        },
        {
            ...
        }
]
複製代碼

咱們經過一個組件將數據渲染出來。在這個組件的componentWillMount生命週期方法中,咱們將觸發數據獲取,一旦請求成功,咱們將發送一個類型爲user_fetch的操做。該操做將由一個reducer處理,咱們將在Redux存儲中得到更新。狀態的改變將觸發咱們的組件從新呈現指定的數據。

Redux具體實現

reducer處理過程以下:

// reducer.js
import { USERS_FETCHED } from './constants';

function getInitialState() {
  return { users: null };
}

const reducer = function (oldState = getInitialState(), action) {
  if (action.type === USERS_FETCHED) {
    return { users: action.response.data };
  }
  return oldState;
};
複製代碼

爲了能派發action請求去改變應用狀態,咱們須要編寫Action Creator

// actions.js
import { USERS_FETCHED } from './constants';
export const usersFetched = response => ({ type: USERS_FETCHED, response });

// selectors.js
export const getUsers = ({ users }) => users;
複製代碼

Redux實現的最關鍵一步就是建立Store:

// store.js
import { USERS_FETCHED } from './constants';
import { createStore } from 'redux';
import reducer from './reducer';

export default () => createStore(reducer);
複製代碼

爲何直接返回的是工廠函數而不是createStore(reducer)?這是由於當咱們在服務器端渲染時,咱們須要一個全新的Store實例來處理每一個請求。

實現React組件

在這裏須要提的一個重點是,一旦咱們想實現服務端渲染,那咱們就須要改變以前的純客戶端編程模式。

服務器端渲染,也叫代碼同構,也就是同一份代碼既能在客戶端渲染,又能在服務端渲染。

咱們必須保證代碼能在服務端正常的運行。例如,訪問Window對象,Node不提供Window對象的訪問。

// App.jsx
import React from 'react';
import { connect } from 'react-redux';

import { getUsers } from './redux/selectors';
import { usersFetched } from './redux/actions';

const ENDPOINT = 'http://localhost:3000/users_fake_data.json';

class App extends React.Component {
  componentWillMount() {
    fetchUsers();
  }
  render() {
    const { users } = this.props;

    return (
      <div> { users && users.length > 0 && users.map( // ... render the user here ) } </div>
    );
  }
}

const ConnectedApp = connect(
  state => ({
    users: getUsers(state)
  }),
  dispatch => ({
    fetchUsers: async () => dispatch(
      usersFetched(await (await fetch(ENDPOINT)).json())
    )
  })
)(App);

export default ConnectedApp;
複製代碼

你看到,咱們使用componentWillMount來發送fetchUsers請求,componentDidMount爲何不能用呢? 主要緣由是componentDidMount在服務端渲染過程當中並不會執行。

fetchUsers是一個異步函數,它經過Fetch API請求數據。當數據返回時,會派發users_fetch動做,從而經過reducer從新計算狀態,而咱們的<App />因爲鏈接到Redux從而被從新渲染。

// client.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';

import App from './App.jsx';
import createStore from './redux/store';

ReactDOM.render(
  <Provider store={ createStore() }><App /></Provider>,
  document.querySelector('#content')
);
複製代碼

運行Node Server

爲了演示方便,咱們首選Express做爲http服務器。

// server.js
import express from 'express';

const app = express();

// Serving the content of the "build" folder. Remember that
// after the transpiling and bundling we have:
//
// build
// ├── client
// ├── server
// │ └── server.js
// └── bundle.js
app.use(express.static(__dirname + '/../'));

app.get('*', (req, res) => {
  res.set('Content-Type', 'text/html');
  res.send(` <html> <head> <title>App</title> </head> <body> <div id="content"></div> <script src="/bundle.js"></script> </body> </html> `);
});

app.listen(
  3000,
  () => console.log('Example app listening on port 3000!')
);
複製代碼

有了這個文件,咱們能夠運行npm run start並訪問http://localhost:3000。咱們看到數據獲取成功,併成功的顯示了。

服務端渲染

目前爲止,咱們的服務端僅僅是返回了一個html骨架,而全部交互全在客戶端完成。瀏覽器須要先下載bundle.js後執行。而服務端渲染的做用就是在服務器上執行全部操做併發送最終標記,而不是把全部工做交給瀏覽器執行。React足夠的聰明,可以識別出這些標記。

還記得咱們在客戶端作的如下事情嗎?

import ReactDOM from 'react-dom';

ReactDOM.render(
  <Provider store={ createStore() }><App /></Provider>,
  document.querySelector('#content')
);
複製代碼

服務端幾乎相同:

import ReactDOMServer from 'react-dom/server';

const markupAsString = ReactDOMServer.renderToString(
  <Provider store={ store }><App /></Provider>
);
複製代碼

咱們使用了相同的組件<App />store,不一樣之處在於它返回的是一個字符串,而不是虛擬DOM。

而後將這個字符串加入到Express的響應裏面,因此服務端代碼爲:

const store = createStore();
const content = ReactDOMServer.renderToString(
  <Provider store={ store }><App /></Provider>
);

app.get('*', (req, res) => {
  res.set('Content-Type', 'text/html');
  res.send(` <html> <head> <title>App</title> </head> <body> <div id="content">${ content }</div> <script src="/bundle.js"></script> </body> </html> `);
});
複製代碼

若是從新啓動服務器並打開相同的http://localhost:3000,咱們將看到如下響應:

<html>
  <head>
    <title>App</title>
  </head>
  <body>
    <div id="content"><div data-reactroot=""></div></div>
    <script src="/bundle.js"></script>
  </body>
</html>
複製代碼

咱們的頁面中確實有一些內容,但它只是<div data-reactroot=""></div>。這並不意味着程序出錯了。這絕對是正確的。React確實呈現了咱們的頁面,但它只呈現靜態內容。在咱們的組件中,咱們在獲取數據以前什麼都沒有,數據的獲取是一個異步過程,在服務器上呈現時,咱們必須考慮到這一點。這就是咱們的任務變得棘手的地方。這能夠歸結爲咱們的應用程序在作什麼。在本例中,客戶端代碼依賴於一個特定的請求,但若是使用redux-saga庫,則多是多個請求,或者多是一個完整的root saga。我意識處處理這個問題的兩種方法:

一、咱們明確知道請求的頁面須要什麼樣的數據。咱們獲取數據並使用該數據建立Redux存儲。而後咱們經過提供已完成的Store來呈現頁面,理論上咱們能夠作到。

二、咱們徹底依賴於運行在客戶端上的代碼,計算出最終的結果。

第一種方法,須要咱們在兩端作好狀態管理。第二種方法須要咱們在服務端使用一些額外的庫或工具,來確保同一套代碼能在服務端和客戶端作相同的事情,我我的比較推薦使用這種方法。

例如,咱們使用了Fetch API向後端發出異步請求,而服務端默認是不支持的。咱們須要作的就是在server.js中將Fetch導入:

import 'isomorphic-fetch';
複製代碼

咱們使用客戶端API接收異步數據,一旦Store獲取到異步數據,咱們將觸發ReactDOMServer.renderToString。它會提供給咱們想要的標記。咱們的Express處理器是這樣的:

app.get('*', (req, res) => {
  const store = createStore();

  const unsubscribe = store.subscribe(() => {
    const users = getUsers(store.getState());

    if (users !== null && users.length > 0) {
      unsubscribe();

      const content = ReactDOMServer.renderToString(
        <Provider store={ store }><App /></Provider>
      );

      res.set('Content-Type', 'text/html');
      res.send(` <html> <head> <title>App</title> </head> <body> <div id="content">${ content }</div> <script src="/bundle.js"></script> </body> </html> `);
    }
  });

  ReactDOMServer.renderToString(<Provider store={ store }><App /></Provider>);
});
複製代碼

咱們使用Storesubscribe方法來監聽狀態。當狀態發生變化——是否有任何用戶數據被獲取。若是users存在,咱們將unsubscribe(),這樣咱們就不會讓相同的代碼運行兩次,而且咱們使用相同的存儲實例轉換爲string。最後,咱們將標記輸出到瀏覽器。

store.subscribe方法返回一個函數,調用這個函數就能夠解除監聽

有了上面的代碼,咱們的組件已經能夠成功地在服務器端渲染。經過開發者工具,咱們能夠看到發送到瀏覽器的內容:

<html>
          <head>
            <title>App</title>
            <style> body { font-size: 18px; font-family: Verdana; } </style>
          </head>
          <body>
            <div id="content"><div data-reactroot=""><p>Eve Holt</p><p>Charles Morris</p><p>Tracey Ramos</p></div></div>
            <script> window.__APP_STATE = {"users":[{"id":4,"first_name":"Eve","last_name":"Holt","avatar":"https://s3.amazonaws.com/uifaces/faces/twitter/marcoramires/128.jpg"},{"id":5,"first_name":"Charles","last_name":"Morris","avatar":"https://s3.amazonaws.com/uifaces/faces/twitter/stephenmoon/128.jpg"},{"id":6,"first_name":"Tracey","last_name":"Ramos","avatar":"https://s3.amazonaws.com/uifaces/faces/twitter/bigmancho/128.jpg"}]}; </script>
            <script src="/bundle.js"></script>
          </body>
        </html>
複製代碼

固然,如今並無結束,客戶端JavaScript不知道服務器上發生了什麼,也不知道咱們已經對API進行了請求。咱們必須經過傳遞Store的狀態來通知瀏覽器,以便它可以接收它。

const content = ReactDOMServer.renderToString(
  <Provider store={ store }><App /></Provider>
);

res.set('Content-Type', 'text/html');
res.send(` <html> <head> <title>App</title> </head> <body> <div id="content">${ content }</div> <script> window.__APP_STATE = ${ JSON.stringify(store.getState()) }; </script> <script src="/bundle.js"></script> </body> </html> `);
複製代碼

咱們將Store狀態放到一個全局變量__APP_STATE中,reducer也有一點變化:

function getInitialState() {
  if (typeof window !== 'undefined' && window.__APP_STATE) {
    return window.__APP_STATE;
  }
  return { users: null };
}
複製代碼

注意typeof window !== 'undefined',咱們必須這樣作,由於這段代碼也會在服務端執行,這就是爲何說在作服務端渲染時要很是當心,尤爲是全局使用的瀏覽器api的時候。

最後一個須要優化的地方,就是當已經取到users時,必須阻止fetch

componentWillMount() {
  const { users, fetchUsers } = this.props;

  if (users === null) {
    fetchUsers();
  }
}
複製代碼

總結

服務器端呈現是一個有趣的話題。它有不少優點,並改善了總體用戶體驗。它還會提高你的單頁應用程序的SEO。但這一切並不簡單。在大多數狀況下,須要額外的工具和精心選擇的api。

這只是一個簡單的案例,實際開發場景每每比這個複雜的多,須要考慮的狀況也會很是多,大家的服務端渲染是怎麼作的?

相關文章
相關標籤/搜索