|
本帖最后由 tvvocold 于 2016-7-10 22:23 编辑
原文转自 Coding 博客:https://blog.coding.net/blog/Rea ... ndering-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 应用交互过程]()
但是,一个 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 服务器端渲染》](https://blog.coding.net/blog/React-server-rendering)
|
|