基於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;
  }
};

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章