React 服務端渲染

圖1

前言

前端崛起後,Vue,React等框架大受歡迎,可是他們構建的單頁應用有如下缺點php

  • 因爲單頁應用是一次性加載全部資源,因此首屏白屏時間會比較長
  • 因爲數據經過異步請求加載,因此不利於SEO

爲了解決這些問題,咱們能夠採用服務端渲染的方式。使用服務端渲染,咱們不能走回老路,因此產生了Vue的next.js和React的next.js等框架。可是,所謂「授人以魚不如授人以漁」,咱們不只要學會使用第三方框架,還要學習其中的原理!css

目標

  1. 簡單服務端渲染
  2. 路由同構
  3. store同構
  4. css樣式處理
  5. 404錯誤處理

簡單服務端渲染

服務端渲染,服務端將HTML以字符串的形式返回給前端,前端去渲染。老式服務端渲染像jsp php那樣,每次請求則刷新頁面。而如今服務端渲染是使用node中間層去代替客戶端請求數據渲染HTML,再發送內容給客戶端html

server

這裏咱們可使用renderToString,這是由react-dom提供的方法,它存在react-dom/server下,它將組件以字符串形式返回。與renderToStaticMarkup不一樣的是,renderToString返回的HTML會帶有data-reactid,而renderToStaticMarkup沒有。但在React16開始,爲了HTML更加簡潔,取消了全部標記,因此跟正常HTML相同前端

import React from 'react';
import { renderToString } from 'react-dom/server';
import Header from '../components/Header';

export default () => {
  return `
  <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <div id="app">${renderToString(<Header />)}</div>
</body>
</html>
  `
}
複製代碼

而後使用express搭建後臺服務,處理請求node

import express from 'express';
import render from './render';

const app = new express();

app.get('*', (req, res) => {
  const html = render();
  res.send(html)
})

app.listen(3000, () => {
  console.log('server is running on port 3000');
})
複製代碼

webpack

從上圖能夠看出,webpack配置分爲服務端客戶端,這裏咱們先配置服務端,同時把二者相同部分抽離到webpack.base.js,使用webpack-merge插件進行合併react

const path = require('path');
const webpackMerge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const baseConfig = require('./webpack.base.js');
const serverConfig = {
  target: 'node', // 排除node內置模塊,fs、path
  mode: 'development',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'build')
  },
  externals: [nodeExternals()] // 排除node_modules模塊
}
module.exports = webpackMerge(baseConfig, serverConfig)
複製代碼

另外,配置一下.babelrcpackage.json。爲pakage.json加上如下scripts,就能夠監聽並動態編譯webpack

"dev:build:server": "webpack --config ./webpack.server.js --watch"
複製代碼

至此,咱們npm run dev:build:server即可獲得編譯後的bundle.js,此時咱們的目錄結構以下git

圖2
進入build目錄 node bundle.js啓動項目,客戶端訪問3000端口,能夠看到結果,可是點擊按鈕控制檯並無輸出結果

圖3

client

後端沒法處理事件綁定,這須要由客戶端來處理。咱們使用React16新提出的hydrate來完成這項任務,此方法由react-dom提供。他能代替以前的render方法,複用服務端傳來內容,並綁定好事件github

import React from 'react';
import ReactDom from 'react-dom';
import Header from '../components/Header';

const App = function() {
  return (
      <Header /> ) } ReactDom.hydrate(<App />, document.getElementById('app')); 複製代碼

而後添加客戶端的webpack配置,經過webpack編譯能夠獲得public文件夾及內部index.js。這裏爲了可以實時編譯和編譯後及時重啓服務器,咱們須要對package.json進行如下配置web

"scripts": {
    "dev": "npm-run-all --parallel dev:**",
    "dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"",
    "dev:build:server": "webpack --config ./webpack.server.js --watch",
    "dev:build:client": "webpack --config ./webpack.client.js --watch"
  },
複製代碼

爲了客戶端能實現功能,咱們須要在server/render.js內經過腳本引用客戶端編譯好的index.js,以及讓服務端響應靜態資源請求

<script src="/index.js"></script>
複製代碼
app.use(express.static('public'));
複製代碼

至此,咱們npm run dev即可並行編譯及開啓服務,請求3000端口,點擊按鈕就能夠看到輸出結果了!

圖4

路由同構

這裏咱們採用配置的方式構建路由

export default [
  {
    path: '/',
    component: App,
    routes: [
      {
        path: '/',
        component: Home,
        exact: true // 默認路由配置
      },
      {
        path: '/login',
        component: Login
      }
    ]
  }
]
複製代碼

這種形式生成路由須要藉助react-router-config提供的renderRoutes方法,此方法最終會將路由配置文件轉爲如下形式

<Switch>
    <Route path="/" component={App} />
    const App = () => {
        <div>
            <Route exact path="/" component={Home} />
            <Route path="/login" component={Login} />
        </div>
    }
</Switch>
複製代碼

React中,通常客戶端渲染時使用BrowserRouter,而服務端渲染,咱們須要使用react-router-dom提供的無狀態的StaticRouter。BrowserRouter會根據url來保持頁面同步,而StaticRouter只會傳入服務器提供的url,以便路由匹配

const App = (
    <StaticRouter location={req.path}> <div> {renderRoutes(routes)} </div> </StaticRouter>
  )
複製代碼

固然,服務端修改了,爲了達到hydrate複用效果,那麼客戶端應該保持一致

const App = function() {
  return (
    <BrowserRouter> <div> { renderRoutes(routes) } </div> </BrowserRouter>
  )
}
複製代碼

到此,咱們路由同構完成,客戶端訪問http://127.0.0.1:3000/login,能夠看到如下結果

圖5

store同構

爲了實現的SEO功能,服務端須要返回帶有數據HTML字符串。首先,咱們先按老套路,構建好store

圖6
與以往不一樣的是,服務端渲染嘛,那咱們就須要生成兩個store了,分別是客戶端的store和服務端的store。並且,咱們不能直接 export出構建好的store,而須要對其再包一層,這樣就 不會是單例模式了。

export const getClientStore = () => {
  return createStore(
    reducer,
    applyMiddleware(thunk)
  )
}

export const getServerStore = () => {
  return createStore(
    reducer,
    applyMiddleware(thunk)
  )
}
複製代碼

而後,將clientStoreserverStore分別經過Provider傳給客戶端和服務端的子組件。接着經過connect將容器組件與Home展現組件鏈接。npm run dev後獲得以下結果

圖7
從結果能夠看出列表雖然渲染出來了,可是這是來自前臺請求的結果。服務端返回的HTML並無數據,由於Home組件的 componentDidMount生命週期在服務端並無執行。因此咱們須要手動去觸發 dispatch,去給予 serverStore數據。這裏咱們經過將 loadData變量掛載到Home組件上,loadData方法返回的都是 Promise對象

Home.loadData = function(store) {
  return store.dispatch(getCommentList())
}
複製代碼

但是,這須要怎麼去觸發此方法呢?咱們能夠在接收到相應的請求時去觸發,那就把他放到路由配置上吧

{
    path: '/',
    component: Home,
    loadData: Home.loadData,
    exact: true // 默認路由配置
  }
複製代碼

接着,咱們須要根據路由去觸發loadData。這裏咱們須要使用到react-router-config提供的matchRoutes方法。此方法能夠根據請求路徑,配置到相應的路由,須要注意的是此處使用的是req.path而不是req.url,由於req.url會帶有query參數。而後,咱們使用Promise.all去執行全部請求,全部請求結束後,此時store已經有數據了,再響應HTML給客戶端

app.get('*', (req, res) => {
  const store = getServerStore()
  const matchedRoutes = matchRoutes(routes, req.path)
  const promises = []
  matchedRoutes.forEach(mRouter => {
    if(mRouter.route.loadData) {
      promises.push(mRouter.route.loadData(store))
    }
  })
  Promise.all(promises)
    .then(resArr => {
      const html = render(req,store);
      return res.send(html)
    })
    .catch(err => {
      console.log('服務端出錯:', err)
    })
})
複製代碼

此時,咱們能夠看到服務端響應HTML中已經存在列表數據了

圖8
可是,咱們能夠看到列表顯示過程爲 有數據 -> 空白 -> 有數據。爲了解決它,咱們須要 初始化clientStore。首先,咱們在HTML字符串中埋好數據

<script>
    window.__context__ = {state: ${JSON.stringify(store.getState())}}
</script>
複製代碼

而後在getClientStore時,初始化store。createStore能夠傳入三個參數,第二個參數用於初始化state,在使用了combineReducers時,其結構要和reducer結構一致

export const getClientStore = () => {
  const defaultStore = window.__context__ || {}
  return createStore(
    reducer,
    defaultStore.state,
    applyMiddleware(thunk)
  )
}
複製代碼

OK,這樣就不會存在空白閃爍間隔了。

css樣式處理

webpack配置

通常咱們處理css樣式,須要使用的插件是style-loader,可是此插件在服務端的node環境是沒法愉快玩耍的。咱們須要使用一個專門爲服務端渲染而生的插件,即isomorphic-style-loader,具體用法可參見其官方文檔。首先配置webpack.client.jswebpack.server.js,注意:此處須要開啓CSS Modules

module:{
    rules:[{
      test:/\.css$/,
      use: [
        'isomorphic-style-loader',
        {
            loader: 'css-loader',
            options: {
              modules: true // 開啓css模塊化
            }
      }]
    }]
  }
複製代碼

服務端

而後,修改一下render.js,第一步引入StyleContext

import StyleContext from 'isomorphic-style-loader/StyleContext';
複製代碼

第二步使用StyleContext包裹住App,StyleContext.Provider的value屬性接收一個包含insertCss的上下文對象,它主要是提供給後面所提到的Withstyles

const css = new Set()
  const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()))
  const context = { insertCss }
  const App = (
    <StyleContext.Provider value={context}> <Provider store={store}> <StaticRouter location={req.path}> <div> {renderRoutes(routes)} </div> </StaticRouter> </Provider> </StyleContext.Provider> ) 複製代碼

第三步,須要將css樣式插入返回的HTML模板字符串

<style>${[...css].join('')}</style>
複製代碼

客戶端

既然服務端修改了,那麼客戶端也要跟上,咱們修改一下client/index.jsx。此處的insertCss與服務端的有點不一樣,node環境下只能使用_getCss方法,而此處使用的是_insertCss,它相似於style.loaderaddStylesToDom

import StyleContext from 'isomorphic-style-loader/StyleContext';

const App = function() {
  const insertCss = (...styles) => {
    const removeCss = styles.map(style => style._insertCss())
    return () => removeCss.forEach(dispose => dispose())
  }
  const context = { insertCss }
  return (
    <StyleContext.Provider value={context}> <Provider store={getClientStore()}> <BrowserRouter> <div> { renderRoutes(routes) } </div> </BrowserRouter> </Provider> </StyleContext.Provider> ) } 複製代碼

組件使用

全部配置完成,咱們能夠開始使用了!首先,咱們引入withStyles,這是一個高階組件,內部有上文提到的_insertCss方法

import withStyles from 'isomorphic-style-loader/withStyles';
複製代碼

而後,引入css樣式並使用,須要注意的是此處不是直接import './Home.css',而是以模塊的形式引入,這就是上文爲什麼要指明css須要開啓模塊化的緣由

import style from './Home.css';

<h3 className={style.title}>Home</h3>
複製代碼

接着,咱們使用withStyles包裹一下Home組件,此處以柯里化的形式,第一個參數能夠傳入style序列,第二參數傳入組件

export default connect(mapStateToProps,
  mapDispatchToProps)(withStyles(style)(Home));
複製代碼

至此,咱們能夠獲得以下結果,能夠看到Home title變爲了紅色

圖9

404錯誤處理

前面,咱們同構好了路由,可是當咱們訪問/home時,子頁面爲空白,並且響應狀態是200,這就不對了!咱們並無設置/home路由,雖然在/時會出現Home頁面內容,但路由是/。因此,咱們須要處理一下這個問題,當沒有路由匹配時,須要響應404並返回404 not found提示內容。
那麼如何判斷請求頁面不存在呢?這時,咱們須要藉助StaticRoutercontext屬性。傳入的context能夠在路由組件內獲取到,咱們須要將404頁面放到最後,當路由匹配到此,咱們將NOT_FOUND變量掛載到context。因此,咱們就能夠經過context上是否有NOT_FOUND變量來判斷請求頁面是否存在
首先,配置404頁面,在路由最後位置添加

{
    path: '*',
    render: ({staticContext}) => {
      if (staticContext) staticContext.NOT_FOUND = true
      return <div>404 not found</div>
    }
  }
複製代碼

而後,給render.js內的StaticRouter傳入context

<StaticRouter location={req.path} context={ctx}>
  <div>
    {renderRoutes(routes)}
  </div>
</StaticRouter>
複製代碼

接着,在server/index.js根據是否有NOT_FOUND變量來判斷是否響應404錯誤

const context = {}
  const html = render(req, store, context);
  if (context.NOT_FOUND) res.status(404)
  return res.send(html)
複製代碼

最後,咱們請求http://127.0.0.1:3000/home能夠看到頁面顯示以下

圖10

圖11

結語

服務端渲染雖然能優化首屏加載速度,但若是數據請求時間較長也不會有顯著效果。所以,是否採用服務端渲染還須要根據實際應用考慮。通常服務端渲染用在注重SEO的網站,或者增改刪查等業務場景較多的後臺管理系統等。
ps:項目地址

相關文章
相關標籤/搜索