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