基于React的同构JS解决方案
越来越多的开发者开始使用react来构建网站,react作为用JS渲染页面的技术,相比Server端直出的页面存在一些问题,比如SEO。当然,有很多思路可以解决这些问题,同构JS就是其中一种。我理解的同构JS,它解决问题的思路是让用react构建的页面在某些方面更像Server端直出的页面。以下列举了我在实践中遇到的几个问题:
SEO
我自己写了一个Blog站点,放到了阿里云上,自己写得很High,但是Blog上的文章一篇都没被搜索引擎收录,因为现在的搜索引擎还不能很好的解析JS动态生成的内容。相信很多用react构建的站点都有SEO的问题(或者他们不在乎SEO)。
有很多思路可以解决这个问题(比如给搜索引擎一个html的快照),同构JS是最直接的一种。
性能问题
这里的性能问题主要是跟非JS渲染的站点相比。比如天猫基于斑马平台的页面,是在服务端就生成好了,浏览器直接渲染就可以了;而基于react的站点,需要等待框架相关的JS资源(如webpack core文件,react)加载完成、异步数据加载完成,才能开始渲染。
路由问题
还是回到我写的Blog站点。我有每天写日记的习惯,日记的路由是/diary/view/2016-04-05,path的最后一段是日记的日期。这里用到了react-router来管理路由,而且用browserHistory作路由状态的展示(让路由跟非JS渲染的站点一致),那么问题来了:如果我想把某篇路由的URI分享出去给大家访问,domain.xxx.com/diary/view/2016-04-05是不能访问的,因为前端的路由并不能直接对应到某个资源,而是需要一个访问的过程。比如上面的URI,我需要先访问domain.xxx.com,然后找到日历,选中2016年4月5号,done。如果缺少这个中间过程,你得到的就是一个404页面。
同构JS
也叫Universal JS,说简单点就是在Server端和Client端能运行同一套用于UI展示的代码(这里是react)。同构JS的原理很简单:对于每个路由,在Server端跑出一份带数据的模板字符串,直接返回给浏览器渲染。为了达到这个目的,Server端必须能运行JS,因此Node是目前最好的选择。
这里有一个同构JS的demo,大家有兴趣可以研究一下
如何让React在Server端运行
babel6之前在node上运行react需要引入node-jsx插件,现在可以直接通过babel+react-preset,在编译es2015的时候顺带编译react,有两种方式:
- 通过
babel-register运行时编译 - 通过
babelbuild出转换成es5和不带jsx的react代码,引入babel-polyfil
React In Server
Server端是没有DOM的!所以如果你的组件需要在Server端运行, 不要在componentDidMount和componentWillUnmount中写Server端相关的逻辑,因为它只在Client端才会被执行。
react1.14及之后的版本中,DOM相关的代码都被抽离到react-dom里了。目前与react在Server端运行相关的API有:
ReactDOM.renderToString将组件转换成htmlReactDOM.renderToStaticMarkup输出基本与renderToString相同,只是渲染出的dom不带data属性
后端API数据接口设计
鉴于天猫的数据接口与Server端本来就是分离的,其实本没什么好说的,这里只略带提一下。数据接口必须保证前后端都能正常调用且结果一致。如果数据接口另起服务,那就没问题,否则最好把数据接口的路由与页面的路由区分开
注意:Server端不能使用相对路径的request比如/diary/view/xxx请求数据,所以在Server端加上domain
Redux相关的知识
一张图看懂redux:

reducer 在这里定义state如何变化,这也是唯一能改变state的地方,这是一个counter的栗子:
const initialState = { count: 0 }; export default function counter(state = initialState, action) { switch (action.type) { case INCREMENT_SUCCESS: return { count: action.payload.count }; break; case GET_COUNT_SUCCESS: return { count: action.payload.count }; break; default: return state; } }- action 可以理解成事件,触发后在reducer里改变state。比如counter的栗子,+1,-1取当前count,所有这些动作通过
action告诉reducer - store 保存state的地方,每个redux应用只有一个,可以看成树。
Step By Step构建一个同构JS应用
1. 配置路由表
用react-router配置路由规则:
// route.js
import React from 'react';
import {IndexRoute, Route} from 'react-router';
import App from './container/App.jsx';
import Home from './container/Home.jsx';
import About from './container/About.jsx';
const route = (
<Route path='/' component={App}>
<IndexRoute component={Home}/>
<Route path='about' component={About}/>
</Route>
);
export default route;
我上面配置了index的路由/,和一个/about,分别使用Home组件和About组件
2. Client端入口文件
因为很多东西在Client端和Server端都会用到,所以最好在全局变量里挂一个或多个能区分是Client端还是Server端的标识。我的做法是**在Client端用webpack.DefinePlugin注入一个__CLIENT__变量,Server端在入口文件里把这个变量挂载到global上。
plugins: [
new webpack.DefinePlugin({
__CLIENT__: true,
__DEVELOPMENT__: true,
__DEVTOOLS__: true
})
]
在Client端的入口文件里,我们做这几件事儿:
把Server端打印到页面的数据纳入
store。前文提到的白屏问题的解决方法是Server端把数据打到页面上,在client端,我们需要把这些打在页面上的数据作为初始数据放到store里(store里的数据只能通过action做修改,所以只能在创建store的时候导入初始数据,具体移步createStore)。假设页面数据通过window.__data挂载:const store = createStore(reducer, window.__data);用
<Provider>连接redux和react。redux的state会通过react组件的props传递下去const component = ( <Provider store={store} key='provider'> <Router history={browserHistory} render={(props) => <ReduxAsyncConnect {...props}/>}> {/* route是step 1中route.js export的变量,即路由表 */} {route} </Router> </Provider> );这里用到了react-async-connect包的
<ReduxAsyncConnect>,它的作用是阻塞当前路由的渲染,直到该路由包含的组件里异步请求都执行完成。用
ReactDOM.render渲染。
3. Server端入口文件
Server端对于路由的处理跟Client端有很大不同。我用exprees作Server端MVC框架,这里的路由全权交给react-router处理
react-router提供的match函数是专为Server端渲染准备的,它负责匹配request请求中的url,返回符合路由规则的组件集合。
const router = express.Router();
router.use((req, res) => {
const history = createHistory(req.originalUrl);
match({
location: req.originalUrl,
routes: route,
history
}, (err, redirectLocation, renderProps) => {
// 匹配出错
if (err) {
res.status(500);
res.send('err: ' + err.message);
}
// 匹配成功
else if (renderProps) {
const store = createStore(reducer);
// loadOnServer是redux-async-connect的方法,负责在Server端请求组件所需的数据
loadOnServer({...renderProps, store}).then(() => {
const component = (
<Provider store={store} key='provider'>
<div>
<ReduxAsyncConnect {...renderProps} />
</div>
</Provider>
);
// 这里把组件转成html字符串返回
const template = ReactDOM.renderToString(<Html component={component} store={store}/>);
res.send('<!doctype html>\n' + template);
});
}
// 404
else {
res.status(404);
res.send('Not found');
}
});
});
4. 首屏数据加载
在step 3里出现了<Html>组件,它是一个辅助组件,用来拼合html通用的部分(比如通用头尾),和把数据打印到页面
// html.js
import React, {Component, PropTypes} from 'react';
import ReactDOM from 'react-dom/server';
import conf from '../conf';
class Html extends Component {
static propTypes = {
component: PropTypes.node,
store: PropTypes.object
}
render() {
const {component, store} = this.props;
const content = component ? ReactDOM.renderToString(component) : '';
return (
<html>
<head>
<title>React Universal App Demo</title>
</head>
<body>
<div id='content' dangerouslySetInnerHTML=\{\{__html: content\}\}></div>
// 这里定义的window.__data
<script dangerouslySetInnerHTML=\{\{__html: `window.__data=${JSON.stringify(store.getState())}`\}\}/>
<script src={`${conf.host}:${conf.webpackDevPort}/app.js`}></script>
</body>
</html>
);
}
}
export default Html;
附录
Container Component和Persistential Component
这两个概念,Presentational Component和Container Component,是写React和Redux的关键。React是以数据流为中心的,把可操作数据流的component和只关心展现的component分开,可以极大的提高展现component的复用性,其实就是降低只负责展现的component与业务的耦合度。
Presentational Component就是只关心展现的component,不需要关心数据的变化,甚至不需要自身的state,只从props里取数据或者操作数据的函数。在粒度很细的组件里非常适用,这些组件一般不需要@connect
Container Component不仅需要关心数据的流转,还要把操作这些数据的函数传给Presentational Component,是数据处理的中心
Presentational and Container Components这篇文章将的很详细
react-async-connect源码解析
reducer
使用这个库需要在你的reducer里绑定它提供的一个reducer,主要作用是记录异步加载所有@asyncConnect对应promise的状态,loaded,loading和@asyncConnect里定义的key
这是绑定reducer的示例:
import {combineReducers} from 'redux';
import {reducer as reduxAsyncConnect} from 'redux-async-connect';
import counter from './counter';
export default combineReducers({
// redux-async-connect的reducer
reduxAsyncConnect,
counter
});
asyncConnect
这个_decorator_负责的部分是**给每个用它注解的Component赋值reduxAsyncConnect属性,该属性的值即是asyncConnect中的key的value(当然这个属性可以直接在需要async的Component里声明 see this,之后无论是server端请求(loadOnServer)还是client端请求(loadAsyncData)都会执行Component.reduxAsyncConnect异步取数据。而这些在asyncConnect里定义的key,最终都会作为mapStateToProps被绑定到react-redux的connect里
export function asyncConnect(mapStateToProps) {
return Component => {
Component.reduxAsyncConnect = (params, store, helpers) => componentLoadCb(mapStateToProps, params, store, helpers);
const finalMapStateToProps = state => {
/**
* @question: what is state.reduxAsyncConnect?
*/
return Object.keys(mapStateToProps).reduce((result, key) => ({...result, [key]: state.reduxAsyncConnect[key]}), {});
};
/**
* @note finially it use react-redux connect to bind promised result to compoent
*/
return connect(finalMapStateToProps)(Component);
};
}
loadOnServer
这就很简单啦,把所有带reduxAsyncConnect的Component都拉出来,在server端请求数据
ReduxAsyncConnect
这个Component需要放在router的顶端,即所有的数据都从它开始向下流转,这样才能保证所有异步请求都能执行完成(通过库里的reducer来判断是否异步加载完成)。
ReduxAsyncConnect的render使用了react-router的[