Redux 应用实例

在学习React中,我们必定逃脱不了Redux来解决我们遇到的数据流问题,这儿根据《深入React技术栈》写的一个实例。
代码放在我的github上

初始化 Redux 项目

建立一个文件
mkdir redux-blod && cd redux-blog

新增一个 package.json文件,安装需要的依赖
npm install --save react react-dom redux react-router react-redux react-router-redux whatwg-fetch

划分目录结构:

.
  ├── node_modules 
  └── package.json

我们把所有源文件放在 src/ 目录下,
把测试文件放在 test/ 目录下,
把最终生成的、供HTML引用的文件放在 build/ 目录下

$ mkdir src
$ mkdir test
$ mkdir build

src中目录划分即采用类型划分的特点,又添加了功能划分的特点。

目录划分.png

基本上,我们只需要关注 views/ 和 components/ 这个两个文件夹

设计路由

src/
├── components
│ ├── Detail 文章详情页
│ └── Home 文章列表页
└── views
   ├── Detail.css
   ├── Detail.js
   ├── DetailRedux.js
   ├── Home.css
   ├── Home.js
   └── HomeRedux.js 

按照我们的目录结构,所有的路由应该放在 src/routes/ 目录下,因此在这个目录下新建 index.js 文件,用来配置整个应用的所有路由信息

src/
├── components
├── routes
│ └── index.js
└── views

在index文件中,我们引入所有需要的依赖

// routes/index.js
import React from 'react';
import { Router, Route, IndexRoute, hashHistory } from 'react-router';
import Home from '../views/Home';
import Detail from '../views/Detail';
//接下来,使用 react-router 提供的组件来定义应用的路由:
const routes = (
 <Router history={hashHistory}>
   <Route path="/" component={Home} />
   <Route path="/detail/:id" component={Detail} />
 </Router>
); 

优化构建脚本

添加 webpack-dev-server 作为项目依赖
$ npm install -D webpack-dev-server
将下面的脚本添加到 npm scripts中,我们后续用 npm run watch 命令执行
./node_modules/.bin/webpack-dev-server --hot --inline --content-base

添加布局文件

在 package.json
的 scripts 中添加一条新的记录可以解决这个问题:"watch":"./node_modules/.bin/webpack --watch"。然后在终端中执行 npm run watch 命令。

新建src/layouts 目录,添加两个文件 --Frame.js和 Nav.js

src/
├── components
├── layouts
│ ├── Frame.js
│ └── Nav.js
├── routes
└── views 
// Nav.js
import React, { Component } from 'react';
import { Link } from 'react-router';

class Nav extends Component {
  render() {
    return (
      <nav>
        <Link to='/'>Home</Link>
      </nav>
    )
  }
}

引入一个新的组件 Frame.js

import React, { Component } from 'react';
import Nav from './Nav';

class Frame extends Component {
  render() {
    return (
       <div className="frame">
         <section className="header">
           <Nav />
         </section>
         <section className="container">
           {this.props.children}
         </section>
       </div>
    );
  }
}

对index.js进行改造

import React from 'react';
import { Router, Route, IndexRoute, hashHistory } from 'react-router';

import Frame from '../layouts/Frame';
import Home from '../views/Home';
import Detail from '../views/Detail';

const routes = {
  <Router history={hashHistory}>
     <Route path='/' component={Frame}>
        <IndexRoute component={Home}>
        <Route path='/detail/:id' component={Detail} />
     </Route>
  </Router>
}

export default routes;

准备首页数据

在src/components/Home/ 文件夹下添加几个新文件

src/
├── components
│ ├── Detail
│ └── Home
│ ├── Preview.css
│ ├── Preview.js
│ ├── PreviewList.js
│ └── PreviewListRedux.js
├── layouts
├── routes
└── views 

在Preview.js 中定义一个纯渲染、无状态的文章预览组件

import React, { Component } from 'react';
import './Preview.css';

class Preview extends Component {
    static propTypes = {
        title: React.PropTypes.string,
        link: React.PropTypes.string,
    };

    render() {
        return (
            <article className="article-preview-item">
                <h1 className="title">{this.props.title}</h1>
                <span className="date">{this.props.date}</span>
                <p className="desc">{this.props.description}</p>
            </article>
        )
    }
}

PreviewList.js的代码

import React, { Component } from 'react';
import Preview from './Preview';

class PreviewList extends Component {
    static propTypes = {
        articleList: React.PropTypes.arrayOf(React.PropTypes.object)
    };

    render() {
        return this.props.articleList.map(item => (
            <Preview {...item} key={item.id} />
        ))
    }
}

在介绍 Redux 应用目录结构时,我们提到过Redux.js 里包含了.js 这个组件需要的reducer、action creator 和 constants。

const initialState = {
    loading: true,
    error: false,
    articleList: [],
};
// 3 个常量定义和一个函数定义在逻辑上属于一个整体
const LOAD_ARTICLES = 'LOAD_ARTICLES';
const LOAD_ARTICLES_SUCCESS = 'LOAD_ARTICLES_SUCCESS';
const LOAD_ARTICLES_ERROR = 'LOAD_ARTICLES_ERROR';

// 而 loadArticles() 就是一个 action creator。因为每次调用 loadArticles() 函数时,它都会返回一个 action,所以 action creator 之名恰如其分
export function loadArticles() {
    return {
        types: [LOAD_ARTICLES, LOAD_ARTICLES_SUCCESS, LOAD_ARTICLES_ERROR],
        url: '/api/articles.json',
    };
}
function previewList(state = initialState, action) {
    switch (action.type) {
        case LOAD_ARTICLES: {
            return {
                ...state,
                loading: true,
                error: false,
            };
        }
        case LOAD_ARTICLES_SUCCESS: {
            return {
                ...state,
                loading: false,
                error: false,
                articleList: action.payload.articleList,
            };
        }
        case LOAD_ARTICLES_ERROR: {
            return {
                ...state,
                loading: false,
                error: true,
            };
        }
        default:
            return state;
    }
}
export default previewList;

连接 Redux

  1. 让容器型组件关联数据
// views/HomeRedux.js包含了 Home 页面所有组件相关的 reducer及actionCreator
import { combineReducers } from 'redux';

// 引入 reducer 及 actionCreator
import list from '../components/Home/PreviewListRedux';

export default combineReducers({
    list,
});

export * as listAction from '../components/Home/PreviewListRedux'

可以看到,views/ 目录下的 *Redux.js 文件在更大程度上只是起到一个整合分发的作用。和components/ 目录下的 *Redux.js 文件一样,它默认导出的是当前路由需要的所有 reducer 的集合。这里我们引入了 Redux 官方提供的combineReducers 方法,通过这个方法,我们可以方便地将多个 reducer 合并为一个。

此外,HomeRedux.js 还 将PreviewListRedux.js 中所有导出的对象合并后,导出一个listAction 对象。稍后,就会看到我们为什么要这么组织文件。

重新对 views/Home.js做一些修改,让它和Redux连接起来

import React, { Component } from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import PreviewList from '../components/Home/PreviewList';
import { listAction } from './HomeRedux';

class Home extends Component {
    render() {
        <div>
            <h1>Home</h1>
            <PreviewList
                {...this.props.list}
                {...this.props.listAction}
            />
        </div>
    }
}

export default connect(state => {
    return {
        list: state.home.list,
    }
}, dispatch => {
    return {
        listAction: bindActionCreators(listActions, dispatch)
    }
})(Home)

connect 最多接受 4 个参数,分别如下

  • [mapStateToProps(state, [ownProps]): stateProps](类型:函数):接受完整的 Redux
    状态树作为参数,返回当前组件相关部分的状态树,返回对象的所有 key 都会成为组件
    的 props。

  • [mapDispatchToProps(dispatch, [ownProps]): dispatchProps] (类型:对象或函数):
    接受 Redux 的 dispatch 方法作为参数,返回当前组件相关部分的 action creator,并可以
    在这里将 action creator 与 dispatch 绑定,减少冗余代码。

  • [mergeProps(stateProps, dispatchProps, ownProps): props] (类型:函数):如果指定
    这个函数,你将分别获得 mapStateToProps、mapDispatchToProps 返回值以及当前组件的
    props 作为参数,最终返回你期望的、完整的 props。

  • [options](类型:对象):可选的额外配置项,有以下两项。

    • [pure = true](类型:布尔):该值设为 true 时,将为组件添加 shouldComponentUpdate()
      生命周期函数,并对 mergeProps 方法返回的 props 进行浅层对比。
    • [withRef = false](类型:布尔):若设为 true,则为组件添加一个 ref 值,后续可
      以使用 getWrappedInstance() 方法来获取该 ref,默认为 false。
  1. 让展示型组件使用数据
    相比于容器型组件与 Redux 的复杂交互,展示型组件实现起来则简单得多,毕竟一切需要的
    东西都已经通过 props 传进来了
import React, { PropTypes, Component } from 'react';
import Preview from './Preview';
class PreviewList extends Component {
    static propTypes = {
        loading: PropTypes.bool,
        error: PropTypes.bool,
        articleList: PropTypes.arrayOf(PropTypes.object),
        loadArticles: PropTypes.func,
    };
    componentDidMount() {
        this.props.loadArticles();
    }
    render() {
        const { loading, error, articleList } = this.props;
        if (error) {
            return <p className="message">Oops, something is wrong.</p>;
        }
        if (loading) {
            return <p className="message">Loading...</p>;
        }
        return articleList.map(item => (<Preview {...item} key={item.id} />));
    }
  1. 注入Redux
    在“让容器型组件关联数据 ”一节中,我们学习了如何使用 connect 方法关联 Redux 状态
    树中的部分状态。问题是,完整的 Redux 状态树是哪里来的呢?
src/
├── app.js
├── components
├── layouts
├── redux
│ ├── configureStore.js
│ └── reducers.js
├── routes
└── views 

先来看看 reducers.js,这个文件里汇总了整个应用所有的 reducer,而汇总的方法则十分简单。
因为我们在 views/ 文件夹中已经对各个路由需要的 reducer 做过一次整理聚合,所以在 reducers.js
中直接引用 views/*Redux.js 中默认导出的 reducer 即可。

而 configureStore.js 则是生成 Redux store 的关键文件,其中将看到 5.1 节中提到的 Redux 的
核心 API——createStore 方法

import { createStore, combineReducers, compose, applyMiddleware } from 'redux';
import { routerReducer } from 'react-router-redux';
import ThunkMiddleware from 'redux-thunk';
import rootReducer from './reducers';
const finalCreateStore = compose(
    applyMiddleware(ThunkMiddleware)
)(createStore);
const reducer = combineReducers(Object.assign({}, rootReducer, {
    routing: routerReducer,
}));
export default function configureStore(initialState) {
    const store = finalCreateStore(reducer, initialState);
    return store;
} 

新建一个实例

// app.js
import ReactDOM from 'react-dom';
import React from 'react';
import configureStore from './redux/configureStore';
import { Provider } from 'react-redux';
import { syncHistoryWithStore } from 'react-router-redux';
import { hashHistory } from 'react-router';
import routes from './routes';
const store = configureStore();
const history = syncHistoryWithStore(hashHistory, store);
ReactDOM.render((
    <Provider store={store}>
        {routes(history)}
    </Provider>
), document.getElementById('root')); 

引入 Redux Devtools

需要单独下载这些依赖
$ npm install --save-dev redux-devtools redux-devtools-log-monitor redux-devtools-dock-monitor
现在讲 DevTools 初始化的相关代码统一放在 src/redux/DevTools.js 中

import React from 'react';
import { createDevTools } from 'redux-devtools';
import LogMonitor from 'redux-devtools-log-monitor';
import DockMonitor from 'redux-devtools-dock-monitor';
const DevTools = createDevTools(
 <DockMonitor toggleVisibilityKey='ctrl-h'
 changePositionKey='ctrl-q'>
 <LogMonitor theme='tomorrow' />
 </DockMonitor>
);
export default DevTools; 

DockMonitor 决定了 DevTools 在屏幕上显示的位置,我们可以按 Control+Q 键切换位置,或者按 Control+H 键隐藏 DevTool。而LogMonitor 决定了 DevTools 中显示的内容默认包含了 action的类型、完整的 action 参数以及 action 处理完成后新的 state。

利用 middleware 实现Ajax请求发送

利用redux-composable-fetch 这个 middleware 实现异步请求
修改configureStore

import { createStore, combineReducers, compose, applyMiddleware } from 'redux';
import { routerReducer } from 'react-router-redux';
import ThunkMiddleware from 'redux-thunk';
// 引入请求 middleware 的工厂方法
import createFetchMiddleware from 'redux-composable-fetch';
import rootReducer from './reducers';
// 创建一个请求 middleware 的示例
const FetchMiddleware = createFetchMiddleware();
const finalCreateStore = compose(
    applyMiddleware(
        ThunkMiddleware,
        // 将请求 middleware 注入 store 增强器中
        FetchMiddleware
    )
)(createStore);
const reducer = combineReducers(Object.assign({}, rootReducer, {
    routing: routerReducer,
}));
export default function configureStore(initialState) {
    const store = finalCreateStore(reducer, initialState);
    return store;
} 

利用webpack-dev-server 在本地启动一个简单的http服务器来响应页面

页面之间的跳转

在 Redux 应用中,路由状态也属于整个应用状态的一部分,所以更合理的方案应该是通过分发action来更新路由
使用 react-router-redux 中提供的 routerMiddleware

// redux/configureStore.js
import { hashHistory } from 'react-router';
import { routerMiddleware } from 'react-router-redux';

import rootReducer from './reducers';
const finalCreateStore = compose(
 applyMiddleware(
// 引入其他 middleware
 // ...
// 引入 react-router-redux 提供的 middleware
 routerMiddleware(hashHistory)
 )
)(createStore); 

引入新的 middleware 之后,就可以像下面这样简单修改当前路由了:

import { push } from 'react-router-redux';
// 在任何可以拿到 store.dispatch 方法的环境中
store.dispatch(push('/'))

跳转修改

// components/Home/Preview.js
import React, { Component, PropTypes } from 'react';
class Preview extends Component {
    static propTypes = {
        title: PropTypes.string,
        link: PropTypes.string,
        push: PropTypes.func,
    };
    handleNavigate(id, e) {
        // 阻止原生链接跳转
        e.preventDefault();
        // 使用 react-router-redux 提供的方法跳转,以便更新对应的 store
        this.props.push(id);
    }
    render() {
        return (
            <article className="article-preview-item">
                <h1 className="title">
                    <a href={`/detail/${this.props.id}`} onClick={this.handleNavigate.bind(this,
                        this.props.id)}>
                        {this.props.title}
                    </a>
                </h1>
                <span className="date">{this.props.date}</span>
                <p className="desc">{this.props.description}</p>
            </article>
        );
    }
} 

优化与改进

调整代码以及构建脚本,最终实现在开发环境中加载 Redux DevTools,而在生产环境中不进行任何加载
要实现这样的需求,首先添加一款 webpack 插件-- DefinePlugin,这款插件允许我们定义任意的字符串,并将所有文件中包含这些字符串的地方都替换为指定值。
我们需要了解一种常见的定义 Node.js 应用环境的方法——环境变量。一般意义上来说,我们习惯使用 process.env.NODE_ENV 这个变量的值来确定当前是在什么环境中运行应用。当读取不到该值时,默认当前是开发环境;而当process.env.NODE_ENV=production 时,我们认为当前是生产环境。

而在生产环境中,配合另一款插件UglifyJS 的无用代码移除功能,可以方便地将任何不必要的依赖统统移除。

if ( process.env.NODE_ENV === 'production' ) {
    // 这里的代码只会在生产环境执行
} else {
    // 这里的代码只会在开发环境执行
}

添加单元测试

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

推荐阅读更多精彩内容