8

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

 3 years ago
source link: https://segmentfault.com/a/1190000040732592
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

发布于 9 月 24 日

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK