基于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(
           <div className='errorToast'>
             <div className='errorToast_text'>{message}</div>
           </div>
        )
    }
}

//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 (
      <div>
        <HomeHeader />
        <Banner />
        <Category />
        <Headline />
        <Activity />
        <Discount data = {discounts}/>
        <LikeList data = {likes} pageCount = {pageCount} fetchData = {this.fetchMoreLikes}/>
        <Footer />
      </div>
    );

组件划分完毕后,我们就需要思考我们的页面需要哪些数据,数据分为两种,一种是直接从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属性实现子页面中的路由跳转<Link to = {`/detail/${item.id}`}>,使用了字符串模块拼接。

关于key属性:这是React要求的,但我们渲染一组子组件,比如我们用map函数把一组items渲染成一组子组件的时候,我们要为每个子组件提供一个key属性,这个key属性最好不是map中传入的回调函数的第二个参数index,而是和这个要被渲染的item相关的且独有的,这里正好就是item.id,这是为了高效的使用diff算法处理visual DOM。

render() {
    const { data } = this.props;
    return (
      <div className="discount">
        <a className="discount__header">
          <span className="discount__title">超值特惠</span>
          <span className="discount__more">更多优惠</span>
          <span className="discount__arrow" />
        </a>
        <div className="discount__content">
          {data.map((item, index) => {
            return (
              <Link
                key={item.id}
                to={`/detail/${item.id}`}
                className="discount__item"
              >
                <div className="discount__itemPic">
                  <img alt="" width="100%" height="100%" src={item.picture} />
                </div>
                <div className="discount__itemTitle">{item.shop}</div>
                <div className="discount__itemPriceWrapper">
                  <ins className="discount__itemCurrentPrice">
                    {item.currentPrice}
                  </ins>
                  <del className="discount__itemOldPrice">{item.oldPrice}</del>
                </div>
              </Link>
            );
          })}
        </div>
      </div>
    );
  }

2.7一些比较重要的组件,购买组件,搜索组件,订单组件,包括了打分和评价功能

购买组件Purchase:

首先我们需要一个购买的功能,我们拿到设计图后,首先切分成几个子组件,然后我们还需要一个购买的确定按钮,这个Tip组件是通用的,所以我们把它放到根目录下的component文件夹下,使用条件渲染来决定是否渲染这个组件。确定好页面的结构后,我们就要思考我们需要用到哪些数据:首先是product的价格信息,因为我们需要知道多少钱,然后是购买的数量,然后是下单用户的电话,然后是总价格,然后是是否要显示下单。确定好数据后,我们编写mapStateToProps从state中获取这些数据。这里比较特殊的是,productId,我们是从上一个页面跳转过来的,所以我们可以在props.match.params.id中拿到我们是从哪个商品的详情页过来的,我们用这个id调用领域实体提供的getProduct函数,拿到对应的product。

然后我们还需要一些action函数,比如购买这个动作。

首先我们要明白一点。redux中数据是单向流动的,我们在子组件改变当前组件的某个数据,比如这里的购买的数量,数据是子组件调用父组件传来的action函数,携带数据发出dispatch函数,state改变,引发React重新渲染,子组件再更新。比如以这个增加quantity为例,点击<span className='purchaseForm_counter--inc onClick={this.handleIncrease}''>+</span>,在这个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 (
      <div>
        <Header title="下单" onBack={this.handleBack} />
        {product ? (
          <PurchaseForm
            product={product}
            phone={phone}
            quantity={quantity}
            totalPrice={totalPrice}
            onSubmit={this.handleSubmit}
            onSetQuantity={this.handleSetQuantity}
          />
        ) : null}
        {showTip ? (
          <Tip message="购买成功!" onClose={this.handleCloseTip} />
        ) : null}
      </div>
    );
  }

  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 (
      <div className="userMain">
        <div className="userMain__menu">
          {tabTitles.map((item, index) => {
            return (
              <div
                key={index}
                className="userMain__tab"
                onClick={this.handleClickTab.bind(this, index)}
              >
                <span
                  className={
                    currentTab === index
                      ? "userMain__title userMain__title--active"
                      : "userMain__title"
                  }
                >
                  {item}
                </span>
              </div>
            );
          })}
        </div>
        <div className="userMain__content">
          {data && data.length > 0
            ? this.renderOrderList(data)
            : this.renderEmpty()}
        </div>
        {deletingOrderId ? this.renderConfirmDialog() : null}
      </div>
    );
  }

  renderOrderList = data => {
    const { commentingOrderId, orderComment, orderStars } = this.props;
    return data.map(item => {
      return (
        <OrderItem
          key={item.id}
          data={item}
          isCommenting={item.id === commentingOrderId}
          comment={item.id === commentingOrderId ? orderComment : ""}
          stars={item.id === commentingOrderId ? orderStars : 0}
          onCommentChange={this.handleCommentChange}
          onStarsChange={this.handleStarsChange}
          onComment={this.handleComment.bind(this, item.id)}
          onRemove={this.handleRemove.bind(this, item.id)}
          onSubmitComment={this.handleSubmitComment}
          onCancelComment={this.handleCancelComment}
        />
      );
    });
  };

  renderEmpty = () => {
    return (
      <div className="userMain__empty">
        <div className="userMain__emptyIcon" />
        <div className="userMain__emptyText1">您还没有相关订单</div>
        <div className="userMain__emptyText2">去逛逛看有哪些想买的</div>
      </div>
    );
  };

  //删除对话框
  renderConfirmDialog = () => {
    const {
      userActions: { hideDeleteDialog, removeOrder }
    } = this.props;
    return (
      <Confirm
        content="确定删除该订单吗?"
        cancelText="取消"
        confirmText="确定"
        onCancel={hideDeleteDialog}
        onConfirm={removeOrder}
      />
    );
  };

  // 评价内容变化
  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;
  }
};

 


版权声明:本文为renye_lpl原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。