React 服务端渲染 搭建同构应用

# 为什么要使用服务端渲染(SSR 的优点)

  • 更快的首屏加载速度
  • 更好的 SEO

# React 如何服务端渲染

# 服务端渲染(SSR)和客户端渲染(CSR)的区别

  • 服务端没有 dom,无法直接将 react 组件 render 到一个 dom 节点
  • 服务端无法绑定事件到 dom 元素
  • 服务端没有页面路由

# 什么是同构

  • 同构既是服务端渲染结果和客户端渲染结果保持一致
  • 同构大致上分为路由同构和数据同构
  • 同构完成后,客户端只需要使用 ReactDOM.hydrate 完成事件绑定即可

# 在服务端运行 React

  • react-dom/server 提供 renderToString, renderToStaticMarkup, renderToNodeStream, renderToStaticNodeStream 四种方法用于在服务端渲染
  • renderToStringrenderToStaticMarkup 可在服务端和客户端运行,左右都是将一个 React 组件转化为静态 html。官方文档说两者的区别是 renderToStaticMarkup 不会在 React 内部创建的额外 DOM 属性,例如 data-reactroot,但如果有交互的话,不要使用此方法。个人在实际使用 16.10 版本中感受到两者的区别是:renderToString 也不会生成额外的 DOM 属性,但是会生成额外的空注释;renderToStaticMarkup 则是非常干净、没有多余东西的 html,然而在客户端使用 ReactDOM.hydrate 时会报错客户端渲染结果和服务端渲染结果不一致。官方推荐的是使用 renderToStaticMarkup 来做静态站点生成器,真正的服务端渲染还是需使用 renderToString
  • renderToNodeStreamrenderToStaticNodeStream 返回一个可读流。仅可在 Node 端使用

# 路由同构

路由同构需保证访问同一个地址时,客户端需要渲染的组件和服务端是一致的。

# 配置式(目录约定式)

  • 此种方法的原理是客户端和服务端使用同一份路由配置。客户端根据此配置生成 Route 组件,服务端根据此配置查找 url 对应的组件
  • next.js 使用的既是这种方式,优点是比较简单,缺点是每个页面必须都引入布局组件。有公共的 Provider 等等也必须每个页面重复引入

# 使用 StaticRouter

  • 需要 React Router 版本 >= 4
  • 可以方便的将一个 csr app 转化为 ssr app
import http from 'http'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { StaticRouter } from 'react-router'

http
  .createServer((req, res) => {
    // This context object contains the results of the render
    const context = {}
    const html = ReactDOMServer.renderToString(
      <StaticRouter location={req.url} context={context}>
        <App />
      </StaticRouter>
    )
    // context.url will contain the URL to redirect to if a <Redirect> was used
    if (context.url) {
      res.writeHead(302, {
        Location: context.url
      })
      res.end()
    } else {
      res.write(html)
      res.end()
    }
  })
  .listen(3000)

# 数据同构

数据同构指服务端渲染和客户端渲染的数据保持一致。若渲染结果不一致 ReactDOM 在开发环境下会抛出警告。

数据同构往往采用数据注水的方式来实现,即服务端将渲染的数据嵌入 html,客户端拿到该数据后再进行客户端渲染。

# 配置式

  • next js 使用的既是此种方法,在路由配置的基础上给每个组件添加静态 getInitalProps 方法。getInitialProps 方法在服务端执行,将执行取得的数据注入根据路由配置查找到的组件之中即可。

# 使用 context

  • 和 StaticRouter 结合使用,在组件中获取对应 context 的值进行渲染即可
  • 客户端
ReactDOM.hydrate(
  <BrowserRouter>
    <context.Propvider context={window.__INITIAL_STATE}>
      <App />
    </context.Provider>
  </BrowserRouter>,
  document.getElementById('root')
)
  • 服务端
const data = await getData()

ReactDOMServer.renderToString(
  <StaticRouter location={req.url}>
    <context.Propvider context={data}>
      <App />
    </context.Provider>
  </StaticRouter>
)

# 打包和配置

在使用 Webpack 打包时,服务端和客户端使用的 dist 是不一样的。具体表现为:

  • 客户端无法 require 一个 module,需要将所有依赖打包进 js bundle
  • 服务端可以 require 第三方 module,服务端的包不需要把第三方依赖打包进来
  • 开发环境下若服务端和客户端使用的 react 和 react-dom 不是同一个引用会报错
  • 服务端无法处理 css
  • 服务端的 js bundle 为一个 commonjs module

# 客户端配置

和 spa 的 webpack 配置无二,需要注意客户端和服务端的入口稍有不同。可以将入口分为 ClientEntry.jsxServerEntry.jsx

  • ClientEntry.jsx
import React from 'react'
import { BrowserRouter as Router } from 'react-router-dom'
import { hydrate } from 'react-dom'
import { Provider } from './Context'
import App from './App'

hydrate(
  <Router>
    <Provider value={window.__INITIAL_STATE__}>
      <App />
    </Provider>
  </Router>,
  document.getElementById('root')
)
  • ServerEntry.jsx
import React from 'react'
import { StaticRouter as Router } from 'react-router-dom'
import { Provider } from './Context'
import App from './App'

export default (url, data) => {
  return (
    <Router location={url}>
      <Provider value={data}>
        <App />
      </Provider>
    </Router>
  )
}

# 服务端配置

打包为 node 模块,忽略 css 文件。使用 watch 每次更改文件时重新编译。用 externals 属性排除所有第三方包。

module.exports = {
  target: 'node',
  output: {
    libraryTarget: 'commonjs2'
  },
  module: {
    rules: [
      {
        test: /\.s?css$/,
        use: ['ignore-loader']
      }
    ]
  },
  watch: true,
  externals: Object.keys(require('../package.json').dependencies)
}

# 构建开发环境

开发环境应有以下特性

  • 页面的 html 是服务端渲染过的 html
  • 页面嵌入的 css 和 js 为客户端生成
  • 更改客户端代码时需要热更新

# 客户端处理

由于客户端的生成的资源需要给服务端使用(包括客户端的 html),因此可以将客户端的 publicPath 设置成 ip 端口的形式供服务端使用。即

module.exports = {
  output: {
    publicPath: 'http://localhost:2999/'
  }
}

这样在开发环境下生成的 html 里面引入的 js 是加 origin 的。

同时 devServer 需要允许资源跨域

module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*'
    }
  }
}

# 服务端处理

服务端的 html 模板可以使用客户端开发环境生成的:

async function resolveTemplate() {
  return axios.get('http://localhost:2999/').then((res) => res.data)
}

在 renderMethod 中替换 <div id='root'></div> 即可。

然后服务端访问 http://localhost:3000 即可访问经过 ssr 的客户端页面了。此时的 hmr 由于是引用自客户端的并且允许了跨域,所以依然生效。

# 构建生产环境

# 客户端

同普通 spa 一样构建即可。

# 服务端

此时的 template 应使用构建后的 template

async function resolveTemplate() {
  return promisify(fs.readFile)('../dist/index.html', 'utf8')
}

# SPA 和 SSR 二合一

SPA 的好处是使用 Link 阻止了默认事件,切换页面时页面不会重新载入。然而在 SSR 项目中使用 Link 的话会导致页面跳转时不请求服务端,数据注水在客户端无法获取。

Next.js 对此的做法是封装了自己的 Link,使用 Link 可以预加载页面,原理是在页面嵌入 <link rel="prefetch" href="/xxx.js"> 来达到预加载的效果,这个 js 已经包含了要跳转页面的内容。跳转页面后页面实际的 html 没有改变,只是被预拉取的 js 修改了。这种方法常常被用于静态网站,例如本站使用的 vuepress 即采用了这种方法。然而使用这种办法没法做动态内容页,例如跟路由地址和数据库强相关的页面,因为这类页面文章内容都是用户发布的,不可能特地给每个页面都生成一个 js。

  • 最简单的方法当然是使用 a 标签,这样会从服务端重新拉取页面,会获取到正确的页面注水数据
  • 第二种方法是在组件中判断,如果页面没有注水数据,则拉取接口获取数据
  • 使用第二种方法并不会影响 SEO,因为爬虫引擎每爬取一个链接,都会重新请求服务器