前言
前段时间做了一个 “剪映”课程学习 的demo,还有很多功能和页面不完善,最近学习了React redux和 Hooks 可以说对React有了更深的了解,所以我把这个项目继续完善了一下,当然这篇文章会延续上一篇一样,使用到新的技术我会尽量给出详细说明,希望能够帮助到大家!
项目更新
项目展示
- 搜索展示
- 详情页实现
- 购买展示
- 学习中心页面
项目优化说明
-
redux数据管理不再使用简单的fastmock传入数据,而是使用redux集中管理数据,组件数据管理功能被剥夺
-
react-lazyload实现懒加载
-
- 安装
video.js对视频进行引入
- 安装
-
debounce和react-transition-group 中的 CSSTransition实现搜索框的防抖和进入滑动效果
-
- 对颜色样式进行整理 对重复使用的颜色在
global-style文件中统一管理
- 对颜色样式进行整理 对重复使用的颜色在
-
- 使用
meno对组件进行性能优化
- 使用
相关优化介绍
1. redux
- redux 是什么?
Redux是一个用来管理管理数据状态和UI状态的JavaScript应用工具。简单一点理解,redux就是给你提供一个大仓库,当你需要对数据进行操作的时候向他请求一下,然后执行一系列的操作,把数据传到你的手上。(这里我不做太多介绍,如果对这里一点都不了解的uus建议去看些视频自学下哈技术胖redux)
-
安装redux
npm i redux react-redux redux-thunk
这里我仅用首页的course数据进行示范,其他页面的数据都是类似就不重复介绍了
- 建立store仓库
在src目录下创建一个store文件夹,然后在文件夹下创建一个index.js文件和reducer.js文件。
(这里是store相当于总仓 汇总多个reducer 合并子仓的store)
index.js文件 (创建整个项目的store)
// 1. 管理数据流 createStore
// 2. 模块化 分仓 管理数据 多个reducer
// 3. 修改数据
import { createStore, compose, applyMiddleware } from 'redux'
// 组件 - 中间件redux-thunk - 数据
// compose 合并中间件
import thunk from 'redux-thunk' // 支持 异步数据管理(接口请求)
import reducer from './reducer'
// 这里是使用Redux DevTools 工具方便查看redux
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(reducer,
// 创建一个仓库
composeEnhancers(
// 异步 thunk
applyMiddleware(thunk)
)
)
export default store;// 暴露仓库
reducer.js文件 (集合各个子数据仓库)
// 模块化能力 路由模块 基本上就是 数据模块
import { combineReducers } from 'redux'
// store 中央 - reducer 地方
import {reducer as courseReducer} from '../pages/Jingpin/store/index'
// 把 数据仓库 放在 页面自己的 目录下
// 原名 as 别名 取别名
export default combineReducers({
Jingpin:courseReducer,
})
2. 首页main.jsx配置
main.jsx作为容器组件,引入Provider 组件 声明式引入数据管理功能
import { Provider } from 'react-redux'
import store from './store'
// 仓库来自于:架构中的 store 模块
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
{/* BrowserRouter 路由 SPA */}
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
)
3. 建立分仓store
子仓store就在你需要数据的页面目录下 比如我的course数据需要在Jingpin页面中使用,那么就在响应的Jingpin目录下创建一个store文件夹,在store文件夹下继续创建四个文件,下面我一一为大家介绍
- index.js文件
为子store仓库的核心,暴露子仓库的reducer和actionCreators
import reducer from './reducer'
import * as actionCreators from './actionCreators'
export {
reducer,
actionCreators
}
- reducer.js 文件
这里是redux最关键的地方,完成获取数据操作数据的都在这里,首先会给状态设置一个默认值,当没有发生改变时,redux会把这个默认值传给组件,一旦有状态发生改变,接收action,首先会去匹配相应的type并执行相应的操作,同步给组件,完成MVVM操作
import * as actionTypes from './contains'
const defaultState={
allCourses:[]
}
const reducer=(state=defaultState,action)=>{
switch(action.type){
case actionTypes.CHANGE_COURSE:
return{
...state,
allCourses:action.data
}
default:
return state
}
}
export default reducer;
- actionCreators.js
这里暴露相应的函数,数据都要通过dispatch action来更新,拉取数据
import {getCourse} from '../../../api/request'
//仍是从后端获取数据 axios
import * as actionTypes from './contains'
export const changeCourse=(data)=>({
type:actionTypes.CHANGE_COURSE,
data
})
export const getCourseList=()=>{
return(dispatch)=>{
getCourse().then(data=>{
const action=changeCourse(data.data);
dispatch(action)
})
}
}
- contains.js
这里是配置文件 给type取一个别名,可以有利于代码的维护和防止程序员代码写错,后期需要添加或者修改都可以直接在这个文件修改
export const CHANGE_COURSE = 'CHANGE_COURSE'
- 页面组件连接仓库
Jingpin页面需要和仓库进行connect连接,mapStateToProps读操作,如果没有ajax请求数据,那就读取在reducer.js文件里的默认数据,mapDispatchToProps写操作,调用内置的dispatch函数获取数据
import React,{useState,useEffect} from 'react'
import { actionCreators } from './store/index'
import { connect } from 'react-redux'
function Jingpin(props) {
const {getCourseDataDispatch}=props
const {allCourses}=props;
useEffect(()=>{
getCourseDataDispatch()
},[])
return (
...
<Course allCourses={allCourses}/>
...
)
}
const mapStateToProps=(state)=>{
return{
allCourses:state.Jingpin.allCourses
}
}
const mapDispatchToProps=(dispatch)=>{
return{
getCourseDataDispatch(){
dispatch(actionCreators.getCourseList())
}
}
}
export default connect(mapStateToProps,mapDispatchToProps)(Jingpin)
这里只是简单的介绍一下在这个组件页面我是如何引用redux的,写的不是很详细,因为redux实在太复杂了,我也学了好久才搞清楚一点,但是学会了还是发现管理数据用redux真的很方便,如果这里看的一脸懵的uus,建议去看些视频自学下哈,推荐我学习的视频->技术胖redux
2. react-lazyload
- react-lazyload 懒加载
懒加载是一种对网页性能优化的方式,而图片懒加载当一个网站加载图片过多时就需要懒加载的协助,从而提高页面的加载速度,减轻服务器的压力,节省流量。因为我这里图片较少,所以我并未对图片进行处理,而是搭配神三元大佬封装的
Scroll组件,对首页Jinping和页面详情页Coursedetail里面的组件进行了懒加载
- 安装react-lazyload
npm i react-lazyload
- 相关代码
从react-lazyload中解构forceCheck函数,配合onScroll事件,当页面滑动到当前视口加载当前组件(以首页为例)
import React,{useState,useEffect} from 'react'
import {useNavigate,Outlet,useParams} from 'react-router-dom'
import { connect } from 'react-redux'
import { forceCheck } from 'react-lazyload';
import Scroll from '../../components/common/Scroll'
function Jingpin(props) {
return (
<PullToRefresh
onRefresh={doRefresh}
completeText={ <h3>刷新一下 收获更多</h3>}
>
<Wrapper>
<Scroll className="list" onScroll={forceCheck}>
...
</Scroll>
</Wrapper>
</PullToRefresh>
)
}
...
export default connect(mapStateToProps,mapDispatchToProps)(Jingpin)
3. Video.js
- Video.js 插件
Video.js 是一个通用的在网页上嵌入视频播放器的 JS 库,可以定制界面,纯javascript和css打造。Video.js相比HTML5原生的video标签视频播放,有以下几个优点:
- 有良好的跨浏览器的样式,更美观
- 支持流媒体格式
- 支持社交媒体平台,例如Youtube,Vimeo等
- 有丰富的第三方插件
- 没有浏览器兼容问题
(看起来是不是很好用,但是这个有很多坑,我真的是一路踩坑┭┮﹏┭┮)
- 安装和使用Video.js
npm i video.js
import videojs from "video.js";
import "video.js/dist/video-js.css";
官方网站(建议详细阅读官方文档)
- 相关代码
这里我只是举个例子,并不是我项目中的代码哈,因为项目中的太复杂了...
import React, { useEffect, useRef } from 'react';
import VideoJs from 'video.js';
// import videozhCN from 'video.js/dist/lang/zh-CN.json'
import 'video.js/dist/video-js.css';
import styles from './index.less';
export default function Img(props) {
const videoRef = useRef(null);
useEffect(() => {
const player = VideoJs(
videoRef.current,
{
autoplay: false, // 自动播放
muted: false, //静音
preload: 'auto', // 预加载
controls: true, // 是否显示控制条
controlBar: {
// 设置控制条组件
// /* 设置控制条里面组件的相关属性及显示与否
currentTimeDisplay: true,
timeDivider: true,
durationDisplay: true,
remainingTimeDisplay: true, // 显示倒计时时间
fluid: true,
language: 'zh-CN', // 设置语言
volumePanel: {
inline: false,
},
// */
/* 使用children的形式可以控制每一个控件的位置,以及显示与否 */
children: [
{ name: 'playToggle' }, // 播放按钮
{ name: 'currentTimeDisplay' }, // 当前已播放时间
{ name: 'progressControl' }, // 播放进度条
{ name: 'durationDisplay' }, // 总时间
{
// 倍数播放
name: 'playbackRateMenuButton',
playbackRates: [0.5, 1, 1.5, 2, 2.5],
},
{
name: 'volumePanel', // 音量控制
inline: false, // 不使用水平方式
},
{ name: 'FullscreenToggle' }, // 全屏
],
},
},
() => {
player.src(props.video);
player.poster(props.photo);
},
);
return () => {
player.dispose();
};
}, [props]);
return (
<video
ref={videoRef}
preload="true"
// className={styles.videoContent}
className={['video-js', 'vjs-16-9', 'vjs-big-play-centered', styles.videoContent].join(' ')}
playsInline
></video>
);
};
- 浅谈一下自己踩过的坑
刷新页面,视频无法加载,单页跳转页面,视频也加载失败,这里就要对video.js销毁和重构,调用dispose(),还有在刷新页面时const player=VideoJs(...)一直会报这个错误 The element or ID supplied is not valid,想了想应该是数据还未获取到,网上的方法是加一个定时器 async异步等待数据到达,不得不说video.js真的很方便,但是真的坑太多了...不想这么麻烦的使用原生的<Video/>也很好哈
4. CSSTransition
- react-transition-group
react-transition-group可以满足日常动画开发需求,而且是React官方提供的动画过渡库,而我这里使用的就是从这里解构出的CSSTransition组件
- 安装和使用react-transition-group
npm i react-transition-group
import { CSSTransition } from 'react-transition-group'
- 相关代码
使用<CSSTransition/>再加上参数设置就可以实现相关飞入飞出的效果(有点厉害)
import { CSSTransition } from 'react-transition-group'
import Scroll from '@/components/common/Scroll'
import Lazyload, { forceCheck } from 'react-lazyload';
const Search = (props) => {
return (
// 当组件挂载上去后,应用css transition效果
<CSSTransition
in={show}
timeout={300}
appear={true}
classNames="fly"
unmountOnExit
onExit={() => {
navigate(-1)
}}
>
...
</CSSTransition>
)
}
}
// export default Search
export default connect(mapStateToProps, mapDispatchToProps)(React.memo(Search))
5. global-style
这里我参考了神三元大佬的云音乐项目,对需要复用的颜色进行封装,方便修改,提高效率
-
在assets目录下建一个global-style.js文件
-
相关代码
// 全局风格定义
export default {
"theme-color": "#c20c0c",
"font-color-active":"#f92b4f",
"font-color-span-active":"#ec2e5a",
"font-color-input-active":"#f3f3f3",
}
...
import style from '@/assets/global-style'
// 引用就直接使用es6语法
background-color: ${style['font-color-span-active']};
...
6. memo
- 组件性能优化memo
memo用于性能优化, 如果当前组件中的数据没有发生变化,那么memo方法就会阻止本次组件的更新,可以大大提高性能
- 相关代码
在每一个组件下都可以添加这串代码
import { memo } from "react";
const Jinping = () => {}
export default memo(Jinping)
页面效果实现
1. 搜索页面
搜索页面用到三元大佬的手写的debounce对搜索框进行防抖,加redux实现模糊搜索,页面的飞入动画效果在之前有提到,这里就不再次说明了
- 实现效果
- 代码如下
外层使用Search组件,嵌套内层Search-box组件
Search-box输入数据并对数据进行防抖,并将输入的数据query返回到Search组件中
Search组件负责所有数据的渲染和展示,根据子组件传入的query进行响应
search
const Search = (props) => {
const {
hotList,
searchResult
} = props;
const {
getHotKeywordsDispatch,
getSearchResultDispatch,
} = props;
// 搜索内容 非共享数据,不用 redux 解决共享状态问题
const [query, setQuery] = useState('');
const [show, setShow] = useState(false);
// 搜索列表 api action redux
const searchBack = useCallback(() => {
setShow(false)
}, [])
useEffect(() => {
if(query.trim()){
getSearchResultDispatch(query)
}
}, [query])
const handleQuery = (q) => {
// 给子组件防抖后得到的数据修改方法
setQuery(q)
}
useEffect(() => {
setShow(true)
if (!hotList.length) {
getHotKeywordsDispatch();
}
}, [])
// 热词
const renderHotKey = () => {
...
}
// 搜索出的数据展示
const renderSearchList=()=>{
...
}
// 数据为空时
const renderNull = () => {
...
}
return (
<Container >
<div className="search_box_wrapper">
<SearchBox
back={searchBack}
newQuery={query}
handleQuery={handleQuery}
>
</SearchBox>
</div>
<ShortcutWrapper show={!query}>
{/* props 传值给 styled-components 做样式业务 */}
<Scroll>
<div>
<HotKey>
<h1 className='title'>热搜</h1>
{renderHotKey()}
</HotKey>
</div>
</Scroll>
</ShortcutWrapper>
<ShortcutWrapper show={query}>
<Scroll onScroll={forceCheck}>
<div className='search'>
{ searchResult == false ? renderNull() : renderSearchList() }
</div>
</Scroll>
</ShortcutWrapper>
</Container>
)
}
const mapStateToProps = (state) => {
...
}
const mapDispatchToProps = (dispatch) => {
...
}
// export default Search
export default connect(mapStateToProps, mapDispatchToProps)(React.memo(Search))
这里仅展示部分代码,store等代码欢迎到github去看
2. 课程详情页
这里我拆分成了8个部分,下面我将介绍重点几个
2.1 CourseHeader
使用antd-mobile的部分组件NavBar(导航),Popup(弹出层), Space, Button antd-mobile
header做了页面滚动监听,当页面滚动到不同的位置时出现激活效果,简介列在顶部的时候不显示,向下移动的时候出现
- 实现效果
- 代码实现
export default function CourseHeader({course}) {
const [visible, setVisible] = useState(false)
const [show,setShow]=useState(true)
const [active,setActive]=useState(true)
const navigate=useNavigate()
let {id}=useParams();
if(!id){
navigate('/home');
return;
}
useEffect(()=>{
// console.log('--------')
navigate(`/coursedetail/${id}`)
},[])
useEffect(()=>{
const debounceAjax=debounce(handleScroll,50)
window.addEventListener('scroll',debounceAjax)
window.addEventListener('scroll',handleScrollTop)
function handleScrollTop(){
const top = document.documentElement.scrollTop //获取scroll偏移值
if(top>10){
setShow(false)
}else{
setShow(true)
}
}
// 对span 进行监听
function handleScroll(){
const span1=document.getElementById("1")
const span2=document.getElementById("2")
const span3=document.getElementById("3")
const span4=document.getElementById("4")
...
}
},[])
return (
<Wrapper>
<NavBar onBack={()=>navigate('/home')} style={{textAlign:"center"}}>
{
!course.length && <div>详情</div>
}
{
course
}
</NavBar>
<div className='btn'>
<Space direction='vertical'>
<>
<Button style={{border:"none",width:"0px",height:'0px',top:"-0.5rem",right:"0.25rem"}}
onClick={() => {
setVisible(true)
}}
>
<i className='iconfont icon-fenxiang'></i>
</Button>
<Popup
visible={visible}
onMaskClick={() => {
setVisible(false)
}}
bodyStyle={{
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
minHeight: '15vh',
}}
className="popup"
>
<div className='pic'>
<a href="#">
<img src="src/assets/images/p1.jpg" alt="" />
<span>微信</span>
</a>
<a href="#">
<img src="src/assets/images/p2.jpg" alt="" />
<span>朋友圈</span>
</a>
<a href="#">
<img src="src/assets/images/p3.jpg" alt="" />
<span>QQ</span>
</a>
<a href="#">
<img src="src/assets/images/p4.jpg" alt="" />
<span>QQ空间</span>
</a>
{/* <a href="#">
<img src={p5} alt="" />
<span>微博</span>
</a> */}
</div>
{/* <div className="p">
<p style={{margin:"0.5rem", fontSize: "28px"}}>分享至朋友圈</p>
<p style={{margin:"0.5rem",fontSize: "28px"}}>分享至微信好友</p>
<p style={{margin:"0.5rem",fontSize: "28px"}}>分享至QQ</p>
<p style={{margin:"0.5rem",fontSize: "28px"}}>分享至QQ空间</p>
</div> */}
</Popup>
</>
</Space>
{/* <i className='iconfont icon-fenxiang'></i> */}
</div>
{/* */}
<div className={`banner ${show===true?"header_bg":null}`}>
<span onClick={() => window.scrollTo(0, 0)} id="1">简介</span>
<span onClick={() => window.scrollTo(0, 430)} id="2">课程</span>
<span onClick={() => window.scrollTo(0, 940)} id="3">推荐</span>
<span onClick={() => window.scrollTo(0, 1400)} id="4">评价</span>
</div>
</Wrapper>
)
}
2.2 Appraisals
这里主要是用redux 实现点赞效果,要获取当前的id 然后在redux中进行相应的状态判断,并将数据返回给页面
- 实现效果
- 代码展示
index.jsx
...
const {commonList}=props
const {getCommonDataDispatch,changeZan}=props
const setZan=(id)=>{
changeZan(id)
let anchorElement=document.getElementsByClassName("icon-dianzan")[id-1]
anchorElement.classList.toggle("collected")
}
...
<p>{item.state?item.zan:item.zan*1 - 1 }</p>
<i className="iconfont icon-dianzan" onClick={()=>setZan(item.id)}></i>
...
reducer.js
const changeCart=(list,id)=>{
let idx=list.findIndex(data=>id==data.id);
list[idx].state=!list[idx].state;
return list;
}
export default (state=defaultState,action)=>{
switch(action.type){
case actionTypes.CHANGE_ZAN:
return{
...state,
// 浅拷贝,传入数据
commonList:changeCart(Object.assign([],state.commonList),action.data)
}
default:
return state
}
}
2.3 CourseFooter
这里使用的是antd-mobile的Popup双层弹窗,将数据一层一层传入,并把open弹窗开关也一起传入
- 实现效果
- 代码展示
index.jsx
const [hasCollect, setHasCollect] = useState(false);
const [showTab, setShowTab] = useState(false);
<div className="shopping">
{/* 立即购买 */}
<Button className='btn'
onClick={() => {
setShowTab(true)
}}
>
立即购买
</Button>
<Popup
visible={showTab}
onMaskClick={() => {setShowTab(false)}}
bodyStyle={{ height: "70vh" }}>
{/* // 将商品配置组件的隐藏函数传递给该组件 */}
{<BuyTab onClose={()=>setShowTab(false)} courses={courses}/>}
</Popup>
</div>
// BugTab 判断是否勾选协议
<button onClick={() => {
if(open==true){
setVisible1(true)
}else{
Toast.show({
content: '请先勾选协议',
afterClose: () => {
console.log('after')
},
})
}
}}>立即购买</button>
// pay 支付成功就跳转到新页面否则就提示取消支付
const pullbuy=(item)=>{
Toast.show({ content: '已支付', position: 'bottom' })
changeCartListDispatch(item)
navigate(`/wode`)
}
<Button block className='btn' size='large' onClick={async () => {
const result = await Dialog.confirm({
content: '确定支付吗',
})
if (result) {
pullbuy()
// 要换成课程
&&navigate(`/wode`)
} else {
Toast.show({ content: '取消支付', position: 'bottom' })
}
}}>
确认支付
</Button>
3. 学习中心
这里就是简单的数据展示切页面,滑动效果用display: flex; overflow: auto;即可实现
- 效果实现
- 代码展示
function Wode(props) {
return (
<Wrapper>
<NavBar onBack={()=>navigate('/home')}>学习中心</NavBar>
<List header=''>
<List.Item onClick={() => {}} style={{fontSize:"16px"}}>
<i className='iconfont icon-zuijinyuedu'></i>
最近学习
</List.Item>
<div className="course">
{
allCourses&&allCourses.map(
item=>(
<div className="course-flex" key={item.id}>
<Link
className='course-List'
to={`/coursedetail/${item.id}`}
key={item.id}
>
<div className="course-Box">
<div className="course-Img">
<img src={item.img} alt="" />
</div>
<div className="course-Content">
<div className="course_header">{item.header}</div>
</div>
</div>
</Link>
</div>
)
)
}
</div>
</List>
</Wrapper>
)
}
export default connect(mapStateToProps, mapDispatchToProps)(memo(Wode))
结束
这个版本相对于旧版总算有了一点内容,不再是简单的切页面,做起来也是磕磕盼盼一路踩坑,好了很多时间不断修改,这个项目中我借鉴了很多神三元项目中的知识,如
useMemo、Scroll、CSSTransition,这里我也大力推荐一些神三元的掘金小册React Hooks 与 Immutable 数据流实战,绝对不亏。后期这个项目应该不会有太大改动了,最多会再加几个板块,期待能够再出一篇后续。
感谢观看到最后,如果觉得还不错的话请不要吝啬你的赞,这是我写文章的最大动力!如果有想法或者建议欢迎在评论区留言,谢谢!