仿抖音组件2.0 -React Hooks + Redux

993 阅读10分钟

前言

前段时间,用react斗胆模仿了一下抖音,制作了一下抖音的播放组件。这次继续(还敢),在原来的基础上用上对代码进行了优化,用Redux进行跨页面级别的数据传输,并且添加了几个页面。

项目简介

  • react + react-router:组件化+路由切换构建用户界面的 MVVM 框架
  • redux:用来对数据流进行管理
  • styled-components:用来对样式组建进行样式编写
  • axios:用来对后端发送数据请求,用来获取后端数据
  • antd-mobile:蚂蚁金融团队推出开源组件库。
  • react-transition-group:动画过度库,给页面的路由切换带来动画效果

成果展示

界面展示

未标题-1.gif

页面交互

未标题5.gif

项目结构

|—— public       //自适应配置 20px=1rem
|——src
    |——api      //网络请求与相关配置
    |——assets   //全局样式与字体配置
    |——components         //可以复用的UI组件
    |——pages              //页面显示
    |——routes             //路由配置问件
    |——store              //redux相关配置文件
    App.jsx              // 根组件 
    main.jsx             // 入口文件

具体的详细代码可以去github或gitee详细观看

具体实现

路由配置

这里的路由配置使用了懒加载,加载首页的时候并不需要加载其他业务模块,因此这些业务模块对应的组件都可以通过懒加载的形式来引入,加快首屏渲染速度,提高用户转化率。

import { lazy } from 'react'
import { Routes, Route } from 'react-router-dom'
import Home from '../pages/Home'
const Friend = lazy(() => import('../pages/Friend'))  
const Search = lazy(() => import('../pages/Search'))  
const Mine = lazy(() => import('../pages/Mine'))  
const Message = lazy(() => import('../pages/Message'))  
const UserDetail= lazy(() => import('../pages/UserDetail'))  
const DouyinRank = lazy(() => import('../pages/Search/DouyinRank'))  


const RoutesConfig=()=>(
    <Routes>
        <Route path='/' element={<Home/>}></Route>
        <Route path='/home' element={<Home/>}></Route>
        <Route path='/mine' element={<Mine/>}></Route>
        <Route path='/friend' element={<Friend/>}></Route>
        <Route path='/message' element={<Message/>}></Route>
        <Route path='/userdetail/:id' element={<UserDetail/>}></Route>
        <Route path='/search' element={<Search/>}>
                <Route path="/search" element={<DouyinRank/>}/>
                <Route path="/search/douyinrank" element={<DouyinRank/>}/>
                

        </Route>


    </Routes>
)

export default RoutesConfig

app.jsx文件

import { useState,Suspense,memo} from 'react'
import './App.css'
import RoutesConfig from './routes/index'

function App() {
  return (
    <div className="App">
      <Suspense fallback={<div>loading...</div>}>
        <RoutesConfig />
      </Suspense>
    </div>
  )
}
export default memo(App)

redux配置

如果说父子之间的数据传输,还可以直接通过props直接传输。可是跨页面级数据共享传输props传值就没有办法做到了。这时就需要用到redux了,例如在刷Home页面你点亮了小红心,那么你的资料页面喜欢列表下就需要将你点赞的视频加入到该列表下,取消红心,那么该视频就需要从列表里清除。

首先我们需要创建一个总的store,整个应用只有这一个store对象。

  • store/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;
  1. redux-thunk :可以实现redux处理异步action
  2. applyMiddleware: 可以增强原始createStore返回的dispatch的功能。
  3. const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose:可以让浏览器插件 Redux DevTools 生效,以便开发人员模式运行应用。

并且我们还需要在react-redux中引用Provider将整个App包裹起来,并且将总的store传过去。

  • main.jsx代码如下
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import './index.css'
import 'font-awesome/css/font-awesome.min.css'
import './assets/styles/reset.css'
import './assets/font/iconfont.css'
import store from './store'


ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
  <BrowserRouter>
    <App />
  </BrowserRouter>
</Provider>
)

本项目中我们拆分了reducer,每个页面都拥有一个独立管理statereducer函数,然后再合并为一个总的reducer

  • store/reducer.js 的代码如下

import { combineReducers } from "redux";

import {reducer as searchReducer } from '@/pages/Search/store/index'
import {reducer as videosReducer } from '../pages/Home/store/index'
import {reducer as mylikelistsReducer} from '../pages/Mine/store/index'

export default combineReducers({
    search:searchReducer,
    videos:videosReducer,
    mylike:mylikelistsReducer
})


这样我们总的redux算是基本建成了,现在就是在各个页面的建子store了,每当我们在store中dispatch一个action时,store中的数据就会变成你需要的数据(当然这需要你自己编写函数逻辑才能获得你想要的数据)。

实现Home页面

页面结构

|——Home
    |——store
        |——actionCreator.jsx
        |——constants.jsx
        |——index.jsx
        |——reducer.jsx
    |——Video
    |——index.jsx
    |——style.js
    

  • reducer.jsx代码+constants.jsx代码
import * as actionTypes from './constants'
const defaultState={
    videosList: []
}



export default (state=defaultState,action)=>{
    
    switch (action.type){
        case actionTypes.CHANGE_VIDEOS_LIST:
            return {
                ...state,
                videosList:action.data
            }

        default:
        return state
        
    }
}

// constants.js
export const CHANGE_VIDEOS_LIST= 'CHANGE_VIDEOS_LIST'
  • actionCreator.js代码
import * as actionTypes from "./constants"
import { getVideosRequest } from "../../../api/request"


const changeVideosList =(data)=>({
    type:actionTypes.CHANGE_VIDEOS_LIST,
    data
})

export const getVideosList = ()=>{
    return(dispatch)=> {
        getVideosRequest()
          .then(data=>{
            const action = changeVideosList(data.data);
            dispatch(action)
          })
    }
}

redux仓库建好了之后,我们还需要通过一些工具将react与redux连接起来,这个工具就是react-redux,它给我们提供了两个重要的对象,Providerconnect

  • provider我们在前面已经使用过了,我就不多说了。
  • connect将react和Redux的store连接起来。

具体的代码如下:

import React, { useEffect, useState,memo} from 'react'
import './style.css'
import Video from '../Home/Video'
import Header from '../../components/Home_header';
import Bottom from '../../components/common/Bottom';
import { getVideosList } from './store/actionCreators'
import { connect } from 'react-redux'
import {changeMyLikeList,
        addLikeing,
        delsteLikeing
} from '../Mine/store/actionCreators'


// import { useEffect, useState } from 'react'

function Home(props) {

  // const [liked,setLiked]=useState(false)
  const { videosList,like } = props
  const { getVideosListDispatch,
          changeMyLikeListDispatch,
          addLikeStateDispatch,
          deleteLikeStateDispatch
  } = props

  useEffect(() => {
    getVideosListDispatch()
    
  }, [])
  
   const addLike =(item)=>{
    changeMyLikeListDispatch(item)
    addLikeStateDispatch(item)
    // setLiked(false)
   }
   const deleteLike=(item)=>{
    deleteLikeStateDispatch(item)

   }
  return (

    <div >
    </div>
  )

}

const mapStateToProps = (state) => {
  return {
    videosList: state.videos.videosList,
    like:state.mylike.like


  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    getVideosListDispatch() {
      dispatch(getVideosList())
    },
    changeMyLikeListDispatch(data){
      dispatch(changeMyLikeList(data))
    },
    addLikeStateDispatch(data){
      dispatch(addLikeing(data))
    },
    deleteLikeStateDispatch(data){
      dispatch(delsteLikeing(data))
    }


  }
}
export default connect(mapStateToProps, mapDispatchToProps)(memo(Home))

我们需要用connect将redux与组件连接起来,我们需要向connect中传入两个参数:mapStateToProps,mapDispatchToProps。

  • mapDispatchToProps函数将数据状态传入props中。
  • mapDispatchToProps函数将action传入props中。 这样我们就可以到使用redux仓库从后端拿到的数据了。

Mine页面的实现

  • reducer.jsx代码
import * as actionTypes from './constants'
const defaultState={
    mylikeLists: [],
    like:[]videos
}
const addvideos = (list,data) => {
    let newList=list;
    
    newList.push(data);
    return newList;
  }
const changelike=(list)=>{
    let newList=list;
    
    return newList
}  
const deletelike =(list,data)=>{
  let newList=list;
  //在mylikelist中通过indexof找知道指定的id 首次出现的位置 并删除它
  newList.splice(newList.indexOf(newList.find(function(element){ 
    return element.id === data.id; }
    )
), 1);
  return newList

}

export default (state=defaultState,action)=>{
    console.log('123456',action.data);
   
    switch (action.type){
        case actionTypes.CHANGE_MYLIKE_LIST:
            return {
                ...state,
                mylikeLists:addGoods(Object.assign([],state.mylikeLists),action.data)
            }
        case actionTypes.ADD_LIKE:
            return{
                ...state,
                like:changelike(Object.assign([],state.mylikeLists))
            }
        case actionTypes.DELETE_LIKE:
        return{
            ...state,
            like:deletelike(Object.assign([],state.mylikeLists),action.data),
            mylikeLists:deletelike(Object.assign([],state.mylikeLists),action.data)
        }
        default:
        return state
        
    }
}

我们需要在reduer文件下,对我们需要获取的数据进行处理,例如我们需要或取点赞的视频,我就写了一个addVideo函数对数据进行处理,当点击事件触发时发送action对reducer进行识别之后,将传过来的数据通过addVideo函数放到一个新的数组中去,这个数组就是我们个人资料页面需要拿到的视频数据。

  • constants.jsx代码
export const CHANGE_MYLIKE_LIST= 'CHANGE_MYLIKE_LIST'
export const ADD_LIKE='ADD_LIKE'
export const DELETE_LIKE='DELETE_LIKE'

  • actionCreator.js代码
import * as actionTypes from "./constants"
import { getSearchRequest } from "../../../api/request"


export const changeMyLikeList =(data)=>({
    type:actionTypes.CHANGE_MYLIKE_LIST,
    data
})

export const addLikeing =(data)=>({
  type:actionTypes.ADD_LIKE,
    data
})

export const delsteLikeing =(data)=>({
  type:actionTypes.DELETE_LIKE,
    data
})
  • Mine/index.jsx代码如下:
import React, { useState, useEffect,memo } from 'react'
import Bottom from '../../components/common/Bottom'
import { Wrapper } from './style'
import { Image,Tabs,Empty } from 'antd-mobile'
import { CSSTransition } from 'react-transition-group'
import { useNavigate } from 'react-router-dom';
import { ShowplayerWrapper } from '../../components/DetailvideoNav/style'
import { PictureOutline} from 'antd-mobile-icons'
import { connect } from 'react-redux'


 function Mine(props) {
  const navigate = useNavigate();
  const [show, setShow] = useState(false);
  const [more,setmore] =useState(false)
  const {mylikeLists} =props


 console.log( mylikeLists,"mylikelist");
  useEffect(() => {
    setShow(true)
    if(mylikeLists.length>0){
      setmore(true)
    }
  }, [])

  return (
          ...
          <ShowplayerWrapper>
          <Tabs activeLineMode="fixed"
            style={{
              color: "#78777d",
              "--active-title-color": "#171723",
              "--fixed-active-line-width": "33.333%",
              "--active-line-color": "#171723",
              "--content-padding": "0"
            }} >
            <Tabs.Tab title="作品" key='photo'>
              {/* <div className='playshow'>
                <div className="play"  >
                
                </div>
              </div> */}
              <div className="photo">
              <div className="circle">
              <PictureOutline fontSize={36}/>
              </div>
              <h3>发一张你被点赞最多的照片</h3>
              </div>

            </Tabs.Tab>
            <Tabs.Tab title='收藏' key='collect'>
            <div className="shoucangempty">
              <Empty imageStyle={{ width: 150 }}/>
            <h4>还没有收藏视频</h4>
            <p>用分组收藏,找到视频更方便</p>
            </div>
            </Tabs.Tab>
            <Tabs.Tab title='喜欢' key='like'>
            <div className='playshow'>
                        {
                           mylikeLists.map((item) => {
                            // {setCount(4)}
                                return (
                     
                                    <div className="play" key={item.id} >
                                        <video src={item.video}></video>
                                        <i className='iconfont icon-aixin'>{item.hearts</i> 
                                    </div>                       
                                )
                            })                                              
                        }                      
                     </div>
                    {
                      more?<p>暂时没有更多了</p>
                      :<div style={{"marginTop":"10%"}}>
                        <Empty  
                          imageStyle={{ width: 150 }}
                          description="还没有点赞视频"  />
                        {/* <p>还没有点赞视频</p> */}
                      </div>
                    }          
           </Tabs.Tab>
          </Tabs>
          </ShowplayerWrapper>
          <Bottom />
        </div>
      </Wrapper>
    </CSSTransition>
  )
}

const mapStateToProps = (state) => {
  return {
    videosList: state.videos.videosList,
    mylikeLists:state.mylike. mylikeLists,
    like:state.mylike.like


  }
}

// const mapDispatchToProps = (dispatch) => {
//   return {
//     // getVideosListDispatch() {
//     //   dispatch(getMylikeList())
//     // }

//   }
// }

export default connect(mapStateToProps,
  //  mapDispatchToProps
   )(memo(Mine))

点赞功能的实现

效果展示:

未标题-7.gif 想要实现点赞功能我们首先得分析,怎样才能实现,我们想要的效果是。当我们将首页一个视频的小红心点亮时,我们需要在我们的个人资料页的喜欢列表下显示该视屏,再点一下小红心消失,喜欢列表下的视频也随之消失。
所以我们需要写两个点击事件,一个是在初始状态下触发,另个在被点亮的时候触发。

//封装点击事件函数
   const addLike =(item)=>{
    changeMyLikeListDispatch(item)
    addLikeStateDispatch(item)

   }
   
   const deleteLike=(item)=>{
    deleteLikeStateDispatch(item)

   }
   
-----------------
//Home/Video/video_sidebar

<div className="video_sidebar_button" >
            {like.filter(item=> item.id==id).map((item)=>{return item.id}) == id? (
            <div className="heart" >
            <i className='iconfont icon-aixin1'  onClick={ deleteLike.bind(null,item) }></i>
             </div> ):(
            <i className='iconfont icon-aixin1' onClick={ addLike.bind(null,item)}
             
              ></i>                 
           )} 

---------------

 const mapDispatchToProps = (dispatch) => {
    return {
    getVideosListDispatch() {
      dispatch(getVideosList())
    },
    changeMyLikeListDispatch(data){
      dispatch(changeMyLikeList(data))
    },
    addLikeStateDispatch(data){
      dispatch(addLikeing(data))
    },
    deleteLikeStateDispatch(data){
      dispatch(delsteLikeing(data))
    }


  }
}

这些都处理完之后还要写reducer.js文件下写视屏数据的添加删除功能

  • 就是在Mine文件下的reducer.js里的文件里的代码,前面在已经展示过了我就不重复展示了。

  • 这里要注意的是我们这个点击事件不仅要实现数据交互功能,还要实现样式改变,所以这里的状态我不是通过布尔值来修改,而是通过id匹配来进行判断。 ‘

点击查看具体代码

UseDetail页面的实现

页面展示:

个人资料页面我们需要给每个路由添加一个id,因为每一个用户的个人资料页都是单独显示的

<Route path='/userdetail/:id' element={<UserDetail/>}></Route>

<div className='video_sidebar' key={id}>  //id是由页面级组件Home传过来的
      <Link to={`/userdetail/${id}`}>
        <div className="video_sidebar_button">
            <Image src={users} 
                    width={60} 
                    height={60}
                    fill="cover"
                    style={{ borderRadius: 60 }}  >  
            
            </Image>

在详情页我们还需要对数据进行筛选,选出我们需要的的那一条数据.

 let { id } = useParams();//
 
 const res = videosList.filter(
    (item) =>
      //对象包对象筛选不出来  不能加{}
      item.id == id

  )
  

这里我们需要通过用useParams来获取路由上的id,从而进行数据的筛选。useParams需要我们从react-router-dom里进行引用。

路由切换动画的实现

效果如下:

未标题-3.gif 这里的我用的是CSStranstion组件来自react-transition-group,给页面带来路由切换的动画效果。

  • 代码如下
 <CSSTransition
      in={show}                   //为控制动画开启关闭的“开关”,true为开启,false为关闭
      timeout={300}               // 动画执行的时间
      appear={true}               //是否第一次加载该组件时启用相应的动画渲染
      classNames="fly"            //为对应的样式类名,和下面的css内的名字对应
      unmountOnExit               //当动画效果为隐藏时,该标签会从dom树上移除
      onExit={() => {
        navigate(-1)
      }}
    >
      <Wrapper>
      </Wrapper>
    </CSSTransition>
    
    
    // css 代码
    transform-origin: right bottom;
    /* position: relative; */
    &.fly-enter,&.fly-appear {
        opacity: 0;
        /* 启用GPU加速 */
        transform: translate3d(100%, 0, 0);
    }
    &.fly-enter-active, &.fly-apply-active {
        opacity: 1;
        transition: all .3s;
        transform: translate3d(0, 0, 0);
    }
    &.fly-exit {
        opacity: 1;
        transform: translate3d(0,0,0)
    }
    &.fly-exit-active {
        opacity: 0;
        transition: all .3s;
        transform: translate3d(100%, 0, 0);
    }
  • appear和enter进场 exitc出场,CSStranstion应用一些列className类名来对这些动作进行描述。
  • 首先 appear与enter被应用到组件className上,接着添加“activc”类名来激活CSS动画。
  • 后exit 被应用到组件className上,接着添加“activc”类名来激活CSS动画。
  • 在动画完成后,原class改变为done表明组件动画已经应用完成并加载完成。

优化

路由懒加载

使用了React的lazySuspense组合对路由进行懒加载。

import { useState,Suspense,memo} from 'react'
import './App.css'
import RoutesConfig from './routes/index'

function App() {
  return (
    <div className="App">
      <Suspense fallback={<div>loading...</div>}>
        <RoutesConfig />
      </Suspense>
    </div>
  )
}
export default memo(App)

  • Suspense 组件
  1. React.lazy() 加载的组件只能在 组件中渲染

    目的:使得我们可以使用在等待加载 lazy 组件时做优雅降级(如 loading 指示器等)。

  2. fallback 属性

    接受任何在组件加载过程中你想展示的 React 元素。即在加载异步组件显示之前先显示的内容

页面渲染优化 Memo

每当有数据变化时,代码都会重新执行一遍,子组件的数据没有变化也会重新执行一遍。所以我们可以用memo将子组件封装起来使得子组件在数据变化的时候再重新执行一遍。

页面显示的优化

对首页视屏播放显示进行优化,平且能够更好的获取视频资源。

styled-components样式的复用

我们再编写css样式时,我们将那些可能重复用到的组件单独拎出来,这样我们下次还想用改样式时,直接引用就可以了,不需要重复编写。

遇到的问题

  • 最开始写点赞功能时,我准备使用true和false来表示点赞状态,但是发现将状态设为true与false时每当我点一个攒时我的所有的视频都会被点赞。经过几次修改还是没有办法改变一个视频的状态。所以我就换了一种方式,用id进行匹配筛选当需要传递的视频的id与正点击的视频id一致时,才能改变点赞状态,变为小红星。

结语

这就是我的"抖音2.0",虽然还是有很多的功能没有实现,但是后续我会慢慢完善。如果觉得还不错可以点个攒再走哦!
你们的点赞就是对我这个新人最大的鼓舞了。
你们有什么想法和建议可以再评论区提出来,我们可以一起讨论!

gitee源码地址
github源码地址