我正在参加「创意开发 投稿大赛」详情请看:AdminJS创意开发大赛来了!
一 . 前言呀~
前段时间,Cavan在AdminJS上分享了一篇 【构建】react打造你的第一个Bilibili首页开发项目 - AdminJS (Admin.net)的文章,收到了很多反馈和鼓励,感谢各位支持,和大佬们的指正,不以至于我有了成长的方向和动力。正好最近也学习了Redux,于是Cavan在上个初级项目构建的基础上进行完善并增加新页面,使用React-Hooks + Redux完善了更为完整的Bilibili项目。
在这篇(更新)文章中,Cavan将会分享如何完整的实现一个React-Hooks + Redux项目,主要介绍如何使用Redux实现数据流管理,以及项目优化和Cavan遇到的小坑。
持续更新,持续学习,欢迎关注收藏,希望对你所有帮助~
在 线 体 验 地 址 : BilibiliLike
二 . 你将学到这些
1. 主要开发业务
react 18.0.0 + react-dom
:时下最流行的 MVVM框架 React开发流程
redux
:时下大厂必备的 全局数据流管理功能
react-hooks
:各种 Hooks,实操使用小技巧
antd-mobile
:移动端最好用的,来自于阿里的,封装套用组件的使用指南
react-router
:路由配置,和二级路由实现
styled-components
:React 开发常用样式搭建方法
axios
:前端界面,拉取后端数据的,最新异步Promise网络请求方法 + api工程化封装流程
fastmock
:穷学生必备免费后端小接口
项目架构搭建标准
:开发一个项目,可以这么分项目解构和资源层级 ...
2. 实用小技巧
classnames
: 动态添加类名,实现可操控的样式方法
prop-types
: 严格控制父子组件传值的类型合理性
react-lazyload
:懒加载 优化用户第一次进站体验
memo
: React自带 页面渲染性能优化 让你的网页选择性渲染需要更渲染资源的组件
vite.config.js
: 配置小tips 端口配置 路径配置
初始化相对单位
:自适应手机像素比例 配置方法
新手简单取数据小技巧
:非爬虫,爬虫爬的好,牢饭少不了(bushi
细节开发数据处理小函数
:正则匹配应用 + 时间戳转换具体时间 + 数字数据格式化 ...
三 . 为你展示项目
1. 主页展示
react 18.0.0 + react-dom
:时下最流行的 MVVM框架 React开发流程redux
:时下大厂必备的 全局数据流管理功能react-hooks
:各种 Hooks,实操使用小技巧antd-mobile
:移动端最好用的,来自于阿里的,封装套用组件的使用指南react-router
:路由配置,和二级路由实现styled-components
:React 开发常用样式搭建方法axios
:前端界面,拉取后端数据的,最新异步Promise网络请求方法 + api工程化封装流程fastmock
:穷学生必备免费后端小接口项目架构搭建标准
:开发一个项目,可以这么分项目解构和资源层级 ...classnames
: 动态添加类名,实现可操控的样式方法prop-types
: 严格控制父子组件传值的类型合理性react-lazyload
:懒加载 优化用户第一次进站体验memo
: React自带 页面渲染性能优化 让你的网页选择性渲染需要更渲染资源的组件vite.config.js
: 配置小tips 端口配置 路径配置初始化相对单位
:自适应手机像素比例 配置方法新手简单取数据小技巧
:非爬虫,爬虫爬的好,牢饭少不了(细节开发数据处理小函数
:正则匹配应用 + 时间戳转换具体时间 + 数字数据格式化 ...1. 主页展示
1.1. 加载过程 + 二级路由 步骤分解
1.2. 懒加载 步骤分解
2. 视频详情页展示
2.1. 文字轮播效果 + 视频信息展开收起 步骤分解
2.2. Swiper+Tabs可滑动可选菜单栏 + 分评论点赞取消点赞 步骤分解
3. 个人主页展示
3.1. 区域可选切换Tabs + 文字展开收起 步骤分解
四 . 带你实现代码
1. Redux 是这样子配置的
1.1. redux 架构思路
- 分仓库
- 数据管理和组件,在有了 redux 后,变成了平级关系
/store /page
- 模块化数据管理,每个模块 reducer+action 下放到页面级路由模块中,方便管理
- 每个模块都提供 index.js , 方便统一管理 store,
所有的 reducer,action,constans 都一起 export,作为清单文件
- 主仓库
- 用于统一管理各个分仓数据,并给根组件提供Provider功能的store,和state树根
1.2. 主仓库配置
- index.js
import { createStore, compose, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import reducer from './reducer'
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ // 引入Redux可视化插件
|| compose;
const store = createStore(
reducer, // 仓库数据
composeEnhancers( // 组合中间件
applyMiddleware(thunk) // 异步用中间件
)
)
export default store;
- reducer.js
import { combineReducers } from 'redux'
import { reducer as RecommendPart } from
'@/pages/VideoDetail/RecommendPart/store'
import ...
// 引入并合并分仓
export default combineReducers({
donghuatuijian: DonghuaTuijianReducer,
shouye: ShouyeReducer,
space: SpaceReducer,
recommend: RecommendPart,
comments: CommentsReducer
})
- main.js(根组件配置:Provider声明式开发,提供给子组件数据管理功能)
import {BrowserRouter} from 'react-router-dom'
import { Provider } from 'react-redux'
import store from './store'
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider> )
1.3. 分仓库配置 + 点赞/取消点赞功能介绍
- 在评论区列表中,建立仓库文件夹store
- store 分为四个文件 分别是:
- index.js(负责整理仓库功能,并统一向外输出)
import reducer from './reducer'
import * as actionCreators from './actionCreators'
import * as constants from './constants'
export {
reducer,
actionCreators,
constants
}
- actionCreators.js(负责统一管理数据状态改变的函数执行,给reducer分配相应的action:状态类型,数据)
import * as actionTypes from './constants'
import { getCommentsListRequest } from '@/api/request'
const changeCommentList = (data) => ({
type: actionTypes.SET_COMMENTLIST,
data
})
export const getCommentList = () => {
return (dispatch) => {
getCommentsListRequest() // 异步请求 axios 外部数据
.then(data => {
dispatch(changeCommentList(data.data.replies))
})
}
}
export const changeDianzan = (id) => {
return ({ // 通知点赞模块调整点赞状态
type: actionTypes.SET_DIANZAN,
id
})
}
- reducer.js(负责根据action值,做相应操作,以实现数据流管理)
import * as actionTypes from './constants'
const defaultState = {
commentList: [],
enterLoading: true
}
export default (state = defaultState, action) => {
switch (action.type) {
case actionTypes.SET_DIANZAN:
return {
...state,
commentList: state.commentList.map(item => {
// 当 匹配到 相应评论数据的id值 ,在该数据上就行like数调整
// item.action 初始值为 0 ,用于做是否已点赞判断
// 0 未点赞(可点赞) ;1 已点赞(可取消赞)
if (item.rpid == action.id) {
if (!item.action) {
item.like++;
item.action++;
}
else {
item.like--;
item.action--;
}
}
return item
}),
}
case actionTypes.SET_COMMENTLIST:
return {
...state,
commentList: action.data
}
default:
return state;
}
}
- 需求store组件:index.js
const CommentsPart = (props) => {
const { commentList } = props;
const { getCommentListDispatch, setDianzanDispatch } = props;
const ChangeDianzan = (id) => {
setDianzanDispatch(id)
}
useEffect(() => {
getCommentListDispatch();
}, [])
return (
<ListWrapper>
<div className="list">
<ul>
{
commentList.map(comment => {
return (
<CommentItem
comment={comment}
key={comment.rpid}
ChangeDianzan={ChangeDianzan}
/>
)
})
}
</ul>
</div>
</ListWrapper>
)
}
const mapStateToProps = (state) => {
return {
commentList: state.comments.commentList,
idtest: state.comments.idtest,
}
}
const mapDispatchToProps = (dispatch) => {
return {
getCommentListDispatch() {
dispatch(getCommentList())
},
setDianzanDispatch(id) {
dispatch(changeDianzan(id))
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(memo(CommentsPart))
// 子组件 点击控制 点赞Redux执行 传回给父组件 setDianzanDispatch(id)
<span className="like"
onClick={() => ChangeDianzan(rpid)}
>
2. 二级路由巧实现
- 数据管理和组件,在有了 redux 后,变成了平级关系 /store /page
- 模块化数据管理,每个模块 reducer+action 下放到页面级路由模块中,方便管理
- 每个模块都提供 index.js , 方便统一管理 store, 所有的 reducer,action,constans 都一起 export,作为清单文件
- 用于统一管理各个分仓数据,并给根组件提供Provider功能的store,和state树根
import { createStore, compose, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import reducer from './reducer'
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ // 引入Redux可视化插件
|| compose;
const store = createStore(
reducer, // 仓库数据
composeEnhancers( // 组合中间件
applyMiddleware(thunk) // 异步用中间件
)
)
export default store;
import { combineReducers } from 'redux'
import { reducer as RecommendPart } from
'@/pages/VideoDetail/RecommendPart/store'
import ...
// 引入并合并分仓
export default combineReducers({
donghuatuijian: DonghuaTuijianReducer,
shouye: ShouyeReducer,
space: SpaceReducer,
recommend: RecommendPart,
comments: CommentsReducer
})
import {BrowserRouter} from 'react-router-dom'
import { Provider } from 'react-redux'
import store from './store'
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider> )
import reducer from './reducer'
import * as actionCreators from './actionCreators'
import * as constants from './constants'
export {
reducer,
actionCreators,
constants
}
import * as actionTypes from './constants'
import { getCommentsListRequest } from '@/api/request'
const changeCommentList = (data) => ({
type: actionTypes.SET_COMMENTLIST,
data
})
export const getCommentList = () => {
return (dispatch) => {
getCommentsListRequest() // 异步请求 axios 外部数据
.then(data => {
dispatch(changeCommentList(data.data.replies))
})
}
}
export const changeDianzan = (id) => {
return ({ // 通知点赞模块调整点赞状态
type: actionTypes.SET_DIANZAN,
id
})
}
import * as actionTypes from './constants'
const defaultState = {
commentList: [],
enterLoading: true
}
export default (state = defaultState, action) => {
switch (action.type) {
case actionTypes.SET_DIANZAN:
return {
...state,
commentList: state.commentList.map(item => {
// 当 匹配到 相应评论数据的id值 ,在该数据上就行like数调整
// item.action 初始值为 0 ,用于做是否已点赞判断
// 0 未点赞(可点赞) ;1 已点赞(可取消赞)
if (item.rpid == action.id) {
if (!item.action) {
item.like++;
item.action++;
}
else {
item.like--;
item.action--;
}
}
return item
}),
}
case actionTypes.SET_COMMENTLIST:
return {
...state,
commentList: action.data
}
default:
return state;
}
}
const CommentsPart = (props) => {
const { commentList } = props;
const { getCommentListDispatch, setDianzanDispatch } = props;
const ChangeDianzan = (id) => {
setDianzanDispatch(id)
}
useEffect(() => {
getCommentListDispatch();
}, [])
return (
<ListWrapper>
<div className="list">
<ul>
{
commentList.map(comment => {
return (
<CommentItem
comment={comment}
key={comment.rpid}
ChangeDianzan={ChangeDianzan}
/>
)
})
}
</ul>
</div>
</ListWrapper>
)
}
const mapStateToProps = (state) => {
return {
commentList: state.comments.commentList,
idtest: state.comments.idtest,
}
}
const mapDispatchToProps = (dispatch) => {
return {
getCommentListDispatch() {
dispatch(getCommentList())
},
setDianzanDispatch(id) {
dispatch(changeDianzan(id))
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(memo(CommentsPart))
// 子组件 点击控制 点赞Redux执行 传回给父组件 setDianzanDispatch(id)
<span className="like"
onClick={() => ChangeDianzan(rpid)}
>
- 二级路由实现思路:
- 首先,利用antd-mobile的Tabs组件,实现每一层菜单样式
- 其次,把菜单数据封装在一个对象数组中。结构如:
const CannelData = [ { "cannelname": "/donghua", "ctitle": "动画", "children": [ { "cannelname": "/donghua/1", "ctitle": "推荐" },...] } ] ```
- 因为 Tabs 需要 activeKey 来设置选中的分区。所以要动态读取path,更新activeKey值。
let fenqu = pathname.match(/^\/[^\/]*/); <Tabs activeKey={fenqu}> // fenqu 还能用于 navigate 跳转每个分区的推荐页 const { pathname } = useLocation(); const navigate = useNavigate(); if (/^\/\w+$/.test(pathname) || /^\/\w+\/$/.test(pathname)) { // 找到第一个斜杆后的路由参数,如果有,就做二级子路由跳转 let fenqu = pathname.match(/^\/[^\/]*/) navigate(`${fenqu}/1`) } // 路由实现是通过解套数组,map出数据 // 优点是:可以把通过改变api数据,去增删改查分区路由 CannelData.map( (item) => { return ( <Tabs.Tab title={ // 注意:title 中 装菜单详细html标签(离谱) // NavLink 会给所选路由加个active,以便所选路由可视化 <NavLink to={item.cannelname} className={classnames({ active: pathname == item.cannelname })}> <span>{item.ctitle}</span> </NavLink> } key={item.cannelname} > </Tabs.Tab> ) } ) // 注意:这段代码要实时监听pathname,实现重新选中activeKey! // 可以用 useEffect(()=>{...},[pathname]) 实现
- 二级路由实现
// 简单聊一下吧: const CannelItems = () => { const res = CannelData.filter( ({ children }) => children.length > 0 ) const items = res.filter( ({ cannelname }) => pathname.includes(cannelname) ) } // 首先 二级路由也要读取pathname以便加载出需要的二级子路由数据 // 其次 这边map前,要做两次数据筛选: // 1. 有孩子(二级菜单)的才要加载二级路由,没有的就把二级路由栏隐藏掉,不能影响布局 // if (isPathPartlyExisted(pathname)) return; // 可以写个工具函数隐藏没有二级路由的菜单栏 // 2. 再通过pathname取到所在分区的数据,再去给map输出子分区数据 // 子路由数据 map 逻辑和父路由一样 items.map... 就好
- 下拉分区实现
- antd-mobile 魔改 Dropdown 实现
- 最主要是 pathname 监听,实现classNames的active改变,与 二级路由activeKey 实现 共同跳转路由 + active 响应
- ref useRef 用于绑定dom值,这里用于恢复下拉框标签
<DropdownWrapper ActiveKey={pathname}>
<Dropdown arrow={<DownOutline />} ref={ref}>
<Dropdown.Item key='sorter' title=''>
<DrawerWrapper>
<div>
{
CannelData.map(
(item) => {
return (
<NavLink key={item.cannelname}
to={item.cannelname}
className={classnames({ active: pathname == item.cannelname })}
onClick={() => {
ref.current?.close()
}}
>
<span>{item.ctitle}</span>
</NavLink>
)
}
)
}
</div>
<i className="iconfont general_pullup_s" onClick={() => {
ref.current?.close()
}}></i>
</DrawerWrapper>
</Dropdown.Item>
</Dropdown>
</DropdownWrapper>
3. 轮播文字妙实现
- 参考文章:文字轮播与图片轮播?CSS 不在话下 - AdminJS (Admin.net)
- @chokcoco 膜拜大佬的css 简单易懂!!!
4. 视频信息下拉框小动画爽实现
- antd-mobile 魔改 Collapse组件 实现下拉和收起
- 上拉收起小细节:
- 小头像的消失,点赞、收藏、缓存图标的显示
// 用一个变量show,实现display是否消失小头像和播放量数据 let show = display ? { "display": "" } : { "display": "none" }; <div className="left" style={show}> <a className="avatar" href='/space'> <img src="xxx" className="bfs-img face"/> </a> <a className="name" href='/space'>CAVAN咔叽</a> <span className="view-stat">4万观看</span> </div> <div className="right"> ... </div> // 因为 left 和 right 都是inline-block,当 left 被 "display": "none";right 就会并入行首。从而实现,小头像收起,点赞数据向左并入
5. Tabs与Swiper的绑定 实现菜单和数据 双向绑定
- 代码很清晰,可以看一下
import React, { useRef, useState } from 'react'
import { Tabs, Swiper } from 'antd-mobile'
import { TabsWrapper } from './style'
import RecommendPart from '../RecommendPart'
import CommentsPart from '../CommentsPart'
const tabItems = [
{ key: 'recommendPart', title: '相关推荐' },
{ key: 'commentsPart', title: '评论 145' },
]
const TabPart = () => {
const swiperRef = useRef(null)
const [activeIndex, setActiveIndex] = useState(0)
return (
<TabsWrapper>
<div className='v-switcher__header'>
<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
indicator={() => null}
ref={swiperRef}
defaultIndex={activeIndex}
onIndexChange={index => {
setActiveIndex(index)
}}
>
<Swiper.Item>
<RecommendPart />
</Swiper.Item>
<Swiper.Item>
<CommentsPart />
</Swiper.Item>
</Swiper>
</div>
</TabsWrapper>
)
}
export default TabPart
- 实在不会的话,可以参考antd-mobile Tabs 标签页 - Ant Design Mobile
6. 性能优化Part~:
- memo
- import { memo } from 'react'
- export default memo(xxxx)
- 就可以实现减少渲染重复未变数据
- lazyLoad
<LazyLoad
// 占位图片
placeholder={<img
src={placeholderImg}
className='m-bfs-pic pic'
/>}
>
<img src={pic}
className={classnames("m-bfs-pic pic", { notfond: !pic })} />
</LazyLoad>
- 路由懒加载
- import { lazy, Suspense } from "react"
- const XXX = lazy(() => import('@/pages/XXX'))
- 类似于下方