前言
秋招正当时,没有一个拿得出手的React实战项目怎么能行?笔者最近恰好读到了神三元大佬在掘金的React Hooks 与 Immutable 数据流实战,研究了一下大神的项目 顿时灵感来了,便使用React简单仿造了一下58到家的APP。
项目整体使用:react + hooks + redux + mocker-api + koa
优化: better-scroll, styled-component, react-config-router,react-lazyload,防抖,路由懒加载,memo等
话不多说,先上成果图:
项目整体目录结构如下:
├─ src
│ ├─ api // 数据请求,接口
│ ├─ assets // 静态资源
│ ├─ baseUI //UI组件
│ ├─ common //公用组件
│ ├─ components // 组件
│ ├─ Data // 数据
│ ├─ index.css
│ ├─ index.js
│ ├─ layouts // 布局
│ ├─ pages // 页面
│ ├─ routes // 路由
│ ├─ store
│ └─ Utils // 本地存储
└─
前端部分
要开发一个项目应用时,我们应该先理清项目整体结构,所以在这里我们先从路由入手。
路由
我们使用react-router-config对路由进行配置。
配置
- routes/index.js 部分代码如下:
import React from 'react';
import { Redirect, Link } from 'react-router-dom';
import BlankLayout from '../layouts/BlankLayout';
import Tabbuttom from '../components/tabbuttom/Tabbuttom';
import Main from '../pages/Main/Main';
import Detail from '../pages/details/Detail';
export default [{
component: BlankLayout,
routes: [
{
path: '/',
exact: true,
render: () => < Redirect to={"/home"} />,
},
{
path: '/home',
component: Tabbuttom,
routes: [
{
path: '/home',
exact: true,
render: () => < Redirect to={"/home/main"}
/>,
},
{
path: '/home/main',
component: Main,
}
],
},
{
path: '/detail',
component: Detail,
routes: [
{
path: "/detail/:id",
component: Detail
}
]
}
]
}];
-
为了使路由生效,必须在App中导入路由配置。App.js代码如下:
由于renderRoutes 方法只会渲染第一层路由,现在的App是第一层,要想在Main组件中也生效,那么只需要在Main等其他的子组件中再次调用renderRoutes。
import React from 'react';
import { BrowserRouter,HashRouter } from 'react-router-dom';
import {renderRoutes} from 'react-router-config';
import routes from './routes/index.js';
function App() {
return (
<div className="App">
<HashRouter>
{renderRoutes(routes)}
</HashRouter>
</div>
);
}
export default App;
路由懒加载
为了美妙的用户体验,我们可以使用React.lazy
和Suspense
组合实现路由懒加载进行优化,以提高首屏加载速度,这样第一次打开的小伙伴就可以减少等待时间啦。
思想是,当我们使用某个组件时,需要一个Suspense来包裹。而React.lazy接受一个函数作为参数,表明我们是动态引入了某个组件。
Suspense用法不过多赘述,在这里我们已经封装好一个SuspenseComponent
组件,在使用时只要用它包裹住组件即可。
const Main = lazy(() => import('../pages/Main/Main')); // 组件的引入方式
const SuspenseComponent = Component => props => {
return (
<Suspense fallback={null}>
<Component {...props}></Component>
</Suspense>
)
}
{
path: '/Main',
component: SuspenseComponent(Main)
}
redux
这里使用redux进行数据状态管理。随着应用变得复杂,需要对reducer函数进行拆分,每个页面独立管理state的一部分。最后使用**combineReducers **辅助函数,把一个由多个不同 reducer 函数组成的 object,合并成一个最终的 reducer 函数。
然后就可以对这个reducer
调用 createStore
,创建store
,每当我们在 store
上 dispatch
一个 action
,store
内的数据就会相应地发生变化。我们在最外层容器组件中初始化 store
,然后将 state
上的属性作为 props
层层传递下去。
- store/reducer.js 代码如下:
import { combineReducers } from 'redux';
import { reducer as serverReducer } from "../pages/server/store/index";
import { reducer as orderReducer } from "../pages/details/store/index";
import { reducer as mainReducer } from '../pages/Main/store/index'
import { reducer as searchReducer } from '../pages/search/store/index'
// 将各个reducer合并起来
export default combineReducers({
server: serverReducer,
main: mainReducer,
order: orderReducer,
search: searchReducer
});
- store/index.js 代码如下:
import thunk from 'redux-thunk';
import { createStore, compose, applyMiddleware } from 'redux';
import reducer from "./reducer";
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
// 创建store
const store = createStore(reducer, composeEnhancers(applyMiddleware(thunk)));
export default store;
子页面中的store以Main主页为例:
-
Main/store/constants.js
// 定义常量 export const CHANGE_MAINDATA = 'CHANGE_MAINDATA'; export const CHANGE_INDEX = 'CHANGE_INDEX'; export const CHANGE_LISTITEMDATA = 'CHANGE_LISTITEMDATA'; export const CHANGE_UPLOADING = 'CHANGE_UPLOADING'; export const CHANGE_DOWNLOADING = 'CHANGE_DOWNLOADING'; export const CHANGE_LIST_OFFSET = 'CHANGE_LIST_OFFSET';
-
Main/store/reducer.js
reducer 纯函数 返回状态及接受状态的更新 只有一个状态与之相对应。
import * as actionTypes from './constants'; // 初始状态 const defaultstate = { maindata: [], index: 0, ListItemData: [], listOffset: 0, Uploading: false, Downloading: false } const reducer = (state = defaultstate, action) => { switch (action.type) { case actionTypes.CHANGE_MAINDATA: return {...state, maindata: action.data } case actionTypes.CHANGE_INDEX: return {...state, index: action.data } case actionTypes.CHANGE_LISTITEMDATA: return {...state, ListItemData: action.data } case actionTypes.CHANGE_UPLOADING: return {...state, Uploading: action.data } case actionTypes.CHANGE_DOWNLOADING: return {...state, Downloading: action.data } case actionTypes.CHANGE_LIST_OFFSET: return {...state, listOffset: action.data } default: return state; } } export default reducer;
-
Main/store/actionCreators.js 部分代码
当reqmain方法成功请求数据之后,diapatch(changeMainData)修改主页数据,并将成功的数据作为参数传入,实现主页数据的修改。
import { reqmain, reqgetmainListoffset } from '../../../api/index'; import * as actionType from './constants.js'; //修改主页数据 export const changeMainData = (data) => { console.log("进去成功..............."); return { type: actionType.CHANGE_MAINDATA, data: data } } //请求主页数据 export const getMainData = () => { return (dispatch) => { reqmain().then((res) => { if (res.data.success) { dispatch(changeMainData(res.data.data)) } else { console.log("失败", res); } }).catch((e) => { console.log("服务页面数据请求错误!"); }) } };
到这里仓库就已经创建好了,那么怎么使用呢?
这里要用到react-redux提供的两个对象 ,Provider
和connect
。
- 在最外层容器中,把所有内容包裹在Provider组件中,并且将之前创建的store作为prop传给Provider。
import React from 'react';
import { BrowserRouter,HashRouter } from 'react-router-dom';
import {Provider} from 'react-redux';
function App() {
return (
<Provider store={store}>
<div className="App">
<Main></Main>
</div>
</Provider>
);
}
export default App;
- Provider内部任何的组件(比如这里的Main),如果需要store中的数据,就必须是被
connect
过得组件。
import React from 'react';
function Main() {
return (
<div></div>
);
}
//这个函数允许我们将 store 中的数据作为 props 绑定到组件上
const mapStateToProps = (state) => ({
maindata: state.main.maindata
})
//这个函数将 action 作为 props 绑定到 Main上。
const mapDispatchToProps = (dispatch) => {
return {
getMainDataDispatch() {
dispatch(actionTypes.getMainData())
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(memo(Main))
超好用的scroll
紧跟三元大大的步伐, 将better-scroll应用在本项目中,打造上下滑动如丝般顺滑的体验,这里直接上代码和用法。
在项目中,我们只需要将组件用Scroll包裹住。这里注意,Scroll的外层一定要有一层包裹元素,Scroll内部的元素要设置好宽高。
import React, { forwardRef, useState,useEffect, useRef, useImperativeHandle } from "react"
import PropTypes from "prop-types"
import BScroll from "better-scroll"
import styled from'styled-components';
const ScrollContainer = styled.div`
width: 100%;
height: 100%;
overflow: hidden;
`
const Scroll = forwardRef ((props, ref) => {
const [bScroll, setBScroll] = useState ();
const scrollContaninerRef = useRef ();
const { direction, click, refresh, bounceTop, bounceBottom } = props;
const { pullUp, pullDown, onScroll } = props;
useEffect (() => {
const scroll = new BScroll (scrollContaninerRef.current, {
scrollX: direction === "horizental",
scrollY: direction === "vertical",
probeType: 3,
click: click,
bounce:{
top: bounceTop,
bottom: bounceBottom
}
});
setBScroll (scroll);
return () => {
setBScroll (null);
}
//eslint-disable-next-line
}, []);
useEffect (() => {
if (!bScroll || !onScroll) return;
bScroll.on ('scroll', (scroll) => {
onScroll (scroll);
})
return () => {
bScroll.off ('scroll');
}
}, [onScroll, bScroll]);
useEffect (() => {
if (!bScroll || !pullUp) return;
bScroll.on ('scrollEnd', () => {
// 判断是否滑动到了底部
if (bScroll.y <= bScroll.maxScrollY + 100){
pullUp ();
}
});
return () => {
bScroll.off ('scrollEnd');
}
}, [pullUp, bScroll]);
useEffect (() => {
if (!bScroll || !pullDown) return;
bScroll.on ('touchEnd', (pos) => {
// 判断用户的下拉动作
if (pos.y > 50) {
pullDown ();
}
});
return () => {
bScroll.off ('touchEnd');
}
}, [pullDown, bScroll]);
useEffect (() => {
if (refresh && bScroll){
bScroll.refresh ();
}
});
useImperativeHandle (ref, () => ({
refresh () {
if (bScroll) {
bScroll.refresh ();
bScroll.scrollTo (0, 0);
}
},
getBScroll () {
if (bScroll) {
return bScroll;
}
}
}));
return (
<ScrollContainer ref={scrollContaninerRef}>
{props.children}
</ScrollContainer>
);
})
Scroll.defaultProps = {
direction: "vertical",
click: true,
refresh: true,
onScroll:null,
pullUpLoading: false,
pullDownLoading: false,
pullUp: null,
pullDown: null,
bounceTop: true,
bounceBottom: true
};
Scroll.propTypes = {
direction: PropTypes.oneOf (['vertical', 'horizental']),
refresh: PropTypes.bool,
onScroll: PropTypes.func,
pullUp: PropTypes.func,
pullDown: PropTypes.func,
pullUpLoading: PropTypes.bool,
pullDownLoading: PropTypes.bool,
bounceTop: PropTypes.bool,// 是否支持向上吸顶
bounceBottom: PropTypes.bool// 是否支持向上吸顶
};
export default Scroll;
<div className="main">
<Scroll direction={"vertical"} refresh={false}
</Scroll>
</div>
styled-components
我们不使用传统的css文件,而是使用styled-components
创建样式组件。使用styled-components我们可以将样式写在jsx文件中,也不用再担心样式命名的问题,因为每个style.js都是独立的,定义太多变类名冲突的问题就解决啦。走出舒适圈,试试用styled-components
写样式吧。
用法如下
// jsx
return (
<>
<OrderTab>
<div className='order-tab__icon'></div>
</OrderTab>
</>)
// style.js
import styled from "styled-components";
// 这里将OrderTab 定义为div标签
export const OrderTab = styled.div`
font-family: PingFangSC-Regular;
height: 1.5648rem /* 169/108 */;
background-color: #fff;
& .order-tab__icon{
width: .7407rem /* 80/108 */;
height: .7407rem /* 80/108 */;
}
`
页面渲染优化 Memo
如果一个组件在相同 props 的情况下 会渲染相同的结果,那么我们可以通过将其包装在 React.memo 中调用。通过Memo渲染结果的方式来提高组件的性能。在这种情况下,React 将跳过渲染组件的操作 直接复用最近一次渲染的结果,以避免子组件进行不必要的更新。
function Main(props) {
}
export default React.memo(Main);
后端部分
在前端,我们将axios的GET和POST请求封装在一个函数中,以方便后面的数据请求。
- 创建api/ajax.js
import axios from 'axios';
export default function Ajax(url, data = {}, type) {
return new Promise((resolve, rejet) => {
let Promise;
if (type === 'GET') {
Promise = axios.get(url, {
params: data
})
} else {
Promise = axios.post(url, {
params: data
})
}
Promise.then((response) => {
resolve(response);
}).catch((error) => {
console.error("数据请求异常!", error)
})
})
}
- 创建api/index.js,以下为真实的数据请求。
import Ajax from './ajax.js';
export const reqserver = () => {
return Ajax("/home/server", {}, "GET");
}
export const reqmain = () => {
return Ajax("/home/main", {}, 'GET');
}
export const reqdetail = (data) => {
return Ajax("/detail", { data }, 'GET');
}
export const reqgetmainListoffset = (count) => {
return Ajax('/home/main', { count }, 'GET');
};
export const reqsearchkeywords = (keywords) => {
return Ajax("/search", { keywords }, 'GET');
};
export const reqsearchhot = () => {
return Ajax("/hot", {}, 'GET');
};
koa
使用koa来搭建后端服务
使用路由塑造接口
我们新建一个后端的目录,index.js部分代码如下:
const fs = require('fs')
const ServerData = require('./Data/serverData/ServerData.json')
const Koa = require('koa');// 引入koa模块
const router = require('koa-router')();// 引入路由
const app = new Koa();// 实例化
// 配置路由
router.get('/home/server', async (ctx) => {
ctx.response.body = {
success:true,
data:ServerData
}
})
// 启动路由
app
.use(router.routes())
.use(router.allowedMethods());
//服务在本地9090端口启动
app.listen(9090, () => {
console.log('server is running 9090');
});
跨域
由于本项目为前后端分离,即后端采用本地9090端口开启服务,前端采用3000端口访问页面,那么前端请求后端数据必定跨域,浏览器报错。这里使用koa2-cors插件解决跨域。
const cors = require('koa2-cors');
app.use(
cors({
origin: function(ctx) { //设置允许来自指定域名请求
// if (ctx.url === '/test') {
return '*'; // 允许来自所有域名请求
// }
// return 'http://localhost:3000'; //只允许http://localhost:8080这个域名的请求
},
maxAge: 5, //指定本次预检请求的有效期,单位为秒。
credentials: true, //是否允许发送Cookie
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], //设置所允许的HTTP请求方法
allowHeaders: ['Content-Type', 'Authorization', 'Accept'], //设置服务器支持的所有头信息字段
exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'] //设置获取其他自定义字段
})
)
mockjs
你也可以使用mockjs模拟数据请求。拦截发出的请求,返回本地数据。
import Mock from 'mockjs';
export default Mock.mock(/\/home\/server/, 'get', (options) => {
console.log("mock进去", options);
return {
success: true,
data: ServerData
}
});
完结撒花~ 感兴趣的朋友欢迎移步github!如有错误,也请指正,感谢!