一:React基础准备
1.1React是一个专注于View层的组件库,React提供设计的时候目标是尽可能的是接近原生,没有设计出很多高级的功能,这也为后来开发者自定义功能模块提供了很大的空间
1.2React需要使用React技术栈,常见的React技术栈有react-router(基于history库实现路由功能),redux(实现数据的单向流动和管理),create-react-app脚手架(包括jsx语法的的babel配置,编译的输出路径等等)
1.3React使用jsx语法,这是React提供的一种语法糖,在jsx语法中,我们用{}在组件中嵌入变量,使用驼峰式命名来表示组件的属性,同时为了避免与原生js命名冲突,在React中,class改为className,JSX实际上就是React.createElement(component,props,...children)的语法糖,React.createElement也是React从visual DOM生成实际的DOM元素的函数
1.4React组件分为component组件和containers组件,分别是展示型组件和容器型组件,展示型组件也就是UI组件,它只负责接收props然后渲染出对应的UI组件,不与redux的store连接,而容器型组件则使用mapStateToProps和 mapDispatchToProps,再加上conect高阶函数去包装一个UI组件,形成一个容器型组件,从redux中获取到容器型组件需要的数据和action函数(一般是异步数据获取函数)。在容器型组件里,也分为展示型组件,和一个index.js,index.js负责和redux对接,拿到数据,然后通过props注入到展示型组件中,也就是说,index.js是我们这个子组件的根组件。
1.5React使用redux进行数据的管理,redux中的数据分为领域实体数据,也就是我们项目中从后台获取的,商品信息数据:
[
{
"id": "p-1",
"shopIds": ["s-1","s-1","s-1"],
"shop": "院落创意菜",
"tag": "免预约",
"picture": "https://p0.meituan.net/deal/e6864ed9ce87966af11d922d5ef7350532676.jpg.webp@180w_180h_1e_1c_1l_80q|watermark=0",
"product": "「3店通用」百香果(冷饮)1扎",
"currentPrice": 19.9,
"oldPrice": 48,
"saleDesc": "已售6034"
},
{
"id": "p-2",
"shopIds": ["s-2"],
"shop": "正一味",
"tag": "免预约",
"picture": "https://p0.meituan.net/deal/4d32b2d9704fda15aeb5b4dc1d4852e2328759.jpg%40180w_180h_1e_1c_1l_80q%7Cwatermark%3D0",
"product": "[5店通用] 肥牛石锅拌饭+鸡蛋羹1份",
"currentPrice": 29,
"oldPrice": 41,
"saleDesc": "已售15500"
}]
以及WebApp所需的一些其他数据,比如当前的网络状态,isFetching,当前是否是错误信息等等
二 项目开发
2.1脚手架初始化,使用create-react-app 初始化一个空项目,编译的时候,使用 npm run build,create-react-app会指定index.js为默认的项目入口,所以第一步我们编写index.js,使用ReactDOM.render方法将使用Provider,BrowserRoute等高阶组件包裹的APP根组件挂载到document.getElementById('root')上。其中Provider是react-redux包提供的为React注入store的高阶组件,其实现原理是基于React的Context,BrowserRoute是react-router提供的路由高阶组件,实现基于history和React的Context,Context是React为了避免逐层传递props可使用Provider Customer两个高阶组件,由父组件直接为所有子组件注入属性。
2.2编写APP根组件,在APP根组件里面,我们使用BrowserRoute为页面提供路由跳转,路由具有path属性,指向页面的子路由,如‘/’是根路由,'/login'是子路由,Route是全部匹配的,因此根路由要放到路由的最下面,或者使用exact属性,表明唯一匹配
component属性指向要渲染的页面组件 component={Login} component={ProductDetail}
2.3编写通用展示型组件,比如底部的导航栏,通用错误弹框等,以通用错误弹框为例子,因为是展示型组件,我们需要为这个组件提供props,在组件里,我们用const {message} = this.props,将需要展示的错误信息提供给组件,再做一个背景半透明,信息框居中的错误弹框:
import React,{Component} from 'react'
import './style'
export default class ErrorToast extends Component{
render() {
const {message} = this.props
return(
{message}
)
}
}
//style如下
.errorToast {
top:0px;
left: 0px;
width:100%;
height:100%;
position: fixed;
background:rgba(0,0,0,0.5);
z-index: 10000001;
display: flex;
justify-content: center;
align-items: center;
}
.errorToast__text{
max-width: 300px;
max-height: 300px;
padding: 15px;
color:#fff;
background-color: #000;
font-size:14px;
border:1px solid black;
border-radius: 10px;
}
2.4编写各个页面,以主页面Home组件为例,在Home组件中,我们首先根据设计稿,将页面划分为如下组件结构:
return (
);
组件划分完毕后,我们就需要思考我们的页面需要哪些数据,数据分为两种,一种是直接从redux提供的store中获取,在mapStateToProps中,我们可以直接使用select函数,从store中获取我们想要的数据。当我们构建完页面的基本结构后,我们就要开始思考数据的组织形式,首先我们看到,后台返回的数据是一个对象数组,这里用的是mock数据,实际上返回的是JSON对象,需要一个JSON.parse()的过程,拿到数据后,我们就需要思考怎么获取数据最方便。我们看到每个商品都有唯一的id,那么我们如果用一个对象保存所有的数据,key是id,value就是这个商品,我们在查询的时候,就可以用id去从这个对象里直接获得,那么我们把获得领域信息做一个序列化和包装
const normalizeData = (data, schema) => {
const {id, name} = schema
let kvObj = {}
let ids = []
if(Array.isArray(data)) {
data.forEach(item => {
kvObj[item[id]] = item
ids.push(item[id])
})
} else {
kvObj[data[id]] = data
ids.push(data[id])
}
return {
[name]: kvObj,
ids
}
}
我们这是在redux中间件里处理这个带有[FETCH_DATA]属性的action,然后中间件处理的action会被重新包装成一个新的action,通过next继续后面的中间件处理,所有中间件都处理完后,就调用redux的dispatch函数来改变state,在reducer函数中,我们通过检测action的type属性来判断此时的action是哪种。reducer是由许多子reducer合并而成的。我们在领域数据里,写一个reducer,对于action有response字段,我们就判断:
const reducer = (state = {}, action) => {
if(action.response && action.response.products) {
return {...state, ...action.response.products}
}
return state;
}
在我们的页面里,我们不需要重复保存两次数据,我们在页面里就保存ids列表,需要的时候,我们直接:
const getLikes = state =>{
return state.home.likes.ids.map(id => {
return state.entites.products[id]
})
}
我们在组件挂载的时候,componentDidMount中调用module对应的home.js提供的loadLikes函数,这就是所谓的action异步函数,在mapDispatchToProps中,我们会使用bindActionCreator这个方法,更方便的拿到这个action异步函数,然后使用,在页面挂载的时候,我们要加载数据,调用这个函数,保证我们渲染组件的时候,一定有数据。
我们为不同的组件用props注入数据,在组件内,用const { } = this.props语法来解析,用{}注入jsx中
2.6 如何处理子页面的路由
我们使用Link高阶组件的to属性实现子页面中的路由跳转,使用了字符串模块拼接。
关于key属性:这是React要求的,但我们渲染一组子组件,比如我们用map函数把一组items渲染成一组子组件的时候,我们要为每个子组件提供一个key属性,这个key属性最好不是map中传入的回调函数的第二个参数index,而是和这个要被渲染的item相关的且独有的,这里正好就是item.id,这是为了高效的使用diff算法处理visual DOM。
render() {
const { data } = this.props;
return (
超值特惠
更多优惠
{data.map((item, index) => {
return (
{item.shop}
{item.currentPrice}
{item.oldPrice}
);
})}
);
}
2.7一些比较重要的组件,购买组件,搜索组件,订单组件,包括了打分和评价功能
购买组件Purchase:
首先我们需要一个购买的功能,我们拿到设计图后,首先切分成几个子组件,然后我们还需要一个购买的确定按钮,这个Tip组件是通用的,所以我们把它放到根目录下的component文件夹下,使用条件渲染来决定是否渲染这个组件。确定好页面的结构后,我们就要思考我们需要用到哪些数据:首先是product的价格信息,因为我们需要知道多少钱,然后是购买的数量,然后是下单用户的电话,然后是总价格,然后是是否要显示下单。确定好数据后,我们编写mapStateToProps从state中获取这些数据。这里比较特殊的是,productId,我们是从上一个页面跳转过来的,所以我们可以在props.match.params.id中拿到我们是从哪个商品的详情页过来的,我们用这个id调用领域实体提供的getProduct函数,拿到对应的product。
然后我们还需要一些action函数,比如购买这个动作。
首先我们要明白一点。redux中数据是单向流动的,我们在子组件改变当前组件的某个数据,比如这里的购买的数量,数据是子组件调用父组件传来的action函数,携带数据发出dispatch函数,state改变,引发React重新渲染,子组件再更新。比如以这个增加quantity为例,点击+,在这个handleIncrease中
handleIncrease = () => {
const {quantity} = this.props;
this.props.onSetQuantity(quantity +1);
}
通过调用传来的action函数,我们提供了一个quantity+1的数据,dispatch了一个action,这里就是bindActionCreator帮我们做的,我们不需要写dispatch(actionfunc1(params))这种形式,其实也就是方便。,这样我们就改变了quantity,在子组件里显示的quantity也会跟着变化,在提交的时候,我们dispatch一个action,这个action包含了所需的order信息,形成了一个订单。这个订单也是需要我们提前设计数据结构的
import React, { Component } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import Header from "../../components/Header";
import PurchaseForm from "./components/PurchaseForm";
import Tip from "../../components/Tip";
import {
actions as purchaseActions,
getProduct,
getQuantity,
getTipStatus,
getTotalPrice
} from "../../redux/modules/purchase";
import { getUsername } from "../../redux/modules/login";
import { actions as detailActions } from "../../redux/modules/detail";
class Purchase extends Component {
render() {
const { product, phone, quantity, showTip, totalPrice } = this.props;
return (
{product ? (
) : null}
{showTip ? (
) : null}
);
}
componentDidMount() {
const { product } = this.props;
if (!product) {
const productId = this.props.match.params.id;
this.props.detailActions.loadProductDetail(productId);
}
}
componentWillUnmount() {
this.props.purchaseActions.setOrderQuantity(1);
}
handleBack = () => {
this.props.history.goBack();
};
handleCloseTip = () => {
this.props.purchaseActions.closeTip();
};
// 提交订单
handleSubmit = () => {
const productId = this.props.match.params.id;
this.props.purchaseActions.submitOrder(productId);
};
//设置购买数量
handleSetQuantity = quantity => {
this.props.purchaseActions.setOrderQuantity(quantity);
};
}
const mapStateToProps = (state, props) => {
const productId = props.match.params.id;
return {
product: getProduct(state, productId),
quantity: getQuantity(state),
showTip: getTipStatus(state),
phone: getUsername(state),
totalPrice: getTotalPrice(state, productId)
};
};
const mapDispatchToProps = dispatch => {
return {
purchaseActions: bindActionCreators(purchaseActions, dispatch),
detailActions: bindActionCreators(detailActions, dispatch)
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Purchase);
搜索组件Search,首先我们还是要加载热门的商品和店铺,然后保存在领域实体里,页面保存ids。
搜索的时候,头部是搜索框,然后是热门搜索,自然是在Search组件挂载的时候,调用loadPopularkeywords,然后在里面渲染出来,9宫格,可以直接用grid布局,指定popularSearch这个容器div的display为grid,然后设置grid-templete-colunm:33% 33% 33%,grid-templete-rows:33% 33% 33%这样就分成了9个div,在每个div里面,设置text-align为center line-height = height,就可以了。
再下面是搜索结果栏
确定了页面的结构,我们需要思考需要什么数据,搜索框是典型的受控组件,我们用redux控制,它的inputText我们从state中拿,然后搜索需要几个函数,分别处理inputChange Clear cancel clickitem这四种,我们用mapDispatchToPros中拿
PopularSearch需要popolarKeyword的数据和一个点击跳转到对应item的handleClick,SearchHistory需要搜索过的项目的historyKeywords,还有一个清除历史记录的handleClear和一个点击item跳转的handleClickitem
确定好以后,我们就可以开始编写对应于这个页面的action,actioncreator action函数,reducer,加载数据的函数了
首先,加载数据仍然是编写一个actionCreator函数,添加[FETCH_DATA]属性。监听input输入框的变化,写一个actionCreator函数,setInputText:text => ({
type:types:SET_INPUT_TEXT,
text
})
在对应的reducer中
const inputText = (state = inisitalState,inputText,action) => {
switch(action.type) {
case types.SET_INPUT_TEXT:
return action.text
case types.CLEAR_INPUT_TEXT:
return ""
default:
return state
}
}
这里顺便说一下合并reducer, export default reducer,
const reducer = combineReducers({
popularKeywords,
relatedKeywords,
inputText,
historyKeywords,
searchShopsByKeyword
})
我们在state里面可以用不同的名字去取得不同页面下的属性,是因为我们在modules这个管理redux的文件夹下有一个根reducer index.js
import { combineReducers } from "redux";
import entities from "./entities";
import home from "./home";
import detail from "./detail";
import app from "./app";
import search from "./search";
import login from './login'
import user from './user'
import purchase from './purchase'
//合并成根reducer,这里,state.entities.products胡entities就来自这里
const rootReducer = combineReducers({
entities,
home,
detail,
app,
search,
login,
user,
purchase,
})
export default rootReduce
所以一般先设计好页面结构,再慢慢思考数据怎么设计,怎么命名,取数据的函数要怎么封装
在store.js中,我们使用rootRedcuer构建一个store,同时传入我们需要的中间件一个是处理异步action函数的中间件thunk,一个是我们自定义的网络请求数据的api
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import api from "./middleware/api";
import rootReducer from "./modules";
let store;
if (
process.env.NODE_ENV !== "production" &&
window.__REDUX_DEVTOOLS_EXTENSION__
) {
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk, api)));
} else {
//store = createStore(rootReducer,applyMiddleware(thunk,api))
store = createStore(rootReducer, applyMiddleware(thunk, api));
}
export default store;
这里再讲一下redux-thunk,这是一个处理异步action函数的中间件,可以检测这个action是不是函数,如果是函数,就执行它
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
最后讲一下个人中心里的订单模块
个人中心分为两个部分,一个是顶部的UserHeader,一个是UserMain
UserMain是一个tab导航的形式,通过在tab上绑定handleClick函数,更改当前组件state的index值
用map渲染一组标签tab的时候,当前的index值和state中的currentTab值是否相等来赋值不同的className
import React, { Component } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import {
actions as userActions,
getCurrentTab,
getDeletingOrderId,
getCurrentOrderComment,
getCurrentOrderStars,
getCommentingOrderId
} from "../../../../redux/modules/user";
import OrderItem from "../../components/OrderItem";
import Confirm from "../../../../components/Confirm";
import "./style.css";
const tabTitles = ["全部订单", "待付款", "可使用", "退款/售后"];
class UserMain extends Component {
render() {
const { currentTab, data, deletingOrderId } = this.props;
return (
{tabTitles.map((item, index) => {
return (
{item}
);
})}
{data && data.length > 0
? this.renderOrderList(data)
: this.renderEmpty()}
{deletingOrderId ? this.renderConfirmDialog() : null}
);
}
renderOrderList = data => {
const { commentingOrderId, orderComment, orderStars } = this.props;
return data.map(item => {
return (
);
});
};
renderEmpty = () => {
return (
您还没有相关订单
去逛逛看有哪些想买的
);
};
//删除对话框
renderConfirmDialog = () => {
const {
userActions: { hideDeleteDialog, removeOrder }
} = this.props;
return (
);
};
// 评价内容变化
handleCommentChange = comment => {
const {
userActions: { setComment }
} = this.props;
setComment(comment);
};
// 订单评级变化
handleStarsChange = stars => {
const {
userActions: { setStars }
} = this.props;
setStars(stars);
};
//选中当前要评价的订单
handleComment = orderId => {
const {
userActions: { showCommentArea }
} = this.props;
showCommentArea(orderId);
};
//提交评价
handleSubmitComment = () => {
const {
userActions: { submitComment }
} = this.props;
submitComment();
};
//取消评价
handleCancelComment = () => {
const {
userActions: { hideCommentArea }
} = this.props;
hideCommentArea();
};
handleRemove = orderId => {
this.props.userActions.showDeleteDialog(orderId);
};
handleClickTab = index => {
this.props.userActions.setCurrentTab(index);
};
}
const mapStateToProps = (state, props) => {
return {
currentTab: getCurrentTab(state),
deletingOrderId: getDeletingOrderId(state),
commentingOrderId: getCommentingOrderId(state),
orderComment: getCurrentOrderComment(state),
orderStars: getCurrentOrderStars(state)
};
};
const mapDispatchToProps = dispatch => {
return {
userActions: bindActionCreators(userActions, dispatch)
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(UserMain);
然后渲染主内容区域,分为不同的订单
这里引入一个React 的Reselect的概念,这个概念的提出是因为,在state发生变化的时候,订阅这个state的组件都要发生变化,
我们可以将一些需要state的几个子state一起计算出的值组合成一个Reselect函数,在这里就是order页面。
我们在订单渲染列表,需要根据上面所说的currentIndex来选择渲染不同的订单
export const getOrders = createSelector(
[getCurrentTab, getUserOrders, getAllOrders],
(tabIndex, userOrders, orders) => {
const key = ["ids", "toPayIds", "availableIds", "refundIds"][tabIndex];
const orderIds = userOrders[key];
return orderIds.map(id => {
return orders[id];
});
}
);
我们在用户订单就分类保存ids,reducer里面分类concat,展示的时候,只需要用currentIndex获取user对应的ids,再用map函数,把ids映射到order领域实体提供的get函数,返回order的数组,然后再OrderItem进行渲染即可
const orders = (state = initialState.orders, action) => {
switch (action.type) {
case types.FETCH_ORDERS_REQUEST:
return { ...state, isFetching: true };
case types.FETCH_ORDERS_SUCCESS:
const toPayIds = action.response.ids.filter(
id => action.response.orders[id].type === TO_PAY_TYPE
);
const availableIds = action.response.ids.filter(
id => action.response.orders[id].type === AVAILABLE_TYPE
);
const refundIds = action.response.ids.filter(
id => action.response.orders[id].type === REFUND_TYPE
);
return {
...state,
isFetching: false,
fetched: true,
ids: state.ids.concat(action.response.ids),
toPayIds: state.toPayIds.concat(toPayIds),
availableIds: state.availableIds.concat(availableIds),
refundIds: state.refundIds.concat(refundIds)
};
case orderTypes.DELETE_ORDER:
case types.DELETE_ORDER_SUCCESS:
return {
...state,
ids: removeOrderId(state, "ids", action.orderId),
toPayIds: removeOrderId(state, "toPayIds", action.orderId),
availableIds: removeOrderId(state, "availableIds", action.orderId),
refundIds: removeOrderId(state, "refundIds", action.orderId)
};
case orderTypes.ADD_ORDER:
const { order } = action;
const key = typeToKey[order.type];
return key
? {
...state,
ids: [order.id].concat(state.ids),
[key]: [order.id].concat(state[key])
}
: {
...state,
ids: [order.id].concat(state.ids)
};
default:
return state;
}
};