# 为什么要服务端渲染(ssr)

# 首屏等待

在 SPA 模式下,所有的数据请求和 DOM 渲染都在浏览器端完成,所以当我们第一次访问页面的时候很可能会存在“白屏”等待,而服务端渲染所有数据和 html 内容已在服务端处理完成,浏览器收到的是完整的 html 内容,可以更快地看到渲染内容,在服务端完成数据请求肯定要比浏览器端效率高得多。

# SEO

SPA 由于加载模版的时候页面骨架上面只有一个节点,其余所有节点都是由 JS 动态生成的,因为搜索引擎爬虫只认识 html 结构的内容,不能识别 JS 代码内容,所以对于 SEO,完全无能为力。

# 核心原理

整体来说服务端渲染原理不复杂,核心内容就是同构。

node server 接收到客户端请求,得到当前的 req url path,然后在已有的路有列表内查找对应的组件,拿到需要请求去的数据,将数据作为 props、context 或者 store 形式存入组件,然后基于 react 内置的服务端渲染 api renderToString 或者 renderToNodeStream 把组件渲染为 html字符串 或者 stream流,在把最终的 html 进行输出前需要将数据注入到浏览器端(注水),server 输出(response)后浏览器可以得到数据(脱水),浏览器开始进行渲染和节点比对,然后执行组件的 componentDidMount 完成组件内事件绑定和一些交互,浏览器重用了服务端输出的 html节点,整个流程结束。

服务端渲染架构图

# React SSR

# 从 ejs 开始

实现 ssr 很简单,先看一个 node ejs 的例子

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
		<meta charset="UTF-8">
		<meta name="viewport" content="width=device-width, initial-scale=1.0">
		<meta http-equiv="X-UA-Compatible" content="ie=edge">
		<title><%= title %></title>
</head>
<body>
		<%= data %>
</body>
</html>
// node ssr
const ejs = require('ejs')
const http = require('http')

http.createServer((rea, res) => {
  if(req.url === '/') {
    res.writeHead(200, {
      'Content-Type': 'text/html'
    })
    ejs.renderFile('./views/index.ejs', {
      title: 'react ssr',
      data: '首页'
    }, (err, data) => {
      if(err) console.log(err)
      else res.end(data)
    })
  }
}).listen(8080)

# JSX 到字符串

参考上面,我们使用 React 组建来实现服务端渲染,使用 jsx 来代替 ejs。

const React = require('react')
const { renderToString } = require('react-dom/server')
const http = require('http')

// 组件
class App extends React.Component {
  render() {
    return <h1>{this.props.data.title}</h1>
  }
}

// 模拟数据获取
const fetch = function() {
  return {
    title: 'react ssr',
    data: []
  }
}

http.createServer((req, res) => {
  if(req.url === '/') {
    res.writeHead(200, {
      'Content-Type': 'text/html'
    })
    const data = fetch()
    const html = renderToString(<Index data={data} />)
    res.end(html)
  }
}).listen(8080)

ps:以上代码不能直接运行,需要结合 babel 使用 @babel/preset-react 进行转换

# 关键问题

在上面非常简单的就实现了 react ssr,它帮助我们引出了一系列关键问题:

# 双端路由如何维护?

首先我们会发现我在 server 端定义了路由 '/',但是在 react SPA 模式下我们需要使用 react-router 来定义路由,那是不是需要维护两套路由?

# 获取数据的方法和逻辑写在哪里?

我们发现获取数据的 fetch 方法是独立的,和组件没有任何关联,我们更希望每个路由组件都有自己的 fetch 方法。

# 服务端 html 节点无法重用

虽然组件在服务端得到了数据,也能渲染到浏览器内,但是当浏览器端进行组件渲染的时候,直出的内容会一闪而过消失。

# 同构才是核心

react ssr 的核心就是同构,没有同构的 ssr 是没有意义的。

所谓同构就是采用一套代码,构建双端(server 和 client)逻辑,最大限度的重用代码,不用维护两套代码。而传统的服务端渲染无法做到,react ssr 的出现打破了这个瓶颈,并且已经得到了比较广泛的应用。

# 路由同构

双端使用同一套路由规则,node server 通过 req url path 进行组件查找,得到需要渲染的组件。

// 组件和路由配置,供双端使用 routes-config.js
import React from 'react'

class Detail from React.Component {
  render() {
    return <div>detail</div>
  }
}

class Index from React.Component {
  render() {
    return <div>index</div>
  }
}

const routes = [
  {
    path: '/',
    exact: true,
    component: Home
  },
  {
    path: '/detail',
    exact: true,
    component: Detail
  },
  {
    path: '/detail/:a/:b',
    exact: true,
    component: Detail
  }
]

export default routes
// 客户端路由组件
import routes from './routes-config.js'

function App() {
  return <Layout>
  	<Switch>
    	{routes.map((item, index) => {
        return <Route 
                 path={item.path} 
                 key={index} 
                 exact={item.exact}
                 render={item.component}
                 />
      })}
    </Switch>
  </Layout>
}

export default App

node server 进行组件查找

路由匹配其实就是对组件 path 规则的匹配,如果规则不复杂可以自己写,如果情况很多种还是使用官方提供的库来完成。

matchRoutes(routes, pathname)

import { matchRoutes } from 'react-router-config'
import routes from './routes-config.js'

http.createServer((req, res) => {
  const url = req.url
  // 简单排出图片等资源文件的请求
  if(url.indexOf('.') > -1) {
    res.end('')
    return false
  }
  
  res.writeHead(200, {
    'Content-Type': 'text/html'
  })
  
  const data = fetch()
  // 查找组件
  const branch = matchRoutes(routes, url)
  // 得到组件
  const Component = branch[0].route.component
  // 将组件渲染为 html 字符串
  const html = renderToString(<Component data={data}/>)
  res.end(html)
  
}).listen(8080)

matchRoutes 具体返回值查看官方文档

# 数据同构(预取同构)

这里我们解决【获取数据的方法和逻辑放在哪里?】

数据预取同构,解决双端如何使用同一套数据请求方法来进行数据请求。

在查找到要渲染的组件后,需要预先得到此组件所需要的数据,然后将数据传递给组件后,再进行组件的渲染。

我们可以通过给组件定义静态方法来处理,组件内定义异步数据请求的方法也合情合理,同时声明为 static,在服务端和组件内部都可以通过组件来进行访问,比如:Index.getInitialProps

// 组件
class Index extends React.Component {
  static async getInitialProps(opt) {
    const fetch1 = await fetch('/xxx.com/a')
    const fetch2 = await fetch('/xxx.com/b')
    return {
      res: [fetch1, fetch2]
    }
  }
  
  render() {
    return <h1>{this.props.data.title}</h1>
  }
}

// 服务端
http.createServer((req, res) => {
  const url = req.url
  if(url.indexOf('.') > -1) {
    res.end('')
    return false
  }
  
  res.writeHead(200, {
    'Content-Type': 'text/html'
  })
  
  // 组件查找
  const branch = matchRoutes(routes, url)
  // 得到组件
  const Component = branch[0].route.component
  // 数据获取
  const data = Component.getinitialProps(branch[0].match.params)
  // 传入数据,渲染为 html字符串
  const html = renderToString(<Component data={data}/>)
  
  res.end(html)
}).listen(8080)

# 渲染同构

假设我们现在基于上面已经实现的代码,同时我们也使用了 webpack 进行了配置,对代码进行了转换和打包,整个服务可以跑起来。

路由能够正确匹配,数据预取正常,服务端可以直出组件的 html,浏览器加载 js 代码正常,查看网页源代码能看到 html 内容,好像我们整个流程都已经走完了。

但是当浏览器端的 js 执行完成后,发现数据重新请求了,组件的重新渲染导致页面看上去有些闪烁。

这是因为在浏览器端,双端节点对比失败,导致组件重新渲染,也就是只有服务端和浏览器端渲染的组件具有相同的 props 和 DOM 结构的时候,组件才能只渲染一次。

刚刚我们实现了双端的数据预取同构,但是数据也仅仅是服务端有,浏览器端并没有获取到这份数据,当浏览器进行首次组件渲染的时候没有初始化的数据,渲染出的节点和服务端直出的节点不同,导致组件重新渲染。

# 数据注水

在服务端将预取的数据注入到浏览器,使浏览器可以访问到,客户端进行渲染前将数据传入对应的组件即可,这样就保证了 props 的一致。

// node server
http.createServer((rea, res) => {
  const url = req.url
  if(url.indexOf('.') > -1) {
    res.end('')
    return false
  }
  res.writeHead(200, {
    'Content-Type': 'text/html'
  })
  
  // 查找组件
  const branch = matchRoutes(routes, url)
  // 得到组件
  const Component = branch[0].route.component
  // 数据预取
  const data = Component.getInitialProps(branch[0].match.params)
  // 组件渲染
  const html = renderToString(<Component data={data} />)
  // 数据注水
  const propsData = `<textarea style="display: none" id="server-render-data-BOX">${JSON.stringify(data)}</textarea>`
  // 通过 ejs 模版引擎将数据注入到页面
  ejs.renderFile('./index.html', {
    htmlContent: html,
    propsData
  }, (err, data) => { // 渲染数据的key:对应到了ejs中的index
    if(err) console.log(err)
    else {
      console.log(data)
      res.end(data)
    }
  })
}).listen(8080)
<!-- node ejs html -->
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>

<body>
    <div id="rootEle">
        <%- htmlContent %> <!-- 组件 html 内容 -->
    </div>
    
    <%- propsData %> <!-- 组件 init state ,现在是个字符串 -->
</body>

</html>
</body>

需要借助 ejs 模版,将数据绑定到页面上。

# 数据脱水

上一步数据已经注入到了浏览器端,这一步要在客户端组件渲染前先拿到数据,并且传入组件就可以了。

客户端可以直接使用 id=server-render-data-BOX 进行数据获取

第一个方法简单粗暴,直接在组件构造函数进行获取,后续可以使用高阶组件复用这部分逻辑

第二个方法可以通过 context 传递,只需要在入口处传入,在组件中声明 static contextType 即可,这种方法有利于后续引入 redux。

// 定义 context生产者 组件
import React from 'react'
import RootContext from './route-context'

export default class index extends React.Component {
  render() {
    return <RootContext.Provider value={this.props.initialData || {}}>
    	{this.props.children}
    </RootContext.Provider>
  }
}

// 入口 app.js
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Routes from '../.'
import Provider from './provider'

// 渲染入口 接收脱水数据
function renderUI(initialData) {
  ReactDOM.hydrate(<BrowserRouter>
    	<Provider initialData={initialData}>
      	<Routes/>
      </Provider>
    </BrowserRouter>,
    document.getElementById('root'),
    e => {
    
  })
}

// 函数执行入口
function entryIndex() {
  let APP_INIT_DATA = {}
  let state = false
  // 取得数据
  let stateText = document.getElementById('server-render-data-BOX')
  if(stateText) {
    APP_INIT_DATA = JSON.parse(stateText.value || '{}')
  }
  
  if(APP_INIT_DATA) renderUI(APP_INIT_DATA) // 客户端渲染
}

entryIndex() // 入口执行

通过 context 获取数据

import React from 'react'
import '../css/index.scss'

export default class Index extends React.Component {
  constructor(props, context) {
    super(props, context)
    
    // 将 context 存储到 state
    this.state = {
      ...context
    }
  }
  
  // 设置此参数,才能拿到 context 数据
  static contextType = RootContext

  // 数据预取方法
	static async getInitialProps(opt) {
    if(__SERVER__) {
      // 如果是服务端渲染,可以做的处理,node 端设置全局变量
    }
    const fetch1 = fetch.postForm('/xxx1')
    const fetch2 = fetch.postForm('/xxx2')
    const resArr = await fetch.multipleFetch(fetch1, fetch2)
    
    return {
      page: {},
      fetchData: resArr
    }
  }

	componentDidMount() {
    if(!this.isSSR) { // 非服务端渲染需要自身进行数据获取
      Index.getInitialProps(this.props.opt).then(data => {
        this.setState(data)
      })
  }
    
  render() {
    const { page, fetchData } = this.state
    const [ res ] = fetchData || []
    
    return <div>{res && res.data.map(item => {
        return <div key={item.id}>{item.name}</div>
      })}</div>
  }  
}

# CSS 过滤

我们在写组件的时候大部分都会导入相关的 css 文件,但是 css 文件无法在服务端执行,所以我们需要在服务端渲染的时候将导入的 css 文件去除掉

# 动态路由

在 SPA 模式下,大部分应用都会实现组件分包和按需加载,为了防止所有代码打包在一个文件,导致资源过大影响页面的加载和渲染。

在 SSR 下,我们限定按需的粒度是路由级别,请求不同的路由动态加载对应的组件。

# 如何实现组件的按需加载?

在 webpack 2 时期主要是用 require.ensure 方法来实现,在当下 webpack 4,有了更加规范的方式实现按需加载,那就是 import('xx.js')

我们都知道 import 方法传入一个 js 文件地址,返回值是一个 promise 对象,然后再回调中得到按需的组件。它的原理其实就是通过 jsonp 的方式,动态请求脚本,然后在回调内得到组件。

import('../index').then(res => {
  // xxx
})

所以我们得到了几个比较有用的信息:

  • 如何加载脚本:import 结合 webpack 自动完成;
  • 脚本是否加载完成:通过 then 方法回调进行处理;
  • 获取异步按需组件:通过 then 方法回调获取。
// 使用 react-loadable 
import React from 'react'
import Loadable from 'react-loadable'

// index.js
class Index extends React.Component {
  render() {
    return <div>index</div>
  }
}

// detail.js
class Detail extends React.Component {
  render() {
    return <div>detail</div>
  }
}

// route.js
// 按需加载 index 组件
const AsyncIndex = props => {
  return <Async load={() => import('../index')}>
  	{C => <C {...props} />}
  </Async>
}

// 按需加载 detail 组件
const AsyncDetail = props => {
  return <Async load={() => import('../detail')}>
  	{C => <C {...props} />}
  </Async>
}

const routes = [
  {
    path: '/',
    exact: true,
    component: AsyncIndex,
  },
  {
    path: '/detail',
    exact: true,
    component: AsyncDetail
  }
]

按照这种方式进行配置,会发现 ssr 无效了。

# 动态路由 SSR 双端配置

ssr 无效的原因:

因为我们做路由同构的时候,双端使用的是同一个 route 配置文件 routes-config.js,现在组件修改成了按需加载,所以在路由查找后得到的组件发生了改变,AsyncDetail 和 AsyncIndex 无法转换出组件内容。

# ssr 模式下服务端如何处理路由按需加载

参考客户端的处理方式,对路由配置二次处理,服务端在组件查找前强制指向 import 方法,得到一个全新的静态路由表,然后再去查找组件。

import routes from 'routes-config.js'

export async function getStaticRoutes() {
  const staticRoutes = []
  
 	for(const route of routes) {
    staticRoutes.push({
      ...route,
      {
      component: (await item.component().props.load()).default
    }
    })
  }
  return staticRoutes
}

# ssr 模式下客户端如何处理路由按需加载

完成服务端配置后,浏览器会一直显示加载中,这是因为双端节点对比失败,重新渲染。

# 解决办法

等待按需组件加载完成后再进行渲染。

# 如何按需

参考服务端设置,不过不转换成静态路由表,只需要找到按需加载组件完成动态加载即可。