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