12月07日
React Server Side Rendering 解决 SPA 应用的 SEO 问题

前端技术的流行,衍生了许多前端框架,如 Angular JS、Polymer、Backbone、Ember.js、Vue.js 等,这些前端框架有些支持创建一个单页 Web 应用(Single Page Web Application)。可是,当需要应用支持良好的 SEO 的时候,你可能就会忧伤了,毕竟普通的搜索引擎可能还不支持 SPA 应用。 听闻 React 支持 Server Side Rendering,顿时激起我的兴趣,想要一探究竟,于是诞生了这篇博文。

刚好 Coding 博客需要做一些调整,而博客支持 SEO 也是首要任务,于是在业余时间,用 React 提升了下 Coding 博客的体验。

写好博客前端的基本组件和页面之后,开始搜索 React Server Side Rendering 相关的关键词,Clone 各种项目的源码下来看,找到几个比较好的 React Server Side Rendering 的 Demo:

看完之后基本理解 React Server Side Rendering 的处理方法了。

Server Side Render 需要什么

最先想到的肯定是,能够直接把一个 SPA 应用输出成 HTML 字符串吧!嗯,没错,就是它。

React renderToString 和 renderToStaticMarkup 魔法棒

React 提供了俩个神奇的方法,renderToStringrenderToStaticMarkup,它们支持将 React Component 直接输出成 HTML 字符串,有了这俩个方法,我们就可以在服务器端执行 React,直接返回渲染之后的结果。

这样搜索爬虫就能爬出一个具有内容的 HTML 页面,而不是一个 SPA 应用的 Initialize HTML 页面。

你可能会奇怪,为什么提供了俩个 React Component To String 的方法,其实它们是有区别的:

renderToString 方法,只应用在服务器端,把一个 React Component 渲染成 HTMl ,可以将它通过 response 发送到浏览器端,已达到 SEO 的目的。

renderToStaticMarkup 方法,和 renderToString 类似,但是它生成的 HTML Dom 不包含类似 data-react-id 这样的额外属性,可以用于生成简单的静态页面,节省大量字符串。

可以直接输出 HTML 字符串了,是不是就可以做到服务器端渲染了?

有了魔法还不够

React 提供的俩个渲染 HTML 字符串方法,虽然能做到直接渲染出 React Component,但是我们应用中的数据该如何处理、如何管理、如何渲染。

熟悉 React 的都知道 Flux ,使用 Flux 可以更加方便 React 的数据交互,让 React 更专注于 View 的逻辑。(附图:React Flux 应用交互过程)
React Flux 应用交互过程
但是,一个 React Flux 应用即使可以在浏览器端正常的运行,直接在服务器端使用 renderToString 方法也只能渲染出 React Component 不包含数据的 HTML Dom,React 的 ComponentDidMount 不会在服务器端触发,你的 Flux Action 事件也无法触发, Store 中不会存在任何数据,并不能解决根本问题。

所以,我们需要解决哪些问题呢?

A:嗯,把 Store 里初始化好数据就可以了!
B:等等,好像还要解决异步数据请求的问题。
C:处理 Action 事件的时候似乎应该在数据返回之后。
D:他们说的好像都对,既然做了服务器端渲染,那么在浏览器端首次就不需要在 ComponentDidMount 的时候去请求数据了,这么一来,Store 中就没有数据了,岂不是好多操作都没法做了?是不是应该在浏览器端存一个数据副本?要不要在浏览器端重新渲染一次?

从上面的回答,可以看到我们暂时需要解决的问题:

  1. 初始数据异步请求的问题。所有需要在服务器端渲染的数据请求,都需要在返回 HTML 之前处理完毕。
  2. Action 和 Store 该如何管理?Action 事件发送是在数据之前还是之后?
  3. 服务器端数据需要初始化,浏览器端的数据同样需要初始化。所以,浏览器需要保存一个数据副本。副本如何保存,浏览器端 Store 如何初始化?
  4. 客户端端渲染的问题。

带着这些问题,也许会有一个轮子可以解决这个问题,不然就得自己造一个轮子了,好吧,看看有没有好用的轮先。在 GitHub 找找,找到 ReduxFluxible 俩个好轮子,看了下文档,选择了 Fluxible,因为觉得它对于 Component Context 的管理比较好,而且是在 Flux 的基础上实现的。(最关键可以用酷酷的 Decorator Pattern)。

怎么用 Fluxible 完成 Server Side Rendering 的魔法

1. 前端路由的选择

作为 SPA 应用,都需要一个前端路由来处理不同的渲染。Fluxible 提供了自己的 router 组件,当然,使用 react-router 也可以。本文就是使用 react-router 来作为路由组件的。新建一个 Router.jsx ,作为博客的路由入口:

import ....;
// polyfill
if (!Object.assign)
    Object.assign = React.__spread; // eslint-disable-line no-underscore-dangle

var {
    Route,
    DefaultRoute,
    NotFoundRoute,
    RouteHandler,
    Link
    } = Router;

module.exports = (
    <Route path="/" handler={App}>
        <DefaultRoute handler={Demo}/>
        <NotFoundRoute handler={PageNotFound}/>
    </Route>
);

react-router 具体使用方法,请参考文档(v1.3.x)

2. Store 、Action、Service

使用 Fluxible 之后,Store 最好只作为数据存储使用,Store 中不要加入数据请求之类的方法,数据请求方法可以使用 Fluxible Plugins 来管理,也可以自己封装 service 类来管理。Fluxble 提供了 createStore 方法和 BaseStore 基类来创建 Store,可以根据自己的需求选择,下面创建一个 Blog.store.js :

import ...;
var CHANGE_EVENT = 'change';
class DemoStore extends BaseStore {
    constructor(dispatcher) {
        super(dispatcher);
        this.dispatcher = dispatcher; // Provides access to waitFor and getStore methods
        this.data = null;
    }
    loadData(data) {
        this.data = data;
        this.emitChange();
    }
    getState() {
        return {
            data: this.data
        };
    }
    // For sending state to the client
    dehydrate() {
        return this.getState();
    }
    // For rehydrating server state
    rehydrate(state) {
        this.data = state.data;
    }
}
DemoStore.storeName = 'DemoStore';
DemoStore.handlers = {
    "LOAD_DATA": "loadData",
};
export default DemoStore;

可以注意到 BlogStore 中有几个比较重要的方法和属性:

  • dehydrate 方法,用于将服务器端载入数据之后的 Store 中的 state ,序列化输出到浏览器端。
  • rehydrate 方法,用于将序列化之后的 Store 中的 state,在浏览器端反序列化成 Store 对象。
  • storeName 属性,可以直接使用 storeName 在 Component Context 中获取 Store 实例。
  • handlers 属性,定义 Store 监听的 Action 事件。

有了 Store 之后,接下来创建一个 Action 。

import ...;

class DemoAction {

    /**
     * @param  {string} text
     */
    static loadData(actionContext, payload, done) {
        DemoService.loadData(payload, function (data) {
            actionContext.dispatch('LOAD_DATA', data);
            done && done();
        });
    }


}

export default  DemoAction;

Action 在 Fluxible 中使用 context.executeAction 方法来执行,会将actionContext 上下文作为参数传入 Action 中。
Action 有一个回调函数 done 主要用于使用 async 处理异步请求时使用(不需要在服务器端加载数据的 Action 可以不传入此函数)。
Action 只负责分发事件,以及处理在不同业务逻辑下的事件分发。具体数据加载交给 Service 来处理。

import Request from 'superagent';
class BlogService {

    static loadData(payload, done) {
        var req = Request
            .get("http://127.0.0.1:4011/api/data");
        if (payload.req) {
            req.set("Cookie", payload.req.headers.cookie || "");
        }
        req.query(payload.form)
            .end(function (err, res) {
                var result = res.body;
                done && done(result, done);
            });
    }


}
export default BlogService;

有了 Store、Action、Service ,数据和事件的绑定也就有了,下面只需要把数据跟 React Component 交互处理好就可以了。

  1. 增加一个 Route Page
    使用 react-router 作为路由组件,它为每一个 url 正则都指定了一个 Handler,这个 Handler 就是一个 React Component,react-router 会直接渲染这个 React Component 以及它的子节点。
import React from 'react';
import DemoStore from "Demo.store.js";
import DemoAction from "Demo.action.js";

import { connectToStores } from 'fluxible-addons-react';
@connectToStores([DemoStore], (context) => ({
    DemoStore: context.getStore(DemoStore).getState()
})) class Demo extends React.Component {

    static contextTypes = {
        getStore: React.PropTypes.func,
        executeAction: React.PropTypes.func
    };

    constructor(props) {
        super(props);
    }

    reload() {
        this.context.executeAction(DemoAction.loadData, {});
    }

    /**
     * @return {object}
     */
    render() {
        console.info(this.props.DemoStore);
        var data = this.props.DemoStore.data || [];
        var itemContent = data.map(function (item, i) {
            return (<p>{item.content}</p>);
        });
        return (
            <div>
                {itemContent}
                <div className="align-center">
                    <a className="button" onClick={this.reload.bind(this)}>重新加载</a>
                </div>
            </div>
        );
    }


}

Demo.loadAction = [DemoAction.loadData];

export default Demo;

从上面的代码中可以看到几个比较重要的地方:

  • connectToStores ,这里使用的是 Decorator 模式,也可以直接作为函数使用,具体请查阅 Fluxible 的文档,这个函数可以让你为 React Component 的 props 执行注入回调函数,如注入 state。
  • contextTypes,为 React Component 提供俩个比较重要的方法,getStore 和 executeAction。
  • loadAction ,此处是我自定义的属性,主要用于在入口处为每个 Page Handler React Component 加入需要初始化的数据触发事件。

鉴于 react-router 的使用,需要为 App 提供一个 RouteHandler 的入口(App.jsx)。

...
import {connectToStores, provideContext} from 'fluxible-addons-react';
var {RouteHandler} = Router;


@provideContext class App extends React.Component {

    static contextTypes = {
        getStore: React.PropTypes.func,
        executeAction: React.PropTypes.func
    };

    constructor(props, context) {
        super(props, context);
    }

    /**
     * @return {object}
     */
    render() {
        return (
            <div className="main-container">
                <RouteHandler {...this.props}/>
            </div>
        );
    }


}

export default App;

可以看到在 App Class 前面加入了一个 provideContext 的 Decorator Pattern。

provideContext ,会为React Component 以及它的子节点加入 executeAction getStore 方法,当然它也支持为子节点加入新的方法或者属性。

  1. 服务器端入口和客户端入口

Store、Action、Service、Route 和 React Component 都有了之后,接下来就需要为 App 的入口做一些准备工作了。我们需要为 Server 端和 Client 端分别创建渲染入口。在 Server 端预渲染好 HTML 页面(这次渲染只是生成 HTML 字符串),Client 端接收到 HTML 之后从预存储的数据中再次渲染页面(这次渲染可以初始化一些 Dom 和 Dom 事件)。

Client 端处理相对来说比较简单,只需要把 Store 的数据反序列化,然后渲染出页面即可:

	 var dehydratedState = window.App;
        app.rehydrate(dehydratedState, function (err, context) {

            if (err) {
                throw err;
            }

            window.context = context;

            var mountNode = document.getElementById(app.uid);
            Router.run(app.getComponent(), Router.HistoryLocation, function (Handler, state) {
                var Component = React.createFactory(Handler);
                React.render(
                    React.createElement(
                        FluxibleComponent,
                        {context: context.getComponentContext()},
                        Component()
                    ),
                    mountNode,
                    function () {
                    }
                );
            });

Fluxible 提供 rehydrate 方法,将 Store 数据反序列化到 context 中。然后再使用 react-router 的 Router.run 方法渲染 HTML。

Server 端处理相对比较复杂,基本过程是:

  1. 创建 Fluxible Context 对象
  2. 使用 react-router 的 Router.run 方法根据 Request URL 渲染。
  3. 渲染之前把 Router Handler 需要进行 SEO 的数据发送 Action 请求(需要处理异步的问题)。
  4. 待所有数据请求完毕之后,序列化 Fluxible Context 。
  5. 渲染 Router Handler 对应的 React Component。
  6. 使用 React.renderToStaticMarkup渲染出 html, body,head 等外层标签。并使用 React Component 渲染的结果填充 body 内部内容。
  7. 发送 HTML 字符串到浏览器端。

参考代码:

 render(req, res) {
        var context = app.createContext({
            api: process.env.API || ('http://127.0.0.1:'+process.env.PORT),
            env: {
                NODE_ENV: process.env.NODE_ENV
            }
        });
        var actions = this.actions || [];
        Router.run(app.getComponent(), req.url, function (Handler, state) {
            if (state.routes.length === 0) {
                res.status(404);
            }
            async.filterSeries(
                state.routes.filter(function (route) {
                    return route.handler.loadAction ? true : false;
                }),
                function (route, done) {
                    async.map(actions.concat(route.handler.loadAction), function (action, callback) {
                        context.getActionContext().executeAction(action, {
                            form: Lodash.merge(state.params, state.query),
                            params: Lodash.extend({}, state.params),
                            query: Lodash.extend({}, state.query),
                            req: Lodash.extend({}, req),
                            res: Lodash.extend({}, res),
                            state: Lodash.extend({}, state),
                            route: Lodash.extend({}, route)
                        }, callback);
                        //在 Server Side 执行 Action 的时候,传入一些 App 上下文参数
                    }, function (err, result) {
                        done();
                    });
                },
                function () {

                    const state = "window.App=" + serialize(app.dehydrate(context)) + ";";
                    var Component = React.createFactory(Handler);
                    var HtmlComponent = React.createFactory(Html);

                    var markup = React.renderToString(
                        React.createElement(
                            FluxibleComponent,
                            {context: context.getComponentContext()},
                            Component()
                        ));

                    var html = React.renderToStaticMarkup(HtmlComponent({
                        context: context.getComponentContext(),
                        state: state,
                        uid: app.uid,
                        markup: markup
                    }));
                    res.send(html);
                }
            );
        });
    }

到这一步,React Server Side Rendering 案例已经可以完整运行起来了。

运行环境首推 nodejs,毕竟都是 js,兼容性会很好。

然后使用 curl 命令查看输出的内容,可以看到不再只是简单的输出一个 React App 的入口基本标签,而是整个包含数据的 HTML 页面。 如此一来,搜索爬虫就能爬出一个完整的 HTML 页面了。

总结

React 提供原生的 Component To String 支持,使得 React Server Side Rendering 成为可能,但是还有很多其他的过程,会根据个人业务不同会有区别,还是需要开发者自己熟悉这个过程,以及根据自身业务做出不同的方案。


  • 本文示例 Demo 地址 https://coding.net/u/kin/p/react-server-side-demo/git 代码中有什么问题,欢迎指正。
  • 示例代码是在 React 1.3.x 的基础上编写的,其他使用的 npm 库也都是在 React 1.3.x 的基础上。
  • React 1.4.0 已经更新,并且有一部分调整,React Server Side Rendering 方案也有所调整,Fluxible 已经对 React 1.4.0 有新的版本,React-router 也升级了,并且使用也有比较大的调整。有兴趣的可以研究一下。
  • React Starter 项目实现的 Server Side Rendering 也值得看一看。
  • Redux 的服务器端渲染实现,可以参考 @hulufei 的博客 《玩转 React 服务器端渲染》
书一57557

7条评论

这篇写的很好,用的时候再详细看看~

李鹏龙1 年前回复

@malcolm 他们实现原理大致都是一样的,没什么好伤心的!

书一2 年前回复

博主居然在 Redux 和 Fluxible 之间选择了后者!伤透了心

malcolm2 年前回复

虽然看不懂,但是感觉好腻害的样子

HuangKai2 年前回复

tandaly2 年前回复

赞啊~

夏天2 年前回复

cool

shooter2 年前回复