React SSR(服务器端渲染) 细微探究

最近看了下 React SSR相关的东西,这里记录一下相关内容

本文实例代码已经上传到 github,感兴趣的可参见 Basic | SplitChunkV

初识 React SSR

nodejs遵循 commonjs规范,文件的导入导出如下:

// 导出
module.exports = someModule
// 导入
const module = require('./someModule')
复制代码

而我们通常所写的 react代码是遵循 esModule规范的,文件的导入导出如下:

// 导出
export default someModule
// 导入
import module from './someModule'
复制代码

所以想要让 react代码兼容于服务器端,就必须先解决这两种规范的兼容问题,实际上 react是可以直接以 commonjs规范来书写的,例如:

const React = require('react')
复制代码

这样一看似乎就是个写法的转换罢了,没什么问题,但实际上,这只是解决了其中一个问题而已,react中常见的渲染代码,即 jsxnode是不认识的,必须要编译一次

render () {
  // node是不认识 jsx的
  return <div>homediv>
}
复制代码

客户端编译 react代码用到最多的就是 webpack,服务器端同样可以使用,这里使用 webpack的作用有两个:

  • jsx编译为 node认识的原生 js代码
  • exModule代码编译成 commonjs

webpack示例配置文件如下:

// webpack.server.js
module.exports = {
  // 省略代码...
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          // 需要支持 react
          // 需要转换 stage-0
          presets: ['react', 'stage-0', ['env', {
            targets: {
              browsers: ['last 2 versions']
            }
          }]]
        }
      }
    ]
  }
}
复制代码

有了这份配置文件之后,就可以愉快的写代码了

首先是一份需要输出到客户端的 react代码:

import React from 'react'

export default () => {
  return <div>homediv>
}
复制代码

这份代码很简单,就是一个普通的 react stateless组件

然后是负责将这个组件输出到客户端的服务器端代码:

// index.js
import http from 'http'
import React from 'react'
import { renderToString } from 'react-dom/server'
import Home from './containers/Home/index.js'

const container = renderToString()

http.createServer((request, response) => {
  response.writeHead(200, {'Content-Type': 'text/html'})
  response.end(`
    
    
    
      
      
      
      Document
    
    
      
${container}
`) }).listen(8888) console.log('Server running at http://127.0.0.1:8888/') 复制代码

上述代码就是启动了一个 node http服务器,响应了一个 html页面源码,只不过相比于常见的 node服务器端代码而言,这里还引入了 react相关库

我们通常所写的 React代码,其渲染页面的动作,其实是 react调用浏览器相关 API实时进行的,即页面是由 js操纵浏览器DOM API组装而成,服务器端是无法调用浏览器 API的,所以这个过程无法进行,这个时候就需要借助 renderToString

renderToStringReact提供的用于将 React代码转换为浏览器可直接识别的 html字符串的 API,可以认为此 API提前将浏览器要做的事情做好了,直接在服务器端将DOM字符串拼凑完成,交给 node输出到浏览器

上述代码中的变量 container,其实就是如下的 html字符串:

<div data-reactroot="">homediv>
复制代码

所以,node响应到浏览器端的就是一个正常的 html字符串了,浏览器直接展示即可,由于浏览器端不需要下载 react代码,代码体积更小,也不需要实时拼接 DOM字符串,只是简单地进行渲染页面的动作,因而服务器端渲染的速度会比较快

另外,除了 renderToString之外,React v16.x还提供了另外一个功能更加强大的 APIrenderToNodeStream renderToNodeStream支持直接渲染到节点流。渲染到流可以减少你的内容的第一个字节(TTFB)的时间,在文档的下一部分生成之前,将文档的开头至结尾发送到浏览器。 当内容从服务器流式传输时,浏览器将开始解析HTML文档,有的文章称此 API的渲染速度是 renderToString的三倍(到底几倍我没测过,不过一般情况下渲染速度会更快是真的)

所以,如果你使用的是 React v16.x,你还可以这么写:

import http from 'http'
import React from 'react'
// 这里使用了 renderToNodeStream
import { renderToNodeStream } from 'react-dom/server'
import Home from './containers/Home/index.js'

http.createServer((request, response) => {
  response.writeHead(200, {'Content-Type': 'text/html'})
  response.write(`
    
    
    
      
      
      
      Document
    
    
      
`) const container = renderToNodeStream() // 这里使用到了 数据流的概念,所以需要以流的形式进行传送数据 container.pipe(response, { end: false }) container.on('end', () => { // 响应流结束 response.end(`
`) }) }).listen(8888) console.log('Server running at http://127.0.0.1:8888/') 复制代码

BOM / DOM 相关逻辑同构

有了 renderToString / renderToNodeStream之后,似乎服务器端渲染触手可及,但实际上还差得远了,对于如下 react代码:

const Home = () => {
  return <button onClick={() => { alert(123) }}>homebutton>
}
复制代码

期望是点击按钮的时候,浏览器会弹出一个提示 123的弹窗,但是如果只是按照上述的流程,其实这个事件并不会被触发,原因在于 renderToString只会解析基本的 html DOM元素,并不会解析元素上附加的事件,也就是会忽略掉 onClick这个事件

onClick是个事件,在我们通常所写的代码中(即非 SSR), React是通过对元素进行 addEventListener来进行事件的注册,也就是通过 js来触发事件,并调用相应的方法,而服务器端显然是无法完成这个操作的,除此之外,一些与浏览器相关的操作也都是无法在服务器端完成的

不过这些并不影响 SSRSSR目的之一是为了能让浏览器端更快地渲染出页面,用户交互操作的可执行性不必非要跟随页面 DOM同时完成,所以,我们可以将这部分浏览器相关执行代码打包成一个 js文件发送到浏览器端,在浏览器端渲染出页面后,再加载并执行这段 js,整个页面自然也就拥有了可执行性

为了简化操作,下面在服务器端引入 Koa

既然浏览器端也需要运行一遍 Home组件,那么就需要另外准备一份给浏览器端使用的Home打包文件:

// client
import React from 'react'
import ReactDOM from 'react-dom'

import Home from '../containers/Home'

ReactDOM.render(<Home />, document.getElementById('root'))
复制代码

就是平常写得浏览器端 React代码,把 Home组件又打包了一次,然后渲染到页面节点上

另外,如果你用的是 React v16.x,上述代码的最后一句建议这么写:

// 省略代码...
ReactDOM.hydrate(<Home />, document.getElementById('root'))
复制代码

ReactDOM.renderReactDOM.hydrate 之间主要的区别就在于后者有更小的性能开销(只用于服务器端渲染),更多详细可见 hydrate

需要将这份代码打包成一段 js代码,并传送到浏览器端,所以这里还需要对类似的客户端同构代码进行 webpack的配置:

// webpack.client.js
const path = require('path')

module.exports = {
  // 入口文件
  entry: './src/client/index.js',
  // 表示是开发环境还是生产环境的代码
  mode: 'development',
  // 输出信息
  output: {
    // 输出文件名
    filename: 'index.js',
    // 输出文件路径
    path: path.resolve(__dirname, 'public')
  },
  // ...
}
复制代码

这份配置文件与服务器端的配置文件 webpack.server.js相差无几,只是去除了服务器端相关的一些配置罢了

此配置文件声明将 Home组件打包到 public目录下,文件名为 index.js,所以我们只要在服务器端输出的 html页面中,将这个文件加载进去即可:

// server
// 省略无关代码...
app.use(ctx => {
  ctx.response.type = 'html'
  ctx.body = `
    
    
      
        
        
        
        Document
      
      
        
${container}
`
}) app.listen(3000) 复制代码

对于 Home这个组件来说,它在服务器端被运行了一次,主要是通过 renderToString/renderToNodeStream生成纯净的 html元素,又在客户端运行了一次,主要是将事件等进行正确地注册,二者结合,就整合出了一个可正常交互的页面,这种服务器端和客户端运行同一套代码的操作,也称为 同构

路由同构(Router)

解决了事件等 js相关的代码同构后,还需要对路由进行同构

一般情况下在 react代码中会使用 react-router进行路由的管理,这里在服务器端传送给浏览器端的同构代码中,依旧按照通用做法即可(HashRouter/BrowserRouter),这里以 BrowserRouter为例

路由的定义:

import React, { Fragment } from 'React'
import { Route } from 'react-router-dom'

import Home from './containers/Home'
import Login from './containers/Login'

export default (
  <Fragment>
    <Route path='/' exact component={Home}>Route>
    <Route path='/login' exact component={Login}>Route>
  Fragment>
)
复制代码

浏览器端代码引入:

import React from 'react'
import ReactDOM from 'react-dom'
// 这里以 BrowserRouter 为例,HashRouter也是可以的
import { BrowserRouter } from 'react-router-dom'
// 引入定义的路由
import Routes from '../Routes'
const App = () => {
  return (
    <BrowserRouter>
      {Routes}
    BrowserRouter>
  )
}
ReactDOM.hydrate(<App />, document.getElementById('root'))
复制代码

主要在于服务器端的路由引入:

// 使用 StaticRouter
import { StaticRouter } from 'react-router-dom'
import Routes from '../Routes'
// ...
app.use(ctx => {
  const container = renderToNodeStream(
    <StaticRouter location={ctx.request.path} context={{}}>
      {Routes}
    StaticRouter>
  )
  // ...
})
复制代码

服务器端的路由是无状态的,也就是不会记录一些路由的操作,无法自动获知浏览器端的路由变化和路由状态,因为这都是浏览器的东西,React-router 4.x为服务器端提供了 StaticRouter用于路由的控制,此API通过传入的 location参数来被动获取当前请求的路由,从而进行路由的匹配与导航,更多详细可见 StaticRouter

状态同构(State)

当项目比较大的时候,通常我们会使用 redux来对项目进行数据状态的管理,为了保证服务器端的状态与客户端状态的一致性,还需要对状态进行同构

服务器端的代码是给所有用户使用的,必须要独立开所有用户的数据状态,否则会导致所有用户共用了同一个状态

// 这种写法在客户端可取,但在服务器端会导致所有用户共用了同一个状态
// export default createStore(reducer, applyMiddleware(thunk))
export default () => createStore(reducer, applyMiddleware(thunk))
复制代码

注意上述代码导出的是一个函数而不是一个 store对象,想要获取 store只需要执行这个函数即可:

import getStore from '../store'
// ...

  
    {Routes}
  

复制代码

这样一来就能保证服务器端在每次接收到请求的时候,都重新生成一个新的 store,也就相当于每个请求都拿到了一个独立的全新状态

上面只是解决了状态独立性问题,但 SSR状态同步的关键点在于异步数据的同步,例如常见的数据接口的调用,这就是一个异步操作,如果你像在客户端中使用 redux来进行异步操作那样在服务器端也这样做,那么虽然项目不会报错,页面也能正常渲染,但实际上,这部分异步获取的数据,在服务器端渲染出的页面中是缺失的

这很好理解,服务器端虽然也可以进行数据接口的请求操作,但由于接口请求是异步的,而页面渲染是同步的,很可能在服务器响应输出页面的时候,异步请求的数据还没有返回,那么渲染出来的页面自然就缺失数据了

既然是因为异步获取数据的问题导致数据状态的丢失,那么只要保证能在服务器端响应页面之前,就拿到页面所需要的正确数据,问题也就解决了

这里其实存在两个问题:

  • 需要知道当前请求的是哪个页面,因为不同的页面所需要的数据一般都是不同的,所需要请求的接口和数据处理的逻辑也都是不同
  • 需要保证服务器端在响应页面之前就已经从接口拿到了数据,也就是拿到了处理好的状态(store)

对于第一个问题,react-router 其实已经在 SSR方面给出了解决方案,即通过 配置路由/route-config 结合 matchPath,找到页面上相关组件所需的请求接口的方法并执行:

另外,react-router提供的 matchPath只能识别一级路由,对于多级路由来说只能识别最顶级的那个而会忽略子级别路由,所以如果项目不存在多级路由或者所有的数据获取和状态处理都是在顶级路由中完成的,那么使用 matchPath是没有问题的,否则就可能出现子级路由下的页面数据丢失问题

对于这个问题,react-router也给出了 解决方案,即由开发者自行使用 react-router-config中提供的 matchRoutes 来替代 matchPath

对于第二个问题,其实这就容易多了,就是 js代码中常见的异步操作同步化,最常用的 Promiseasync/await都可以解决这个问题

const store = getStore()
const promises = []
// 匹配的路由
const mtRoutes = matchRoutes(routes, ctx.request.path)
mtRoutes.forEach(item => {
  if (item.route.loadData) {
    promises.push(item.route.loadData(store))
  }
})
// 这里服务器请求数据接口,获取当前页面所需的数据,填充到 store中用于渲染页面
await Promise.all(promises)
// 服务器端输出页面
await render(ctx, store, routes)
复制代码

然而,解决了这个问题之后,另一个问题又来了

前面说了,SSR的过程要保证服务器端和客户端页面的数据状态一致,根据上述流程,服务器端最终会输出一个带有数据状态的完整页面,但是客户端这边的代码逻辑,是首先渲染出一个没有数据状态的页面架子,之后才会在 componentDidMount之类的钩子函数里发起数据接口请求拿到数据,进行状态处理,最后得到的页面才和服务器端输出的一致

那么在客户端代码拿到数据之前的这段时间,客户端的数据状态其实是空的,而服务器端的数据状态是完整的,所以两端数据状态不一致,就会出问题

解决这个问题的流程,其实就是数据的 脱水注水

在服务器端,当服务端请求接口拿到数据,并处理好数据状态(例如 store的更新)后,保留住这个状态,在服务器端响应页面HTML的时候,将这个状态一并传递给浏览器,这个过程,叫做脱水(Dehydrate);在浏览器端,就直接拿这个脱水数据来初始化 React组件,也就是客户端不需要自己发起请求获取数据处理状态了,因为服务器端已经做好了这件事情,直接从服务器端那里获取处理好的状态即可,这个过程叫注水(Hydrate)

而服务器端将状态连同 html一并传送给浏览器端的方式,一般都是通过全局变量完成:

ctx.body = `
  
  
    
      
      
      
      Document
    
    
      
${data.toString()}
`
复制代码

然后浏览器端在接收到服务器端发送来的页面后,就可以直接从 window对象上获取到状态了,然后使用此状态来更新浏览器端本身的状态即可:

export const getClientStore = () => {
  // 从服务器端输出的页面上拿到脱水的数据
  const defaultState = window.context.state
  // 当做 store的初始数据(即注水)
  return createStore(reducer, defaultState, applyMiddleware(thunk))
}
复制代码

引入样式

样式的引入就比较简单了,可以从两个角度来考虑:

  • 在服务器端输出 html文档的同时,在 html上加个