在服务器端渲染React组件(对应 v0.11.x)

#1

#在服务器端渲染React组件

在使用React.js开发web应用时,我们除了可以在浏览器端渲染组件,还可以在服务器进行组件渲染。一般来说,在服务器端渲染组件的好处有以下几个:

  1. 页面加载更快。通过在服务器端渲染组件,服务器端返回的内容就是一个完整的页面,而无需在浏览器端再进行一次渲染;
  2. 更好的SEO。一个爬虫爬到你的页面的时候,得到的将是一个完整的页面,而不是一个需要执行一段JavaScript的不完整的页面。

当然,这并不是说服务器端渲染就一定要好过在浏览器端渲染,这完全依赖于你想要什么。

在本文中,我们将一起来学习如何在服务器端渲染React组件。

#基础

##静态标签

首先,我们将从静态标签开始,所谓的静态标签,指的就是那些在浏览器端不会进行二次渲染的组件。

在React中,有一个方法叫做renderComponentToStaticMarkup,这个方法会将一个React组件处理成一个静态的HTML字符串。这个方法在服务器端渲染的时候非常有用,你只需要将这个方法返回的值传递给一个模板引擎,然后将最终产生的内容返回给浏览器即可。例如,如果你使用的是Handlebars模板引擎,你可以将你的组件渲染成一个静态标签,然后将它作为markup变量传递给模板引擎即可:

<div>{{{ markup }}}</div>

##交互组件

但是大部分的时候,我们都不希望我们的组件是静态的。原因很简单,静态组件对用户的交互完全没有反应,在我们更新了state的时候,这个组件完全不会发生变化,它就是静态的。

在React中,你可以使用renderComponentToString这个方法来生成动态组件,这个方法同样会返回一个HTML字符串,但是这个生成的字符串在浏览器端是可交互的。

这个怎么做到的呢?关键就在于使用这个方法生成的组件会在页面加载完成之后马上重新渲染一遍。

##一个例子

假设我们现在有一个叫做Item的组件,有这个组件具有一个叫做initialCount的prop属性,以及一个叫做count的state属性。这个组件会初始化一个count,并在点击这个组件的时候增加count。下面是一段简单的代码:

var Item = React.createClass({
    getInitialState: function() {
        return {
            count: this.props.initialCount
        };
    },

    _increment: function() {
        this.setState({ count: this.state.count + 1 });
    },

    render: function() {
        return <div onClick={this._increment}>
            {this.state.count}
        </div>;
    }
});

现在,我们想要首先在服务器端渲染这个组件,假设我们使用的是express和Handlebars:

var React = require('react');
...
var markup = React.renderComponentToString(
    Item({ initialCount: 7 })
);
res.render('template', {
    markup: markup
});

在我们的模板中,我们这样写:

<div id="container">{{{ markup }}}</div>

如果我们就此停住,在页面加载后,我们会看到数字7,但是我们的组件依然是一个静态组件。这是因为我们并没有在浏览器端将这个组件进行实例化,因此它根本不会再次渲染。

为了解决这个问题,我们需要在浏览器端再次渲染一次这个组件:

var container = document.getElementById('container');
var component = Item({ initialCount: 7 });
React.renderComponent(component, container);

React真正的强大之处在于,如果我们同时在浏览器端和服务器端使用同一个节点和同一个属性渲染一个组件,React会意识到Item这个组件已经存在于DOM中,因此它并不会再渲染一遍。

##同步属性

现在一切看起来都很美好,但是有一个问题是我们需要在服务器端和浏览器端都使用同样的代码,这看起来似乎有些冗余。解决的办法有以下几个:

  1. 通过模板传递属性:

    var props = { initialCount: 7 };
    var markup = React.renderComponentToString(Item(props));
    res.send(

    ’ + markup + ‘
    ’ +
    ’’
    );
  2. 将需要初始化的属性都放在一个script标签中,type设置为type="application/json":

    {{{ markup }}}

在上面的例子中,我们同时使用了两个script标签,一个更好的方法是我们首先判断window对象是否存在,若存在,则加入浏览器端的代码:

if (typeof window !== 'undefined') {
    var container = document.getElementById("container");
    var props = JSON.parse(document.getElementById("props").innerHTML);
    React.renderComponent(Item(props), container);
}
  1. 直接在组件中加入一个<script>标签来进行初始化:

    render: function() {
    var json = safeStringify(this.props);
    var propStore = <script type=“application/json”
    id={propStoreID}
    dangerouslySetInnerHTML={{__html: json}}>
    ;

     return <div onClick={this._increment}>
         {propStore}
         {this.state.count}
     </div>;
    

    }

在上面的例子中,dangerouslySetInnerHTML属性是用来避免转义的方法,虽然稍有一点复杂,但是能帮助我们避免很多麻烦。

  1. 直接将初始化的属性作为全局变量,然后直接在浏览器端进行调用。

##使用Browserify

由于你的组件需要在浏览器端和服务器端进行复用,你可以使用Browserify或者webpack等工具将代码进行打包,然后使用一个<script>标签引入代码。

因此,我们需要将前面的代码进行一点点修改:

render: function() {
    return <div onClick={this._increment}>
        <script src="/bundles/item.js"></script>
        {this.state.count}
    </div>;
}

在服务器端,我们需要使用item.js中的内容,在这里我使用Browserify-middleware,代码如下所示:

var browserify = require('browserify-middleware');
var reactify = require('reactify');
browserify.settings('transform', ['reactify']);
app.get('/bundles/item.js', browserify('./jsx/item.jsx'));]

在这里,创建一个共享的代码块非常有用,你可以将上述的代码做一些修改:

...
var shared = ['react'];
router.get('/bundles/shared.js', browserify(shared));
app.get('/bundles/item.js', browserify('./jsx/item.jsx', {
    external: shared
}));

#React的魔法

到目前为止,你已经对如何在服务器端渲染React组件有了一些了解。我们现在就更进一步,来了解一下React是如何实现在服务器端渲染组件的。

如果你查看一个在服务器端渲染的React组件,你会发现上面带有一个你并不熟悉的属性:data-react-checksum,这个属性在浏览器端渲染的组件上斌不存在。例如,在上面的例子中,我们的的Item组件是这个样子的:

<div id="container">
    <div data-reactid=".feh782p6o0" data-react-checksum="75238508">
        7
    </div>
</div>

查看一下React源码中的renderComponentToString,我们会看到下面的部分:

function renderComponentToString(component) {
    ...
    var componentInstance = instantiateReactComponent(component);
    var markup = componentInstance.mountComponent(id, transaction, 0);
    return ReactMarkupChecksum.addChecksumToMarkup(markup);
}

深入的查看addChecksumToMarkup函数,我们会发现其中的data-react-checksum属性是有Adler—32算法生成的,并在服务器端渲染的时候添加到了我们的组件中。

接下来,我们在浏览器端调用renderComponent方法。由于这个组件并没有在浏览器端进行实例化,因此这个过程会调用canReuseMarkup方法:

canReuseMarkup: function(markup, element) {
    var existingChecksum = element.getAttribute(
        ReactMarkupChecksum.CHECKSUM_ATTR_NAME
    );
    existingChecksum = existingChecksum && parseInt(existingChecksum, 10);
    var markupChecksum = adler32(markup);
    return markupChecksum === existingChecksum;
}

如果返回true,那么React将不会去渲染这个组件,而是会将这个组件在浏览器端进行关联。

总的来说,步骤如下所示:

  • 在服务器端,React将一个checksum属性添加到最外层的DOM上
  • 当一个新组件传递给浏览器端的React,React首先回去检查这个组件上的checksum属性
  • 这个组件会和浏览器端的React根据组件内容和属性生成的React进行比较
  • 如果两个值相同,那么就没有渲染的必要了,因此不会有渲染发生

例如,如果我们在服务器端渲染Item({ initialCount: 5 }),同时在浏览器端渲染React.renderComponent(Item({ initialCount: 5 }), container),二者的checksum值相同。此时在浏览器端就不会进行二次渲染了。

#小贴士

有一些事情还是值得我们关注:

  • 在服务器端进行渲染时,getDefaultPropsgetInitialState,以及componentWillMount等方法可以正常运行,但是有些方法例如componentDidMount不会再服务器端运行。
  • 如果你使用Handlebars作为模板引擎,你需要使用{{{来包裹你的组件,否则它不会被插值为原生的HTML标签。
  • 如果你使用Handlebars,一定要这样写:
    {{{ markup }}}

而不能这样写:


{{{ markup }}}

React对空格很敏感。如果你像后面这样写的话,React会认为最外层的<div>firstChild是一个换行符,而不是你的组件。这将会导致一次重新渲染。到目前为止,这个问题暂时没有好的解决方法。
  • 为了避免重新渲染,服务器端和浏览器端的组件应该完全相同。

本文参考自Rendering React Components on the Server,原文地址http://www.crmarsh.com/react-ssr/

7 Likes
#2

checksum 第一次碰到的时候是有点手足无措的感觉, 需要保证代码一致才行

#3

运行不了啊,能不能给出一个详细的例子?感觉React写在服务器端太必要了。

#4

renderComponentToString 已经更名成renderToString,但是写的组件里只有render有用,组件里的事件啊什么的都不起作用啊

#5

强制加了个版本号上去, 看来以后改 API 的话这些帖子也要跟着编辑提示一下.

#6

看了https://github.com/DavidWells/isomorphic-react-example 这个例子,发现浏览器端React.render组件的时候,根本没有去调用canReuseMarkup方法啊

#7

原来是服务端渲染模板的时候换行的缘故
见:https://github.com/cvan/taro/issues/28