- 文中的蓝色字体是相关内容的超链接,网址不另外列出,请放心点击。
- 本文内容适合 Redux 和 React 新手,也欢迎 Redux 和 React 专家指导点评。
摘要
阅读本文并实际上手编码运行,你将解决如下几个疑问:
- Redux是如何运作的?
- 如何规范化State?
- 如何创建一个简单的React+Redux应用(简单路由)
工具
预备知识
- 熟悉 ES6 相关知识
- 了解 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} - ${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:${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 ${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! :)
网友评论