基于React搭建一个美团WebApp

一: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;
  }
};

 

你可能感兴趣的:(React)