绝对不容错过的Redux实战版——抖音“剪映”之创作课堂(全新升级篇)

1,350 阅读9分钟

前言

前段时间做了一个 “剪映”课程学习 的demo,还有很多功能和页面不完善,最近学习了React redux和 Hooks 可以说对React有了更深的了解,所以我把这个项目继续完善了一下,当然这篇文章会延续上一篇一样,使用到新的技术我会尽量给出详细说明,希望能够帮助到大家!

项目更新

项目展示

  • 搜索展示

search01.gif

  • 详情页实现

xiangqing02.gif

  • 购买展示

pay04.gif

  • 学习中心页面

study05.gif

项目优化说明

    1. redux 数据管理不再使用简单的fastmock传入数据,而是使用redux集中管理数据,组件数据管理功能被剥夺
    1. react-lazyload 实现懒加载
    1. 安装video.js对视频进行引入
    1. debouncereact-transition-group 中的 CSSTransition 实现搜索框的防抖和进入滑动效果
    1. 对颜色样式进行整理 对重复使用的颜色在global-style文件中统一管理
    1. 使用meno对组件进行性能优化

相关优化介绍

1. redux

  • redux 是什么?

Redux是一个用来管理管理数据状态和UI状态的JavaScript应用工具。简单一点理解,redux就是给你提供一个大仓库,当你需要对数据进行操作的时候向他请求一下,然后执行一系列的操作,把数据传到你的手上。(这里我不做太多介绍,如果对这里一点都不了解的uus建议去看些视频自学下哈技术胖redux)

  • 安装redux

    npm i redux react-redux redux-thunk

正式开始使用redux

这里我仅用首页的course数据进行示范,其他页面的数据都是类似就不重复介绍了

  1. 建立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仓库的核心,暴露子仓库的reduceractionCreators

  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'
  1. 页面组件连接仓库

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-1.gif

  • 代码如下

外层使用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做了页面滚动监听,当页面滚动到不同的位置时出现激活效果,简介列在顶部的时候不显示,向下移动的时候出现

  • 实现效果

header-1.gif

  • 代码实现
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中进行相应的状态判断,并将数据返回给页面

  • 实现效果

zan-1.gif

  • 代码展示

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弹窗开关也一起传入

  • 实现效果

pay-1.gif

  • 代码展示

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;即可实现

  • 效果实现

study_.gif

  • 代码展示
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 数据流实战,绝对不亏。后期这个项目应该不会有太大改动了,最多会再加几个板块,期待能够再出一篇后续。

项目地址:github gitee

感谢观看到最后,如果觉得还不错的话请不要吝啬你的赞,这是我写文章的最大动力!如果有想法或者建议欢迎在评论区留言,谢谢!