React Server Side Rendering 解决 SPA 应用的 SEO 问题

2015-12-09 11:21:13 +08:00
 CodingNET
> 原文地址: https://blog.coding.net/blog/React-Server-Side-Rendering-for-SPA-SEO

前端技术的流行,衍生了许多前端框架,如 Angular JS 、 Polymer 、 Backbone,Ember.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 Starter]( https://github.com/webpack/react-starter)
- [isomorphic500]( https://github.com/gpbl/isomorphic500)

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

### Server Side Render 需要什么

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

#### React renderToString 和 renderToStaticMarkup 魔法棒

React 提供了俩个神奇的方法,`renderToString` 和 `renderToStaticMarkup`,它们支持将 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 应用交互过程]( https://dn-coding-net-production-pp.qbox.me/5f297b2b-ae3f-4f4d-9ec7-2bd94ddce266.png)
但是,一个 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 找找,找到 [Redux]( https://github.com/rackt/redux) 和 [Fluxible]( https://github.com/yahoo/fluxible) 俩个好轮子,看了下文档,选择了 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)]( https://github.com/rackt/react-router/tree/master/docs)

##### 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 交互处理好就可以了。

3. 增加一个 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 方法,当然它也支持为子节点加入新的方法或者属性。

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

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) `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]( https://github.com/webpack/react-starter) 项目实现的 Server Side Rendering 也值得看一看。
- [Redux]( https://github.com/rackt/redux) 的服务器端渲染实现,可以参考[@hulufei]( https://coding.net/u/hulufei) 的博客[《玩转 React 服务器端渲染》]()
3319 次点击
所在节点    React
3 条回复
jiongxiaobu
2015-12-09 12:26:04 +08:00
想问问服务端渲染时有什么好办法处理本地存储的 state ?
oott123
2015-12-09 12:27:31 +08:00
上班之外的时间
pinkman
2015-12-09 12:38:10 +08:00
没有 markdown 内容无法看啊

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/242209

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX