从简单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。也但愿读者能给咱公司前端团队的公众号点个关注,会定时推送技术分享~

相关文章
相关标签/搜索