基於 React.js 和 Node.js 的 SSR 實現方案

銅板街技術發表於2019-03-19

基礎概念

  1. SSR:即服務端渲染(Server Side Render) 傳統的服務端渲染可以使用Java,php 等開發語言來實現,隨著 Node.js 和相關前端領域技術的不斷進步,前端同學也可以基於此完成獨立的服務端渲染。

  2. 過程:瀏覽器傳送請求 -> 伺服器執行 react程式碼生成頁面 -> 伺服器返回頁面 -> 瀏覽器下載HTML文件 -> 頁面準備就緒 即:當前頁面的內容是伺服器生成好給到瀏覽器的。

基於 React.js 和 Node.js 的 SSR 實現方案

  1. 對應CSR:即客戶端渲染(Client Side Render) 過程:瀏覽器傳送請求 -> 伺服器返回空白 HTML(HTML裡包含一個root節點和js檔案) -> 瀏覽器下載js檔案 -> 瀏覽器執行react程式碼 -> 頁面準備就緒 即:當前頁面的內容是js渲染出來

基於 React.js 和 Node.js 的 SSR 實現方案

  1. 如何區分頁面是否服務端渲染: 右鍵點選 -> 顯示網頁原始碼,如果頁面上的內容在HTML文件裡,是服務端渲染,否則就是客戶端渲染。

  2. 對比

  • CSR:首屏渲染時間長,react程式碼執行在瀏覽器,消耗的是瀏覽器的效能
  • SSR:首屏渲染時間短,react程式碼執行在伺服器,消耗的是伺服器的效能

為什麼要用服務端渲染

  • 首屏載入時間優化,由於SSR是直接返回生成好內容的HTML,而普通的CSR是先返回空白的HTML,再由瀏覽器動態載入JavaScript指令碼並渲染好後頁面才有內容;所以SSR首屏載入更快、減少白屏的時間、使用者體驗更好。

  • SEO (搜尋引擎優化),搜尋關鍵詞的時候排名,對大多數搜尋引擎,不識別JavaScript 內容,只識別 HTML 內容。 (注:原則上可以不用服務端渲染時最好不用,所以如果只有 SEO 要求,可以用預渲染等技術去替代)

構建一個服務端渲染的專案

(1) 使用 Node.js 作為服務端和客戶端的中間層,承擔 proxy代理,處理cookie等操作。

(2) hydrate 的使用:在有服務端渲染情況下,使用hydrate代替render,它的作用主要是將相關的事件注水進HTML頁面中(即:讓React元件的資料隨著HTML文件一起傳遞給瀏覽器網頁),這樣可以保持服務端資料和瀏覽器端一致,避免閃屏,使第一次載入體驗更高效流暢。

 ReactDom.hydrate(<App />, document.getElementById('root'));
複製程式碼

(3) 服務端程式碼webpack編譯:通常會建一個webpack.server.js檔案,除了常規的引數配置外,還需要設定target引數為'node'。


const serverConfig = {
  target: 'node',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, '../dist')
  },
  externals: [nodeExternals()],
  module: {
    rules: [{
      test: /\.js?$/,
      loader: 'babel-loader',
      exclude: [
        path.join(__dirname, './node_modules')
      ]
    }
    ...
   ]
  }
  (此處省略樣式打包,程式碼壓縮,執行壞境配置等等...)
  ...
};
複製程式碼

(4) 使用react-dom/server下的 renderToString方法在伺服器上把各種複雜的元件和程式碼轉化成 HTML 字串返回到瀏覽器,並在初始請求時傳送標記以加快頁面載入速度,並允許搜尋引擎抓取頁面以實現SEO目的。

const render = (store, routes, req, context) => {
  const content = renderToString((
    <Provider store={store}>
      <StaticRouter location={req.path} context={context}>
        <div>
          {renderRoutes(routes)}
        </div>
      </StaticRouter>
    </Provider>
  ));
  return `
    <html>
      <head>
        <title>ssr</title>
      </head>
      <body>
        <div id='root'>${content}</div>
        <script src='/index.js'></script>
      </body>
    </html>
  `;
}
app.get('*', function (req, res) {
  ...
  const html = render(store, routes, req, context);
  res.send(html);
});
      
複製程式碼

與renderToString類似功能的還有: i. renderToStaticMarkup:區別在於renderToStaticMarkup 渲染出的是不帶data-reactid的純HTML,在JavaScript載入完成後因為不認識之前服務端渲染的內容導致重新渲染(可能頁面會閃一下)。

ii. renderToNodeStream:將React元素渲染為其初始HTML,返回一個輸出HTML字串的可讀流。

iii. renderToStaticNodeStream:與renderToNodeStream此類似,除了這不會建立React在內部使用的額外DOM屬性,例如data-reactroot。

(5) 使用redux 承擔資料準備,狀態維護的職責,通常搭配react-redux, redux-thunk(中介軟體:發非同步請求用到action)使用。(本猿目前使用比較多是就是Redux和Mobx,這裡以Redux為例)。 A. 建立store(伺服器每次請求都要建立一次,客戶端只建立一次):

const reducer = combineReducers({
  home: homeReducer,
  page1: page1Reducer,
  page2: page2Reducer
});

export const getStore = (req) => {
  return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios(req))));
}

export const getClientStore = () => {
  return createStore(reducer, window.STATE_FROM_SERVER, applyMiddleware(thunk.withExtraArgument(clientAxios)));
}

複製程式碼

B. action: 負責把資料從應用傳到store,是store資料的唯一來源

export const getData = () => {
  return (dispatch, getState, axiosInstance) => {
    return axiosInstance.get('interfaceUrl/xxx')
      .then((res) => {
        dispatch({
          type: 'HOME_LIST',
          list: res.list
        })
      });
  }
}
      
複製程式碼

C. reducer:接收舊的state和action,返回新的state,響應actions併傳送到store。

export default (state = { list: [] }, action) => {
  switch(action.type) {
    case 'HOME_LIST':
      return {
        ...state,
        list: action.list
      }
    default:
      return state;
  }
}
export default (state = { list: [] }, action) => {
  switch(action.type) {
    case 'HOME_LIST':
      return {
        ...state,
        list: action.list
      }
    default:
      return state;
  }
}     
複製程式碼

D. 使用react-redux的connect,Provider把元件和store連線起來

  • Provider 將之前建立的store作為prop傳給Provider
const content = renderToString((
  <Provider store={store}>
    <StaticRouter location={req.path} context={context}>
      <div>
        {renderRoutes(routes)}
      </div>
    </StaticRouter>
  </Provider>
));     
複製程式碼
  • connect([mapStateToProps],[mapDispatchToProps],[mergeProps], [options])接收四個引數 常用的是前兩個屬性 mapStateToProps函式允許我們將store中的資料作為props繫結到元件上mapDispatchToProps將action作為props繫結到元件上
 connect(mapStateToProps(),mapDispatchToProps())(MyComponent)
複製程式碼

(6) 使用react-router承擔路由職責 服務端路由不同於客戶端,它是無狀態的。React 提供了一個無狀態的元件StaticRouter,向StaticRouter傳遞當前URL,呼叫ReactDOMServer.renderToString() 就能匹配到路由檢視。

服務端

import { StaticRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config'
import routes from './router.js'

<StaticRouter location={req.path} context={{context}}>
{renderRoutes(routes)}
</StaticRouter>     
複製程式碼

瀏覽器端

import { BrowserRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config'
import routes from './router.js'

<BrowserRouter>
  {renderRoutes(routes)}
</BrowserRouter>

複製程式碼

當瀏覽器的位址列發生變化的時候,前端會去匹配路由檢視,同時由於req.path發生變化,服務端匹配到路由檢視,這樣保持了前後端路由檢視的一致,在頁面重新整理時,仍然可以正常顯示當前檢視。如果只有瀏覽器端路由,而且是採用BrowserRouter,當頁面地址發生變化後去重新整理頁面時,由於沒有對應的HTML,會導致頁面找不到,但是加了服務端路由後,重新整理發生時服務端會返回一個完整的html給客戶端,頁面仍然正常顯示。 推薦使用 react-router-config外掛,然後如上程式碼在StaticRouter和BrowserRouter標籤的子元素里加renderRoutes(routes):建一個router.js檔案

const routes = [{ component: Root,
  routes: [
    { path: '/',
      exact: true,
      component: Home,
      loadData: Home.loadData
    },
    { path: '/child/:id',
      component: Child,
      loadData: Child.loadData
      routes: [
        path: '/child/:id/grand-child',
        component: GrandChild,
        loadData: GrandChild.loadData
      ]
    }
  ]
}];
複製程式碼

在瀏覽器端請求一個地址的時候,server.js 裡在實際渲染前可以通過matchRouters 這種方式確定要渲染的內容,呼叫loaderData函式進行action派發,返回promise->promiseAll->renderToString,最終生成HTML文件返回。

import { matchRoutes } from 'react-router-config'
  const loadBranchData = (location) => {
    const branch = matchRoutes(routes, location.pathname)

    const promises = branch.map(({ route, match }) => {
      return route.loadData
        ? route.loadData(match)
        : Promise.resolve(null)
    })

    return Promise.all(promises)
}
複製程式碼

(7) 寫元件注意程式碼同構(即:一套React程式碼在服務端執行一次,在客戶端再執行一次) 由於伺服器端繫結事件是無效的,所以伺服器返回的只有頁面樣式(&注水的資料),同時返回JavaScript檔案,在瀏覽器上下載並執行JavaScript時才能把事件綁上,而我們希望這個過程只需編寫一次程式碼,這個時候就會用到同構,服務端渲染出樣式,在客戶端執行時綁上事件。

優點: 共用前端程式碼,節省開發時間 弊端: 由於伺服器端和瀏覽器環境差異,會帶來一些問題,如document等物件找不到,DOM計算報錯,前端渲染和服務端渲染內容不一致等;前端可以做非常複雜的請求合併和延遲處理,但為了同構,所有這些請求都在預先拿到結果才會渲染。

作者簡介

朵拉,銅板街前端開發工程師,2015年8月加入團隊,目前主要負責運營側APP端專案開發。

基於 React.js 和 Node.js 的 SSR 實現方案

更多精彩內容,請掃碼關注 “銅板街技術” 微信公眾號。

相關文章