React + Redux 应用的开发流程,以购物车为例


项目的 repository:react-redux-shopping-cart-demo

Redux 是最热门的 React 架构。介绍 Redux 的文章不少,官方文档的内容也非常丰富。
本文根据个人对 Redux 的理解,提供一种和官方文档不一样的视角,阐述如何从零开始开发一个 React + Redux 应用。

阅读之前,假设你已经了解 Redux 的三个核心概念:Action,Reducer,Store,并且知道三者在 Redux 的工作流程所承担的角色和功能。
文中使用的实例 “shopping-cart”,参考自官方的示例shopping-cart,虽然实现同样的效果,但是代码有较大的差异。

零、 react-redux 目录结构

使用 create-react-app 创建项目,并安装相关依赖:

1
2
3
$ create-react-app react-redux-shopping-cart-demo
$ cd react-redux-shopping-cart-demo
$ npm i -S redux react-redux

src/ 下创建项目的目录结构:

|- /src
  |- /actions
  |- /constants
    |- action_types.js
  |- /components
  |- /containers
    |- app.js
  |- /reducers
    |- index.js
  |- index.js

一、<Provider> 封装根组件 <App/>

使用 react-redux 提供 <Provider> ,它提供 store 属性,并封装应用的根组件 App,所有子组件都可以访问 state。 src/index.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import React from 'react';
import { render } from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux'
import App from './containers/App';
import reducer from './reducers';

const store = createStore(reducer);

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

store 由 Redux 提供,它需要 Reducer 作为参数。因此,我们还需要创建 App 和 Reducer。
先创建 Reducer,因为项目刚刚创建,我们可以创建一个没有内容的 Reducer。
reducers/index.js

1
2
3
import { combineReducers } from 'redux'

export default combineReducers({});

其次是 App,可以把它看作是应用的首页,同样可以先建一个没有实际内容的页面。

1
2
3
4
5
6
7
8
9
import React from 'react'

const App = () => (
  <div>
    <h2>hello world</h2>
  </div>
)

export default App

这样,一个 React + Redux 应用就可以通过 npm start 运行起来了。你也可以把它看作是一个 boilerplate。

二、设计 state 的结构

上一个步骤完成了项目的初始化,在开始真正的开发之前,最好先思考一下整个应用的 state 结构(state shape)。
state 的设计可以化整为零,比如以 Container(容器组件)为单位划分,该部分 state 的初始值定义在和 Container 对应的 Reducer 中,该 Reducer 就处理这个部分的 state。
以 shopping cart 应用为例,页面展示上需要两个独立的容器组件:

因此,state 的结构可以设计如下:

1
2
3
4
state = {
  products,
  cart
}

至于 products 和 cart 的数据类型应该是怎样,可以在各自的 Reducer 里在具体定义。

三、展示组件开发

步骤一只是搭建了 react/redux 框架,里面并没有我们的数据和 UI,如何把这些呈现出来?
在 React/Redux,数据由 Action 提供,它可以是从 api 获取的,也可以是本地的数据。
而 UI 部分,React-Redux 将所有组件分成两大类:展示组件(presentational component)和容器组件(container component),分别保存在 components/containers/ 两个目录。

展示组件负责视觉呈现,和 redux 几乎没有联系。如果是复杂的应用,还可以把展示组件分别保存在 components/pages/ 两个目录,pages/ 对应页面,由 component 按需组合起来。
容器组件负责管理数据和业务逻辑,这里的数据并不是直接从 Action 获取,而是从 state 获取,而 Action 和 state 需要通过 Reducer 才能互动。

因为展示组件和 redux 关系不大,可以先行开发,

1
2
3
4
5
|- /components
  |- product.js
  |- product_item.js
  |- products_list.js
  |- cart.js

展示组件的开发这里不做介绍,可以直接看源码 [components](https://github.com/chasecs/react-redux-shopping-cart-demo/tree/master/src/components)

四、容器组件开发

展示组件完成后,要使它和数据产生联系,需要通过容器组件。
因为容器组件相对复杂一些,所以不采用 const FuntionName 的方式来创建,使用 class XX extends Component 创建。
创建容器组件 products_list_container.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import ProductsList from '../components/products_list';
import ProductItem from '../components/product_item';

class ProductsListContainer extends Component {

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

  render(){
    return (
      <ProductsList title="Product List">
        {products.map( product =>
          <ProductItem
            key={product.id}
            product={product}
            onAddToCartClicked={() => {}}
          />
        )}
      </ProductsList>
    )
  }
}

如果仅仅是上述的代码,ProductsListContainer 仍然只是一个展示组件,把它变为容器组件,你还需要 react-redux 提供的 connect() 方法,所以,在文件最后加上:

1
2
3
....
ProductsListContainer = connect()(ProductsListContainer);
export default ProductsListContainer;

下一步是往 ProductsListContainer 里面传入 products 数据和 onAddToCartClicked 方法。
这里需要的数据是:库存商品数据,因此,可以在 constants/action_types.js 里定义一个 type:

1
export const SHOW_INVENTORY = 'SHOW_INVENTORY'

传入数据可以调用 mapStateToProps 方法,把数据传给组件。 修改 products_list_container.js, 最终代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import React from 'react'
import PropTypes from 'prop-types'
import ProductsList from '../components/products_list';
import Product from '../components/product';
import { connect } from 'react-redux'

const ProductsContainer =({products}) => (
  <ProductsList title="hehe">
    {products.map( product =>
      <Product
        key={product.id}
        title={product.title}
        price={product.price}
        inventory={product.inventory}
      />
    )}
  </ProductsList>
)

const mapStateToProps = () => ({
  /* 不规范的代码 BEGIN*/
  products: [
    {"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}
  ]
  /* 不规范的代码是 END*/
})

export default connect(mapStateToProps)(ProductsContainer)

然后在 containers/app.js 修改 <App> 组件,加入 ProductsListContainer

1
2
3
4
5
6
7
import ProductsListContainer from './products_list_container'

const App = () => (
  <div>
    <ProductsListContainer/>
  </div>
)

再运行一下,就可以在浏览器页面看到商品的列表了。

五、Action 和 Reducer 处理数据

正如 ProductsContainer.js 里标注的,mapStateToProps 里的代码是错误的,数据不能直接赋值。正确的做法是,把 Redux 的 store 里保存的 state 输出为组件的 props。
不过,store 里的 state 又从哪里获取?
按照 Redux 的工作流程,它应该从 Action 和 Reducer 产生。Action 提供原始数据,经过 Reducer 的处理,更新到 state。
先在 actions/products_action.js 实现这个 Action:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import * as types from '../constants/action_types';

export const showInventory = () => {
  let products = {
    1: {"id": 1, "title": "iPad 4 Mini", "price": 500.01, "inventory": 2},
    2: {"id": 2, "title": "H&M T-Shirt White", "price": 10.99, "inventory": 10},
    3: {"id": 3, "title": "Charli XCX - Sucker CD", "price": 19.99, "inventory": 5}
  }
  return {
    type: types.SHOW_INVENTORY,
    products
  }
}

数据要经过 Reducer 才能和 state 产生关联,新建一个 reducers/products_reducer.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import * as types from '../constants/action_types';

const initialState = {}

const productsReducer = (state = initialState, action) => {
  switch (action.type) {
    case types.SHOW_INVENTORY:
      return Object.assign({}, action.products)
    default:
      return state
  }
}

export default productsReducer

productsReducer 添加到 reducers/index.js

1
2
3
4
5
import products from './products_reducer';

export default combineReducers({
  products: productsReducer
});

现在 Action 已经有了,但是还需要调用 dispatch(action) 这个方法,使用 mapDispatchToProps 方法。如果向 mapDispatchToProps 返回一个对象,它的 key 对应的 value 必须是一个返回 Action 对象的函数,调用该函数时,返回的 Action 会由 Redux 自动 dispatch

1
2
3
4
5
6
7
8
9
import { showInventory, addToCart } from '../actions/products_action';
...
const mapDispatchToProps = {
  showInventory,
}
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(ProductsListContainer)

showInventory 方法就可以在 ProductsListContainer 通过 this.props 来访问

1
2
3
4
componentDidMount() {
  const { showInventory } = this.props;
  showInventory()
}

这一步也类似于现实应用中向api请求数据。
showInventory() 调用成功后,store 中的 state 就已经更新了。容器组件就可以通过 mapStateToProps 获取 state 并映射到 props。 因为 state.products 的格式不是和展示组件要求的格式不太一样,所以我们要用 parseProducts 转换一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const parseProducts = (products) => {
  let mappedProducts = []
  for(let key in products){
    mappedProducts.push(products[key])
  }
  return mappedProducts
}
const mapStateToProps = (state) => (
  {products: parseProducts(state.products)}
)

同样,最终的 products 可以在 ProductsListContainer 通过 this.props 来访问,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  render(){
    const {products} = this.props;
    return (
      <ProductsList title="Product List">
        {products.map( product =>
          <ProductItem
            key={product.id}
            product={product}
            onAddToCartClicked={() => {}}
          />
        )}
      </ProductsList>
    )
  }

再运行一下,就可以在浏览器页面看到商品的列表了。至此,这个应用已经是一个完整地运用 redux 的 Action,Reducer,Store。

六、实现交互操作

迄今为止,我们的应用只是展示了商品,作为一个购物车应用,还需要增加交互操作。下来将实现 addToCart 按钮的功能。
在 Redux 的工作流程里,一个引发 state 变化的 function,必须是一次 dispatch(Action)。
先不考虑购物车 CartContainer 的情况,对于 ProductsContainer 这个容器组件,每点击一下,库存数量就应该减少一个。

实现 ADD_TO_CART 的 Action: constants/action_types.js

1
export const ADD_TO_CART = 'ADD_TO_CART'

actions/products_action.js

1
2
3
4
5
6
export const addToCart = productId => {
  return ({
    type: types.ADD_TO_CART,
    productId
  })
}

在 Reducer 实现对应的方法,修改 reducers/products_reducer.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const decreaseInventory = (product) => {
  return {
    ...product,
    inventory: product.inventory - 1
  }
}

const productsReducer = (state = initialState, action) => {
  switch (action.type) {
    case types.SHOW_INVENTORY:
      return Object.assign({}, action.products)
    case types.ADD_TO_CART:
      const { productId } = action
      return {
        ...state,
        [productId]: decreaseInventory(state[productId])
      }
    default:
      return state
  }
}

修改 products_list_container.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
...
render(){
  const {products, addToCart} = this.props;
  return (
    <ProductsList title="Product List">
      {products.map( product =>
        <ProductItem
          key={product.id}
          product={product}
          onAddToCartClicked={() => addToCart(product.id)}
        />
      )}
    </ProductsList>
  )
}

...

const mapDispatchToProps = {
  showInventory,
  addToCart
}

这样,ProductsContaineraddToCart 功能就已经实现了,每点击一次,对应商品的数量就会减少一个。

一个 Action 引发多个 Reducer

在商品列表中,点击 “Add to Cart” 按钮之后,发生的变化不仅是库存减少,还有购物车物品增多、购物金额变化。
因为库存和购物车被划分在不同的 Reducer 里,所以就会出现一个 Action 触发两个 Reducer 的情况。
实现 reducers/cart_reducer.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import * as types from '../constants/action_types';

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

const addProduct = (state = initialState.addedIds, productId) => {
  if(state.indexOf(productId) === -1){
    return [...state, productId]
  }
  return state
}

const addQuantity = (state = initialState.quantityById, productId) => {
  return {
    ...state,
    [productId]: (state[productId] || 0) + 1
  }
}


const cartReducer = (state = initialState, action) => {
  switch (action.type) {
    case types.ADD_TO_CART:
      return Object.assign({}, state, {
        addedIds: addProduct(state.addedIds, action.productId),
        quantityById: addQuantity(state.quantityById, action.productId)
      })
    default:
      return state
  }
}

export default cart

cartReducer 加入 reducers/index.js

1
2
3
4
5
6
7
....
+import cartReducer from './cart_reducer'

export default combineReducers({
  ...
+  cart: cartReducer
});

实现 CartContainer,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import React, { Component} from 'react'
import PropTypes from 'prop-types'
import Cart from '../components/cart'
import { connect } from 'react-redux'

class CartContainer extends Component {

  render(){
    const { products, total } = this.props;

    return(
      <Cart
        products={products}
        total={total}
        onCheckoutClicked={() => alert('checkout')}
      />
    )
  }
}

const productsInCart = (state) => {
  const { products, cart } = state;
  let result = [];
  let total = 0;
  for(let id in cart.quantityById){
    result.push({
      ...products[id],
      quantity:cart.quantityById[id]
    });
    total += products[id].price * cart.quantityById[id]
  }

  return {
    products: result,
    total: total.toFixed(2)
  }
}

const mapStateToProps = (state) => productsInCart(state)

export default connect(
  mapStateToProps,
)(CartContainer)

其中 productsInCart 方法用于解析 state 中的购物车数据,输出我们需要的格式,从而在组件中渲染出来。 至此,点击 “add to cart” 按钮之后,会同时发生库存减少、购物车商品数量、金额变化,最基本的购物车 demo 就已经完成。

当然,目前这个购物车是十分简陋的,如果想继续利用它来熟悉 Redux 或者 React,还有许多可以开发的方向,比如:减少购物车商品数量,购物车商品可选、全选,加入 React-Router,页面样式…

(完)

参考资料