从零开始搭建第一个React+Redux应用

  • 文中的蓝色字体是相关内容的超链接,网址不另外列出,请放心点击。
  • 本文内容适合 Redux 和 React 新手,也欢迎 Redux 和 React 专家指导点评。

摘要

阅读本文并实际上手编码运行,你将解决如下几个疑问:

  • Redux是如何运作的?
  • 如何规范化State?
  • 如何创建一个简单的React+Redux应用(简单路由)

工具

预备知识

  1. 熟悉 ES6 相关知识
  2. 了解 React 相关知识

Redux API

在学习前,如果你从未接触过Redux或者对Redux不甚了解,别担心,通过API你可以了解详尽的背景知识。API的内容完善且丰富,故不在此赘述。本示例应用基于Redux官方示例应用之一的Shopping Cart ,我在此基础上添加了一点点简单的路由功能,在本文中,我会针对这个示例项目结合Redux知识作详尽的讲解。官方示例应用都很容易理解和上手而且富含Redux知识点。希望意图学习Redux的读者们照着所有的官方示例项目动手实践一番,相信你很快就能将Redux运用得得心应手。

应用效果展示

应用首页效果如下


首页效果

点击了Add to cart 按钮后的效果如下


购物车效果

点击了Checkout按钮后,将跳转到我们的结果页面,如下所示


支付确认

点击Pay for it !按钮,会进行支付。


支付成功

支付失败

点击back 按钮后,又将跳转回到我们的应用首页,如下所示。注意观察,我们商品数量减少了!


Wow!购物车被清空了

现在开始实现它

好啦,现在让我们开始一步一步实现这个简单的小应用吧。

First 我们的需求是什么

首先,我们要知道自己要做什么,根据上面的效果图,来看看我们有哪些工作内容。

  • 我们会有两个页面,一个展示产品信息和购物车信息的页面,一个确认付款的结果页面,这两个页面可以相互跳转。
  • 展示产品信息和购物车信息的页面(以下就简称它为主页面)包含了两部分的内容。产品信息模块包含了一个大标题和一个产品的列表,每个条目展示了产品的名称,价格和数量以及一个把它加入购物车的按钮,点击加入购物车的按钮一下,相应的产品就会减少一个,直到该产品的数量归零,该按钮将不再能被继续点击。购物车页面包含了我们添加到购物车的产品的信息,包括了每个产品的名称,价格和数量还有一个结算按钮,点击这个按钮我们会跳转到一个确认付款的结果页面(以下简称它为结果页面)。
  • 结果页面。结果页面分为确认模块和结果模块两部分,确认模块的内容非常简单,就只有简单的两行话和一个付款按钮。最后一行会获取到我们添加到购物车的商品的总价并展示出来。点击付款按钮时,会进行支付。支付会展示支付结果模块,有成功和失败两种,如果成功的话,我们的购物车会被清空。点击返回按钮,返回主页面。

这些就是我们的全部需求了。
看到需求,我们不着急先进行编码,不如先让我们仔细思考下我们数据该怎么组织,页面该怎么划分,数据该怎么流动。

思考一下

首先我们需要为我们的数据对象建立个模型,在我们这个简单的小应用里只有一个简单的数据对象,就是产品。一个产品对象应该包含哪些东西呢?想必你已经发现了,它至少要包含产品的名字,产品的价格,以及产品的库存数量。这些是全部吗?当然不是。如果你有一定的应用开发经验(或者你设计过数据库表),你肯定知道我们通常需要一个标识来区分这个产品对象模型的实体。我们产品实体对象需要一个id以将它和其他产品实体对象区别开来。Id,名称,价格,库存数量,这些是全部吗?哈哈哈哈,几乎是全部了。我们这么来表示一个产品对象基本没问题。但是别忘了,我们还有一个购物车的功能,我们需要把加入购物车的产品数量也记录下来,这样我们就可以计算购物车内商品的总价格了。在这个小项目里,为了尽可能的简单,我们悄悄把用户添加到购物车里的产品数量,也附加到产品对象模型上(注意,在生产实践上你也许不能这么做!!!)。
现在一个产品对象模型就展现在我们面前了。
产品对象product [id,名称,价格,库存量,买入量]
建立产品模型也是我们设计State的一部分工作内容。

建立了模型,再让我们来看看页面该怎么划分呢?按照React组件化的思想,我们将页面划分的适度小一点最好,这样在页面重新渲染的时候,需要渲染的部分也很少。
如下图,我们将产品信息组件分成了三块,我用不同颜色的框将他们框出来了。


产品信息组件拆分

产品们是以一个列表的形式来展示的,每个列表条目又包含了该产品的信息块和一个按钮。产品的信息块包含了产品的名称、价格以及库存数量。我们把这一大块的内容,尽可能得拆成小块的内容,然后再用小块像搭建乐高积木一样将大块一点一点搭建出来。
购物车信息块也是相似的划分。不再次赘述了。

接下来我们思考下数据的流动。我们先来看看哪些部分会产生变化,产品信息页面的的库存数量会变化,添加进入购物车的按钮会产生变化,这个变化依赖于库存数量的变化,库存数量归0了,按钮就发生变化了。购物车信息会产生变化,会展示出被我们点击过加入购物车按钮的产品的名称以及价格,以及购买的件数即(点击按钮的次数)。购物车内的总价会变化,结果页的总价也会产生变化,不过总价的变化依赖于加入购物车的产品数量。

根据页面和数据流动我们大致可以确定这个应用的state的大概样子了。

state
  • products[ ]
  • carts[ ]

我们的state可能包含两个数组一个是产品信息的数组,一个是购物车信息的数组,里面是我们的产品对象实体。

开发复杂的应用时,不可避免会有一些数据相互引用。建议你尽可能地把 state 范式化,不存在嵌套。把所有数据放到一个对象里,每个数据以 ID 为主键,不同实体或列表间通过 ID 相互引用数据。把应用的 state 想像成数据库。这种方法在 normalizr 文档里有详细阐述。例如,实际开发中,在 state 里同时存放 todosById: { id -> todo }todos: array<id> 是比较好的方式

让我们以normalizr的方式让我们的state更规范一点。

新的state
  • products
    -- productIds[id1,id2,id3]
    -- productsById{id1:{product},id2:{product},id3:{product}}
  • carts
    --addedIds[id1,id2]
    --addedById{id1:{product},id2:{product}}
    --paid

相信这么一长串的分析也让你对应用可能会产生的交互动作action有所感悟。
应用至少会产生以下几个动作:

  • 请求数据的动作
  • 将产品添加到购物车的动作
  • 点击结算按钮的动作
  • 点击付款按钮的动作
  • 点击返回按钮的动作

充分的思考过后我们就可以开始实践了!
让我们首先从一些简单的部分开始着手,比如把产品展示出来。

展示产品

首先我们让制作点儿产品假数据。


项目目录结构

项目依赖

我们在api文件夹下新建一个product.json文件,里面包含了一些我们用来模拟从后台接收到的产品数据。

[
  { "id": 1, "title": "iPad 4 Mini", "price": 500.01, "inventory": 2 },
  { "id": 2, "title": "H&M T-Shirt White", "price": 10.99, "inventory": 10 },
  { "id": 3, "title": "Charli XCX - Sucker CD", "price": 19.99, "inventory": 5 }
]

现在我们需要来获取这些模拟数据了,我们在api文件夹下再新建一个shop.js文件。

import _products from "./product.json";

const TIME_OUT = 2000;

/**
 * cb 是个函数参数,延迟多少ms后获取产品信息
 */
export default {
  getProduct: (cb, timeout) => setTimeout(cb(_products), timeout || TIME_OUT),
};

通过把模拟数据传入函数里,实现接收数据这一动作。使用了setTimeout来模拟请求响应之间的耗时。

接下来我们可以定义接收数据这一动作类型了。
在constants文件夹下新建一个ActionTypes.js文件来定义一些动作类型常量。

export const RECEIVE_PRODUCTS = "RECEIVE_PRODUCTS";

定义好了常量类型,就需要去定义动作(action)了,动作是一个改变state的信号,它包含了需要的数据。
我们在actions文件夹下新建一个index.js文件(我们的应用比较简单,所以才使用一个文件),来存放所有的动作事件。

import shop from "../api/shop";
import * as types from "../constants/ActionTypes";

/**
 * 这是在接收到产品数据后发送的动作
 * @param {产品JSON} products  参数是json
 */
const receiveProducts = products => ({
  type: types.RECEIVE_PRODUCTS,
  products
});

export const getAllProducts = () => dispatch => {
  shop.getProduct(products => {
    dispatch(receiveProducts(products));
  });
};

在获取到我们的模拟数据后我们发送一个带有产品数据的action来告诉应用我们要改变state了。但是action仅仅只是一个信号,它并不负责去更新state。我们通过编写reducer纯函数来实现更新保存着应用所有state的单一的store内的某一state。即真正更新Store中的state的是我们reducer,它接受action作为参数之一,拿到action中的数据来更新state。
我们在reducers文件夹下新建一个products.js文件来保存所有有关处理关于产品state变更的reducer。reducer保证只要传入参数相同,返回计算得到的下一个 state 就一定相同。

import { RECEIVE_PRODUCTS } from "../constants/ActionTypes";
import { combineReducers } from "redux";

/**
 * 把产品数据以normalized化的形式组织,即通过id对应一个产品(1)
 * 通过reduce的方式来实现
 * @param {*} state
 * @param {*} action
 */
const productsOrangedById = (state = {}, action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      return {
        ...state,
        ...action.products.reduce((obj, product) => {
          obj[product.id] = product;
          return obj;
        }, {})
      };
    
    default:
      return state;
  }
};

//把产品数据以normalized化的形式组织,记录所有的id值(2)
//map 把一个数组映射成一个新数组
//map() 原数组中的每个元素调用这个方法后返回值组成的新数组
const visibleIds = (state = [], action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      return action.products.map(product => product.id);
    default:
      return state;
  }
};

//组合成state
/**
 * 结构应该是这样的
 * {
 *    visibleIds:[productId1,productId2],
 *    productsOrangedById:{
 *          productId1:{
 *             属性:值
 *          }
 *          productId2:{
 *             属性:值
 *          }
 *    }
 * }
 */
export default combineReducers({
  productsOrangedById,
  visibleIds
});

//获取state中的值的方式
/**
 * 直接获取state中的某个产品的方式
 * */
export const getProduct = (state, id) => state.productsOrangedById[id];

/**
 * 获取产品数组的方式
 * @param {*} state
 */
export const getVisibleProducts = state =>
  state.visibleIds.map(id => getProduct(state, id));

我们定义了两个reducer函数来处理action。注意我们使用了combineReducers生成了一个函数来调用这些reducer,对于Redux中的每一个action,每一个reducer都将被调用到,如果它有处理action定义的类型的逻辑,它就会执行这段逻辑,如果没有,它就返回一个默认值,这个默认值通常就是这个reducer接收的state。

通过使用reduce()函数和map()函数,我们实现了对state的normalizr化组织方式。
最后我们提供了两个函数来获取state中的某一产品和产品组。

我们在reducers文件夹下新建一个index.js文件用来将所有的子reducer组合起来(其实你现在不一定需要这么做,因为我们只有一个子文件)。

import { combineReducers } from "redux";
import products, * as fromProducts from "./products";
export default combineReducers({
  products,
});

将reducer再整合一次,以后生成的store结构树也因此多了一层。

编写完了reducer,我们就可以来编写页面展示的组件了。首先从外层大的List组件开始。
在components文件夹下新建Product.js文件。

import React from "react";
import PropTypes from "prop-types";

//产品
const Product = ({ title, price, quantity }) => (
  <div>
    {title} - &#36;{price}
    {quantity ? ` x ${quantity}` : null}
  </div>
);

Product.propTypes = {
  price: PropTypes.number,
  quantity: PropTypes.number,
  title: PropTypes.string
};

export default Product;

我们的产品组件需要展示名称、价格和数量,因此它接收这三项参数。

在components文件夹下新建ProductItem.js文件,构建产品条目组件。

import React from "react";
import PropTypes from "prop-types";
import Product from "./Product";

const ProductItem = ({ product}) => (
  <div style={{ marginBottom: 20 }}>
    <Product
      title={product.title}
      price={product.price}
      quantity={product.inventory}
    />
  </div>
);

ProductItem.propTypes = {
  product: PropTypes.shape({
    title: PropTypes.string.isRequired,
    price: PropTypes.number.isRequired,
    inventory: PropTypes.number.isRequired
  }).isRequired,
};

export default ProductItem;

它接收产品对象作为参数,并且将产品内的具体信息传递给产品组件。

在components文件夹下新建ProductsList.js文件,构建产品列表组件。

import React from "react";
import PropTypes from "prop-types";

//传入了一个对象
const ProductsList = ({ title, children }) => (
  <div>
    <h3>{title}</h3>
    <div>{children}</div>
  </div>
);

ProductsList.propTypes = {
  children: PropTypes.node,
  title: PropTypes.string.isRequired
};
export default ProductsList;

它将接收我们要展示的标题和一些产品条目子组件。

我们将组件划分为两类,一类是视图类展示组件,一类的状态管理类容器组建,有点类似于MVC的感觉。视图类组件不需要管理状态,其中很多都是React函数式组件。在完成了展示组件后,我们需要一个容器组件,获取状态变化并传递给视图组件。
在containers文件夹下新建ProductsContainer.js文件,创建产品信息容器组件。

import React from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { getVisibleProducts } from "../reducers/products";
import ProductItem from "../components/ProductItem";
import ProductsList from "../components/ProductsList";

const ProductsContainer = ({ products }) => (
  <ProductsList title={"产品"}>
    {products.map(product => (
      <ProductItem
        key={product.id}
        product={product}
      />
    ))}
  </ProductsList>
);

ProductsContainer.propTypes = {
  products: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number.isRequired,
      title: PropTypes.string.isRequired,
      price: PropTypes.number.isRequired,
      inventory: PropTypes.number.isRequired
    })
  ).isRequired
};

//容器组件需要和状态映射起来
const mapStateToProps = state => ({
  products: getVisibleProducts(state.products)
});

export default connect(
  mapStateToProps
)(ProductsContainer);

我们将容器组件和state映射起来,这样一旦state变化,容器组件就能感知到,对视图展示进行重新渲染。

当然还需要一个根容器将我们全部的其他容器都装起来。
在containers文件下新建一个App.js文件

import React from 'react'
import ProductsContainer from './ProductsContainer'

const App = () => (
  <div>
    <h2>Shopping Cart Example</h2>
    <hr/>
    <ProductsContainer />
    <hr/>
  </div>
)

export default App

App组件是最大的组件,决定了我们应用的主体展示结构。

现在修改项目根路径下的index.js文件,让我们创建集中管理state的store,获取产品数据,并通过Provider将state注入到容器组件中。值得注意的是我们获取产品的动作是一个异步操作,因此需要引入redux-thunk中间件来解决异步问题。

import React from 'react'
import { render } from 'react-dom'
import { createStore, applyMiddleware } from 'redux'
import { Provider } from 'react-redux'
import thunk from 'redux-thunk'
import reducer from './reducers'
import { getAllProducts } from './actions'
import App from './containers/App'

const middleware = [ thunk ];

const store = createStore(
  reducer,
  applyMiddleware(...middleware)
)

store.dispatch(getAllProducts())

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

现在我们就可以看到产品的展示信息了。效果如下图所示:


产品展示效果

添加到购物车按钮

现在我们仅仅是把产品信息展示出来了,还没有加上添加到购物车的按钮。现在就让我们来添加上这一按钮吧。
点击这一按钮,会触发一个将商品添加到购物车的动作。那么我们先来定义一下这个动作类别吧!
在constants/ActionTypes.js文件下添加新定义的动作类型

export const ADD_TO_CART = "ADD_TO_CART";

紧接着,我们来定义动作。在actions/index.js文件里做一些修改,现在它变成这样了。

import shop from "../api/shop";
import * as types from "../constants/ActionTypes";
import { push } from "react-router-redux";

/**
 * 这是在接收到产品数据后发送的动作
 * @param {产品JSON} products  参数是json
 */
const receiveProducts = products => ({
  type: types.RECEIVE_PRODUCTS,
  products
});

export const getAllProducts = () => dispatch => {
  shop.getProduct(products => {
    dispatch(receiveProducts(products));
  });
};

const addToCartUnsafe = productId => ({
  type: types.ADD_TO_CART,
  productId
});

export const addToCart = productId => (dispatch, getState) => {
  if (getState().products.productsOrangedById[productId].inventory > 0) {
    dispatch(addToCartUnsafe(productId));
  }
};

通过addToCart函数我们可以对产品的库存量做一个判断,只有库存量大于0才可以向我们的reducer发送action来修改state。修改我们的reducers/products.js文件定义处理新action的reducer。

import { RECEIVE_PRODUCTS, ADD_TO_CART } from "../constants/ActionTypes";
import { combineReducers } from "redux";

const reduceProducts = (state, action) => {
  switch (action.type) {
    case ADD_TO_CART:
      return {
        ...state,
        inventory: state.inventory - 1
      };
    default:
      return state;
  }
};

/**
 * 把产品数据以normalized化的形式组织,即通过id对应一个产品(1)
 * 通过reduce的方式来实现
 * @param {*} state
 * @param {*} action
 */
const productsOrangedById = (state = {}, action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      return {
        ...state,
        ...action.products.reduce((obj, product) => {
          obj[product.id] = product;
          return obj;
        }, {})
      };
    //通过id更新state里的产品数量
    default:
      const { productId } = action;
      if (productId) {
        return {
          ...state,
          [productId]: reduceProducts(state[productId], action)
        };
      }
      //action里没有产品id则直接返回
      return state;
  }
};

//把产品数据以normalized化的形式组织,记录所有的id值(2)
//map 把一个数组映射成一个新数组
//map() 原数组中的每个元素调用这个方法后返回值组成的新数组
const visibleIds = (state = [], action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      console.log("bbbbbb");
      return action.products.map(product => product.id);
    default:
      return state;
  }
};

//组合成state
/**
 * 结构应该是这样的
 * {
 *    visibleIds:[productId1,productId2],
 *    productsOrangedById:{
 *          productId1:{
 *             属性:值
 *          }
 *          productId2:{
 *             属性:值
 *          }
 *    }
 * }
 */
export default combineReducers({
  productsOrangedById,
  visibleIds
});

//获取state中的值的方式
/**
 * 直接获取state中的某个产品的方式
 * */
export const getProduct = (state, id) => state.productsOrangedById[id];

/**
 * 获取产品数组的方式
 * @param {*} state
 */
export const getVisibleProducts = state =>
  state.visibleIds.map(id => getProduct(state, id));

修改了action和reducer,我们接下来可以修改组件了。修改components/ProductItems.js文件添加一个按钮。

import React from "react";
import PropTypes from "prop-types";
import Product from "./Product";

const ProductItem = ({ product, onAddToCartClick }) => (
  <div style={{ marginBottom: 20 }}>
    <Product
      title={product.title}
      price={product.price}
      quantity={product.inventory}
    />
    <button
      onClick={onAddToCartClick}
      disabled={product.inventory > 0 ? "" : "disabled"}
    >
      {product.inventory > 0 ? "Add to cart" : "Sold Out"}
    </button>
  </div>
);

ProductItem.propTypes = {
  product: PropTypes.shape({
    title: PropTypes.string.isRequired,
    price: PropTypes.number.isRequired,
    inventory: PropTypes.number.isRequired
  }).isRequired,
  onAddToCartClick: PropTypes.func.isRequired
};

export default ProductItem;

ProductItem中的按钮点击事件函数依赖于容器组件传递进来,接下来我们修改一下容器组件。把点击事件传递进来。

import React from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { addToCart } from "../actions";
import { getVisibleProducts } from "../reducers/products";
import ProductItem from "../components/ProductItem";
import ProductsList from "../components/ProductsList";

const ProductsContainer = ({ products, addToCart }) => (
  <ProductsList title={"产品"}>
    {products.map(product => (
      <ProductItem
        key={product.id}
        product={product}
        onAddToCartClick={() => addToCart(product.id)}
      />
    ))}
  </ProductsList>
);

ProductsContainer.propTypes = {
  products: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number.isRequired,
      title: PropTypes.string.isRequired,
      price: PropTypes.number.isRequired,
      inventory: PropTypes.number.isRequired
    })
  ).isRequired
};

//容器组件需要和状态映射起来
const mapStateToProps = state => ({
  products: getVisibleProducts(state.products)
});

export default connect(
  mapStateToProps,
  { addToCart }
)(ProductsContainer);

通过connect()函数将addToCart动作作为组件的props的一部分绑定到组件上。好啦,这样我们就实现了这个按钮。让我们来看看效果。


加入购物车按钮

如果库存为0了按钮就会被禁用,点击按钮一次展示的产品的数量也会减少一个。

展示购物车信息

产品模块的展示基本就完成啦,现在让我们来实现一下购物车模块吧!
首先还是先考虑下购物车模块有哪些会更新state的动作。

  • 产品添加到购物车这个动作显然也会触发购物车信息的更新
  • 总价会变化,不过这依赖于上面的这个动作

首先我们在actions/index.js内添加一下结算按钮的点击动作事件,我们意图做路由跳转,但是目前我们还不跳转。于是先写一个空的函数体。

export const checkout = () => dispatch => {
  
};

添加到购物车这个动作在之前我们已经定义过了,不必重复定义了。我们直接开始设计接收这个action来变更state的reducer。在reducers文件夹下,新建cart.js文件。

import {
  ADD_TO_CART
} from "../constants/ActionTypes";

const initialState = {
  addedIds: [],
  quantityById: {},
};

const addedIds = (state = initialState.addedIds, action) => {
  switch (action.type) {
    case ADD_TO_CART:
      if (state.indexOf(action.productId) !== -1) {
        return state;
      }
      return [...state, action.productId];
    default:
      return state;
  }
};

const quantityById = (state = initialState.quantityById, action) => {
  switch (action.type) {
    case ADD_TO_CART:
      const { productId } = action;
      return {
        ...state,
        [productId]: (state[productId] || 0) + 1
      };
    default:
      return state;
  }
};

export const getQuantity = (state, productId) =>
  state.quantityById[productId] || 0;

export const getAddedIds = state => state.addedIds;

const cart = (state = initialState, action) => {
  switch (action.type) {
    default:
      return {
        addedIds: addedIds(state.addedIds, action),
        quantityById: quantityById(state.quantityById, action)
      };
  }
};

export default cart;

同样的,购物车模块的state也以normalized化的形式进行组织。分成了id和实体集两部分 id对应了实体集合。

我们需要把它和产品模块的state整合一下。修改我们reducers/index.js文件。顺便提供一些获取state内容的方法。

import { combineReducers } from "redux";
import cart, * as fromCart from "./cart";
import products, * as fromProducts from "./products";

export default combineReducers({
  cart,
  products,
});

const getAddedIds = state => fromCart.getAddedIds(state.cart);
const getQuantity = (state, id) => fromCart.getQuantity(state.cart, id);
const getProduct = (state, id) => fromProducts.getProduct(state.products, id);

export const getTotal = state =>
  getAddedIds(state)
    .reduce(
      (total, id) =>
        total + getProduct(state, id).price * getQuantity(state, id),
      0
    )
    .toFixed(2);

export const getCartProducts = state =>
  getAddedIds(state).map(id => ({
    ...getProduct(state, id),
    quantity: getQuantity(state, id)
  }));

接下来我们开始编写购物车模块的视图组件。
在components文件夹下新建Cart.js文件,得益于购物车模块内有一部分组件和产品模块内相似,因此我们可以复用一下,这也是尽量把组件做小的原因之一,可以便于以后复用。

import React from "react";
import PropTypes from "prop-types";
import Product from "./Product";

const Cart = ({ products, total, onCheckoutClicked }) => {
  const hasProducts = products.length > 0;
  const nodes = hasProducts ? (
    products.map(product => (
      <Product
        title={product.title}
        price={product.price}
        quantity={product.quantity}
        key={product.id}
      />
    ))
  ) : (
    <em>Please add some products to cart.</em>
  );
  return (
    <div>
      <h3>Your Cart</h3>
      <div>{nodes}</div>
      <p>Total:&#36;{total}</p>
      <button
        onClick={onCheckoutClicked}
        disabled={hasProducts ? "" : "disabled"}
      >
        Checkout
      </button>
    </div>
  );
};

Cart.PropTypes = {
  products: PropTypes.array,
  total: PropTypes.string,
  onCheckoutClicked: PropTypes.func
};

export default Cart;

编写完视图组件,就可以开始编写容器组件了。在Containers文件夹下新建CartContainer.js文件

import React from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { checkout } from "../actions";
import { getTotal, getCartProducts } from "../reducers";
import Cart from "../components/Cart";

const CartContainer = ({ products, total, checkout }) => (
  <Cart products={products} total={total} onCheckoutClicked={checkout} />
);

CartContainer.PropTypes = {
  products: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number.isRequired,
      title: PropTypes.string.isRequired,
      price: PropTypes.number.isRequired,
      quantity: PropTypes.number.isRequired
    })
  ).isRequired,
  total: PropTypes.string,
  checkout: PropTypes.func.isRequired
};

const mapStateToProps = state => ({
  products: getCartProducts(state),
  total: getTotal(state)
});

export default connect(
  mapStateToProps,
  { checkout }
)(CartContainer);

最后在整个大的App容器内加上我们的购物车容器。修改Containers/App.js

import React from "react";
import ProductsContainer from "./ProductsContainer";
import CartContainer from "./CartContainer";

const App = () => (
  <div>
    <h2>Shopping Cart Example</h2>
    <hr />
    <ProductsContainer />
    <hr />
    <CartContainer />
  </div>
);

export default App;

我们的购物车模块也就完成了。
效果如下:


购物车模块效果

结果页

虽然实现了购物车模块的展示,但是还没有实现点击checkout按钮的功能。不过不着急,我们先来开发结果页好了,最后再把它们联系起来。
思考一下结果页会有哪些更新state的动作呢?

  • 会有一个支付按钮,点击支付会把我们的购物车状态置为已支付,如果支付成功则还会清空我们的购物车内容。
    -还会有一个返回按钮,返回时会重置我们的购物车支付状态。

依据这些动作,我们来定义下动作类型。

//支付
export const CHECKOUT_SUCCESS = "CHECKOUT_SUCCESS";
export const CHECKOUT_FAILURE = "CHECKOUT_FAILURE";
//清空支付状态
export const CLEAR_PAYMENT_STATUS = "CLEAR_PAYMENT_STATUS";

点击付款按钮,我们要模拟一个发起请求到接收响应的过程,我们定义一个延时函数来模拟一下,修改下api/shop.js文件。在返回对象里添加一个buyProducts的函数。

import _products from "./product.json";

const TIME_OUT = 2000;

/**
 * cb 是个函数参数,延迟多少ms后获取产品信息
 */
export default {
  getProduct: (cb, timeout) => setTimeout(cb(_products), timeout || TIME_OUT),
  buyProducts: (payload, cb, timeout) =>
    setTimeout(() => cb(), timeout || TIME_OUT)
};

现在开始编写我们的动作。修改actions/index.js文件添加几个函数。

export const payForSomething = products => (dispatch, getState) => {
  const { cart } = getState();
  shop.buyProducts(products, () => {
    const payResult = Math.random() > 0.495;

    if (payResult) {
      dispatch({
        type: types.CHECKOUT_SUCCESS,
        cart
      });
    } else {
      dispatch({
        type: types.CHECKOUT_FAILURE,
        cart
      });
    }
  });
};

//回到主页清空付款状态
export const backToHomePage = () => (dispatch, getState) => {
  const { cart } = getState();
  dispatch({
    type: types.CLEAR_PAYMENT_STATUS,
    cart
  });
};

定义了一个随机事件来模拟随机支付成功和支付失败。
接下来定义处理state的reducer,修改reduces/cart.js,并且为cart添加一个支付状态。

export const getPaymentStatus = state => state.paid;

const cart = (state = initialState, action) => {
  switch (action.type) {
    case CHECKOUT_SUCCESS:
      return {
        ...initialState,
        paid:true
      };
    case CHECKOUT_FAILURE:
      return {
        ...action.cart,
        paid:true
      };
    case CLEAR_PAYMENT_STATUS:
      return{
        ...action.cart,
        paid:false
      }
    default:
      return {
        addedIds: addedIds(state.addedIds, action),
        quantityById: quantityById(state.quantityById, action)
      };
  }
};

再修改reducers/index.js文件添加一个获取支付状态的函数。

export const getPaymentStatus = state => fromCart.getPaymentStatus(state.cart);

现在开始编写结果页的视图组件。结果页有确认模块和支付结果模块两个部分组成。先来编写确认模块。在components文件夹下新建ConfirmPage.js文件。

import React from "react";
import PropTypes from "prop-types";

const ConfirmPage = ({ total, payClick }) => (
  <div>
    <h3>Please confirm</h3>
    <em>You will spend &#36;{total} for this.</em>
    <hr />
    <button onClick={payClick}>Pay for it !</button>
  </div>
);

ConfirmPage.propTypes = {
  total: PropTypes.string,
  payClick: PropTypes.func
};

export default ConfirmPage;

接下来编写支付结果模块。在components文件夹下新建ResultPage.js文件。

import React from "react";
import PropTypes from "prop-types";
import * as constants from "../constants/Constants";

const ResultPage = ({ paymentResult, backClick }) => (
  <div>
    <h3>Payment Result</h3>
    <p>
      {paymentResult === constants.PAID_SUCCESS
        ? "Congratulations!"
        : "Ops! Payment fail"}
    </p>
    <hr />
    <button onClick={backClick}>back</button>
  </div>
);

ResultPage.propTypes = {
  paymentResult: PropTypes.bool,
  backClick: PropTypes.func
};

export default ResultPage;

最后定义一个容器组件,来把结果页的所有部分整合起来。在Containers文件夹下新建ResultContainer.js文件。

import React from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import ConfirmPage from "../components/ConfirmPage";
import ResultPage from "../components/ResultPage";
import { getTotal, getPaymentStatus, getCartProducts } from "../reducers";
import { backToHomePage, payForSomething } from "../actions";
import * as constants from "../constants/Constants";

const ResultContainer = ({
  products,
  total,
  paid,
  payForSomething,
  backToHomePage
}) => {
  const hasProducts = products.length > 0;

  return (
    <div>
      {paid !== constants.PAID ? (
        <ConfirmPage total={total} payClick={payForSomething} />
      ) : null}
      {paid === constants.PAID ? (
        <ResultPage paymentResult={!hasProducts} backClick={backToHomePage} />
      ) : null}
    </div>
  );
};

ResultContainer.propTypes = {
  products: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number.isRequired,
      title: PropTypes.string.isRequired,
      price: PropTypes.number.isRequired,
      quantity: PropTypes.number.isRequired
    })
  ),
  total: PropTypes.string,
  paid: PropTypes.bool,
  payForSomething: PropTypes.func,
  backToHomePage: PropTypes.func
};

const mapStateToProps = state => ({
  products: getCartProducts(state),
  total: getTotal(state),
  paid: getPaymentStatus(state)
});

export default connect(
  mapStateToProps,
  { backToHomePage, payForSomething }
)(ResultContainer);

至此,我们的结果页面也开发完成了,接下来我们就要开始制作路由,让页面联系起来。

路由跳转

在进行路由开发前,我们需要对React-Router有一点点了解。请参阅React-Router的官方文档以了解一些背景知识。

点击主页面上的checkout按钮可以跳转到我们的结果页。
点击结果页上的back按钮可以返回主页面。

首先需要引入路由组件 我们这儿使用browserHistory,使得路由更像一个App应用程序的路由。修改根目录下的index.js文件。

import React from "react";
import ReactDOM from "react-dom";
import thunk from "redux-thunk";
import { createStore, applyMiddleware } from "redux";
import reducer from "./reducers";
import { getAllProducts } from "./actions";
import Root from "./containers/Root";
import createBrowserHistory from "history/createBrowserHistory";
import { syncHistoryWithStore, routerMiddleware } from "react-router-redux";

import "./styles.css";

let browserHistory = createBrowserHistory();

const browserHistoryMiddleware = routerMiddleware(browserHistory);

const middleware = [thunk, browserHistoryMiddleware];

const store = createStore(reducer, applyMiddleware(...middleware));

const history = syncHistoryWithStore(browserHistory, store);

store.dispatch(getAllProducts());

console.log(store.getState());

const rootElement = document.getElementById("root");

ReactDOM.render(<Root store={store} history={history} />, rootElement);

在这里我们新建了一个全新的容器Root将原来的App容器再包裹了一层。在containers文件夹下新建Root.js文件。

import React from "react";
import PropTypes from "prop-types";
import { Provider } from "react-redux";
import { Router, Route } from "react-router";
import App from "./App";
import ResultContainer from "./ResultContainer";

const Root = ({ store, history }) => (
  <Provider store={store}>
    <Router history={history}>
      <Route exact path="/" component={App} />
      <Route path="/result" component={ResultContainer} />
    </Router>
  </Provider>
);

Root.propTypes = {
  store: PropTypes.object.isRequired,
  history: PropTypes.object.isRequired
};

export default Root;

这里使用Route的exact属性,使得对根路由的匹配更精准。

引入browserHistory后我们还要在state中加入routerReducer来处理路由状态变更。
修改reducers/index.js文件。添加以下内容。

import { routerReducer } from "react-router-redux";

export default combineReducers({
  cart,
  products,
  routing: routerReducer
});

现在已经引入了路由组件了,离实现路由跳转只剩一步之遥了。
我们修改actions/index.js为某些动作添加路由跳转动作事件。修改如下的两个函数。

//跳转到结果页。
export const checkout = () => dispatch => {
  dispatch(push("/result"));
};

//回到主页清空付款状态
export const backToHomePage = () => (dispatch, getState) => {
  const { cart } = getState();
  dispatch({
    type: types.CLEAR_PAYMENT_STATUS,
    cart
  });
  dispatch(push("/"));
};

好了,至此我们就实现了整个应用了。

写在最后

这个小小的示例项目非常简单,但是包含了日常前端开发会涉及的数个方面。本文旨在帮助刚刚接触React和Redux的新手迅速上手React和Redux进行开发实践,故只分享了一些开发过程中的常用方法,没有涉及到一些高级用法。如果你想了解更多,请参阅官方的API使用说明,里面有详尽的使用示例和原理讲解。

稍后我可能会将源码上传到GitHub,届时会将链接贴在下方,请耐心等待。如果你有任何疑问和建议,欢迎评论和私信我。Happy Coding! :)

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

推荐阅读更多精彩内容