一、为什么要进行服务端渲染
随着前端技术栈和工具链的迭代成熟,前端工程化、模块化的趋势也愈发明显,在这波前端技术浪潮中,涌现了诸如React、Vue、Angular等基于客户端渲染的前端框架,这类框架所构建的单页应用(SPA)具有渲染性能好、可维护性高等优点。但也同时带来了两个缺陷:
1.首屏加载时间过长
2.不利于SEO复制代码
与传统web项目直接获取服务器端渲染好的HTML不同,单页应用使用JavaScript在客户端生成HTML来呈现内容,用户需要等待JS解析执行完成才能看到页面,这就使得首屏加载时间变长,影响用户体验。此外当搜索引擎爬取网站HTML文件时,单页应用的HTML没有内容,从而影响搜索排名。为了解决这两个缺陷,业界借鉴传统的服务器端直出HTML方案,提出在服务器端执行前端框架(React/Vue/Angular)代码生成HTML,然后将渲染好的HTML返回给客户端,实现CSR前端框架的服务器端渲染。
本文通过一个简单的demo,向读者讲解React服务器端渲染(SSR)的基本原理,在阅读完本文后,读者应该能够掌握:
-
服务器端渲染的基本概念和原理
-
在SSR项目中渲染组件
-
在SSR项目中使用路由
-
在SSR项目中使用redux
二、在SSR项目中渲染组件
1. 使用node进行服务端渲染
我们使用express启动一个Node服务器来进行基本的服务端渲染。首先安装初始化node项目和安装express
npm init
npm install express –save
在根目录中创建文件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代码是行不通的,原因有以下几个:
-
Node不能识别import和export,这二者属于esModule的语法,而Node遵循common.js规范
-
Node不能识别JSX语法,我们需要使用webpack对项目进行打包转换,使之成为Node能识别的语法
为了使代码能够运行,我们需要安装webpack并进行配置
-
npm install webpack webpack-cli –save
安装webpack和webpack-cli -
根目录下创建配置文件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入门》
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做一些改动,使得服务器能够自动重启和打包
-
在webpack --config webpack.server.js后加上—watch就能实现webpack的自动监听打包,当需要被打包的文件发生变化时,webpack就会自动重新打包
-
安装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配置项。
-
新建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的优点。
总结一下服务端渲染组件的步骤:
-
建立一个node项目
-
编写服务器端的React代码并使用webpack进行打包编译,使用renderToString方法将组件渲染成为HTML。
-
编写客户端需要执行的React代码,并使用webpack进行打包编译,通过script标签引入页面,接管页面的操作。
三、在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的客户端路由将接管页面路由,实现无刷新跳转。
四、在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)