5858快到家 React+hooks+redux项目实战

6,702 阅读8分钟

前言

哔————传送门
项目地址
github地址

​ 秋招正当时,没有一个拿得出手的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.lazySuspense组合实现路由懒加载进行优化,以提高首屏加载速度,这样第一次打开的小伙伴就可以减少等待时间啦。

​ 思想是,当我们使用某个组件时,需要一个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,每当我们在 storedispatch 一个 actionstore 内的数据就会相应地发生变化。我们在最外层容器组件中初始化 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提供的两个对象 ,Providerconnect

  • 在最外层容器中,把所有内容包裹在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的外层一定要有一层包裹元素,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!如有错误,也请指正,感谢!