2

概述:
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)

此时到目录结构如下
image.png

引入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>
)

此时目录结构如下
image.png

此时需要将原有的文件修改下,添加上路由的配置

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

image (15).png
现在目录结构是这样的,需要新建一个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已经渲染到页面上了。

image (17).png

服务端获取数据

需要注意当是,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 (
    <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。

image (19).png
将匹配的路由数据打印出来如下
image (20).png
上面只是查看一下数据

下面这个文件是actions.js文件
image.png
将其中的axios.get()使用return返回,实际上就是返回一个promsie对象。

然后Home/index.js的loadData方法也要进行修改,如下
image.png
此时store.dispatch(getHomeList())提交的参数是一个promise对象,因此dispatch此处返回的也是一个promise对象。

然后server/index.js文件中进行如下修改
image.png
此时再刷新页面,可以看到控制台打印如下
image.png
此处就说明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字符串回送给浏览器。
image.png

数据的脱水和注水:


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

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

这里将服务端获取的的数据,通过json序列化字符串,放在html中一起回送给客户端
image.png
这是数据脱水
image.png

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


hebe700
729 声望15 粉丝

d