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 使用問題等問題,折騰了幾天,但最終仍是把想要的方案實踐出來。javascript

摸索階段

在查閱 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數據,跑通後,再逐漸改造完善)。html

依賴說明

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.javashuo.com/article/p-vnrsyvyn-dd.html):前端

react-redux.png | center

Redux 介紹

Redux 是 javaScript 狀態管理容器

經過 Redux 能夠很方便進行數據集中管理和實現組件之間的通訊,同時視圖和數據邏輯分離,對於大型複雜(業務複雜,交互複雜,數據交互頻繁等)的 React 項目, Redux 可以讓代碼結構(數據查詢狀態、數據改變狀態、數據傳播狀態)層次更合理。另外,Redux 和 React 之間沒有關係。Redux 支持 React、Angular、jQuery 甚至純 JavaScript。vue

Redux 的設計思想很簡單

Redux是在借鑑Flux思想上產生的,基本思想是保證數據的單向流動,同時便於控制、使用、測試java

  • 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.htmlreact

  • connect(mapStateToProps, mapDispatchToProps, mergeToProps)(App)
  • provider

react_redux.png | center

更多信息請參考 http://cn.redux.js.org/webpack

服務端渲染同構實現

頁面模板實現

  • 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服務端渲染同構實現

在服務端渲染時,這裏糾結了一下,遇到兩個問題git

  • 參考一些資料的寫法Node服務端都是在路由裏面處理的,寫起來好彆扭, 但願 render時
  • ReactDOMServer.renderToString(ReactElement) 參數必須是ReactElement
  • 組件異步獲取的數據Node render怎麼獲取到

這裏經過函數回調的方式能夠解決上面問題,也就是 export 出去的是一個函數,而後 render 判斷是否直接renderToString仍是調用函數,而後再進行renderToString。目前在 egg-view-react-ssr 作了一層簡單判斷,代碼以下:github

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文件。web

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 項目吧!

相關文章
相關標籤/搜索