关于javascript:手把手教会搭建react服务端渲染

33次阅读

共计 13559 个字符,预计需要花费 34 分钟才能阅读完成。

概述:

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

正文完
 0