React 服务端渲染实践

Apr 02 2018

前言

前段时间,学习了 React 服务端渲染,并给公司项目增加了服务端渲染的功能。

之前项目的开发模式,是服务端返回 HTML 模板文件,客户端加载 HTML 文件中的 JavaScript 文件,等待 JavaScript 文件加载完毕后才会执行后续操作,渲染出期望的页面。

客户端渲染

在服务端渲染,服务端返回渲染好的 HTML 文件,客户端接能直接显示出页面。JavaScript 代码执行时,根据已渲染的内容减少许多不必要的重复操作。

服务端渲染

好处:

  • 首屏渲染速度更快,提高用户体验。
  • 方便搜索引擎获取页面内容,有利于 SEO。
  • 前后端共用 JavaScript 代码,复用性提高。

接下来我将对在构建项目服务端渲染过程中遇到的问题进行分析:

如何在服务端渲染虚拟DOM树?

服务端渲染的原理就是在服务端就将页面的 React 组件渲染成的 HTML 字符串,然后将字符串替换进现有的 HTML 文件模版中返回给浏览器,即把之前的前端渲染逻辑放在后端进行处理。

原理

在客户端,React 通过 ReactDOM.Render 方法将虚拟DOM树渲染到页面中。

import { render } from 'react-dom'
import Root from './Root'

render(<Root />, document.getElementById('app'))

在服务端,React 提供了另外两个API:ReactDOMServer.renderToStringReactDOMServer.renderToStaticMarkup 可将其渲染为 HTML 字符串。

这两个API的区别在于:

  • renderToString:将 React 元素渲染为 HTML 字符串,同时为每一个节点带上 data-react-id ,根节点 DOM 会有 data-checksum 属性,在浏览器端渲染的时候,React 根据 data-checksum ,判断是否重新渲染 DOM 树,还是单纯为元素添加事件,这就是为什么服务端渲染能够做到高效的首屏渲染。
  • renderToStaticMarkup:同样能将 React 元素渲染为 HTML 字符串,但不会有额外属性,但到客户端时会进行重新渲染,替换最初的页面内容(可能页面会闪一下),这种方法产生的 HTML 字符串长度更小,适合将 React 作为模板引擎时使用。

下面是在项目中的代码示例:

getReactString 函数用来获取根组件渲染的 HTML 字符串。

import 'babel-polyfill'
import React from 'react'
import { renderToString } from 'react-dom/server'
import { AppContainer } from 'react-hot-loader'
import Root from '../client/containers/Root'

export default function getReactString () {
  return renderToString(
    <AppContainer>
      <Root />
    </AppContainer>
  )
}

render 函数将 React 组件渲染的字符串插入 HTML 文件模版中并返回。

import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import getReactString from './getReactString'

export default function render (ctx, View) {
  return (props) => {
    return new Promise(function (resolve, reject) {
      const { userInfo = {} } = props
      const htmlString = '<!DOCTYPE html>' + renderToStaticMarkup(<View {...props} />)
      const { reactString } = getReactString()
      const document = renderFullPage(htmlString, reactString)
      ctx.type = 'text/html'
      ctx.body = document
      resolve()
    })
  }
}

const renderFullPage = (htmlString, reactString) => {
  return htmlString.replace(/<div id="app"><\/div>/,
    `
    <div id="app">><div>${reactString}</div></div>
    `
  )
}

如何保持前后端数据状态一致?

服务端渲染时,React 组件的 state 只存在于服务器内存中,而生命周期方法只会执行到 componentDidMount 以前。
到了客户端,又会将组件重新实例化,并将剩余的生命周期方法执行完,这时如果客户端初始状态与当前不一致,就会重新请求数据并渲染,造成不必要的性能损耗。
所以我们需要在服务端渲染前请求数据,根据这个数据作为状态并渲染,之后再把数据同步到客户端,才能保证在客户端正确进行组件的实例化。

下面是在项目中的例子,用的 Redux 来做状态管理:

getInitialState 函数获取初始数据,之后通过 configureStore 来进行创建带有初始状态的 Redux store,然后将这个 store 送入根组件,执行后续的渲染。

......
import configureStore from './store'

const getInitialState = () => {
  ......
}

export default function getReactString (ctx, userInfo) {
  return new Promise((resolve, reject) => {
    getInitialState()
      .then((initialState) => {
        const { store } = configureStore(initialState)
        const reactString = renderToString(
          <AppContainer>
            <Root store={store} />
          </AppContainer>
        )
        resolve({ reactString, initialState })
      })
      .catch((err) => {
        reject(err)
      })
  })
}

之后我们在组件渲染字符串插入到 HTML 模板文件时,将序列化后的初始状态写入 script 标签中,赋到 window.__INITIAL_STATE__ 变量中。

......

export default function render (ctx, View) {
  return (props) => {
    return new Promise(async function (resolve, reject) {
      ......
      const { reactString, initialState } = await getReactString(ctx, userInfo)
      const document = renderFullPage(htmlString, reactString, initialState)
      ......
    })
  }
}

const renderFullPage = (htmlString, reactString, initialState) => {
  return htmlString.replace(/<div id="app"><\/div>/,
    `
    <div id="app">><div>${reactString}</div></div>
    <script>window.__INITIAL_STATE__=${JSON.stringify(initialState)};</script>
    `
  )
}

如此,客户端便能获取服务端渲染时的初始状态,并根据这个状态来初始化客户端的 store,继续接下来的操作,完成服务端和客户端之间状态的对接。

......

const store = configureStore(window.__INITIAL_STATE__)

ReactDOM.render(
  <AppContainer>
    <Root store={store} history={history} />
  </AppContainer>,
  document.getElementById('app')
)

......

如何解决前后端路由匹配问题?

项目是一个单页应用(SPA),那么应该确保前后端路由一致,否则可能出现页面跳转可以正常访问,刷新后却出现异常的情况。

下面是在项目中的例子,用 React-Router 配合 Redux 来做路由管理:

在客户端,我们通过浏览器的 hitstory API,实现不同的URL渲染不同的视图,无缝的页面切换,给用户良好的体验。

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import { createBrowserHistory } from 'history'
import { routerMiddleware } from 'react-router-redux'
import rootReducer from '../reducers'

const history = createBrowserHistory()

const router = routerMiddleware(history)

const enhancer = applyMiddleware(thunk, router)

function configureStore (initialState) {
  return createStore(rootReducer, initialState, enhancer)
}

export default { configureStore, history }

在服务端,我们也需要根据请求的URL来找到相匹配的组件。

......

export default function getReactString (ctx, userInfo) {
  return new Promise((resolve, reject) => {
    getInitialState()
      .then((initialState) => {
        const { store, history } = configureStore(initialState, ctx.url)
        const reactString = renderToString(
          <AppContainer>
            <Root store={store} history={history} />
          </AppContainer>
        )
        resolve({ reactString, initialState })
      })
      .catch((err) => {
        reject(err)
      })
  })
}

服务端没有浏览器环境,所以我们使用 createMemoryHistory来创建 history 对象,并通过 react-router-redux 初始化 store。

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import { createMemoryHistory } from 'history'
import { routerMiddleware } from 'react-router-redux'
import rootReducer from '../client/reducers'

function configureStore (initialState, url) {
  const history = createMemoryHistory({ initialEntries: [url] })
  const router = routerMiddleware(history)
  const enhancer = applyMiddleware(thunk, router)
  const store = createStore(rootReducer, initialState, enhancer)
  return { store, history }
}

export default configureStore

如何处理服务端对静态资源的依赖?

在客户端中,我们使用了大量的 ES6/7 语法,JSX 语法,SCSS 语法,图片资源,最终通过 Webpack 配合各种 loader 打包成一个文件最后运行在浏览器环境中。但是在服务端,不支持 import、JSX 这些语法,并且无法识别对 SCSS、image 等后缀的模块引用,那么要怎么处理这些静态资源呢?

下面是在项目中的例子,通过借助 webpack-isomorphic-tools 来消除前后端的模块加载的差异。

./webpack-config-production.js

......
const WebpackIsomorphicToolsPlugin = require('webpack-isomorphic-tools/plugin')

const webpackIsomorphicToolsPlugin = new WebpackIsomorphicToolsPlugin(require('./webpack-isomorphic-tools-configuration'))

module.exports = merge.smart(baseConfig, {
  ......

  plugins: [
    ......
    webpackIsomorphicToolsPlugin
  ]
})

webpack-isomorphic-tools 的配置文件如下,基本类似于 Webpack 的配置。

// ./webpack-isomorphic-tools-configuration.js

const path = require('path')
const WebpackIsomorphicToolsPlugin = require('webpack-isomorphic-tools/plugin')

module.exports = {
  debug: false,

  assets: {
    images: {
      extensions: ['jpeg', 'jpg', 'png', 'gif'],
      parser: WebpackIsomorphicToolsPlugin.url_loader_parser
    },
    fonts: {
      extensions: ['woff', 'woff2', 'ttf', 'eot'],
      parser: WebpackIsomorphicToolsPlugin.url_loader_parser
    },
    svg: {
      extension: 'svg',
      parser: WebpackIsomorphicToolsPlugin.url_loader_parser
    },
    styles: {
      extensions: ['less', 'scss'],
      filter: function (module, regex, options, log) {
        if (options.development) {
          return WebpackIsomorphicToolsPlugin.style_loader_filter(module, regex, options, log)
        } else {
          return regex.test(module.name)
        }
      },
      path: function (module, options, log) {
        if (options.development) {
          return WebpackIsomorphicToolsPlugin.style_loader_path_extractor(module, options, log)
        } else {
          return module.name
        }
      },
      parser: function (module, options, log) {
        if (options.development) {
          return WebpackIsomorphicToolsPlugin.css_loader_parser(module, options, log)
        } else {
          return module.source
        }
      }
    }
  },

  alias: {
    '@components': `${__dirname}/client/components`,
    '@utils': `${__dirname}/client/utils`,
    '@styles': `${__dirname}/client/styles`,
    react: path.resolve(__dirname, 'node_modules', 'react'),
    'react-dom': path.resolve(__dirname, 'node_modules', 'react-dom')
  }
}

webpack-isomorphic-tools 的 require hook,是通过一个回调函数进行的,webpack-isomorphic-tools 启动时,会先等待指定目录下 assets.json 文件编译完成,只有该文件就绪后,require hook 才会进行,进而触发 server 回调,这时就能在服务端渲染中使用这些静态资源了。

require('babel-register')
require('babel-polyfill')

const path = require('path')
const WebpackIsomorphicTools = require('webpack-isomorphic-tools')

global.WebpackIsomorphicTools = new WebpackIsomorphicTools(require('./webpack-isomorphic-tools-configuration'))
  .server(path.resolve('./client'), () => {
    require('./server/main')
})

总结

这次项目的服务端渲染实践,其间踩坑无数,最后也只是构造了一个雏形,还不够完善。服务端渲染性能的评估、首屏时间优化,也需要更为专业和准确的数据来进行判断,有哪些优秀的工具和测试方法,还请不奢赐教~!

参考资料