前言
😄😄继Vue3全家桶仿网易云Demo(详情上一篇文章),我又携带React全家桶仿Eyepetizer | 开眼视频的WebApp来啦~~
项目简介
1.使用到的技术栈:
React18
+React Hooks
函数组件代码编写React-Router V6
进行路由配置编写Recoil+Recoil-Persist
持久化数据存储)- 坚守前端
MVVM
的设计理念,遵循组件化、模块化的编程思想
2.后端:
- 🙏感觉大佬提供的在线开源api接口➡️GitHub
效果图
项目结构
├─ src
├─api // 网路请求代码
├─assets // 字体配置及全局样式
├─components // 可复用的 UI 组件
├─pages // 页面文件
├─recoil // recoil 相关文件
├─route // 路由配置文件
├─utils // 工具类函数和相关配置
App.jsx // 根组件
main.jsx // 入口文件
页面结构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H7dOKXFd-1664428704728)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/901487a722724296a4216cd3b82dff99~tplv-k3u1fbpfcp-watermark.image?)]
项目内容
Router V6内容
- 路由表配置
/* route/index.jsx文件下 */
import React from 'react';
//路由重定位
import { Navigate } from 'react-router-dom'
//Home组件和其子组件
import Home from '../pages/Home'
import Recommend from '../pages/Home/Recommend'
......
export default [
{
path: '/home',
element: <Home />,
children: [
{
path: 'recommend',
element: <Recommend />
},
{
path: 'attention',
element: <Attention />
},
{
path: 'texts',
element: <Texts />
},
]
},
......
{
path: '/',
//重定位
element: <Navigate to="/home/recommend"></Navigate>
}
]
/* App.jsx文件下 */
import React from 'react'
import './App.less'
//使用route
import { useRoutes } from 'react-router-dom';
import route from '../src/route'
export default function App() {
const routes = useRoutes(route)
return (
<div className='App'>
{routes}
</div>
)
}
- 路由的跳转实现
/* <NavLink>是<Link>的一个特定版本,具备多个组件属性,如可以设置高亮效果 */
import { NavLink } from 'react-router-dom';
......
export default function Footer() {
function getActive({ isActive }) {
return isActive ? 'Active' : ''
}
return (
<div className="Footer">
<NavLink className={getActive} to="/home/recommend">首页</NavLink>
<NavLink className={getActive} to={{ pathname: '/square' }}>广场</NavLink>
......
</div>
)
}
- 编程式路由跳转与路由传参
/*
V5 的useHistory已经被V6 的useNavigate替代实现路由跳转
路由传参有以下三种方式:
1.params(需要在路由表声明占位符)
2.search(不需要在路由表声明占位符)
3.state(不需要在路由表声明占位符)
*/
import React, { useEffect, useState, Fragment, useRef } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
......
export default function Recomment() {
const navigate = useNavigate()
//函数式路由跳转,只能用于state传参
navigate('/details',
{
replace: true,
state: {
index: index
}
}
)
}
......
/* 接收参数 */
import { useLocation } from 'react-router-dom'
export default function Details() {
......
//推荐页拿过来的index
const { state: { index } } = useLocation()
}
- 组件间通信
1.父组件向子组件通信
......
return (
<div className='Recommend'>
<ListRe recommentEye={recommentEye} handlePlay={handlePlay} />
......
</div>
)
}
/* 子组件接收 */
......
export default function listRe(props) {
const { recommentEye, name, handlePlay } = props
......
2.子组件向父组件通信(在这里是孙组件状态提升将index
传给子组件,子组件再与父组件通信)
......
//孙组件
export default function videoRe(props) {
const { handleClassDetail } = props
return (
<div className='videoRe'>
......
<i onClick={() => handleClassDetail(index)} className='iconfont icon-bofang'></i>
......
</div >
)
}
/* 父组件回调函数接收 */
export default function Details() {
const { state: { index } } = useLocation()
let handleClassDetail = async (index) => {
......
setClickVideoState(getClassDetails[index])
}
......
return (
<div className='Details' >
......
{/* 子组件 */}
<Introduction handleClassDetail={handleClassDetail}/>
......
</div>
)
}
3.兄弟间通信
兄弟间组件通信可以将父组件作为桥梁,兄弟组件进行自身的状态提升,从而达到互相通信的效果。
4.跨级组件通信(父向孙或者孙以下组件通信)
应用Provider-Consumer
的生产者消费者模式,即可在组件树间进行数据传递。
5.非嵌套关系的组件通信
页面级或者较为复杂的数据通信则需要用到数据状态共享了,这里我使用的是Recoil
。
安装Recoil:npm install recoil
将 `RecoilRoot` 放置在根组件:
......
import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue,
useSetRecoilState
} from 'recoil';
......
ReactDOM.createRoot(document.getElementById('root')).render(
<RecoilRoot>
<App />
</RecoilRoot>
)
创建appState.js文件:
import { atom, selector } from "recoil";
......
import { getVideoClass } from '../api'
//视频推荐拿到的推荐列表
export const videoState = atom(
{
key: "videoState",
default: '',
}
);
..
//依赖的 atom 发生变更时,selector 代表的值会自动更新(相当于getter)
export const powerState = selector({
key: 'powerState',
get: async ({ get }) => {
const res = await getVideoClass({
userID: get(currentUserIDState),
});
return response.name;
},
})
Recoil
的三个Hooks API
:
1.useRecoilState
:类似 useState 的一个 Hook
,可以取到 atom
的值以及 setter
函数
......
import { useRecoilState} from "recoil"
export default function Details() {
......
let [getClassDetails, setClassDetails] = useRecoilState(classState)
let [getClickVideoState, setClickVideoState] = useRecoilState(clickVideoState)
......
let handleClassDetail = async (index) => {
......
setClassDetails(videoData)
//获取点击的视频详情
setClickVideoState(getClassDetails[index])
}
......
}
2.useSetRecoilState
:只获取 setter
函数,如果只使用了这个函数,状态变化不会导致组件重新渲染
......
import { useSetRecoilState } from "recoil"
export default function Recomment() {
let setData = useSetRecoilState(videoState)
let setClassDetails = useSetRecoilState(classState)
//点击icon播放
let handlePlay = async (index) => {
......
setClassDetails(videoData)
setData([...recommentEye, ...recommentCard])
}
3.useRecoilValue
:只获取状态
......
import { useRecoilValue} from "recoil"
export default function videoRe(props) {
......
let recommentCard = useRecoilValue(classState)
return(
<div className='videoRe'>
{
recommentCard.map((item, index) => {
return (
......
)
})
}
)
</div >
}
主要页面编写
开眼短视频的UI风格在视觉上非常地简洁明了,并且在许多页面中很多地方的页面结构都是类似的,因此我都把类似的结构抽离出来作为一个组件并进行重复引用。
推荐页和日报页:
这两个页面的主要区别:推荐页面第一个渲染的内容是视频形式,而日报页面渲染的第一个内容是图片形式,我把推荐页和日报页作为父组件向共同的子组件传递某个常量值,子组件通过判断常量值来进行区分,并作出相应的渲染。
export default function listRe(props) {
......
return (
......
{/* 判断是否为第一个视频并判断是否为日报父组件传过来的数据 */}
{index === 0 && name !== 'Texts' ?
<Fragment>
{
<video ref={player} controls autoPlay muted width="100%" onPlay={play} onPause={pause} >
<source src={recommentEye[0].data.content.data.playUrl}
type="video/webm" />
</video>
}
{
Icon === true ? '' :
<Fragment>
<i onClick={() => handleRePlay(index)} className='iconfont icon-bofang inconVideo'></i>
<div className='iconMain'><p>开眼</p><p>精选</p></div>
</Fragment>
}
</Fragment> :
<Fragment>
<img src={item.data.content.data.cover.detail} />
<i onClick={() => handlePlay(index)} className='iconfont icon-bofang inconVideo'></i>
<div className='iconMain'><p>开眼</p><p>精选</p></div>
</Fragment>
}
......
)
}
关注页和广场页:
这两个页面的共同组件的数据来源唯一的区别为图片大小不一,只要设置为width:100%
即可,在图片介绍的文字里,我设置了规定的文字长度,超过一定的文字长度才会去看到“展示”,“收起”的字样;并通过过判断当前点击的index
和图片列表的index
是否一致,实现“展示”,“收起”的效果。
export default function listVideo(props) {
......
let [isAcitive, setAcitive] = useState(false)
let [isIndex, setIndex] = useState('')
function handleActive(index) {
setAcitive(!isAcitive)
setIndex(index)
}
return (
<div className='listVideo'>
<hr />
{
listData.map((item, index) => {
return (
......
<div className="listVideo-video">
<div style={{ position: 'relative' }}>
<i className='iconfont icon-bofang'></i>
<img src={item.data.content.data.cover.detail} alt="" />
</div>
<p className={isAcitive && index === isIndex ? '' : 'isActive'}>{item.data.content.data.description}</p>
{
item.data.content.data.description.length > 55 ? <h5 onClick={() => handleActive(index)}>{isAcitive && index === isIndex ? '收起' : '展开'}</h5> : ''
}
</div>
......
)
)
}
视频详情页
视频详情页主要有“简介”和“评论”两个子组件,父组件上制作了一个简单的播放器,通过操作原生video
的DOM
操作来对icon
进行点击播放、暂停和加速;点击“简介”组件的视频列表的某个播放按钮更新父组件视频播放的内容,并同步更新类似视频列表。
import React, { useEffect, useState, useRef, useLayoutEffect } from 'react'
......
export default function Details() {
//获取元素的dom操作
const player = useRef()
//播放暂停点击
let [isPlay, setPlay] = useState(false)
//处理播放和暂停
let handlePlay = (value) => {
!value ? player.current.play() : player.current.pause()
if (!value && isIcon) {
clearTimeout(time)
time = setTimeout(() => {
setIcon(false)
}, 3000);
}
setPlay(value)
}
}
优化
Recoil
推荐使用Suspense
,Suspense
将会捕获所有异步状态,另外可以配合ErrorBoundary
来进行错误捕获。
......
import { Spin } from 'antd';
ReactDOM.createRoot(document.getElementById('root')).render(
......
<React.Suspense fallback={<div className="example"><Spin /></div>}>
<BrowserRouter>
<App />
</BrowserRouter>
</React.Suspense>
)
Recoil
引入recoil-persist
进行持久化存储
Recoil
进行数据共享时,每一次刷新都会把数据给重置,因此需要安装recoil-persist
插件来进行持久化本地数据存储。
安装:npm install recoil-persist
(appState.js文件增加)使用:
import { atom, selector } from "recoil";
+ import { recoilPersist } from 'recoil-persist'
+ const { persistAtom } = recoilPersist()
export const videoState = atom(
{
key: "videoState",
default: '',
+ effects_UNSTABLE: [persistAtom],
}
);
- 页面渲染优化
Memo
如果你的组件在相同props
的情况下渲染相同的结果,那么你可以通过将其包装在React.memo
中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现。这意味着在这种情况下,React
将跳过渲染组件的操作并直接复用最近一次渲染的结果。
React.memo(function App(props) {
/* 使用 props 渲染 */
});
项目遇到的坑
useState
异步回调的问题
当一些页面需要立即获取最新的数据的时候,发现使用useState
并不能第一次时间更新,后面通过深入了解才知道useState
更新状态是异步更新。
//解决方案:通过事件回调监听数据变化
let [Icon, setIcon] = useState(false)
//监听video播放
let play = (event) => {
setIcon(true)
}
//解决方案:通过useEffect监听,监听到变化(数据变新)后再使用与渲染该数据
let [listData, setData] = useState([])
async function getData() {
......
setData(res.data.itemList)
}
useEffect(() => {
getData()
return () => {
};
}, []);
- 点击播放视频,不及时更新问题
当点击类似视频列表中的某一个播放视频按钮,其他内容是会及时更新的,比如说视频简介,视频的链接也会及时更新,但是视频的内容并没有及时更新。
//问题原代码:
<video controls width="250">
<source src="/media/cc0-videos/flower.webm" type="video/webm">
</video>
//解决方法(把source去掉):
<video controls width="250" src="/media/cc0-videos/flower.webm"></video>
- 使用
push
,pop
,splice
等直接更改数组对象的问题
setState
的更新函数会直接替换旧的state
,因此使用push
,pop
,splice
等直接更改数组对象是不被允许的。
//解决方案
增:数组解构生成一个新数组,在数组后面加上我们新增的随机数达成数组新增项
setData([...recommentEye, ...recommentCard])
删:使用filter数组进行过滤
let videoData = res.data.itemList.filter((item, index) => {
return item.type !== 'textCard' && index < 6
})
总结
本次项目是为了熟练使用React
全家桶,整体的项目内容没有全部完善,例如说一些页面还只是静态页面,因为代码是本人手把手去撸出来的,会比较缺乏代码优化和规范;但是项目页面结构比较完善,交互功能也比较俱全,需要学习的小伙伴可以把我的项目拉下来将整个项目继续开发下去,肯定会达到实践学习操作的效果哦!💪 (ps:后端接口也不是很完善,许多数据无法全部展现)
源码
项目源码地址:GitHub,欢迎star😘