概述
在实现 Egg + React 服务端渲染解决方案 egg-react-webpack-boilerplate 时,因在 React + React Router + Redux 方面没有深入的实践过以及精力问题, 只实现了多页面服务端渲染方案。最近收到社区的一些咨询,想知道 Egg + React Router + Redux 如何实现 SPA 同构实现。如是就开始了 Egg + React Router + Redux 的摸索之路,实践过程中遇到 React-Router 版本问题,Redux 使用问题等问题,折腾了几天,但最终还是把想要的方案实践出来。
摸索阶段
在查阅 react router 和 redux 的相关资料,发现 react router 有 V3 和 V4 版本, V4 新版本又分为 react-router,react-router-dom,react-router-config,react-router-redux 插件, redux 相关的有 redux,react-redux,只能硬着头皮一个一个看看啥含义,看一下简单的Todo例子, 相比 Vue 的 vuex + vue-router 的工程搭建过程,这个要复杂的多,只好采用分阶段完成。先完成了纯前端渲染的 React Router + Redux 结合的例子,把 React Router 和 Redux 的相关 API 撸了一遍,基本掌握 React-Redux actions,reducer,store使用(这里自己先通过简单的例子让整个流程跑通,然后逐渐添砖加瓦,实现自己想要的功能. 比如不考虑异步,不考虑数据请求,直接hack数据,跑通后,再逐渐改造完善)。
依赖说明
react router(v4)
react-router |
React Router 核心 |
---|---|
react-router-dom |
用于 DOM 绑定的 React Router |
react-router-native |
用于 React Native 的 React Router |
react-router-redux |
React Router 和 Redux 的集成 |
react-router-config |
静态路由配置辅助 |
// 客户端用BrowserRouter, 服务端渲染用 StaticRouter 静态路由组件 import { BrowserRouter,StaticRouter } from 'react-router-dom';
redux 和 react-redux
这里直接借个图(http://www.jb51.cc/article/p-duyiwrsw-boa.html):
Redux 介绍
Redux 是 javaScript 状态管理容器
通过 Redux 可以很方便进行数据集中管理和实现组件之间的通信,同时视图和数据逻辑分离,对于大型复杂(业务复杂,交互复杂,数据交互频繁等)的 React 项目, Redux 能够让代码结构(数据查询状态、数据改变状态、数据传播状态)层次更合理。另外,Redux 和 React 之间没有关系。Redux 支持 React、Angular、jQuery 甚至纯 JavaScript。
Redux 的设计思想很简单
Redux是在借鉴Flux思想上产生的,基本思想是保证数据的单向流动,同时便于控制、使用、测试
- Web 应用是一个状态机,视图与状态是一一对应的。
- 所有的状态,保存在一个对象里面,也就是单一数据源
Redux 核心由三部分组成:Store,Action,Reducer。
- Store : 贯穿你整个应用的数据都应该存储在这里。
// component/spa/ssr/actions 创建store,初始化store数据 export function create(initalState){ return createStore(reducers,initalState); }
// component/spa/ssr/actions export function add(item) { return { type: ADD,item } } export function del(id) { return { type: DEL,id } }
// component/spa/ssr/reducers export default function update(state,action) { const newState = Object.assign({},state); if (action.type === ADD) { const list = Array.isArray(action.item) ? action.item : [action.item]; newState.list = [...newState.list,...list]; } else if (action.type === DEL) { newState.list = newState.list.filter(item => { return item.id !== action.id; }); } else if (action.type === LIST) { newState.list = action.list; } return newState }
redux 使用
// store的创建 var createStore = require('redux').createStore; var store = createStore(update); // store 里面的数据发生改变时,触发的回调函数 store.subscribe(function () { console.log('the state:',store.getState()); }); // action触发state改变的唯一方法,改变store里面的方法 store.dispatch(add({id:1,title:'redux'})); store.dispatch(del(1));
react-redux
react-redux 对 redux 流程的一种简化,可以简化手动 dispatch 繁琐过程。 react-redux 重要提供以下两个API,详细介绍请见:http://cn.redux.js.org/docs/react-redux/api.html
- connect(mapStateToProps,mapDispatchToProps,mergeToProps)(App)
- provider
更多信息请参考 http://cn.redux.js.org/
服务端渲染同构实现
页面模板实现
- home.jsx
// component/spa/ssr/components/home.jsx import React,{ Component } from 'react' import { connect } from 'react-redux' import { add,del } from 'component/spa/ssr/actions'; class Home extends Component { // 服务端渲染调用,这里mock数据,实际请改为服务端数据请求 static fetch() { return Promise.resolve({ list:[{ id: 0,title: `Egg+React 服务端渲染骨架`,summary: '基于Egg + React + Webpack3/Webpack2 服务端渲染同构工程骨架项目',hits: 550,url: 'https://github.com/hubcarl/egg-react-webpack-boilerplate' },{ id: 1,title: '前端工程化解决方案easywebpack',summary: 'programming instead of configuration,webpack is so easy',url: 'https://github.com/hubcarl/easywebpack' },{ id: 2,title: '前端工程化解决方案脚手架easywebpack-cli',summary: 'easywebpack command tool,support init Vue/Reac/Weex boilerplate',hits: 278,url: 'https://github.com/hubcarl/easywebpack-cli' }] }).then(data => { return data; }) } render() { const { add,del,list } = this.props; const id = list.length + 1; const item = { id,title: `Egg+React 服务端渲染骨架-${id}`,summary: '基于Egg + React + Webpack3/Webpack2 服务端渲染骨架项目',hits: 550 + id,url: 'https://github.com/hubcarl/egg-react-webpack-boilerplate' }; return <div className="redux-nav-item"> <h3>SPA Server Side</h3> <div className="container"> <div className="row row-offcanvas row-offcanvas-right"> <div className="col-xs-12 col-sm-9"> <ul className="smart-artiles" id="articleList"> {list.map(function(item) { return <li key={item.id}> <div className="point">+{item.hits}</div> <div className="card"> <h2><a href={item.url} target="_blank">{item.title}</a></h2> <div> <ul className="actions"> <li> <time className="timeago">{item.moduleName}</time> </li> <li className="tauthor"> <a href="#" target="_blank" className="get">Sky</a> </li> <li><a>+收藏</a></li> <li> <span className="timeago">{item.summary}</span> </li> <li> <span className="redux-btn-del" onClick={() => del(item.id)}>Delete</span> </li> </ul> </div> </div> </li>; })} </ul> </div> </div> </div> <div className="redux-btn-add" onClick={() => add(item)}>Add</div> </div>; } } function mapStateToProps(state) { return { list: state.list } } export default connect(mapStateToProps,{ add,del })(Home)
- about.jsx
// component/spa/ssr/components/about.jsx import React,{ Component } from 'react' export default class About extends Component { render() { return <h3 className="spa-title">React+Redux+React Router SPA Server Side Render Example</h3>; } }
react-router 路由定义
// component/spa/ssr/ssr import { connect } from 'react-redux' import { BrowserRouter,Route,Link,Switch } from 'react-router-dom' import Home from 'component/spa/ssr/components/home'; import About from 'component/spa/ssr/components/about'; import { Menu,Icon } from 'antd'; const tabKey = { '/spa/ssr': 'home','/spa/ssr/about': 'about' }; class App extends Component { constructor(props) { super(props); const { url } = props; this.state = { current: tabKey[url] }; } handleClick(e) { console.log('click ',e,this.state); this.setState({ current: e.key,}); }; render() { return <div> <Menu onClick={this.handleClick.bind(this)} selectedKeys={[this.state.current]} mode="horizontal"> <Menu.Item key="home"> <Link to="/spa/ssr">SPA-Redux-Server-Side-Render</Link> </Menu.Item> <Menu.Item key="about"> <Link to="/spa/ssr/about">About</Link> </Menu.Item> </Menu> <Switch> <Route path="/spa/ssr/about" component={About}/> <Route path="/spa/ssr" component={Home}/> </Switch> </div>; } } export default App;
SPA前端渲染同构实现
import React,{ Component } from 'react' import ReactDOM from 'react-dom' import { Provider } from 'react-redux' import {match,RouterContext} from 'react-router' import { BrowserRouter,StaticRouter } from 'react-router-dom'; import { matchRoutes,renderRoutes } from 'react-router-config' import Header from 'component/layout/standard/header/header'; import SSR from 'component/spa/ssr/ssr'; import { create } from 'component/spa/ssr/store'; import routes from 'component/spa/ssr/routes' const store = create(window.__INITIAL_STATE__); const url = store.getState().url; ReactDOM.render( <div> <Header></Header> <Provider store={ store }> <BrowserRouter> <SSR url={ url }/> </BrowserRouter> </Provider> </div>,document.getElementById('app') );
SPA服务端渲染同构实现
在服务端渲染时,这里纠结了一下,遇到两个问题
- 参考一些资料的写法Node服务端都是在路由里面处理的,写起来好别扭,希望 render时
- ReactDOMServer.renderToString(ReactElement) 参数必须是ReactElement
- 组件异步获取的数据Node render怎么获取到
这里通过函数回调的方式可以解决上面问题,也就是 export 出去的是一个函数,然后 render 判断是否直接renderToString还是调用函数,然后再进行renderToString。目前在 egg-view-react-ssr 做了一层简单判断,代码如下:
app.react.renderElement = (reactElement,locals,options) => { if (reactElement.prototype && reactElement.prototype.isReactComponent) { return Promise.resolve(app.react.renderToString(reactElement,locals)); } const context = { state: locals }; return reactElement(context,options).then(element => { return app.react.renderToString(element,context.state); }); }
这样处理了以后,Node 服务端controller处理时就无需自己处理路由匹配问题和store问题,全部交给底层处理。现在的这种处理方式与Vue服务端渲染render思路一致,把服务端逻辑写到模板文件里面,然后由Webpack构建js文件。
SPA服务端渲染入口文件
Webpack 构建的文件 app/ssr.js
到 app/view 目录
import React,renderRoutes } from 'react-router-config' import Header from 'component/layout/standard/header/header'; import SSR from 'component/spa/ssr/ssr'; import { create } from 'component/spa/ssr/store'; import routes from 'component/spa/ssr/routes' // context 为服务端初始化数据 export default function(context,options) { const url = context.state.url; // 根据服务端URL地址找到匹配的组件 const branch = matchRoutes(routes,url); // 收集组件数据 const promises = branch.map(({route}) => { const fetch = route.component.fetch; return fetch instanceof Function ? fetch() : Promise.resolve(null) }); // 获取组件数据,然后初始化store, 同时返回ReactElement return Promise.all(promises).then(data => { const initState = {}; data.forEach(item => { Object.assign(initState,item); }); context.state = Object.assign({},context.state,initState); const store = create(initState); return () =>( <div> <Header></Header> <Provider store={store}> <StaticRouter location={url} context={{}}> <SSR url={url}/> </StaticRouter> </Provider> </div> ) }); };
Node服务端controller调用
- controller 实现
exports.ssr = function* (ctx) { yield ctx.render('spa/ssr.js',{ url: ctx.url }); };
- 路由配置
app.get('/spa(/.+)?',app.controller.spa.spa.ssr);
- 效果演示
服务端实现与普通模板渲染调用无差异,写起来简单明了。如果你对 Egg + React 技术敢兴趣,赶快来玩一玩 egg-react-webpack-boilerplate 项目吧!