2022新年奖励自己一辆特斯拉😎(React Hooks + Redux 入门级全栈实战项目)

29,777

前言

经过一段时间 React 的学习,正值春招来临,发现现在需要一个小小的项目来让自己对所学的知识做个总结与应用。正所谓百学不如一练,实战出真理👀。

项目简介

  • 技术栈:React Hooks + Redux + Koa
  • 使用React Hooks 对前端页面的编写,Koa 进行后台的搭建,Redux进行数据流管理,全项目使用 styled-components 样式组件进行样式编写,React-Router V5进行路由配置编写
  • 坚守后端MVC、前端 MVVM 的设计理念,遵循 组件化、模块化 编程思想

上成果

1.gif

项目结构

├─ server                   // 后端 
    ├─Data                  // 数据
    index.js
├─ src
    ├─api                   // 网路请求代码、工具类函数和相关配置
    ├─assets                // 字体配置及全局样式
    ├─baseUI                // 基础 UI 轮子
    ├─components            // 可复用的 UI 组件
    ├─layouts               // 布局
    ├─pages                 // 页面
    ├─routes                // 路由配置文件
    └─store                 // redux 相关文件
      App.jsx               // 根组件
      main.jsx              // 入口文件

前端部分

路由配置

本项目使用 react-router-config 对路由进行配置。

  • routes/index.js 代码如下:
import React, { lazy, Suspense } from 'react';
import HomeLayout from '../layouts/HomeLayout';
import { Redirect } from 'react-router-dom';

const Tesla = lazy(() => import('../pages/Tesla'));
const Find = lazy(() => import('../pages/Find'));
............

const SuspenseComponent = Component => props => {
    return (
        <Suspense fallback={null}>
            <Component {...props}></Component>
        </Suspense>
    )
}

export default [{
    path: "/",
    component: HomeLayout,
    routes: [
        {
            path: '/',
            exact: true,
            render: () => < Redirect to={"/tesla"} />,
        },
        {
            path: "/tesla",
            component: SuspenseComponent(Tesla),
            routes: [
                {
                    path: '/tesla/car/:id',
                    component: SuspenseComponent(Model)
                },
                {
                    path: '/tesla/order',
                    component: SuspenseComponent(Order)
                }
            ]
        }
        .......
    ]
}]

具体代码看这里噢

  • 使用 renderRouter 渲染下级路由 为了使路由生效,在所需要开启子路由的地方使用 renderRoutes

此处为 App.js:

import React from 'react';
// renderRoutes 读取路由配置转化为 Route 标签
import { renderRoutes } from 'react-router-config';
import { BrowserRouter } from 'react-router-dom';

// 所有组件的外壳组件
function App() {
  return (
    <div className="App">
      <BrowserRouter>
        {renderRoutes(routes)}
      </BrowserRouter>
    </div>
  )
}

export default App;

数据流管理 Redux

路由已经配置好了,接下来开始页面编写了,正所谓兵马未动,粮草先行。笔者个人喜好在页面编写之前先进行数据流的管理

这里对redux的工作原理就不多加赘述了。本项目中我们拆分了reducer,每个页面都拥有一个独立管理statereducer函数,然后再合并为一个总的reducer

  • store/reducer.js 代码如下
import { combineReducers } from 'redux';
import { reducer as TeslaReducer } from '../pages/Tesla/store';
import { reducer as ShopReducer } from '../pages/Shop/store'
import { reducer as FindReducer } from '../pages/Find/store';

export default combineReducers({
    tesla: TeslaReducer,
    shop: ShopReducer,
    find: FindReducer
});

然后我们用这个总的reducer创建store,每当我们在 storedispatch 一个 actionstore 内的数据就会随之发生改变。

/**
 * 该文件专门用于暴露一个store对象,整个应用只有一个store对象
 */
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;

redux-thunk 可以实现redux处理异步actionapplyMiddleware 就是增强了原始createStore返回的dispatch的功能。 const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;可以让浏览器插件 Redux DevTools 生效,以便开发人员模式运行应用。

总的store创建好了,以 Tesla 子页面的store为例,结构为

├─Tesla
    ├─store
        actionCreators.js
        constants.js
        index.js
        reducer.js
  • Tesla/store/reducer.js

该文件是用于创建一个Tesla组件服务的reducer,reducer的本质就是一个纯函数 reducer函数会接到两个参数,分别为:之前的状态(state),动作对象(action)

import * as actionTypes from './constants';

// 初始化状态
const defaultstate = {
    tesladata: [],
    // 记录tabbar点击高亮
    index: 2,
    // 记录详情页车选择的颜色
    colorIndex: 0,
    // 记录详情页轮子所选择的样式
    wheelIndex: 0
}

const reducer = (state = defaultstate, action) => {
    //根据type决定如何加工数据
    switch (action.type) {
        case actionTypes.CHANGE_TESLADATA:
            return { ...state, tesladata: action.data }
        case actionTypes.CHANGE_INDEX:
            return { ...state, index: action.data }
        case actionTypes.SET_COLORINDEX:
            return { ...state, colorIndex: action.data }
        case actionTypes.SET_WHEELINDEX:
            return { ...state, wheelIndex: action.data }
        default:
            return state;
    }
}
export default reducer;
  • Tesla/store/constants.js
export const CHANGE_TESLADATA = 'tesla/CHANGE_TESLADATA';
export const CHANGE_INDEX = 'tesla/CHANGE_INDEX';
export const SET_COLORINDEX = 'teslaInfo/model/SET_COLORINDEX';
export const SET_WHEELINDEX = 'teslaInfo/model/SET_WHEELINDEX';
  • Tesla/store/actionCreators.js 部分代码

remain 方法从后端拿到数据之后,dispatch(changeMianData函数返回的action) 就会修改Tesla页面的数据。

import * as actionType from './constants.js';
import { reqmain } from '../../../api/index'

export const changeMainData = (data) => {
    return {
        type: actionType.CHANGE_TESLADATA,
        data: data
    }
}

// 这里的getMainData直接暴露到 import { actionCreators } from './store'就可以拿到
export const getMainData = () => {
    // api 请求 
    // dispatch一个同步任务
    return (dispatch) => {
        reqmain()
            .then((res) => {
                // console.log(res);
                dispatch(changeMainData(res.data.data))
            })
            .catch((e) => {
                console.log('出错了');
            })
    }
}
  • api/index.js 部分代码
// Ajax 封装好了
export const reqmain = () => {
    // 默认传了 GET
    return Ajax('/tesla')
}

Redux 仓库创建好之后,如果要将 Redux 和 React 结合起来使用,就还需要一些额外的工具,其中最重要的莫过于 react-redux 了。

react-redux 提供了两个重要的对象,Providerconnect,前者使React组件可被连接(connectable),后者把 React 组件和 Redux 的 store 真正连接起来。 在最外层容器中使用Provider包裹,并将创建好的 store 作为参数传给Provider

import {Provider} from 'react-redux';

function App() {
  return (
    <Provider store={store}>
      <div className="App">
        <BrowserRouter>
          <Tesla></Tesla>
        </BrowserRouter>
      </div>
    </Provider>
  )
}

export default App;

我们在Provider中包裹的组件都不要忘了使用connect,才能使用store中的数噢。

import React from 'react';

function Tesla() {
  return (
    <div></div>
  );
}
//这个函数允许我们将 store 中的数据作为 props 绑定到组件上
const mapStateToPorps = (state) => {
  return {
    tesladata: state.tesla.tesladata
  }
}

//这个函数将 action 作为 props 绑定到 Main上。
const mapStateToDispatch = (dispatch) => {
  return {
    getMainDataDispatch() {
      dispatch(actionCreators.getMainData())
    }
  }
}

export default connect(mapStateToPorps, mapStateToDispatch)(memo(Tesla))

至此,我们就可以在每个子页面的 Redux 仓库中使用从后端接口拿到的数据了。 数据有了,可以正式开始写页面了。


页面编写

Tesla 页面

tesla1.gif place.gif tesla2.gif


项目打开首先进入到的是 Tesla 页面,首页轮播图展示四辆在售汽车,点击即可进入选配页面。

我的后端数据都是 json 格式,其实使用两个 useState 分别确定 colorIndexwheelIndex 就可以实现不同轮胎和不同颜色的选配。 但是由于汽车配置可能在多个页面使用到,我将配置信息都使用 Redux 数据流管理。

通过点击循环排列的颜色图片,触发 setCarColorIndex 方法,将颜色的索引作为index传入,改变colorIndex。轮毂选配实现原理同理。因此选配还具备了记忆功能,在退出选配后再次进入仍然有刚才的选配信息。

  • TeslaInfo/Model/index.jsx
<div className="carColor">
    {
        color.map((item, index) => {
            return (
                <div key={item.id} className={colorIndex == item.id - 1 ? "colorImg" : ""}>
                    <img src={item.picUrl} onClick={() => {setCarColorIndex(item.id - 1)}} />
                </div>
            )
        })
    }
</div>

setCarColorIndex(index) {
    // actionCreators.js 里面的方法 setColorIndex
    dispatch(actionCreators.setColorIndex(index))
}

具体代码点这里噢

Find 页面和 Map 页面

find.gif place.gif map.gif


发现页逻辑使用到了下拉刷新,这里安利一波神三元大大的scroll组件,超级好用😎, 买了掘金小册的可以看看噢

  • Find/index.jsx 部分代码如下

当下拉时即会触发 handlePullUp,++page

// 判断仓库是否为空,空则getFindDataDispatch(),page改变重新渲染页面
useEffect(() => {
  if (!finddata.length) {
    getFindDataDispatch(page)
    setTimeout(() => {
      setIsLoading(false)
    }, 500)
  }
}, [page])

// 上拉加载更多
const handlePullUp = () => {
  if (isLoading) return;
  setPage(++page)
  setIsLoading(true)
}

const mapStateToDispatch = (dispatch) => {
  return {
    getFindDataDispatch(page) {
      dispatch(actionCreators.getFindData(page))
    }
  }
}

具体代码点这里噢

  • api/index.js 部分代码
export const reqfind = (page) => {
    return Ajax(`/find/${page}`)
}

page改变重新渲染页面,reqfind将通过新的 page 从后端拿到新数据。

  • Find/store/actionCreators.js 部分代码
export const getFindData = (page) => {
  return (dispatch) => {
    reqfind(page)
      .then((res) => {
        dispatch(changeFindData(res.data.data))
      })
      .catch((e) => {
        console.log('出错了');
      })
  }
}

关于Tesla 地图的制作这里只是简单地实现了定位功能,to be continue...详见此

Shop 页面

shop1.gif place.gif shop2.gif


Shop 页面的 T-ZONE Swiper3 Slides居中 + 自动分组 模板

  • Shop/index.jsx 部分代码如下
  setTimeout(() => {
    new Swiper('.swiper-container', {
      slidesPerView: 'auto',
      centeredSlides: true,
      paginationClickable: true,
      spaceBetween: 20
    });
  }, 100)
  <div className="swiper-container">
    <p>T - ZONE</p>
    <div className="swiper-wrapper">
      {
        TZONE.map((item, _) => {
          return (
            <div key={item.id} className="swiper-slide">
              <img src={item.picUrl} onClick={() => goDetail(item)}/>
            </div>
          )
        })
      }
    </div>
  </div>

具体代码点这里噢

点击 Tesla 商店即可进入商品详情页

商品顶部悬浮的导航栏可以随着屏幕的滚动出现不同的样式,此处使用了两个 useState,初始值分别为两种主颜色。通过监听超好用的Scroll组件里的onScroll属性,来改变 state 的值。颜色也会随之改变。

  • ShopInfo/TeslaShop/index.jsx 部分代码如下
  const [currentColor, setCurrentColor] = useState('white')
  const [currentBgColor, setCurrentBgColor] = useState('#2B2D2E')
  
  const showHeader = (e) => {
    if (e.y < -90) {
      setCurrentColor('black')
      setCurrentBgColor('white')
    } else {
      setCurrentColor('white')
      setCurrentBgColor('#2B2D2E')
    }
  }
  
  <div className="shop-header" style={{ backgroundColor: `${currentBgColor}`, color: `${currentColor}` }}>
    <div className="shop-header-left">
      <svg viewBox="0 0 342 35" xmlns="http://www.w3.org/2000/svg"><path d="...." fill={currentColor}></path></svg>
      <span>|</span>
      <span>商店</span>
    </div>
    <div className="shop-header-right">
      <svg viewBox="0 0 1024 1024" p-id="7071"><path d="...." p-id="7072" fill={currentColor}></path></svg>
      <span>导航栏</span>
    </div>
  </div>

具体代码点这里噢

样式组件 styled-components

style-components 是针对React写的一套css in js框架,简单来讲就是在js中写css。相对于与预处理器(sass、less)的好处是,css in js使用的是js语法,不用重新再学习新技术,也不会多一道编译步骤。无疑会加快网页速度。

基础用法也不难,直接把样式以组件的格式引入即可。

  • Tesla/index.js
import { Main } from './index.style';
return (
  <Main> 
  </Main>
)
  • Tesla/index.style.js
import styled from 'styled-components'

export const Main = styled.div`
....
`

优化

路由懒加载

从路由配置代码可以看出,为了友友们的用户体验,我们使用了React的lazySuspense组合了路由懒加载进行。

React.lazy 函数能让你像渲染常规组件一样处理动态引入的组件。

在React使用了lazy之后,会存在一个加载中的空档期,React不知道在这个空档期中该显示什么内容,所以需要我们指定,接下来就要使用到Suspense

在 App 渲染完成后,包含 Tesla 的模块还没有被加载完成我们可以使用 Suspense 为此React.lazy导入的组件也就是Tesla做优雅降级。

import React, { lazy, Suspense } from 'react';

// 使用 React.lazy 导入 Tesla 组件
const Tesla = lazy(() => import('../pages/Tesla'));

// 封装好的 SuspenseComponent 组件,即用即包
const SuspenseComponent = Component => props => {
    return (
        <Suspense fallback={null}>
            <Component {...props}></Component>
        </Suspense>
    )
}

{
    path: "/tesla",
    // 使用时只要用它包裹住组件
    component: SuspenseComponent(Tesla)
}

页面渲染优化 Memo

若是你的函数组件在给定相同props 的状况下渲染相同的结果,那么你能够经过将其包装在React.memo 中调用,以此经过记忆组件渲染结果的方式来提升组件的性能表现。这意味着在这种状况下,React将跳过渲染组件的操做并直接复用最近—次渲染的结果。

也就是使用React.memo 避免不必要的组件跟随页面更新而进行重新渲染

import memo from "react";

const main = () => {}

export default memo(Main)

图片懒加载

引入react-lazyload 实现图片懒加载

import Lazyload from 'react-lazyload'

<div className="newsRight">
  <Lazyload
    height={100}
    placeholder={
      <img width="100%" height="100%" src={loading} />
    }
  >
    <img src={item.picUrl} />
  </Lazyload>
</div>

后端部分

Koa 来搭建后端

  • server/idnex.js 部分代码如下
const fs = require('fs')
const TeslaData = require('./Data/teslaData/TeslaData.json')

const Koa = require('koa');// 引入koa模块
const router = require('koa-router')();// 引入koa-router 并实例化(省略new写法)
const app = new Koa();// 实例化
// 配置路由
router.get('/tesla', async (ctx) => {
    ctx.response.body = {
        success: true,
        data: TeslaData
    }
})

app
    .use(router.routes())// 启动路由
    .use(router.allowedMethods())// 可以配置路由

app.listen(9000, () => {
    console.log('server is running 9000');
})

跨域

  • server/idnex.js 部分代码如下
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'] //设置获取其他自定义字段
    })
)

项目遇到的坑

刷新页面后 TabBar 的 icon 和文字未匹配

分析问题,刷新页面后,图片的状态丢失了,只有NavLink中 className 的 isActive 值保存了,导致文字状态没变,但选中图片的状态回到了初始值。

解决:根据用户路由直接访问的处理,非首页

import { useLocation } from 'react-router-dom';

const { pathname } = useLocation()

index = route.routes.findIndex(item => item.path == pathname)

刷新页面后选配信息丢失

一开始的思路时使用浏览器的storage来实施本地存储,后来学习之后发现redux-persist可以实现redux的持久化本地数据存储,将需要保留的store加到白名单中即可。

import thunk from 'redux-thunk';
// 引入createStore,专门用于创建redux中最为核心的store对象
import { createStore, compose, applyMiddleware } from 'redux';
// 引入为组件服务的的reducer
import reducer from "./reducer";

// redux-persist实现redux持久化本地数据存储。
import {persistStore, persistReducer} from 'redux-persist';
//  存储机制,可换成其他机制,当前使用sessionStorage机制
import storageSession from 'redux-persist/lib/storage/session'
// import storage from 'redux-persist/lib/storage'; //localStorage机制

const storageConfig = {
    key: 'root', // 必须有的
    storage:storageSession, // 缓存机制
    // blacklist: ['index'] // reducer 里不持久化的数据,除此外均为持久化数据
    // 必须为跟store,不能是某个属性
    whitelist: ['tesla'] // reducer 里持久化的数据,除此外均为不持久化数据
}

const myPersistReducer = persistReducer(storageConfig, reducer)
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(myPersistReducer, composeEnhancers(applyMiddleware(thunk)));
export const persistor = persistStore(store)
// 暴露store
export default store;

总结

这个大大的DEMO,小小的项目可以算是对我21年学习 react 的总结吧,功能还非常的不全,但是对于我个人来说,算是由理论向实践迈出了一小步了。如果需要熟悉 hooks 写法及应用场景的同学也可以跟着学习一下。欢迎掘友们多多点赞噢!!👀


源码