概述:
react有一个比较成熟的服务端渲染框架,next.js,它还支持预渲染。vue也有一个服务端渲染框架nuxt.js,这篇文章主要讲解不借助框架,如何从零实现服务端渲染的搭建。
至于服务端的优势不再赘述,大致是提高首屏渲染速度以提高用户体验,同时便于seo。这里谈一下劣势,一是需要消耗服务器的资源进行计算渲染react。二是因为增加了渲染服务器会增加运维的负担,诸如增加反向代理、监控react渲染服务器防止服务器挂掉导致页面无法响应。因为使用客户端渲染实际上就是让nginx、iis这类服务器直接返回 html、js文件,即便出错,它也只是在客户端出错,而不影响服务器对其他用户的服务,而如果使用了服务端渲染,一旦因为某种错误导致渲染服务器挂掉,那么它将导致所有用户都无法得到页面响应,这会增加运维负担。
服务端执行react代码实现回送字符串:
服务端渲染有个关键是,需要在服务端执行react代码。显然node本身是无法直接执行react代码到,这需要通过webpack将react代码编译为node可执行到代码
下面搭建一个最基础的服务端渲染,展示其基本原理
webpack.server.js
const path=require('path')
const nodeExternals=require('webpack-node-externals')
module.exports={
target:'node',
mode:'development',
entry:'./src/index.js',
output:{
filename:'bundle.js',
path:path.resolve(__dirname,'build')
},
externals:[nodeExternals()],
module:{
rules:[{
test:/\.js?$/,
loader:'babel-loader',
exclude:/node_modules/,
options:{
presets:['@babel/preset-react','@babel/preset-env']
}
}]
}
}
这里有个至关重要的配置项,就是externals:[nodeExternals()],告诉webpack在打包node服务端文件时,不会将node_modules里的包打包进去,也就是诸如express、react等都不会打包进bundle.js文件里。
src/server/index.js,webpck编译的入口文件
//const express=require('express')
import express from 'express'
import React from 'react'
import Home from './containers/Home'
import { renderToString } from 'react-dom/server'
const app=express()
app.get('/',(req,res)=>{
res.send(renderToString(<Home />))
})
const server=app.listen(3000,()=>{
const host=server.address().address
const port=server.address().port
console.log('aaa',host,port)
})
入口文件中引入里React,这是因为使用在renderToString(<Home />)代码里使用里jsx语法。由于webpack里使用里babel-loader和@babel/preset-env,因此这里都这个index.js文件可以以es6模块都方式去引入express等库,因为它会被webpack编译为commonJS等requre语法。
src/containers/home.js
import React from 'react'
const Home=()=>{
return (
<div>hello world</div>
)
}
export default Home
这个home.js就是上面index.js引入的home.js的组件。
上面做到了通过renderToString()将react组件转为字符串回送给浏览器,但是每次修改后,需要手动执行命令重新编译和重新启动。
package.json
"scripts": {
"start": "nodemon --watch build --exec node \"./build/bundle.js\"",
"build": "webpack --config webpack.server.js --watch"
},
通过webapck命令加--watch,可以实现我们修改了代码之后,让webpack自动重新编译生成新的bundle.js文件。然后通过nodemon监听build目录,一旦监听到文件发生变动,就执行--exec后面到命令,即重新执行node "./build/bundle.js"文件重新启动服务,这里由于外部使用了双引号,因此内部到要使用双引号需要使用反斜杠进行转义。
通过上面的配置,可以实现文件修改后自动重新编译,自动重新启动node服务,但网页还是需要手动刷新才会呈现出最新的内容。同时上面的命令还有一个问题,那就是需要执行两个命令导致需要启动两个命令行窗口。下面通过一个第三方包实现一个窗口启动上述两条命令。
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"
},
需要【npm i -g npm-run-all】,npm-run-all --parallel dev:** 中的--parallel表示并行执行,dev:** 表示执行以dev:命名空间名称开头的命令。现在既实现了一条命令一个窗口。
同构:
上面仅仅只是实现了react在服务端上渲染,服务端将react转为字符串回送给客户端显示。但是如果react代码中如果绑定了事件,这就需要服务端执行了react回送字符串后,客户端还要再执行一次react以在客户端上实现事件绑定。这就需要同构。
同构,一套react代码在服务端执行一次,在客户端执行一次。服务端执行一次时renderToString()只会渲染字符串内容,对于react代码中的事件是无法渲染的,此时需要客户端环境执行一次这套react代码,将事件渲染到浏览器上。
此时需要更改server/index.js文件
src/server/index.js
import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import Home from '../containers/Home'
const app=express()
app.use(express.static('public'))
app.get('*',(req,res)=>{
const content=renderToString((
<Home />
))
res.send(`
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`)
})
const server=app.listen(3000,()=>{
const host=server.address().address
const port=server.address().port
console.log('aaa',host,port)
})
上面代码有个关键就是,回送到html中多了一行<script src="/index.js"></scrip>,需要回送这段代码给浏览器,浏览器解析后会下载这个index.js在浏览器客户端执行,它实际上就是react代码被webpack编译后的代码,这样才能够在客户端渲染实现一遍渲染,以绑定代码中的各种事件。
上面还有个app.use(express.static('public')),它是用于实现静态资源服务的。客户端通过<script src="/index.js"></scrip>请求下载这个编译后的index.js文件,那么服务端会通过app.use(express.static('public'))去public目录里找这个文件,然后回送给客户端。
此时还需要新增一个客户端渲染需要使用的文件client/index.js
src/client/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import Home from '../containers/Home'
ReactDOM.hydrate(<Home />,document.getElementById('root'))
这段代码是用于客户端渲染的,注意这里需要使用ReactDOM.hydrate()而不是ReactDOM.render(),在服务端渲染项目这里是如此,如果是纯客户端渲染就使用render()方法。客户端肯定无法直接执行这个文件,需要通过weback编译,此时新建一个用于客户端渲染的编译配置文件webpack.client.js。
webapck.client.js
const path=require('path')
const merge=require('webpack-merge').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:{
rules:[{
test:/\.js?$/,
loader:'babel-loader',
exclude:/node_modules/,
options:{
presets:['@babel/preset-react','@babel/preset-env']
}
}]
}
}
module.exports=merge(config,clientConfig)
客户端webpack的entry就是上面的client/index.js文件,编译后的文件输出到了public目录中,也就是服务端回送的html代码中<script src="/index.js"></scrip>指向的文件。客户端拿到编译后的index.js就实现了在客户端渲染react代码以绑定各种事件。
此时有了两个webapck文件,一个webpck.server.js,一个webapck.client.js文件。这两个文件的module部分是相同,因此可以将这部分独立放在一个webapck.base.js文件中,然后通过webapck-merge合并到webpack.server.js和webapck.client.js中。
webapck.base.js
module.exports={
module:{
rules:[{
test:/\.js?$/,
loader:'babel-loader',
exclude:/node_modules/,
options:{
presets:['@babel/preset-react','@babel/preset-env']
}
}]
}
}
webapck.server.js
const path=require('path')
const nodeExternals=require('webpack-node-externals')
const merge=require('webpack-merge').merge
const config=require('./webpack.base.js')
const serverConfig={
target:'node',
mode:'development',
entry:'./src/server/index.js',
output:{
filename:'bundle.js',
path:path.resolve(__dirname,'build')
},
externals:[nodeExternals()]
}
module.exports=merge(config,serverConfig)
webpack.client.js
const path=require('path')
const merge=require('webpack-merge').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)
此时到目录结构如下
引入react-router:
上面代码实现了同构,服务端和客户端都可以渲染react代码,但是它还没有路由。使用路由,就是在浏览器地址栏中输入任何path路径,从而渲染指定路径的react代码。这在实际项目中是必须的,因为会有很多页面很多不同的url路径。
此时在src目录中新建一个Routes.js路由文件
src/Routes.js
import React from 'react'
import { Route } from 'react-router-dom'
import Home from './containers/Home'
import Login from './containers/Login'
export default (
<div>
<Route path="/" exact component={Home}></Route>
<Route path="/login" exact component={Login}></Route>
</div>
)
此时目录结构如下
此时需要将原有的文件修改下,添加上路由的配置
src/client/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Routes from '../Routes'
const App=()=>{
return (
<BrowserRouter>
{Routes}
</BrowserRouter>
)
}
ReactDOM.hydrate(<App />,document.getElementById('root'))
src/server/index.js
import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import Routes from '../Routes'
const app=express()
app.use(express.static('public'))
app.get('*',(req,res)=>{
const content=renderToString((
<StaticRouter location={req.path} context={{}}>
{Routes}
</StaticRouter>
))
res.send(`
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`)
})
const server=app.listen(3000,()=>{
const host=server.address().address
const port=server.address().port
})
这里需要注意的是,客户端渲染使用的路由组件是<BrowserRouter>...</BrowserRouter>,而服务端渲染使用的路由组件是<StaticRouter>...</StaticRouter>。
在浏览器上,使用了BrowserRouter,它会自己自动根据浏览器的url路径找到对应的需要渲染的react组件。但是在服务端上无法做到这个自动,需要使用<StaticRouter location={req.path} context={{}}>,也就是location={req.path}将请求的url路径传递给了StaticRouter组件,这样它可以找到对应的需要渲染的react组件。另外这个context={{}}是必须要传的。
另外要注意这里的app.get(*,(req,res)=>{}),接收任何路径的请求都走这里。
引入react-redux
现在目录结构是这样的,需要新建一个store/index.js文件,同时client/index.js、server/index.js、containers/Home/index.js文件都需要修改。
store/index.js
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
const reducer=(state={name:'delllll'},action)=>{
return state
}
const getStore=()=>{
return createStore(reducer,applyMiddleware(thunk))
}
export default getStore
server/index.js
import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import Routes from '../Routes'
import { Provider } from 'react-redux'
import getStore from '../store'
const app=express()
app.use(express.static('public'))
app.get('*',(req,res)=>{
const content=renderToString((
<Provider store={getStore()}>
<StaticRouter location={req.path} context={{}}>
{Routes}
</StaticRouter>
</Provider>
))
res.send(`
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`)
})
const server=app.listen(3000,()=>{
const host=server.address().address
const port=server.address().port
})
client/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Routes from '../Routes'
import { Provider } from 'react-redux'
import getStore from '../store'
const App=()=>{
return (
<Provider store={getStore()}>
<BrowserRouter>
{Routes}
</BrowserRouter>
</Provider>
)
}
ReactDOM.hydrate(<App />,document.getElementById('root'))
containers/Home/index.js
import React from 'react'
import { connect } from 'react-redux'
const Home=(props)=>{
return (
<div>
<h1>hello world--{props.name}</h1>
<button onClick={()=>alert(1)}>btn</button>
</div>
)
}
const mapStateToProps=(state)=>({
name:state.name
})
export default connect(mapStateToProps,null)(Home)
现在重新启动服务器,可以看到界面如下。放在reducer中都name属性的值dellll已经渲染到页面上了。
服务端获取数据
需要注意当是,app.get('*',(req,res)=>{...})会接收到一个额外到请求,这个请求是浏览器发送到favicon.ico请求,最好弄一个图标文件放在public目录里。
由于配置里这个静态资源服务,浏览器发送到favicon.ico请求会被这个静态资源服务捕获,然后返回favicon.ico图标给浏览器,以此让app.get(*,...)不再接收到这个不请求。
既然是服务端渲染,那肯定是需要在服务端获取数据。服务端根据浏览器请求到url路径,找到对应的react组件,然后调用组件的一个方法,去获取服务器数据,然后将获取的的数据塞进,然后服务端将有数据的组件渲染成html字符串后返回给浏览器。
Homt/index.js
import React,{ useEffect } from 'react'
import { connect } from 'react-redux'
import { getHomeList } from './store/actions';
Home.loadData=()=>{
//home组件获取服务器数据的方法
}
function Home(props){
useEffect(()=>{
console.log(props)
props.getHomeList()
},[])
return (
<div>
<h1>hello world--{props.name}</h1>
{
props.list.map((e,i)=>{
return (
<div key={i}>hello,{e.title}</div>
)
})
}
<button onClick={()=>alert(1)}>btn</button>
</div>
)
}
const mapStateToProps=(state)=>({
name:state.home.name,
list:state.home.newList
})
const mapDispatchProps=dispatch=>({
getHomeList(){
dispatch(getHomeList())
}
})
export default connect(mapStateToProps,mapDispatchProps)(Home)
这里对Home/index.js进行一定对修改,主要就是增加一个Home.loadData方法,用于在服务端中调用。
既然需要在服务端调用组件对loadData方法,那有一个关键就是,需要根据浏览器请求的url路径,找到对应的react组件,然后才能调用其loadData方法。这里就需要对Routes.js文件进行修改,可以对比和以前该文件对区别。
Routes.js
import Home from './containers/Home'
import Login from './containers/Login'
export default [
{
path:'/',
component:Home,
exact:true,
loadData:Home.loadData,
key:'home'
},
{
path:'/login',
component:Login,
exact:true,
key:'login'
}
]
同时,client/index.js文件也需要跟随修改。
client/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter, Route } from 'react-router-dom'
import routes from '../Routes'
import { Provider } from 'react-redux'
import getStore from '../store'
const App=()=>{
return (
<Provider store={getStore()}>
<BrowserRouter>
{
routes.map(route=>(
<Route {...route} />
))
}
</BrowserRouter>
</Provider>
)
}
ReactDOM.hydrate(<App />,document.getElementById('root'))
然后server/index.js也需要修改
server/index.js
import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter, Route } from 'react-router-dom'
import { matchRoutes } from 'react-router-config'
import routes from '../Routes'
import { Provider } from 'react-redux'
import getStore from '../store'
const app=express()
app.use(express.static('public'))
app.get('*',(req,res)=>{
const store=getStore()
//这里是关键
const matchedRoutes=matchRoutes(routes,req.path)
//打印匹配的路由查看其内容
matchedRoutes.forEach((e)=>{
console.log('zzz',e)
})
console.log(matchRoutes)
const content=renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
{
routes.map(route=>(
<Route {...route} />
))
}
</StaticRouter>
</Provider>
))
res.send(`
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`)
})
const server=app.listen(3000,()=>{
const host=server.address().address
const port=server.address().port
})
这里有个关键,const matchedRoutes=matchRoutes(routes,req.path),就是根据req.path的请求路径,匹配出对应的组件数据。这个匹配借助了import { matchRoutes } from 'react-router-config'一个第三方包react-router-config。
将匹配的路由数据打印出来如下
上面只是查看一下数据
下面这个文件是actions.js文件
将其中的axios.get()使用return返回,实际上就是返回一个promsie对象。
然后Home/index.js的loadData方法也要进行修改,如下
此时store.dispatch(getHomeList())提交的参数是一个promise对象,因此dispatch此处返回的也是一个promise对象。
然后server/index.js文件中进行如下修改
此时再刷新页面,可以看到控制台打印如下
此处就说明loadData方法在服务端运行并且成功获取到了数据。
现在再把请求响应到相关代码放到Promise.all().then()中。
import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter, Route } from 'react-router-dom'
import { matchRoutes } from 'react-router-config'
import routes from '../Routes'
import { Provider } from 'react-redux'
import getStore from '../store'
const app=express()
app.use(express.static('public'))
app.get('*',(req,res)=>{
const store=getStore()
const matchedRoutes=matchRoutes(routes,req.path)
const promises=[]
matchedRoutes.forEach((e)=>{
if(e.route.loadData){
promises.push(e.route.loadData(store))
}
})
Promise.all(promises).then(()=>{
const content=renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
{
routes.map(route=>(
<Route {...route} />
))
}
</StaticRouter>
</Provider>
))
res.send(`
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`)
})
})
const server=app.listen(3000,()=>{
const host=server.address().address
const port=server.address().port
})
最终可以看到,网页中回送的html中已经有了数据,说明服务端成功获取了数据,并且通过redux将数据注入到了组件中,然后将有数据的组件renderToString()成html字符串回送给浏览器。
数据的脱水和注水:
服务端获取了数据也回送了html,从上图可以看到此时页面会出现闪烁。这是因为,服务端回送了html后,然后js下载成功,js但客户端渲染开始执行,但是此时客户端store中并没有数据,因此会出现一片空白,然后客户端的数据请求发送出去才获取了数据显示在屏幕上,因此出现了一个有数据显示,然后显示空白,然后又有数据显示到过程,这个就是闪烁到原因。
要解决这个闪烁,那就需要确保客户端渲染时候,redux的store能够直接取到数据,而不是空,这就需要利用到数据到脱水和注水。
这里将服务端获取的的数据,通过json序列化字符串,放在html中一起回送给客户端
这是数据脱水
客户端需要获取到这段,将其注入到redux的store中,这是数据注水。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。