携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情
前言
经过上次的仿学习通项目,在学习了Redux后,借鉴了掘金大佬神三元项目的一些方法和模式,写了这一篇React Hooks + Redux项目。
这篇文章与上篇文章的主要区别是加入了Redux,以及介绍在项目完成过程中遇到的问题及解决方法。
在线体验地址:huya-react
项目总览
前期准备
依赖包介绍
- antd-mobile: 出自蚂蚁金服,非常好用的组件库官方文档
- axios: 获取后端数据请求官方文档
- better-scroll: 移动端滚动如丝般丝滑插件官方文档)
- react-redux: redux与React连接绑定库官方文档
- react-router、react-router-dom: 路由(页面)跳转依赖包官方文档
- react-transition-group: css过度动画官方文档
- redux: 数据状态管理库中文文档
- redux-thunk: 处理异步逻辑的redux中间件npm官网介绍
- styled-components: css in js,组件样式常用写法官方文档
移动端适配及reset
- 适配
因为不同手机型号屏幕分辨率不一样,为了更好适应不同屏幕大小,我们需要做移动端适配操作;其原理是根据标准屏幕宽度
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>
- css-reset
css-reset是每个项目前期准备必备工作,因为不同浏览器对很多标签有默认属性,为了更好统一样式,我们需要去重置这些属性样式,这里就不copy代码了,网上找个reset样式就行。
redux总仓库
- 在
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
插件。浏览器插件显示如下:
- 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
,用于引入并合并各个子仓。
- 引入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
仓库数据,实现react
与redux
连接(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>
</>
)
}
...
组件效果
组件介绍
此页面滚动效果非常丝滑,原因是在最外层包裹了一个组件
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
文件统一向总仓库暴露reducer
和actionCreators
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))
},
...
}
}
订阅组件
组件效果
组件介绍
该组件样式与首页一致,只是把弹出层内容改为了取消订阅,该按钮触发一个
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-mobile
的Tabs
组件和swiper
组件,其中Tabs
是实现导航切换的组件,swiper
是实现轮播的组件,通过监听事件函数双向绑定当前选中Tab
,即可实现滑动切换。
效果如下
关键代码(单纯实现滑动切换)
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>
</>
)
}
管理分类组件
效果如下(注意二级路由变化):
图标的显示与隐藏
点击右上角“管理”,
+
和-
功能图标就会消失,管理
字样也会相应改变为主题色并变为完成
,这是通过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))
搜索组件
效果如下:
很遗憾,没有做模糊查询功能,只是简单写了一下搜索框聚焦及当搜索框有内容时原推荐搜索内容隐藏。只需要通过
onChange
事件拿到搜索框的值,然后根据是否为空,将推荐内容的display
设为""
或none
,即能达到效果。
小趣味组件
夜间模式:
夜间模式的更改,也就是主题样式的修改,在全局样式
: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>
)
注意事项
- 在完成相应增删改查后,或者打开夜间模式按钮后,再次回到页面,数据都会重新渲染,不能记录上次的执行结果,于是我采取了一个比较笨的方式,给仓库添加了一个状态,只有在页面首次渲染时为
false
,之后都为true
,然后在useEffect
或者onClick
事件中加个判断,如果为true
便不重新渲染页面,页面会保持上次状态。 - 在引用
antd-mobile
的Popup
弹出层组件时,第一次我直接放在map
循环中引用,发现弹出层组件样式背景变成了黑色,并把原组件完全遮盖住了,经过一番尝试后,发现不能放在map
循环中,因为map
循环会反复执行,会反复渲染页面,导致样式或逻辑错误,包括点击事件,也不要直接放在map
循环中,要单独拎出来,放在一个单独的组件中,这样也有利于代码可读性。
源码及预览
项目预览:点击预览
Git源码地址:Git源码地址
GitHub源码地址:GitHub源码地址