從簡單React SSR項目透視服務端渲染原理


1、爲何要進行服務端渲染

隨着前端技術棧和工具鏈的迭代成熟,前端工程化、模塊化的趨勢也愈發明顯,在這波前端技術浪潮中,涌現了諸如React、Vue、Angular等基於客戶端渲染的前端框架,這類框架所構建的單頁應用(SPA)具備渲染性能好、可維護性高等優勢。但也同時帶來了兩個缺陷:html

1.首屏加載時間過長 
2.不利於SEO複製代碼

與傳統web項目直接獲取服務器端渲染好的HTML不一樣,單頁應用使用JavaScript在客戶端生成HTML來呈現內容,用戶須要等待JS解析執行完成才能看到頁面,這就使得首屏加載時間變長,影響用戶體驗。此外當搜索引擎爬取網站HTML文件時,單頁應用的HTML沒有內容,從而影響搜索排名。爲了解決這兩個缺陷,業界借鑑傳統的服務器端直出HTML方案,提出在服務器端執行前端框架(React/Vue/Angular)代碼生成HTML,而後將渲染好的HTML返回給客戶端,實現CSR前端框架的服務器端渲染。前端

本文經過一個簡單的demo,向讀者講解React服務器端渲染(SSR)的基本原理,在閱讀完本文後,讀者應該可以掌握:vue

  1. 服務器端渲染的基本概念和原理node

  2. 在SSR項目中渲染組件react

  3. 在SSR項目中使用路由webpack

  4. 在SSR項目中使用reduxios


2、在SSR項目中渲染組件

1.使用node進行服務端渲染

咱們使用express啓動一個Node服務器來進行基本的服務端渲染。首先安裝初始化node項目和安裝expressweb

npm initexpress

npm install express –savenpm

在根目錄中建立文件app.js,監聽3000端口的請求,當請求根目錄時,返回一些HTML

const express = require('express')
const app = express()
app.get('/', (req,res) => res.send(`
<html>
   <head>
       <title>ssr demo</title>
   </head>
   <body>
       Hello world
   </body>
</html>
`))

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

進入項目根目錄,運行node app.js啓動項目。


鼠標右鍵查看網頁源代碼,這就是服務器端直接返回的HTML,咱們已經完成了一個基本的服務端渲染。若是咱們打開一個react項目並查看網頁源代碼,會發現代碼中並無頁面內容對應的HTML,這是由於react所構建的SPA單頁應用是經過在客戶端執行JS動態地生成HTML,初始的HTML文件中並無對應的內容。


2.在服務器端編寫React代碼

咱們已經啓動了一個Node服務器,下一步咱們須要在服務器上編寫React代碼,咱們建立一段這樣的React代碼並在app.js進行引用

import React from 'react'

const Home = () =>{

    return <div>home</div>

}

export default Home複製代碼

然而這段代碼並不會運行成功,由於直接在服務器端運行React代碼是行不通的,緣由有如下幾個:

  1. Node不能識別import和export,這兩者屬於esModule的語法,而Node遵循common.js規範

  2. Node不能識別JSX語法,咱們須要使用webpack對項目進行打包轉換,使之成爲Node能識別的語法

爲了使代碼可以運行,咱們須要安裝webpack並進行配置

  1. npm install webpack webpack-cli –save 安裝webpack和webpack-cli

  2. 根目錄下建立配置文件webpack.server.js並進行相關配置

const path = require('path')    //node的path模塊
const nodeExternals = require('webpack-node-externals')

module.exports = {
    target:'node',
    mode:'development',           //開發模式
    entry:'./app.js',             //入口
    output: {                     //打包出口
        filename:'bundle.js',     //打包後的文件名
        path:path.resolve(__dirname,'build')    //存放到根目錄的build文件夾
    },
    externals: [nodeExternals()],  //保持node中require的引用方式
    module: {
        rules: [{                  //打包規則
           test:   /\.js?$/,       //對全部js文件進行打包
           loader:'babel-loader',  //使用babel-loader進行打包
           exclude: /node_modules/,//不打包node_modules中的js文件
           options: {
               presets: ['react','stage-0',['env', { 
                                  //loader時額外的打包規則,對react,JSX,ES6進行轉換
                    targets: {
                        browsers: ['last 2versions']   //對主流瀏覽器最近兩個版本進行兼容
                    }
               }]]
           }
       }]
    }
}複製代碼

3.安裝對應的babel

npm install babel-loaderbabel-core –save

npm install babel-preset-react –save

npm install babel-preset-stage-0 –save

npm install babel-preset-env –save

npm install webpack-node-externals –save

4.運行webpack --config webpack.server.js

5.啓動打包後的文件node ./build/bundle.js


關於webpack的使用,比較陌生的讀者能夠參考咱們公衆號這篇:《webpack入門》

mp.weixin.qq.com/s/qtw3nKLyo…


3.使用renderToString渲染組件

通過webpack對JSX和ES6進行打包轉化後,咱們仍是沒法正確運行咱們的代碼,之前在客戶端渲染DOM時,咱們使用下面的代碼,但這段代碼沒法在服務端運行。

import Home from './src/containers/Home'

import ReactDom from 'react-dom'

ReactDom.render(<Home/>, document.getElementById('root')) //服務端沒有DOM複製代碼

咱們須要使用react-dom提供的renderToString方法,將組件渲染成爲字符串再插入返回給客戶端的HTML中

import express from 'express'
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import Home from'./src/containers/Home'

const app= express()
const content = renderToString(<Home/>)
app.get('/',(req,res) => res.send(`
<html>
   <head>
       <title>ssr demo</title>
   </head>
   <body>
        ${content}
   </body>
</html>
`))

app.listen(3001, () => console.log('Exampleapp listening on port 3001!'))複製代碼

從新打包並重啓服務器,咱們就能在頁面上看到服務器端渲染的組件


4.webpack自動打包和服務端自動重啓

寫到這裏咱們對以前的Node和webpack的啓動方式作一個小優化,在這以前,咱們每次對項目的改動,都須要從新執行webpack--config webpack.server.js和node ./build/bundle.js來重啓項目,如今咱們對package.json文件中的script作一些改動,使得服務器可以自動重啓和打包

  1. 在webpack --config webpack.server.js後加上—watch就能實現webpack的自動監聽打包,當須要被打包的文件發生變化時,webpack就會自動從新打包

  2. 安裝nodemon,nodemon是nodemonitor的縮寫,nodemon可以幫咱們監聽文件的變化並自動重啓服務器,咱們須要運行 npm install nodemon –g安裝nodemon,在package.json的script配置項中添加這兩句:

"scripts":{
    "dev": "nodemon--watch build --exec node \"./build/bundle.js\"",
    "build": "webpack--config webpack.server.js --watch"
  },複製代碼

在進行了以上兩條配置後,咱們開啓兩個終端,分別運行npm run dev和npm run build就能完成項目的自動打包和服務器重啓

3.安裝npm-run-all進一步簡化流程:

運行npm install npm-run-all –g安裝npm-run-all,並對package.json進行配置

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

咱們在原來的start和build加上dev前綴,表示這是開發環境所使用的命令,在線上環境時咱們並不須要執行這兩條命令去監聽。配置好之後,運行npm run dev,咱們就完成了自動打包和服務端啓動重啓,每次對代碼的更改只須要刷新頁面就能看到效果,沒必要像原來那樣手動從新打包和重啓服務器


5.同構的概念

咱們在上面的過程當中,已經將組件渲染到了頁面上,下面咱們爲組件綁定一個點擊事件。

import React from 'react'

const Home= () =>{
    return (
    <div>
        <div>home</div>
        <button onClick={()=>{alert('click')}}>click</button>
    </div>)
}
 
export default Home複製代碼

運行代碼,刷新頁面,咱們會發現並無執行對應的點擊事件,這是因爲renderToString只渲染了組件的內容,而不會綁定事件,爲了可以給頁面上的組件綁定事件,咱們須要將React代碼在服務端執行一遍,在客戶端再執行一遍,這種服務器端和客戶端共用一套代碼的方式就稱之爲同構。

咱們經過<script>標籤爲頁面引入客戶端執行的React代碼,並經過express的static中間件爲js文件配置路由,修改原來的app.js

import express from 'express'
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from'react-dom/server'//引入renderToString方法
import Home from './src/containers/Home'
 
const app = express()
app.use(express.static('public'));
//使用express提供的static中間件,中間件會將全部靜態文件的路由指向public文件夾
 const content = renderToString(<Home/>)
 
app.get('/',(req,res)=>res.send(`
<html>
   <head>
       <title>ssr demo</title>
   </head>
   <body>
        ${content}
   <script src="/index.js"></script>
   </body>
</html>
`))

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

而後咱們須要編寫咱們的index.js(客戶端的React代碼),咱們嘗試在public文件夾下建立index.js並編寫React代碼,但這些React代碼將沒法運行,由於咱們一樣須要使用webpack對客戶端的React進行打包。


6.在客戶端執行React代碼

咱們先調整一下目錄結構,在src文件夾下新建client文件夾用來存放客戶端代碼,根目錄下新建webpack.client.js做爲客戶端React代碼的webpack配置文件,public文件夾將用來存放webpack打包後的客戶端代碼;新建server文件夾用來存放服務器端代碼,將原來app.js的內容移至server文件夾下的index.js,並修改webpack.server.js的入口。新建containers文件夾存放React代碼

下面咱們開始編寫客戶端webpack配置項,在webpack.client.js中編寫如下代碼:

const path = require('path')                    //node的path模塊

module.exports = {
    mode:'development',                         //開發模式
    entry:'./src/client/index.js',              //入口
    output: {                                   //打包出口
        filename:'index.js',                    //打包後的文件名
        path:path.resolve(__dirname,'public')   //存放到根目錄的build文件夾
    },
    module: {
        rules: [{                               //打包規則
           test:   /\.js?$/,                    //對全部js文件進行打包
           loader:'babel-loader',               //使用babel-loader進行打包
           exclude: /node_modules/,             //不打包node_modules中的js文件
           options: {
               presets: ['react','stage-0',['env', {     
                    //loader時額外的打包規則,這裏對react,JSX進行轉換
                    targets: {
                        browsers: ['last 2versions']   //對主流瀏覽器最近兩個版本進行兼容
                    }
               }]]
           }
       }]
    }
}複製代碼

同時咱們對package.json中的script部分進行修改

"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"
  },複製代碼

從新運行npm run dev,咱們就完成了服務端、客戶端代碼的自動打包,刷新頁面,能夠看到事件已經成功綁定

這裏報了個警告,緣由是在React 16中進行服務端渲染時,應該將render()方法替換爲hydrate()方法,雖然在React16中仍然可以使用render()渲染HTML,但爲了消除錯誤,最好替換成hydrate()

有關hydrate的更多內容,能夠看這個討論:https://www.wengbi.com/thread_50584_1.html


7.webpack優化整理

咱們在項目中編寫了兩個webpack配置文件,其實在這兩個配置文件當中存在不少共同的部分,咱們應該將共同的部分提取出來,減小代碼的冗餘性。咱們安裝webpack-merge模塊來幫助咱們提取公用的webpack配置項。

  1. 新建webpack.base.js文件,將webpack.server.js和webpack.client.js中共同的配置項移到這裏並經過module.exports進行導出

module.exports = {
    module: {
        rules: [{
           test:   /\.js?$/,
           loader:'babel-loader',
           exclude: /node_modules/,
           options: {
               presets: ['react','stage-0',['env', {
                    targets: {
                        browsers: ['last 2versions']
                    }
               }]]
           }
       }]
    }
}複製代碼

2.在webpack.server.js和webpack.client.js中經過merge方法將公用配置項和當前配置項進行合併導出。

//webpack.client.js配置
const path = require('path')
const merge = require('webpack-merge')
const config = require('./webpack.base.js')

const clientConfig = {
    mode:'development',
    entry:'./src/client/index.js',
    output: {
        filename:'index.js',
        path:path.resolve(__dirname,'public')
    },
}

module.exports = merge(config,clientConfig)複製代碼


//webpack.server.js配置
const path = require('path')
const nodeExternals = require('webpack-node-externals')
const merge = require('webpack-merge')
const config = require('./webpack.base.js')

const serverConfig = {
    target:'node',
    mode:'development',
    entry:'./app.js',
    output: {
        filename:'bundle.js',
        path:path.resolve(__dirname,'build')
    },
    externals: [nodeExternals()],
}

module.exports = merge(config,serverConfig)複製代碼

段落小結:

本小節介紹瞭如何在服務端進行基礎的組件的渲染和事件綁定,經過本小節的講解,讀者應該可以體會到React SSR的基本思路——同構,所謂同構,就是一套React代碼在服務器端執行生成HTML,客戶端再執行代碼接管頁面的操做,從而使得頁面兼具SSR和CSR的優勢。

總結一下服務端渲染組件的步驟:

  1. 創建一個node項目

  2. 編寫服務器端的React代碼並使用webpack進行打包編譯,使用renderToString方法將組件渲染成爲HTML。

  3. 編寫客戶端須要執行的React代碼,並使用webpack進行打包編譯,經過script標籤引入頁面,接管頁面的操做。


3、在SSR項目中使用路由

1.在客戶端使用路由

一樣的,在使用路由時,咱們須要在服務器端和客戶端各配置一遍路由,緣由會在下文中解釋。咱們首先進行客戶端的路由配置,安裝react-router。

npm install react-router-dom —save


而後咱們在src文件夾下建立Router.js存放路由條目

import React from 'react'                   //引入React以支持JSX
import { Route } from 'react-router-dom'    //引入路由
import Home from './containers/Home'        //引入Home組件

export default (
    <div>
        <Route path="/" exact component={Home}></Route>
    </div>
)複製代碼

修改client文件夾下的index.js,使用BrowserRouter並引入路由條目

import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter } from'react-router-dom'
import Router from'../Routers'

const App= () => {
    return (
        <BrowserRouter>
           {Router}
        </BrowserRouter>
    )
}

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

運行代碼,刷新頁面,會發現控制檯報錯:

這是因爲咱們在Router.js使用路由時,外層須要套一個div,然而服務器端的HTML外層並無這個div,致使了客戶端渲染的頁面和服務端渲染的頁面內容不一樣,於是報錯,因此咱們須要在服務器端再配置一次路由,使得服務器端和客戶端渲染的內容一致(固然,若是直接在服務器返回的HTML里加多一個div是能夠暫時解決這個報錯的,但在服務器端不寫路由的話,在接下來的步驟中還會遇到其餘錯誤)


2.在服務器端使用路由

修改server文件夾下的index.js,在這裏引入服務器端路由。在服務器端咱們須要使用StaticRouter來替代BrowserRouter,StaticRouter 是 React-Router 針對服務器端渲染專門提供的一個路由組件,因爲StaticRouter不能像BrowserRouter同樣感知頁面當前頁面的url,因此咱們須要給StaticRouter傳入location={當前頁面url},另外使用 StaticRouter時必須傳遞一個context參數,用於服務端渲染時的參數傳遞。

import express from 'express'
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter } from 'react-router-dom'
import Router from '../Routers'
 
const app = express()
app.use(express.static('public'));
//使用express提供的static中間件,中間件會將全部靜態文件的路由指向public文件夾

app.get('/',(req,res)=>{
    const content  = renderToString((
        //在服務端咱們須要使用StaticRouter來替代BrowserRouter
        //傳入當前path
        //context爲必填參數,用於服務端渲染參數傳遞
        <StaticRouter location={req.path} context={{}}>
           {Router}
        </StaticRouter>
    ))
    res.send(`
   <html>
       <head>
           <title>ssr demo</title>
       </head>
       <body>
       <div id="root">${content}</div>
       <script src="/index.js"></script>
       </body>
   </html>
    `)
})


app.listen(3001, () => console.log('Exampleapp listening on port 3001!'))複製代碼

這時再打開咱們的頁面,就不會出現錯誤了。


3.經過link實現多頁面跳轉

咱們建立一個Login組件

並在Routers.js中爲login組件添加路由

import React from'react'                   //引入React以支持JSX
import { Route } from'react-router-dom'    //引入路由
import Home from'./containers/Home'        //引入Home組件
import Login from'./containers/Login'      //引入Login組件
 
exportdefault (
    <div>
        <Route path="/" exact component={Home}></Route>
        <Route path="/login" exact component={Login}></Route>
    </div>
)複製代碼

另外咱們須要將src/server/index.js中的路由從匹配‘/’改爲‘*’,不然當咱們訪問http://localhost:3001/login時將因爲匹配不到路由而提示404錯誤。

import express from 'express'
import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter } from 'react-router-dom'
import Router from '../Routers'

const app= express()
app.use(express.static('public'));
//使用express提供的static中間件,中間件會將全部靜態文件的路由指向public文件夾

app.get('*',(req,res)=>{
    const content  = renderToString((
        <StaticRouter location={req.path} context={{}}>
           {Router}
        </StaticRouter>
    ))
    res.send(`
   <html>
       <head>
           <title>ssr demo</title>
       </head>
       <body>
       <div id="root">${content}</div>
       <script src="/index.js"></script>
       </body>
   </html>
    `)
})

app.listen(3001, () =>console.log('Exampleapp listening on port 3001!'))複製代碼

咱們能夠稍微抽離以上代碼中生成HTML的部分,server文件夾下新建utils.js文件,存放生成HTML的代碼

import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter } from 'react-router-dom'
import Router from '../Routers'

export const render = (req) => {
    const content = renderToString((
        <StaticRouter location={req.path} context={{}}>
           {Router}
        </StaticRouter>
    ));

    return`
       <html>
           <head>
               <title>ssr demo</title>
           </head>
           <body>
           <div id="root">${content}</div>
           <script src="/index.js"></script>
           </body>
       </html>
    `
}複製代碼

原來的server/index.js能夠改爲如下形式

import express from 'express'
import { render } from './utils'
 
const app = express()
app.use(express.static('public'));
//使用express提供的static中間件,中間件會將全部靜態文件的路由指向public文件夾

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

app.listen(3001, () => console.log('Exampleapp listening on port 3001!'))複製代碼

在進行了以上的步驟後,咱們使用Link標籤來實現一個導航功能,咱們須要建立一個導航欄組件並在home和login中引用這個導航欄組件,對於能夠複用的組件,咱們在src文件夾下建立component文件夾存放公共組件,並在component下建立header.js做爲咱們的導航欄組件

import React from 'react'
import { Link } from 'react-router-dom'
 
const Header = () => {
    return (
        <div>
           <Link to='/'>Home </Link>
           <Link to='/login'>Login</Link>
        </div>
    )
}

export default Header複製代碼

而後咱們分別在Home組件和Login組件中引用這個導航欄組件,保存代碼,刷新頁面,如今已經可以在頁面上進行路由跳轉了。

值得注意的是,只有在第一次進入頁面時,瀏覽器請求了頁面文件,以後切換路由的操做都不會從新請求頁面,由於這時頁面的路由跳轉已是客戶端React的路由跳轉了。


段落小結:

本小節介紹瞭如何在SSR項目中使用路由,咱們須要在服務器端和客戶端各配置路由才能正常實現頁面跳轉,對於配置兩次路由的緣由,筆者的理解是

1.服務端路由是爲了第一次進入頁面時可以找到對應的網頁文件

2.客戶端路由是爲了能讓React路由接管頁面實現無刷新跳轉

3.若是服務器端不寫路由的話,會致使頁面內容不統一而出現報錯

此外咱們須要注意到,只有在第一次進入頁面的時候,瀏覽器纔會使用服務器端路由請求網頁文件,當頁面渲染後,React的客戶端路由將接管頁面路由,實現無刷新跳轉。


4、在SSR項目中使用redux

本小節將講解如何在SSR項目中使用redux,這是項目中的一個難點,一樣的咱們須要在客戶端和服務器端各執行一次redux的代碼,緣由會在下文中解釋。

1.安裝redux以及redux中間件

npm install redux –save

npm install react-redux–save

npm install redux-thunk–save

2.在客戶端使用redux

接下來咱們進行一系列常規操做,這裏再也不細講redux、redux-thunk、react-redux的使用。咱們在客戶端代碼(/client/index.js)裏使用redux建立store和reducer,配置中間件thunk,並將store傳遞給組件。

import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Routers from '../Routers'
import { createStore,applyMiddleware } from 'react'
import { Provider } from 'react-redux'
import thunk from 'redux-thunk'

const reducer = (state,action) => {
    return state
}

const store = createStore(reducer,applyMiddleware(thunk))
 
const App = () => {
    return (
        <Provider store={store}>
           <BrowserRouter>
               {Routers}
           </BrowserRouter>
        </Provider>
    )
}

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

在子組件(Home)中咱們使用react-redux中的connect方法與store進行鏈接

import React from 'react'
import Header from '../../component/header'
import { connect } from 'react-redux'

const Home= () =>{
    return (
    <div>
        <Header/>
        <div>{props.name}</div>
        <button onClick={()=>{alert('click')}}>click</button>
    </div>)
}

const mapStateToProps = state => ({
    name:state.name
})

export default connect(mapStateToProps,null)(Home)複製代碼

在寫完客戶端的redux代碼後,咱們能夠刷新頁面看看效果

能夠看到頁面上會報錯,這是因爲在訪問http://localhost:3001/時,首先會進入server文件夾下的index.js,index.js會去渲染Home組件,當Home組件去調用store裏的數據時,因爲此時尚未執行客戶端的redux代碼,致使Home組件找不到store而報錯,所以咱們須要在服務器端代碼(server/until.js)裏也建立一次store,並經過react-redux傳遞給組件


3. 在服務器端使用redux

一樣的,咱們在服務器端的代碼中也引入Redux

import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter } from 'react-router-dom'
import Router from  '../Routers'
import { createStore,applyMiddleware } from 'redux'
import { Provider } from 'react-redux'
import thunk from 'redux-thunk'

export const render = (req) => {
    const reducer = (state = { name:'CJW' },action) => {
        return state
    }
    const store= createStore(reducer,applyMiddleware(thunk))
    const content = renderToString((
        <Provider store={store}>
           <StaticRouter location={req.path} context={{}}>
               {Router}
           </StaticRouter>
        </Provider>
    ));

    return`
       <html>
           <head>
                <title>ssr demo</title>
           </head>
           <body>
           <div id="root">${content}</div>
           <script src="/index.js"></script>
           </body>
       </html>
    `
}複製代碼

然而在服務端這麼寫store是有坑的,createStore建立的store是單例的store,在服務器端這樣的寫法將致使全部用戶共享一個store,因此咱們將建立store這一步封裝成一個方法,每次調用都返回一個新的store。此外咱們能夠將這部分建立store的代碼抽離出來,在server和client分別引用,減小代碼的冗餘。

咱們在src目錄下建立一個store文件夾,store文件夾下建立index.js存放建立store的代碼

import { createStore,applyMiddleware } from'redux'
import thunk from 'redux-thunk'

const reducer = (state = { name:'CJW' }, action) => {
    return state
}

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

export default getStore複製代碼

在client/index.js和server/utils.js中都引入getStore方法,刪除原來建立store的代碼

import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import Routers from '../Routers'
import getStore from '../store'

const App = () => {
    return (
        <Provider store={getStore()}>
           <BrowserRouter>
               {Routers}
           </BrowserRouter>
        </Provider>
    )
}

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

這裏只是展現了一個簡單的store建立,在實際使用中,咱們須要建立一個規範的store,實現reducer、store和action的分離,但這裏做爲一個簡單的demo就不進行這些操做了。


4. 異步請求數據

咱們安裝axios來方便咱們的異步請求

npm install axios --save

因爲已經安裝了thunk,所以咱們能夠在action中發送異步請求,這一塊也是thunk的基礎內容,不作過多的講解。修改Home文件夾下的index.js,代碼以下(我這個axios請求的接口會返回一個列表,讀者能夠請求本身項目中的接口或請求各類公開的api)

import React from 'react'
import Header from '../../component/header'
import { connect } from 'react-redux'
import axios from 'axios'

class Home extends React.Component {
    //在componentDidMount中發送異步請求
    componentDidMount(){
        this.props.getList()
    }
   
    render(){
        console.log(this.props.list)
        return (
           <div>
               <Header/>
               {  this.props.list?
                    <div>
                    {this.props.list.map(item=>(
                        <div>{item.title}</div>
                    ))}
                    </div>:''}
               <button onClick={()=>{alert('click')}}>click</button>
           </div>)
    }
}

//使用redux-thunk,在action中寫axios並dispatch
const getData = () => {
    return (dispatch) => {
        //接收來自mapDispatchToProps的dispatch方法
        axios.get('http://異步請求的接口) .then((res)=>{ const list = res.data.data dispatch({type:'CHANGE_LIST',list:list}) }) } } const mapStateToProps = state => ({ name:state.name, list:state.list }) const mapDispatchToProps = dispatch => ({ getList(){ //調用dispatch時會自動執行getData裏return的方法 dispatch(getData()) } }) export default connect(mapStateToProps , mapDispatchToProps)(Home)複製代碼

折騰了半天,咱們保存代碼,刷新頁面,能夠看到頁面上成功顯示了異步請求的內容,然而咱們右鍵檢查網頁源代碼,卻發現並無對應內容的HTML,這是因爲服務器端執行React代碼時,並不會觸發componentDidMount(),所以服務器端的store始終是空的

5.使用loadData填充數據

爲了使得返回客戶端的HTML包含異步請求的數據,實際上咱們須要根據不一樣的頁面,給當前的store填充數據,爲了實現這個目的,咱們須要知足如下兩個條件:

1.服務器端代碼在進入某個頁面時能匹配到對應組件裏的axios請求

2.被匹配的組件能將axios請求獲得數據傳遞給服務器端的store

對於這個問題,React-Router已經爲SSR提供了一些方法(參考官方文檔:https://reacttraining.com/react-router/web/guides/server-rendering),咱們須要進行如下幾個步驟:

  1. 修改路由條目(Router.js)從導出組件改成導出數組

import Home from './containers/Home'        //引入Home組件
import Login from './containers/Login'      //引入Login組件

export default [
    {
        path:'/',
        component:Home,         //渲染Home組件
        exact:true,             //嚴格匹配
        loadData:Home.loadData, //傳入loadData方法
        key:'Home'              //用於後續循壞時提供key
    },
    {
        path:'/login',
        component:Login,        
        exact:true,            
        key:'login' 
    }
]複製代碼

2. 分別修改server和client文件夾下的util.js和index.js

import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter,Route } from 'react-router-dom'
import { Provider } from 'react-redux'
import routers from '../Routers'
import getStore from '../store'

export const render = (req) => {
    const store= getStore()
    const content = renderToString((
        <Provider store={store}>
           <StaticRouter location={req.path} context={{}}>
               <div>
                    {routers.map(router=> (
                        <Route{...router}/>
                    ))}
               </div>
           </StaticRouter>
        </Provider>
    ));
 
    return`
       <html>
           <head>
               <title>ssr demo</title>
           </head>
           <body>
           <div id="root">${content}</div>
           <script src="/index.js"></script>
           </body>
       </html>
    `
}複製代碼
import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter,Route } from 'react-router-dom'
import { Provider } from 'react-redux'
import routers from '../Routers'
import getStore from '../store'

const App = () => {
    return (
        <Provider store={getStore()}>
           <BrowserRouter>
               <div>
                    {routers.map(router=> (
                        <Route{...router}/>
                    ))}
               </div>
           </BrowserRouter>
        </Provider>
    )
}

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

3.在server/index.js引入matchPath方法匹配當前頁面路由,並執行對應的loadData方法

import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter,Route } from 'react-router-dom'
import { Provider } from 'react-redux'
import routers from '../Routers'
import getStore from '../store'

export const render = (req) => {
    const matchRoutes = []
    routers.some(route=> {
        matchPath(req.path, route) ? matchRoutes.push(route) : ''
    })
    console.log(matchRoutes)
    const store = getStore()
    const content = renderToString((
        <Provider store={store}>
           <StaticRouter location={req.path} context={{}}>
               <div>
                    {routers.map(router=> (
                        <Route{...router}/>
                    ))}
               </div>
           </StaticRouter>
        </Provider>
    ));

    return`
       <html>
           <head>
               <title>ssr demo</title>
           </head>
            <body>
           <div id="root">${content}</div>
           <script src="/index.js"></script>
           </body>
       </html>
    `
}複製代碼

這三步須要結合着看,在這一步驟,咱們的目的是進入某個組件時,執行組件的loadData方法(方法將在下文實現),loadData方法將獲取axios請求獲得的數據,並將數據傳遞給服務器端的store。

咱們能夠打印一下匹配到的路由項,路由項的內容就是咱們在Router.js中配置的路由條目。

下面咱們來進行loadData方法的實現

import React from 'react'
import Header from '../../component/header'
import { connect } from 'react-redux'
import axios from 'axios'

class Home extends React.Component {
    //在componentDidMount中發送異步請求
    componentDidMount(){
        this.props.getList()
    }

    render(){
        console.log(this.props.list)
        return (
           <div>
               <Header/>
               {  this.props.list?
                    <div>
                    {this.props.list.map(item=>(
                        <div>{item.title}</div>
                    ))}
                    </div>:''}
                <button onClick={()=>{alert('click')}}>click</button>
           </div>)
    }
}

Home.loadData = (store) => {
    store.dispatch(getData())
} 

//使用redux-thunk,在action中寫axios並dispatch
const getData = () => {
    return (dispatch) => {
        //接收來自mapDispatchToProps的dispatch方法
        axios.get('接口地址')
           .then((res)=>{
               const list = res.data.data
               dispatch({type:'CHANGE_LIST',list:list})
           })
    }
} 

const mapStateToProps = state => ({
    name:state.name,
    list:state.list
}) 

const mapDispatchToProps = dispatch => ({
    getList(){
        //調用dispatch時會自動執行getData裏return的方法
        dispatch(getData())
    }
})
 
export default connect(mapStateToProps , mapDispatchToProps)(Home)複製代碼


在loadData方法中,咱們直接接收服務器端的store,調用store的dispatch方法來更新store的數據,可是光是這麼寫,咱們仍是會獲得空的內容。這是由於axios請求是異步操做,服務器端渲染已經先於請求執行了,這裏咱們藉助promise來修正咱們的執行順序,因爲axios自己是一個promise對象,咱們能夠return axios對象,在loadData方法中,將dispatch也return出去,這樣咱們就能在server/util.js中獲得這個promise,並調用Promise.all方法,讓全部異步操做執行完後再渲染HTML,修改server/util.js,完成咱們的最後一步


import React from 'react'//引入React以支持JSX的語法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter,Route,matchPath } from 'react-router-dom'
import { Provider } from 'react-redux'
import routers from '../Routers'
import getStore from '../store'
 
export const render = (req,res) => {
    //將res傳入以使用res.send()方法
    const store = getStore()
    const matchRoutes = []
    const promises = []
    routers.some(route=> {
        matchPath(req.path, route) ? matchRoutes.push(route) : ''
    })
    matchRoutes.forEach( item=> {
        promises.push(item.loadData(store))
    })

    Promise.all(promises).then(()=>{
        //能夠console一下看到當前的store已經有數據
        console.log(store.getState())
        const content = renderToString((
           <Provider store={store}>
                <StaticRouter location={req.path} context={{}}>
                    <div>
                        {routers.map(router=> (
                            <Route{...router}/>
                        ))}
                    </div>
               </StaticRouter>
           </Provider>
       ));
 
        res.send(`
           <html>
               <head>
                    <title>ssrdemo</title>
               </head>
               <body>
               <div id="root">${content}</div>
               <script src="/index.js"></script>
               </body>
           </html>
       `)
    })
}複製代碼

代碼寫到這,咱們總算可以在服務器端正確地使用redux,在踩了無數坑以後,咱們終於可以搭建一個基本的SSR項目了。有關服務器端渲染的講解,到這裏也就告一段落了


段落小結:

本小節介紹瞭如何在SSR項目中使用Redux並使用axios異步獲取數據,和組件渲染、路由使用同樣,咱們須要在服務器端和客戶端各使用一次Redux,其緣由在於服務器端渲染只會走一遍生命週期,而且在第一次render後便會中止。異步數據請求放在componentDidMount函數中不會被觸發,即便咱們提早發起數據請求,因爲是異步返回,獲得的數據也沒法再次觸發render,因此返回給客戶端的HTML依然沒有異步請求的數據。對於這種狀況,咱們經過改造路由,使得每次進入對應頁面時可以匹配對應的loadData方法,爲服務器端的store注入數據,並將生成HTML的步驟放在異步請求完成以後。


5、結尾語

經過本文的介紹,讀者應該對React服務端渲染有了初步的認識,可以在服務器端進行基本的組件渲染、路由跳轉和使用redux,對webpack和Node在React項目中所起的做用也有了更進一步的瞭解。對於SSR的取捨,業界存在不少討論,雖然SSR可以解決傳統SPA項目首屏加載時間過長和不利於SEO的缺陷,但SSR一樣帶來了服務器負擔重,實施難度大等諸多問題。若是對首屏加載時間和SEO沒有極致的追求,咱們能夠選擇更輕便的方案,如利用webpack和react-router分割代碼,減小首屏加載時間;利用prerender進行預渲染,優化項目搜索引擎排名,不必定須要對整個項目採用同構的方式進行服務器端渲染。也許目前爲止同構SSR並非一種完美的方案,但在廣大前端技術人員對性能和體驗永不止步的追求中,有關SSR的討論和迭代還將長久地持續下去。


參考文章:

《先後端渲染和同構渲染》:https://blog.csdn.net/qizhiqq/article/details/70904799

《Vue-SSR指南》:https://ssr.vuejs.org/zh/#

《next.js 的服務端渲染機制》:https://www.jianshu.com/p/a3bce57e7349

以上幾篇文章對SSR的原理和優缺點進行了深刻地剖析,幫助讀者認識SSR的應用情景和使用侷限。具備很高的參考價值



文章先後寫了近3個月,但願能給你們帶來幫助呀,本文總結自Dell Lee老師的React服務端渲染課程,Dell老師的課深刻淺出,各個細節也有顧及,很是值得一看。筆者在課程的基礎上進行歸納總結,並對課程中一些模糊的細節進行考據和思考,但願能對讀者有所啓發,另外也但願你們多多支持Dell Lee老師的課程,畢竟光讀一篇文章並不能完整掌握SSR。也但願讀者能給咱公司前端團隊的公衆號點個關注,會定時推送技術分享~

相關文章
相關標籤/搜索