详解React服务端渲染从入门到精通


Posted in Javascript onMarch 28, 2019

前言

这篇文章是我自己在搭建个人网站的过程中,用到了服务端渲染,看了一些教程,踩了一些坑。想把这个过程分享出来。
我会尽力把每个步骤讲明白,将我理解的全部讲出来。

文中的示例代码来自于这个仓库,也是我正在搭建的个人网站,大家可以一起交流一下。示例代码因为简化,与仓库代码有些许出入

本文中用到的技术
React V16 | React-Router v4 | Redux | Redux-thunk | express

React 服务端渲染

服务端渲染的基本套路就是用户请求过来的时候,在服务端生成一个我们希望看到的网页内容的HTML字符串,返回给浏览器去展示。

浏览器拿到了这个HTML之后,渲染出页面,但是并没有事件交互,这时候浏览器发现HTML中加载了一些js文件(也就是浏览器端渲染的js),就直接去加载。

加载好并执行完以后,事件就会被绑定上了。这时候页面被浏览器端接管了。也就是到了我们熟悉的js渲染页面的过程。

需要实现的目标:

  • React组件服务端渲染
  • 路由的服务端渲染
  • 保证服务端和浏览器的数据唯一
  • css的服务端渲染(样式直出)

一般的渲染方式

  • 服务端渲染:服务端生成html字符串,发送给浏览器进行渲染。
  • 浏览器端渲染:服务端返回空的html文件,内部加载js完全由js与css,由js完成页面的渲染

优点与缺点

服务端渲染解决了首屏加载速度慢以及seo不友好的缺点(Google已经可以检索到浏览器渲染的网页,但不是所有搜索引擎都可以)

但增加了项目的复杂程度,提高维护成本。

如果非必须,尽量不要用服务端渲染

整体思路

需要两个端:服务端、浏览器端(浏览器渲染的部分)

第一: 打包浏览器端代码

第二: 打包服务端代码并启动服务

第三: 用户访问,服务端读取浏览器端打包好的index.html文件为字符串,将渲染好的组件、样式、数据塞入html字符串,返回给浏览器

第四: 浏览器直接渲染接收到的html内容,并且加载打包好的浏览器端js文件,进行事件绑定,初始化状态数据,完成同构

React组件的服务端渲染

让我们来看一个最简单的React服务端渲染的过程。

要进行服务端渲染的话那必然得需要一个根组件,来负责生成HTML结构

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.hydrate(<Container />, document.getElementById('root'));

当然这里用ReactDOM.render也是可以的,只不过hydrate会尽量复用接收到的服务端返回的内容,

来补充事件绑定和浏览器端其他特有的过程

引入浏览器端需要渲染的根组件,利用react的 renderToString API进行渲染

import { renderToString } from 'react-dom/server'
import Container from '../containers'
// 产生html
const content = renderToString(<Container/>)
const html = `
  <html>
   <body>${content}</body>
  </html>
`
res.send(html)

在这里,renderToString也可以替换成renderToNodeStream,区别在于前者是同步地产生HTML,也就是如果生成HTML用了1000毫秒,

那么就会在1000毫秒之后才将内容返回给浏览器,显然耗时过长。而后者则是以流的形式,将渲染结果塞给response对象,就是出来多少就

返回给浏览器多少,可以相对减少耗时

路由的服务端渲染

一般场景下,我们的应用不可能只有一个页面,肯定会有路由跳转。我们一般这么用:

import { BrowserRouter, Route } from 'react-router-dom'
const App = () => (
  <BrowserRouter>
    {/*...Routes*/}
  <BrowserRouter/>
)

但这是浏览器端渲染时候的用法。在做服务端渲染时,需要使用将BrowserRouter 替换为 StaticRouter
区别在于,BrowserRouter 会通过HTML5 提供的 history API来保持页面与URL的同步,而StaticRouter
则不会改变URL

import { createServer } from 'http'
import { StaticRouter } from 'react-router-dom'
createServer((req, res) => {
  const html = renderToString(
    <StaticRouter
      location={req.url}
      context={{}}
    >
      <Container />
    <StaticRouter/>)

})

这里,StaticRouter要接收两个属性:

  • location: StaticRouter 会根据这个属性,自动匹配对应的React组件,所以才会实现刷新页面,服务端返回的对应路由的组与浏览器端保持一致
  • context: 一般用来传递一些数据,相当于一个载体,之后讲到样式的服务端渲染的时候会用到

Redux同构

数据的预获取以及脱水与注水我认为是服务端渲染的难点。

这是什么意思呢?也就是说首屏渲染的网页一般要去请求外部数据,我们希望在生成HTML之前,去获取到这个页面需要的所有数据,然后塞到页面中去,这个过程,叫做“脱水”(Dehydrate),生成HTML返回给浏览器。浏览器拿到带着数据的HTML,去请求浏览器端js,接管页面,用这个数据来初始化组件。这个过程叫“注水”(Hydrate)。完成服务端与浏览器端数据的统一。

为什么要这么做呢?试想一下,假设没有数据的预获取,直接返回一个没有数据,只有固定内容的HTML结构,会有什么结果呢?

第一:由于页面内没有有效信息,不利于SEO。

第二:由于返回的页面没有内容,但浏览器端JS接管页面后回去请求数据、渲染数据,页面会闪一下,用户体验不好。

我们使用Redux来管理状态,因为有服务端代码和浏览器端代码,那么就分别需要两个store来管理服务端和浏览器端的数据。

组件的配置

组件要在服务端渲染的时候去请求数据,可以在组件上挂载一个专门发异步请求的方法,这里叫做loadData,接收服务端的store作为参数,然后store.dispatch去扩充服务端的store。

class Home extends React.Component {
  componentDidMount() {
    this.props.callApi()
  }
  render() {
    return <div>{this.props.state.name}</div>
  }
}
Home.loadData = store => {
 return store.dispatch(callApi())
}
const mapState = state => state
const mapDispatch = {callApi}
export default connect(mapState, mapDispatch)(Home)

路由的改造

因为服务端要根据路由判断当前渲染哪个组件,可以在这个时候发送异步请求。所以路由也需要配置一下来支持loadData方法。服务端渲染的时候,路由的渲染可以使用react-router-config这个库,用法如下(重点关注在路由上挂载loadData方法):

import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import Home from './Home'
export const routes = [
 {
  path: '/',
  component: Home,
  loadData: Home.loadData,
  exact: true,
 }
]
const Routers = <BrowserRouter>
  {renderRoutes(routes)}
<BrowserRouter/>

服务端获取数据

到了服务端,需要判断匹配的路由内的所有组件各自都有没有loadData方法,有就去调用,传入服务端的store,去扩充服务端的store。同时还要注意到,一个页面可能是由多个组件组成的,会发各自的请求,也就意味着我们要等所有的请求都发完,再去返回HTML。

import express from 'express'
import serverRender from './render'
import { matchRoutes } from 'react-router-config'
import { routes } from '../routes'
import serverStore from "../store/serverStore"

const app = express()
app.get('*', (req, res) => {
 const context = {css: []}
 const store = serverStore()
 // 用matchRoutes方法获取匹配到的路由对应的组件数组
 const matchedRoutes = matchRoutes(routes, req.path)
 const promises = []
 for (const item of matchedRoutes) {
  if (item.route.loadData) {
   const promise = new Promise((resolve, reject) => {
    item.route.loadData(store).then(resolve).catch(resolve)
   })
   promises.push(promise)
  }
 }
 // 所有请求响应完毕,将被HTML内容发送给浏览器
 Promise.all(promises).then(() => {
  // 将生成html内容的逻辑封装成了一个函数,接收req, store, context
  res.send(serverRender(req, store, context))
 })
})

细心的同学可能注意到了上边我把每个loadData都包了一个promise。

const promise = new Promise((resolve, reject) => {
 item.route.loadData(store).then(resolve).catch(resolve)
 console.log(item.route.loadData(store));
})
promises.push(promise)

这是为了容错,一旦有一个请求出错,那么下边Promise.all方法则不会执行,所以包一层promise的目的是即使请求出错,也会resolve,不会影响到Promise.all方法,也就是说只有请求出错的组件会没数据,而其他组件不会受影响。

注入数据

我们请求已经发出去了,并且在组件的loadData方法中也扩充了服务端的store,那么可以从服务端的数据取出来注入到要返回给浏览器的HTML中了。

来看 serverRender 方法

const serverRender = (req, store, context) => {
 // 读取客户端生成的HTML
 const template = fs.readFileSync(process.cwd() + '/public/static/index.html', 'utf8')
 const content = renderToString(
  <Provider store={store}>
   <StaticRouter location={req.path} context={context}>
    <Container/>
   </StaticRouter>
  </Provider>
 )
 // 注入数据
 const initialState = `<script>
  window.context = {
   INITIAL_STATE: ${JSON.stringify(store.getState())}
  }
</script>`
 return template.replace('<!--app-->', content)
  .replace('<!--initial-state-->', initialState)
}

浏览器端用服务端获取到的数据初始化store

经过上边的过程,我们已经可以从window.context中拿到服务端预获取的数据了,此时需要做的事就是用这份数据去初始化浏览器端的store。保证两端数据的统一。

import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import rootReducer from '../reducers'

const defaultStore = window.context && window.context.INITIAL_STATE
const clientStore = createStore(
 rootReducer,
 defaultStore,// 利用服务端的数据初始化浏览器端的store
 compose(
  applyMiddleware(thunk),
  window.devToolsExtension ? window.devToolsExtension() : f=>f
 )
)

至此,服务端渲染的数据统一问题就解决了,再来回顾一下整个流程:

  • 用户访问路由,服务端根据路由匹配出对应路由内的组件数组
  • 循环数组,调用组件上挂载的loadData方法,发送请求,扩充服务端store
  • 所有请求完成后,通过store.getState,获取到服务端预获取的数据,注入到window.context中
  • 浏览器渲染返回的HTML,加载浏览器端js,从window.context中取数据来初始化浏览器端的store,渲染组件

这里还有个点,也就是当我们从路由进入到其他页面的时候,组件内的loadData方法并不会执行,它只会在刷新,服务端渲染路由的时候执行。

这时候会没有数据。所以我们还需要在componentDidMount中去发请求,来解决这个问题。因为componentDidMount不会在服务端渲染执行,所以不用担心请求重复发送。

样式的服务端渲染

以上我们所做的事情只是让网页的内容经过了服务端的渲染,但是样式要在浏览器加载css后才会加上,所以最开始返回的网页内容没有样式,页面依然会闪一下。为了解决这个问题,我们需要让样式也一并在服务端渲染的时候返回。

首先,服务端渲染的时候,解析css文件,不能使用style-loader了,要使用isomorphic-style-loader。

{
  test: /\.css$/,
  use: [
    'isomorphic-style-loader',
    'css-loader',
    'postcss-loader'
  ],
}

但是,如何在服务端获取到当前路由内的组件样式呢?回想一下,我们在做路由的服务端渲染时,用到了StaticRouter,它会接收一个context对象,这个context对象可以作为一个载体来传递一些信息。我们就用它!

思路就是在渲染组件的时候,在组件内接收context对象,获取组件样式,放到context中,服务端拿到样式,插入到返回的HTML中的style标签中。

来看看组件是如何读取样式的吧:

import style from './style/index.css'
class Index extends React.Component {
  componentWillMount() {
   if (this.props.staticContext) {
    const css = styles._getCss()
    this.props.staticContext.css.push(css)
   }
  }
}

在路由内的组件可以在props里接收到staticContext,也就是通过StaticRouter传递过来的context,
isomorphic-style-loader 提供了一个 _getCss() 方法,让我们能读取到css样式,然后放到staticContext里。
不在路由之内的组件,可以通过父级组件,传递props的方法,或者用react-router的withRouter包裹一下

其实这部分提取css的逻辑可以写成高阶组件,这样就可以做到复用了

import React, { Component } from 'react'

export default (DecoratedComponent, styles) => {
 return class NewComponent extends Component {
  componentWillMount() {
   if (this.props.staticContext) {
    const css = styles._getCss()
    this.props.staticContext.css.push(css)
   }
  }
  render() {
   return <DecoratedComponent {...this.props}/>
  }
 }
}

在服务端,经过组件的渲染之后,context中已经有内容了,我们这时候把样式处理一下,返回给浏览器,就可以做到样式的服务端渲染了

const serverRender = (req, store) => {
 const context = {css: []}
 const template = fs.readFileSync(process.cwd() + '/public/static/index.html', 'utf8')
 const content = renderToString(
  <Provider store={store}>
   <StaticRouter location={req.path} context={context}>
    <Container/>
   </StaticRouter>
  </Provider>
 )
 // 经过渲染之后,context.css内已经有了样式
 const cssStr = context.css.length ? context.css.join('\n') : ''
 const initialState = `<script>
  window.context = {
   INITIAL_STATE: ${JSON.stringify(store.getState())}
  }
</script>`
 return template.replace('<!--app-->', content)
  .replace('server-render-css', cssStr)
  .replace('<!--initial-state-->', initialState)
}

至此,服务端渲染就全部完成了。

总结

React的服务端渲染,最好的解决方案就是Next.js。如果你的应用没有SEO优化的需求,又或者不太注重首屏渲染的速度,那么尽量就不要用服务端渲染。

因为会让项目变得复杂。此外,除了服务端渲染,SEO优化的办法还有很多,比如预渲染(pre-render)。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
不安全的常用的js写法
Sep 15 Javascript
js检查页面上有无重复id的实现代码
Jul 17 Javascript
js opener的使用详解
Jan 11 Javascript
jquery解析xml字符串简单示例
Apr 11 Javascript
javascript的数组和常用函数详解
May 09 Javascript
浅析基于WEB前端页面的页面内容搜索的实现思路
Jun 10 Javascript
Node.js实现JS文件合并小工具
Feb 02 Javascript
JS弹出窗口插件zDialog简单用法示例
Jun 12 Javascript
基于jquery实现的鼠标悬停提示案例
Dec 11 Javascript
jQuery插件HighCharts绘制2D金字塔图效果示例【附demo源码下载】
Mar 09 Javascript
Vue 报错TypeError: this.$set is not a function 的解决方法
Dec 17 Javascript
JavaScript检测是否开启了控制台(F12调试工具)
Oct 02 Javascript
微信小程序学习笔记之函数定义、页面渲染图文详解
Mar 28 #Javascript
JavaScript刷新页面的几种方法总结
Mar 28 #Javascript
Vue批量图片显示时遇到的路径被解析问题
Mar 28 #Javascript
微信小程序学习笔记之目录结构、基本配置图文详解
Mar 28 #Javascript
vue-cli中使用高德地图的方法示例
Mar 28 #Javascript
jQuery ajax仿Google自动提示SearchSuggess功能示例
Mar 28 #jQuery
JavaScript实现汉字转换为拼音及缩写的方法示例
Mar 28 #Javascript
You might like
php中读写文件与读写数据库的效率比较分享
2013/10/19 PHP
php中mysql操作buffer用法详解
2015/03/19 PHP
php使用ftp远程上传文件类(完美解决主从文件同步问题的方法)
2016/09/23 PHP
php实现遍历文件夹的方法汇总
2017/03/02 PHP
PHP中危险的file_put_contents函数详解
2017/11/04 PHP
yii2多图上传组件的使用教程
2018/05/10 PHP
Javascript 中介者模式实例
2009/12/16 Javascript
基于Jquery的开发个代阴影的对话框效果代码
2011/07/28 Javascript
浅谈javascript的原型继承
2012/07/25 Javascript
全面解析JavaScript的Backbone.js框架中的Router路由
2016/05/05 Javascript
基于JQuery实现的跑马灯效果(文字无缝向上翻动)
2016/12/02 Javascript
详解微信小程序 页面跳转 传递参数
2016/12/08 Javascript
从零开始学习Node.js系列教程六:EventEmitter发送和接收事件的方法示例
2017/04/13 Javascript
JavaScript多线程运行库Nexus.js详解
2017/12/22 Javascript
[00:15]TI9地铁玩家打卡
2019/08/11 DOTA
[01:18:35]DOTA2-DPC中国联赛 正赛 Elephant vs LBZS BO3 第一场 1月29日
2021/03/11 DOTA
python条件和循环的使用方法
2013/11/01 Python
详解python时间模块中的datetime模块
2016/01/13 Python
Python  pip安装lxml出错的问题解决办法
2017/02/10 Python
pandas中的DataFrame按指定顺序输出所有列的方法
2018/04/10 Python
基于python OpenCV实现动态人脸检测
2018/05/25 Python
django利用request id便于定位及给日志加上request_id
2018/08/26 Python
详解Python进阶之切片的误区与高级用法
2018/12/24 Python
使用tensorflow根据输入更改tensor shape
2020/06/23 Python
CSS3教程(6):创建网站多列
2009/04/02 HTML / CSS
CSS3中线性颜色渐变的一些实现方法
2015/07/14 HTML / CSS
日本最新流行服饰网购:Nissen
2016/07/24 全球购物
Vilebrequin美国官方网上商店:法国豪华泳装品牌
2020/02/22 全球购物
eHarmony英国:全球领先的认真恋爱约会平台之一
2020/11/16 全球购物
音乐学个人的自荐书范文
2013/11/26 职场文书
装修五一活动策划案
2014/01/23 职场文书
护理助产毕业生的求职信
2014/03/02 职场文书
2014年政府采购工作总结
2014/12/09 职场文书
公司开会通知
2015/04/20 职场文书
2015年幼儿园保育员工作总结
2015/04/23 职场文书
python周期任务调度工具Schedule使用详解
2021/11/23 Python