美文网首页
从零开始搭建第一个React+Redux应用

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

作者: Mscorolla | 来源:发表于2019-04-28 19:35 被阅读0次
    • 文中的蓝色字体是相关内容的超链接,网址不另外列出,请放心点击。
    • 本文内容适合 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! :)

    相关文章

      网友评论

          本文标题:从零开始搭建第一个React+Redux应用

          本文链接:https://www.haomeiwen.com/subject/hwsqnqtx.html