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