概述:
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 中,这是数据注水。