阅读 4513

React 服务端渲染原理及过程

服务端渲染在久远的 JSP、PHP 时期就已经在使用了,但是在单页面应用大行其道的情况下,却依然有着各种各样的方案来支持,因为服务端渲染确实有着很多好多好处,尤其是 Node 和三大框架相结合 的前后端同构,前后端共用一套代码,更是将单页应用的便利和服务端渲染的好处相结合,这里来看一下 React Server Render 的原理和过程。

React 同构

React 同构的关键要素

DOM 的一致性

在前后端渲染相同的 Component,将输出一致的 Dom 结构。

完善的 Component 属性及生命周期与客户端的 render 时机是 React 同构的关键。

React 的虚拟 DOM 以对象树的形式保存在内存中,并且是可以在任何支持 JavaScript 的环境中生成的,所以可以在浏览器和 Node 中生成,这位前后端同构提供了先决条件。

如上图:

  1. React 的虚拟 DOM 的生成是可以在任何支持 Javascript 的环境生成的,所以可以在浏览器 Node 环境生成.
  2. 虚拟 DOM 可以直接转成 String.
  3. 然后插入到 HTML 文件中输出给浏览器便可.

虚拟 Dom 在前后端都是以对象树的形式存在的,但在展露原型的方式确是不一样的。

  1. 在浏览器里,React 通过 ReactDom 的 render 方法将虚拟 Dom 渲染到真实的 Dom 树上,生成网页
  2. 但是在 Node 环境下是没有渲染引擎的,所以 React 提供了另外两个方法::ReactDOMServer.renderToStringReactDOMServer.renderToStaticMarkup 可将其渲染为 HTML 字符串

不同的生命周期

在服务端上 Component 生命周期只会到 componentWillMount,客户端则是完整的。

客户端 render 时机

同构时,服务端结合数据将 Component 渲染成完整的 HTML 字符串并将数据状态返回给客户端,客户端会判断是否可以直接使用或需要重新挂载。

以上便是 React 在同构/服务端渲染的提供的基础条件。在实际项目应用中,还需要考虑其他边角问题,例如服务器端没有 window 对象,需要做不同处理等。

renderToString 和 renderToStaticMarkup

ReactDOMServer 提供 renderToStringrenderToStaticMarkup 的方法,大多数情况使用 renderToString,这样会为组件增加 checksum

React 在客户端通过 checksum 判断是否需要重新render 相同则不重新render,省略创建 DOM 和挂 载DOM 的过程,接着触发 componentDidMount 等事件来处理服务端上的未尽事宜(事件绑定等),从而加快了交互时间;不同时,组件将客户端上被重新挂载 render。

renderToStaticMarkup 则不会生成与 react 相关的 data-*,也不存在 checksum,输出的 html 如下

在客户端时组件会被重新挂载,客户端重新挂载不生成 checknum( 也没这个必要 ),所以该方法只当服务端上所渲染的组件在客户端不需要时才使用。

checknum 实际上是 HTML 片段的 adler32 算法值,实际上调用 React.render(<MyComponent />, container);时候做了下面一些事情:

  • 看看 container 是否为空,不为空则认为有可能是直出了结果。
  • 接下来第一个元素是否有 data-react-checksum 属性,如果有则通过浏览器的 adler32 算法得到的值和 data-react-checksum 对比,如果一致则表示,无需渲染,否则重新渲染,下面是 adler32 算法实现:
var MOD = 65521;

// This is a clean-room implementation of adler32 designed for detecting
// if markup is not what we expect it to be. It does not need to be
// cryptographically strong, only reasonably good at detecting if markup
// generated on the server is different than that on the client.
function adler32(data) {
  var a = 1;
  var b = 0;
  for (var i = 0; i < data.length; i++) {
    a = (a + data.charCodeAt(i)) % MOD;
    b = (b + a) % MOD;
  }
  return a | (b << 16);
}
复制代码

注意事项

  1. 服务端上的数据状态与同步给客户端

服务端上的产生的数据需要随着页面一同返回,客户端使用该数据去 render,从而保持状态一致。服务端上使用 renderToString 而在客户端上依然重新挂载组件的情况大多是因为在返回 HTML 的时候没有将服务端上的数据一同返回,或者是返回的数据格式不对导致,开发时可以留意 chrome 上的提示如

  1. 服务端需提前拉取数据,客户端则在 componentDidMount 调用 平台上的差异,服务端渲染只会执行到 compnentWillMount 上,所以为了达到同构的目的,可以把拉取数据的逻辑写到 React Class 的静态方法上,一方面服务端上可以通过直接操作静态方法来提前拉取数据再根据数据生成 HTML,另一方面客户端可以在 componentDidMount 时去调用该静态方法拉取数据

  2. 保持数据的确定性 这里指影响组件 render 结果的数据,举个例子,下面的组件由于在服务端与客户端渲染上会因为组件上产生不同随机数的原因而导致客户端将重新渲染。

Class Wrapper extends Component {
  render() {
    return (<h1>{Math.random()}</h1>);
  }
};
复制代码

可以将 Math.random() 封装至 Component 的 props 中,在服务端上生成随机数并传入到这个 component 中,从而保证随机数在客户端和服务端一致。如

Class Wrapper extends Component {
  render() {
    return (<h1>{this.props.randomNum}</h1>);
  }
};
复制代码

服务端上传入randomNum

let randomNum = Math.random()
var html = ReacDOMServer.renderToString(<Wrapper randomNum={randomNum} />);
复制代码
  1. 平台区分

当前后端共用一套代码的时候,像前端特有的 window 对象,Ajax 请求 在后端是无法使用上的,后端需要去掉这些前端特有的对象逻辑或使用对应的后端方案,如后端可以使用 http.request 替代 Ajax 请求,所以需要进行平台区分,主要有以下几种方式

1.代码使用前后端通用的模块,如 isomorphic-fetch 2.前后端通过 webpack 配置 resolve.alias 对应不同的文件,如 客户端使用 /browser/request.js 来做 ajax 请求

resolve: {
    alias: {
        'request': path.join(pathConfig.src, '/browser/request'),
    }
}
复制代码

服务端 webpack 上使用 /server/request.js 以 http.request 替代 ajax 请求

resolve: {
    alias: {
        'request': path.join(pathConfig.src, '/server/request'),
    }
}
复制代码

3.使用 webpack.DefinePlugin 在构建时添加一个平台区分的值,这种方式的在 webpack UglifyJsPlugin 编译后,非当前平台( 不可达代码 )的代码将会被去掉,不会增加文件大小。如 在服务端的 webpack 加上下面配置

new webpack.DefinePlugin({
    "__ISOMORPHIC__": true
}),
复制代码

在JS逻辑上做判断

if(__ISOMORPHIC__){
    // do server thing
} else {
    // do browser thing
}
复制代码

4.window 是浏览器上特有的对象,所以也可以用来做平台区分

var isNode = typeof window === 'undefined';
if (isNode) {
    // do server thing
} else {
    // do browser thing
}
复制代码
  1. componentWillReceiveProps 中,依赖数据变化的方法,需考虑在 componentDidMount 做兼容

举个例子,identity 默认为 UNKOWN,从后台拉取到数据后,更新其值,从而触发 setButton 方法

componentWillReceiveProps(nextProps) {
    if (nextProps.role.get('identity') !== UNKOWN &&
        nextProps.role.get('identity')  !== this.props.role.get('identity'))) {
        this.setButton();
    }
}
复制代码

同构时,由于服务端上已做了第一次数据拉取,所以上面代码在客户端上将由于 identity 已存在而导致永不执行 setButton 方法,解决方式可在 componentDidMount 做兼容处理

componentDidMount() {
    // .. 判断是否为同构 
    if (identity !== UNKOWN) {
        this.setButton(identity);
    }
}  
复制代码

参考

React直出实现与原理

React同构直出优化总结

文章分类
前端