手把手教会搭建react服务端渲染

概述:

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())
})

const server=app.listen(3000,()=>{
  const host=server.address().address
  const port=server.address().port
  console.log('aaa',host,port)
})

入口文件中引入里React,这是因为使用在renderToString()代码里使用里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 (
    
hello world
) } 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((
    
  ))

  res.send(`
    
      
        ssr
      
      
        
${content}
`) }) const server=app.listen(3000,()=>{ const host=server.address().address const port=server.address().port console.log('aaa',host,port) })

上面代码有个关键就是,回送到html中多了一行 `) }) const server=app.listen(3000,()=>{ const host=server.address().address const port=server.address().port })

这里需要注意的是,客户端渲染使用的路由组件是...,而服务端渲染使用的路由组件是...

在浏览器上,使用了BrowserRouter,它会自己自动根据浏览器的url路径找到对应的需要渲染的react组件。但是在服务端上无法做到这个自动,需要使用,也就是location={req.path}将请求的url路径传递给了StaticRouter组件,这样它可以找到对应的需要渲染的react组件。另外这个context={{}}是必须要传的。

另外要注意这里的app.get(*,(req,res)=>{}),接收任何路径的请求都走这里。

引入react-redux

手把手教会搭建react服务端渲染_第3张图片
现在目录结构是这样的,需要新建一个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((
    
      
        {Routes}
      
    
  ))

  res.send(`
    
      
        ssr
      
      
        
${content}
`) }) 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 (
    
      
        {Routes}
      
      
  )
}

ReactDOM.hydrate(,document.getElementById('root'))

containers/Home/index.js

import React from 'react'
import { connect } from 'react-redux'

const Home=(props)=>{
  return (
    

hello world--{props.name}

) } const mapStateToProps=(state)=>({ name:state.name }) export default connect(mapStateToProps,null)(Home)

现在重新启动服务器,可以看到界面如下。放在reducer中都name属性的值dellll已经渲染到页面上了。

手把手教会搭建react服务端渲染_第4张图片

服务端获取数据

需要注意当是,app.get('*',(req,res)=>{...})会接收到一个额外到请求,这个请求是浏览器发送到favicon.ico请求,最好弄一个图标文件放在public目录里。

image (18).png

由于配置里这个静态资源服务,浏览器发送到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 (
    

hello world--{props.name}

{ props.list.map((e,i)=>{ return (
hello,{e.title}
) }) }
) } 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 (
    
      
        {
          routes.map(route=>(
            
          ))
        }
      
      
  )
}

ReactDOM.hydrate(,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((
    
      
        {
          routes.map(route=>(
            
          ))
        }
      
    
  ))

  res.send(`
    
      
        ssr
      
      
        
${content}
`) }) 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。

手把手教会搭建react服务端渲染_第5张图片
将匹配的路由数据打印出来如下
手把手教会搭建react服务端渲染_第6张图片
上面只是查看一下数据

下面这个文件是actions.js文件
手把手教会搭建react服务端渲染_第7张图片
将其中的axios.get()使用return返回,实际上就是返回一个promsie对象。

然后Home/index.js的loadData方法也要进行修改,如下
手把手教会搭建react服务端渲染_第8张图片
此时store.dispatch(getHomeList())提交的参数是一个promise对象,因此dispatch此处返回的也是一个promise对象。

然后server/index.js文件中进行如下修改
手把手教会搭建react服务端渲染_第9张图片
此时再刷新页面,可以看到控制台打印如下
手把手教会搭建react服务端渲染_第10张图片
此处就说明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((
      
        
          {
            routes.map(route=>(
              
            ))
          }
        
      
    ))
  
    res.send(`
      
        
          ssr
        
        
          
${content}
`) }) }) const server=app.listen(3000,()=>{ const host=server.address().address const port=server.address().port })

最终可以看到,网页中回送的html中已经有了数据,说明服务端成功获取了数据,并且通过redux将数据注入到了组件中,然后将有数据的组件renderToString()成html字符串回送给浏览器。
手把手教会搭建react服务端渲染_第11张图片

数据的脱水和注水:

手把手教会搭建react服务端渲染_第12张图片
服务端获取了数据也回送了html,从上图可以看到此时页面会出现闪烁。这是因为,服务端回送了html后,然后js下载成功,js但客户端渲染开始执行,但是此时客户端store中并没有数据,因此会出现一片空白,然后客户端的数据请求发送出去才获取了数据显示在屏幕上,因此出现了一个有数据显示,然后显示空白,然后又有数据显示到过程,这个就是闪烁到原因。

要解决这个闪烁,那就需要确保客户端渲染时候,redux的store能够直接取到数据,而不是空,这就需要利用到数据到脱水和注水。

这里将服务端获取的的数据,通过json序列化字符串,放在html中一起回送给客户端
手把手教会搭建react服务端渲染_第13张图片
这是数据脱水
手把手教会搭建react服务端渲染_第14张图片

客户端需要获取到这段,将其注入到redux的store中,这是数据注水。

你可能感兴趣的:(手把手教会搭建react服务端渲染)