Egg + React + React Router + Redux 服務端渲染實踐
概述
在實現 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)
// 客戶端用BrowserRouter, 服務端渲染用 StaticRouter 靜態路由元件
import { BrowserRouter, StaticRouter } from 'react-router-dom';
redux 和 react-redux
這裡直接借個圖([
973)):
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);
}
- Action: 必須包含type這個屬性,reducer將根據這個屬性值來對store進行相應的處理。除此之外的屬性,就是進行這個操作需要的資料。
// component/spa/ssr/actions
export function add(item) {
return {
type: ADD,
item
}
}
export function del(id) {
return {
type: DEL,
id
}
}
- Reducer: 是個函式。接受兩個引數:要修改的資料(state) 和 action物件。根據action.type來決定採用的操作,對state進行修改,最後返回新的state。
// 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
服務端渲染同構實現
頁面模板實現
- 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',
hits: 550,
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, { 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'
// 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 專案吧!