我是一颗小虎牙呀!——React + Redux 让我们一起体验小虎牙~

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情

前言

经过上次的仿学习通项目,在学习了Redux后,借鉴了掘金大佬神三元项目的一些方法和模式,写了这一篇React Hooks + Redux项目。

这篇文章与上篇文章的主要区别是加入了Redux,以及介绍在项目完成过程中遇到的问题及解决方法。

在线体验地址huya-react

项目总览

GIF 2022-8-3 18-37-50.gif

前期准备

依赖包介绍

移动端适配及reset

  1. 适配

因为不同手机型号屏幕分辨率不一样,为了更好适应不同屏幕大小,我们需要做移动端适配操作;其原理是根据标准屏幕宽度750像素进行换算,20px=1rem,我们只需要将决对单位px换成相对单位rem即可,其js代码如下:

var init = function () {
    var clientWidth = document.documentElement.clientWidth || document.body.clientWidth;
    if (clientWidth >= 640) {
      clientWidth = 640;
    }
    var fontSize = 20 / 375 * clientWidth;
    document.documentElement.style.fontSize = fontSize + "px";
  }
  init();
  window.addEventListener("resize", init);
复制代码

我们需要在项目根目录下的index.html 引入适配文件:

<script src="/public/js/adapter.js"></script>
复制代码
  1. css-reset

css-reset是每个项目前期准备必备工作,因为不同浏览器对很多标签有默认属性,为了更好统一样式,我们需要去重置这些属性样式,这里就不copy代码了,网上找个reset样式就行。

redux总仓库

  1. src目录下创建store文件夹

该文件夹下有两个文件,分别为index.js,reducer.js

  • index.js
import { createStore,compose,applyMiddleware } from "redux"
// 组件 中间件redux-thunk 数据
import thunk from 'redux-thunk' // 异步数据管理
import reducer from './reducer'

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const store = createStore(reducer,
    // 组合函数
    // devtool
    composeEnhancers(
        // 异步
        applyMiddleware(thunk)
    )    
)
export default store
复制代码

此文件建立了一个总仓库,集中管理异步action,其中const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose是为了数据在浏览器中可视化,需要在浏览器下载Redux DevTools插件。浏览器插件显示如下:

ed45c6a31292ce31a5f790d406b170d.jpg

2039b502ca6335f01f7599e9829a52a.jpg

  • reducer.js
// 模块化  路由模块 基本就是数据模块
import { combineReducers } from "redux"
// store 中央
import { reducer as recommendReducer } from '@/components/HomeNav/HomeCommend/store/index'
import { reducer as classifyHot } from '@/pages/PlayClassify/store/index'
import { reducer as open } from '@/pages/Mine/store/index'

export default combineReducers({
    recommend:recommendReducer,
    classifyhot:classifyHot,
    open:open,
})
复制代码

此文件为总中央reducer,用于引入并合并各个子仓。

  1. 引入Provider连接Redux
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import './assets/font/font_3514904_dazjw2di2au/iconfont.css'
import { Provider } from 'react-redux'
import './assets/reset.css'
import store from './store'
import { HashRouter } from 'react-router-dom'

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <HashRouter>
      <App />
    </HashRouter>
  </Provider>
)
复制代码

在入口文件mian.jsx中最外层用Provider组件包裹,其必须接受一个store参数,只有加了该参数,才能在每个组件中共享redux仓库数据,实现reactredux连接(connect连接)。

组件实现

首页Commned组件

import React,{ useEffect,useState } from 'react'
import { connect } from 'react-redux'
import { actionCreators } from './store/index'
import { RecommendContent } from './style'
import Scroll from '@/components/common/Scroll'
import { Popup } from 'antd-mobile'
import {  Modal } from './style'

function HomeCommend(props) {
  ...
  return (
    <RecommendContent>
      {modalone()}
      {modalthree()}
      <Scroll>
        <div className="container">
          {
            recommendList.map(item=>{
              return(
                ...
                  <div className="item-desc">
                    <span>{item.title}</span>
                  <PupupItem ...></PupupItem>
                  </div>
                </div>
              )
            })
          }
        </div>
      </Scroll>
    </RecommendContent>
  )
}
// 弹出层事件不能放在map循环里面,否则会循环触发弹出层事件,导致背景样式异常
// 要单独拎出来
const PupupItem =({item,addSubscribeDispatch,deleteItemDispatch,showModal,showDeleteModal})=>{
  ...
  return(
    <>
      <i className='iconfont icon-gengduo' 
        onClick={() => {setVisible1(true)}} 
      />
      <Popup
        visible={visible1}
        onMaskClick={() => {
          setVisible1(false)
        }}
        bodyStyle={{ height: '250px' }}
      >
        ...
      </Popup>
    </>
  )
}
...
复制代码

组件效果

GIF 2022-8-3 18-40-50.gif

组件介绍

此页面滚动效果非常丝滑,原因是在最外层包裹了一个组件Scroll,该组件是直接套用神三元所封装的scroll组件,可以让滚动如丝般丝滑,直接复用就完事了。页面数据由仓库提供,然后在页面map循环输出,每个item有点击事件触发弹出层(antd-moboile),点击弹出层中的“不感兴趣”或者“订阅”触发dispatch事件,改变仓库数据,页面重新渲染;在订阅页面可以看到用户的订阅。订阅之后button变为不可点击,因为数据中的subscribe属性变为true,按钮根据disabled={item.subscribe}将会变为不可选状态;如果点击不感兴趣也会执行相应Dispatch函数,然后数据将从页面删除。

仓库数据管理

将数据从组件中分离在仓库中独立管理,更有利于模块化,而且支持跨组件共享数据,当项目变得庞大起来,redux管理数据非常必要。

  • contains.js文件给type取一个别名(常量),方便引用,reducer.js文件根据别名执行相应操作
export const CHANGE_RECOMMEND_LIST = 'CHANGE_RECOMMEND_LIST'
export const ADD_SUBSCRIBE = 'ADD_SUBSCRIBE'
export const DELETE_ITEM = 'DELETE_ITEM'
export const IS_ARRIVE = 'IS_ARRIVE'
export const DELETE_SUBSCRIBE_ITEM = 'DELETE_SUBSCRIBE_ITEM'
复制代码
  • reducer.js文件是redux最关键的部分,数据相关增删改查等操作在这里进行,给数据状态设置一个默认值,没有发生改变时,redux会把这个默认值传给组件,一旦有状态发生改变,接收action,去匹配相应的type并执行相应的操作,然后把更新的数据传给组件。部分代码如下(添加部分):
import * as actionTypes from './constants'

const defaultState = {
    recommendList: [],
    subscribe: [],
    isArrive: false,
}
export default (state = defaultState,action)=>{
    switch(action.type){
        case actionTypes.ADD_SUBSCRIBE:
            let changeID = action.data
            let subscribe = state.subscribe
            let change = state.recommendList.map(item => {
                if(item.id == changeID){
                    item.subscribe = true
                    if(subscribe.indexOf(item)==-1){
                        subscribe.push(item)
                    }
                }
                return item
            })
            return {
                ...state,
                recommendList: change,
                subscribe: subscribe
            }
            break
        ...
        default:
            return state
    }
    return state
}
复制代码
  • actionCreators.js数据拉取及根据dispatch action执行相应DispatchAction函数更新数据,部分代码如下:
import { getRecommendRequest } from "@/api/request"
import * as actionTypes from './constants'

export const changeSubscrible = (id)=>({
    type: actionTypes.ADD_SUBSCRIBE,
    data: id
})
export const getSubscribleAction = (id) =>{
    return (dispatch) =>{
            dispatch(changeSubscrible(id))
    }
}
...
复制代码
  • index.js文件统一向总仓库暴露reduceractionCreators
import reducer from "./reducer"
import * as actionCreators from './actionCreators'

export { 
    reducer,
    actionCreators 
}
复制代码
  • 组件连接仓库
export default connect(mapStateToProps, mapDispatchToProps)(React.memo(HomeCommend))
复制代码
  • 点击事件onClick={()=>addSubscribe(item.id)触发Dispatch
const addSubscribe =(id) =>{
    showModal(setVisible1)
    addSubscribeDispatch(id)
  }
  ...
const mapDispatchToProps = (dispatch) =>{
  return{
    addSubscribeDispatch(id){
      dispatch(actionCreators.getSubscribleAction(id))
    },
    ...
  }
}
复制代码

订阅组件

组件效果

GIF 2022-8-3 20-11-03.gif

组件介绍

该组件样式与首页一致,只是把弹出层内容改为了取消订阅,该按钮触发一个Dispatch事件函数,然后会在仓库更改相应数据;这里不需要重新建一个子仓库,因为数据都在首页声明过了,只需要连接首页仓库,然后通过点击事件执行相应函数更改仓库数据,仓库重新发送数据到各个组件,下面为连接仓库代码:

const mapStateToProps = (state) =>{
  return{
    subscribe:state.recommend.subscribe
  }
}
const mapDispatchToProps = (dispatch) => {
  return {
    deleteSubscribeItemDispatch(id){
      dispatch(deleteSubscribeItemAction(id))
    }
  }
}
export default connect(mapStateToProps,mapDispatchToProps)(React.memo(Subscribe))
复制代码

滑动切换Tabs组件

该组件效果同时引用了antd-mobileTabs组件和swiper组件,其中Tabs是实现导航切换的组件,swiper是实现轮播的组件,通过监听事件函数双向绑定当前选中Tab,即可实现滑动切换。

效果如下

GIF 2022-8-3 20-38-37.gif

关键代码(单纯实现滑动切换)

const tabItems = [
  { key: 'hot', title: '热门' },
  ...
]
  const swiperRef = useRef(null)
  const [activeIndex, setActiveIndex] = useState(0)
    return(
      <>
        <Tabs
          activeKey={tabItems[activeIndex].key}
          onChange={key => {
              const index = tabItems.findIndex(item => item.key === key)
              setActiveIndex(index)
              swiperRef.current?.swipeTo(index)
          }}
        >
          {tabItems.map(item => (
              <Tabs.Tab title={item.title} key={item.key} />
          ))}
        </Tabs>
        <Swiper
          // direction='horizontal'
          loop={false}
          allowTouchMove={true}
          indicator={() => null}
          ref={swiperRef}
          defaultIndex={activeIndex}
          onIndexChange={index => {
              setActiveIndex(index)
          }}
          >
            <Swiper.Item>
                <ClassifyHot  isManage={isManage}/>
            </Swiper.Item>
            <Swiper.Item>
                网络竞技
            </Swiper.Item>
            ...
        </Swiper>
      </>
    )
  }
复制代码

管理分类组件

效果如下(注意二级路由变化):

GIF 2022-8-3 20-47-53.gif

图标的显示与隐藏

点击右上角“管理”,+-功能图标就会消失,管理字样也会相应改变为主题色并变为完成,这是通过isManage状态和display属性实现的,其代码如下:

  const [isManage,setIsManage] = useState(false)
  let displayStyle = isManage?{display:""}:{display:"none"}  
  
  <i style={displayStyle}
  
  <span onClick={()=>setIsManage(!isManage)}>
      {isManage==false?<>管理</>:<div style={{color:"#ffa200"}}>完成</div>}
  </span>
复制代码

添加和移除

通过点击事件执行相应Dispatch函数,然后更改仓库数据,仓库发送给组件,组件重新渲染页面,这里展示仓库中reducer逻辑函数

export default (state = defaultState,action)=>{
    switch(action.type){
        case actionTypes.ADD_COMMON:
            let addID = action.data
            let addChange = state.classifyHotList.map(item => {
                if(item.id == addID){
                    item.checked = true
                    state.commonList.push(item)
                }
                return item
            })
            return{
                ...state,
                classifyHotList: addChange,
                commonList: state.commonList
            }
            break
        case actionTypes.DELETE_COMMON:
            let deleteCommonID = action.data
            let newCommon =state.commonList.filter((item)=>item.id!==deleteCommonID)
            let change=state.classifyHotList.map(item=>{
                if(item.id==deleteCommonID){
                    item.checked=false
                }
                return item
            })
            return {
                ...state,
                classifyHotList: change,
                commonList: newCommon
            }
            break
        default:
            return state
    }
    return state
}
复制代码

二级路由更新

首页二级路由的Tabs有默认项,也可以根据用户自定义组成,有料推荐是必有项,然后用户可自行添加修改,因为在分类组件中添加完常用后,只需在首页拿到仓库中的常用列表即可更新二级路由Tabs。其二级路由所在组件代码如下:

import React, { useEffect } from 'react'
import  { HeaderWrapper,NavBar,NavItem,Content }  from './style'
import Input from '@/components/common/Input'
import HeaderRight from './HeaderRight/index.jsx'
import { NavLink,Outlet,useNavigate,useLocation } from 'react-router-dom'
import { connect } from 'react-redux'

function Home(props) {
  const { classifyhot } = props
  // 默认导航
  let defaultHomenavs=[
      { id:100,desc:'有料',path:'/material'},
      { id:101,desc:'推荐',path:'/commend'},
      { id:102,desc:'热门',path:'/hot'},
      { id:103,desc:'王者荣耀',path:'/wangzhe'},
      { id:104,desc:'LOL',path:'/lol'},
      { id:105,desc:'户外',path:'/outdoors'},
      { id:106,desc:'星秀',path:'/starshow'},
      { id:107,desc:'一起看',path:'/movie'},
      { id:108,desc:'和平精英',path:'/chiji'},
      { id:109,desc:'交友',path:'/dating'},
  ]
  // 用户自定义导航,initialNavs为默认
  let initialNavs = [
      { id:100,desc:'有料',path:'/material'},
      { id:101,desc:'推荐',path:'/commend'},
      { id:102,desc:'热门',path:'/hot'},
  ]
  let homenavs = initialNavs.concat(classifyhot)
  if(classifyhot.length==0){
    homenavs = defaultHomenavs
  }
  return (
    <div>
      ...
      <NavBar>
        <div className='navBar'>
          {
            homenavs.map((item,index)=>{
              return(
                <NavItem key={item.id}>
                  <NavLink to={`/home${item.path}`} index={index}>
                    <span>{item.desc}</span>
                  </NavLink>
                </NavItem>
              )
            })
          }
        </div>
        ...
      </NavBar>
      <Content>
        <Outlet />
      </Content>
    </div>
  )
}

const mapStateToProps = (state) =>{
  return{
    classifyhot: state.classifyhot.commonList
  }
}
export default connect(mapStateToProps)(React.memo(Home))

复制代码

搜索组件

效果如下:

GIF 2022-8-3 21-16-27.gif

很遗憾,没有做模糊查询功能,只是简单写了一下搜索框聚焦及当搜索框有内容时原推荐搜索内容隐藏。只需要通过onChange事件拿到搜索框的值,然后根据是否为空,将推荐内容的display设为""none,即能达到效果。

小趣味组件

夜间模式:

GIF 2022-8-3 21-26-19.gif

夜间模式的更改,也就是主题样式的修改,在全局样式:root中定义两个样式,其格式为--color: black,然后所有组件都能通过var(--color)来引用这个样式,只需在夜间模式组件中通过setProperty('--background-color','black')就可以改变:root下的相应样式,所有组件只要引用了该样式都会改变,我这里只做了简单的黑白配,达到一个夜间模式的效果而已。其代码如下:

const { isOpen } = props
  const { getIsOpenActionDispatch } = props
  const changeTheme =(val)=>{
    getIsOpenActionDispatch(val)
    if(val==true){
      document.documentElement.style.setProperty('--background-color','black')
      document.documentElement.style.setProperty('--font-color','white')
    }
    else{
      document.documentElement.style.setProperty('--background-color','white')
      document.documentElement.style.setProperty('--font-color','black')
    }
  }
  return (
    <MineWrapper>
      <div style={{paddingTop:"200px"}}>
        <span >
          夜间模式<Switch
            // defaultChecked={false}
            style={{
              '--checked-color': '#ffa200',
              '--height': '30px',
              '--width': '60px',
            }}
            onChange={(val)=>changeTheme(val)}
            checked={isOpen}
          />
        </span>
      </div>
    </MineWrapper>
  )
复制代码

注意事项

  1. 在完成相应增删改查后,或者打开夜间模式按钮后,再次回到页面,数据都会重新渲染,不能记录上次的执行结果,于是我采取了一个比较笨的方式,给仓库添加了一个状态,只有在页面首次渲染时为false,之后都为true,然后在useEffect或者onClick事件中加个判断,如果为true便不重新渲染页面,页面会保持上次状态。
  2. 在引用antd-mobilePopup弹出层组件时,第一次我直接放在map循环中引用,发现弹出层组件样式背景变成了黑色,并把原组件完全遮盖住了,经过一番尝试后,发现不能放在map循环中,因为map循环会反复执行,会反复渲染页面,导致样式或逻辑错误,包括点击事件,也不要直接放在map循环中,要单独拎出来,放在一个单独的组件中,这样也有利于代码可读性。

源码及预览

项目预览:点击预览

Git源码地址:Git源码地址

GitHub源码地址:GitHub源码地址

猜你喜欢

转载自juejin.im/post/7127652500777369607