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)

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

这里直接借个图([


react-redux.png

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
redux.png

更多信息请参考 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',
        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-demo.gif

服务端实现与普通模板渲染调用无差异,写起来简单明了。如果你对 Egg + React 技术敢兴趣,赶快来玩一玩 egg-react-webpack-boilerplate 项目吧!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容