新手必须会的react操作:路由+组件封装--酷狗推荐卡片案例

1,537 阅读11分钟

前言

最近学习react十分快乐,听歌的时候我打开酷狗首页竟然有想切图的冲动。浏览之余,我发现了一丝端倪。 recommend2.png

有没有发现“新歌首发”和“听书精选”这两个板块很像。看的我手痒痒,ok话不多说,挑战一下用react给它封装起来。

在线演示git page(这里要使用电脑查看)

项目地址gitee

注: 文章中贴的代码均不是完整代码,请到项目地址中查看源代码

案例实现

核心技术与使用到的库

  • react: react开发最核心的库,声明式构建页面和UI。
  • axios: 基于 promise 的网络请求库,用于获取后端数据,是前端常用的数据请求工具。
    • (本案例axios 搭配fastmock)使用),fastmock是一个在线网站,它使用json格式的文件模拟后端数据,并且我们可以模拟数据请求。
  • react-route:提供router核心api,如Router、Route、Switch等。完成单页页面跳转
  • react-route-dom: 实现浏览器运行环境下的一些功能,例如Link组件、BrowserRouter和HashRouter组件,通过dom操作控制路由
  • styled-components: css in js,以组件的形式声明样式,使用了es6字符串模板,这意味着你能根据组件的props实现简单的动态样式,同时它在编译阶段给类名加了随机字符的方式实现了css的私有化。
  • classnames库:使用js来动态判断是否为组件添加类名
  • antd库: Ant Design , 一套企业级 UI 设计语言和 React 组件库
  • Hook: useReducer
    • useReducer 是 useState 的替代方案。需要管理多个组件间的state时可以使用它。
    • 注:当项目更加复杂时,建议使用更加专业的react状态管理库redux

设计思路

虽然这两个板块很像,但是细一看,好家伙,竟然隐藏了这么多细节。

recommend2 - 对比.png

再细细观察一下上面的新歌首发板块:

  1. title中间上的地区分类和展示的数据是一一对应的,那么我们需要使用一个状态给他的地区索引保存起来
  2. title右侧有分页按钮,只有三页,那么到时候应该注意一下边界条件
  3. 在当前索引的地区时,高亮且显示一个小播放按钮 而听书精选又不一样:
  4. 不同于上面两列,它展示数据的box是三列排列,这里问一下读者,不同的样式你会怎么做呢?
  5. 分类高亮但没有小播放按钮,看来这里也和状态有关
  6. 卡片的右侧是播放量,而不是两个按钮
  7. title右侧只是“更多”,这样看下来这个板块更简单一点

项目目录配置

|--kugou_template
|  |--src
|  |  |--api                //数据请求文件
|  |  |  └──request.js
|  |  |--assets             //静态资源
|  |  |  └──styles
|  |  |     └──reset.css
|  |  |--components         //通用组件
|  |  |  ├──common          
|  |  |  |  └──CardItem         //数据卡片
|  |  |  ├──Header              
|  |  |  └──RecommendPanel      //推荐板块
|  |  |--pages              //普通页面组件
|  |  |  ├──Detail      
|  |  |  ├──Home                //首页
|  |  |  ├──ListenBook          //听书
|  |  |  └──RankList            //排行榜
|  |  |--routes             //路由配置文件
|  |  |  └──index.jsx
|  |--index.html
|  |--App.css
|  |--App.jsx
|  |--main.jsx
|  |--vite.config.js

基本界面

首页Home设计:Header通用组件(使用路由)+Banner样式组件+swiper轮播图+Content(这里面就是需要封装的组件啦), 结构如下

- index.jsx
    import RecommendPanel from '@/components/RecommendPanel'
    import Header from '@/components/Header'
    import { Carousel } from 'antd';
    import { Content, Banner, NewMV, Footer } from './style'
    
    <Header />
    <Banner>
        {/* 添加Swiper */}
        <Carousel autoplay>
          <div>
            <h3 className='swiper_1'></h3>
          </div>
          <div>
            <h3 className='swiper_2'></h3>
          </div>
          <div>
            <h3 className='swiper_3'></h3>
          </div>
        </Carousel>
    </Banner>
    <Content>
        {/* <NewSongs /> */}
        <RecommendPanel />
        {/* 新MV */}
        <NewMV>
          ...
        </NewMV>
      </Content>
      <Content>
        {/* <NewBooks /> */}
        <RecommendPanel  />
      </Content>
      <Footer />
      
- style.js  //这里展示的是轮播图样式基本使用 其他样式已省略
    import styled from "styled-components";
    export const Banner = styled.div`
    h3 {
        width: 1519px;
        height: 560px;
        overflow: hidden;
    }
    .swiper_1 {
        background: url('https://imgessl.kugou.com/commendpic/20220617/20220617231437393045.jpg') no-repeat center top;
    }
    .swiper_2 {
        background: url('https://imgessl.kugou.com/commendpic/20220630/20220630212022404148.jpg') no-repeat center top;
    }
    .swiper_3 {
        background: url('https://imgessl.kugou.com/commendpic/20220317/20220317145017775531.jpg') no-repeat center top;
    }
`

路由功能

Header组件中我们可以实现路由跳转,点击切换首页,榜单和听书 首先在routes文件夹下,创建路由配置文件

import React, { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
//引入组件
import Detail from '../pages/Detail'
import Home from '../pages/Home'
//需要懒加载的组件
const ListenBook = lazy(()=>import('../pages/ListenBook'))
const RankList = lazy(()=>import('../pages/RankList'))

export default function Router() {
    return (
        <Suspense fallback={<>loading...</>}>
            <Routes>
                <Route path='/home' element={<Home/>}/>
                <Route path='/book' element={<ListenBook/>}/>
                <Route path='/rank' element={<RankList/>}/>
                <Route path='/detail' element={<Detail/>}/>
                {/* 使用 Navigate 将默认路径匹配到/home */}
                <Route path='/' element={<Navigate to='/home'/>}/>
            </Routes>
        </Suspense>
    )
}

这里使用到了懒加载,我们知道先请求加载资源之后,才会渲染页面,如果页面太多,资源多或者逻辑复杂,那么就会严重影响到页面的首屏加载。 使用懒加载,进入项目默认路由路径为首页,那么其他页面资源就不会加载,当路由跳转过来时才实现资源的加载。这样首页渲染更快。 使用懒加载不要忘记用Suspense,这里简单介绍一下Suspense的工作原理:

Suspense的作用就是在遇到异步请求或者异步导入组件的时候等待请求和导入完成再进行渲染, 在这个过程完成之前会将其children都渲染成fallback的值,一旦组件导入, SuspenseComponent的子组件会重新渲染一次。

我们还需在入口文件main.jsx中,引入BrowserRouter,它提供了路由服务。

App.jsx中,我们引入路由配置文件,那么在这个单页应用下的页面都是可以使用路由进行跳转的

- main.jsx
import ReactDOM from 'react-dom/client'
import App from './App'
import { BrowserRouter } from 'react-router-dom'
import '../node_modules/antd/dist/antd.css'
import './index.css'

ReactDOM.createRoot(document.getElementById('root')).render(
    <BrowserRouter>
      <App />
    </BrowserRouter>
)

- app.jsx
import Router from './routes'

function App() {
  return (
    <div className="App">
      <Router />
    </div>
  )
}
export default App

Header组件中使用 NavLink 实现跳转,<NavLink><Link> 的一个特定版本, 会在匹配上当前 URL 的时候会给已经渲染的元素添加样式参数 它有一个属性 activeClassName: string 导航选中激活时会应用添加active样式名 那么我们可以根据这一特性动态添加点击后的样式

- index.jsx
import React from 'react'
import { NavLink } from 'react-router-dom'
import { HeaderWrapper, HeadD0, HeadLeft, HeadRight, HeadD1, HeadD1Box } from './style'

const Header = () => {
    return (
        <>
            <HeaderWrapper>
                <HeadD0>
                    <HeadLeft>
                        ...
                    </HeadLeft>
                    <HeadRight>
                        ...
                    </HeadRight>
                </HeadD0>
                <HeadD1Box>
                    <HeadD1>
                        <ul className='head1_ul'>
                            <li className="head1_li ">
                                {/* <a href="">首页</a> */}
                                <NavLink to='/home'>首页</NavLink>
                            </li>
                            <li className="head1_li">
                                <NavLink to='/rank'>榜单</NavLink>
                            </li>
                            <li className="head1_li">
                                <NavLink to='/book'>听书</NavLink>
                            </li>
                        </ul>
                    </HeadD1>
                </HeadD1Box>
            </HeaderWrapper>
        </>
    )
}
export default Header

- style.js
.head1_ul {
    width: 1000px;
    height: 60px;
    display: flex;
    margin: 0 auto;
    .head1_li {
        height: 100%;
        flex: 1;
        display: flex;
        align-items: center;
        justify-content: center;
        text-align: center;
        a {
            width: 72px;
            color: #000;
            //在这里添加 active 样式
            &.active {
                display: inline-block;
                color: #fff;
                font-weight: 700;
                background: #00a9ff;
                height: 34px;
                line-height: 34px;
                border-radius: 18px;
            }
        }
    }
}

在线演示看一下路由跳转吧

封装卡片CardItem

实现基本页面样式后,我们来到本案例的核心,封装组件,首先封装详情卡片

songCard.png

bookCard.png

  1. 可以看到,两种卡片都是左中右结构,且左中样式是一样的,那么就可以写通用样式了
  2. 而右侧不同,就需要父组件传来的状态来展示.
  3. 这里使用classnames库给右侧动态添加类名,展示不同样式
  4. 另外,这里设计的不同卡片的宽度是不同的,这时styled-components的优势就体现出来了,我们直接给样式组件传参,然后在style.js文件中接收到参数设定不同的宽度

卡片封装时一些样式有点复杂,源样式代码稍长,下面代码为剪切版

- index.jsx
import React, { memo } from 'react'
import { Link } from 'react-router-dom'
import classnames from 'classnames'
import { ItemLi } from './style'

const CardItem = memo(({ data }) => {//props解构

    //这里我偷懒了一下,直接根据数据的不同来判断卡片类型
    //其实这里需要从外部传state进来判断类型
  let card = null
  if (data.hasOwnProperty('hot')) {
  //这里的卡片种类也应该是大的分类名字,
  //方便这个项目展示我直接写成了book和song
    card = 'book'   
  } else {
    card = 'song'
  }

  return (
    <>
    {*/ -----这里给样式组件传参----- /*}
      <ItemLi card={card} >
        <Link className='card_li_a' to='/detail'>
          <div className="card_li_img">
            <img src={data.img} alt="disc" />
          </div>
          <div className="card_li_bd">
            <p className="card_li_bd_name">{data.name}</p>
            <p className="card_li_bd_singer">{data.singer}</p>
          </div>
          {*/ 动态添加类名 /*}
          <div className={classnames(
            { card_li_do: card == 'song' },
            { card_li_hot: card == 'book' }
          )}>
            <span className="card_li_pos_p1"></span>
            {*/ 这里根据传过来的data,显示热度,没有则不展示。因为js是弱类型语言。当然这里加上判断条件更好 /*}
            <span className="card_li_pos_p2">{data.hot}</span>
          </div>
        </Link>
      </ItemLi>
    </>
  )
})
export default CardItem

- style.js
import styled from "styled-components";

export const ItemLi = styled.li`
/* -------根据接收的参数,两列或三列展示------- */
width: ${props => props.card == 'song' ? '50%' : '33%'};
margin-bottom: 10px;
.card_li_a {
    ...
    .card_li_img {
        ...
        }
    }
    .card_li_bd {
        ...
        .card_li_bd_name {
            ...
            /* -----------文字超出用...隐藏-------- */
            text-overflow: ellipsis;
            display: -webkit-box;
            -webkit-line-clamp: 1;
            -webkit-box-orient: vertical;
        }
        .card_li_bd_singer {
            ...
        }
    }
    &:hover { //给左侧图片和文字添加鼠标经过时的过渡
        .card_li_img {
            transition: all .2s ease;
            transform: scale(1.06);
        }
        .card_li_bd {
            color: skyblue;
        }
    }
    /* 上面为通用样式,下面根据最外层类名 使用不同样式 */
    /* -----------------歌曲卡片展示播放和下载----------------- */
    .card_li_do {
        ...
        /* --html结构相同,使用同样的类名,用pos名字占位-- */
        .card_li_pos_p1 {
            width: 24px;
            height: 24px;
            background: url('https://www.kugou.com/common/images/icon_play_style2_black.png') no-repeat;
            background-size: 100%;
            &:hover {//鼠标经过更改为蓝色图片
                background: url('https://www.kugou.com/common/images/icon_play_style2_blue.png');
                background-size: 100%;
            }
        }
        .card_li_pos_p2 {
            ...
            }
        }
    }
    /* -------------------听书卡片展示热度-------------------- */
    .card_li_hot {
        ...
        .card_li_pos_p1 {
            width: 12px;
            height: 12px;
            background: url('https://www.kugou.com/common/images/icon_play_style2_black.png') no-repeat;
            background-size: 100%;
        }
        .card_li_pos_p2 {
            font-size: 12px;
            color: #000;
        }
        &:hover {
            ...
        }
    }
}
`

封装RecommendPanel


我们可以指定卡片使用的流程:

  1. 首先在Home页面组件中请求 fastmock 数据,拿到 data
  2. 再将
    • data:请求回来的数据;
    • title:自定义数据,为推荐页的标题,类型为字符串;
    • titlePlay:自定义数据,为推荐页 ListTitle 中部的小播放按钮开关,类型为Boolean,默认为false;
    • arrange: 自定义数据,为 ListBox 的排列方式,类型为字符串,默认为'two',可接收的值还有'three'。四者作为 props 传给子组件 RecommendPanel
  3. RecommendPanel将数据分别传给ListTitle和ListBox,最后ListBox展示的就是CardItem了

在 Home 页面用useState保存请求到的数据

- index.jsx
const [songData, setSongData] = useState([])
const [bookData, setBookData] = useState([])

useEffect(() => {
    (async () => {
      const data1 = await axios.get('https://www.fastmock.site/mock/cdb98f464d71f15f5b54decc864bed76/kugou/songs')
      // setSongData(data.data)
      setSongData(data1.data)
      
      const data2 = await axios.get('https://www.fastmock.site/mock/cdb98f464d71f15f5b54decc864bed76/kugou/books')
      setBookData(data2.data)
    })()
}, [])

return (
<>
  <Header />
  <Banner>
    ...
  </Banner>
  <Content>
    {/* 推荐页组件 需要传入: 1.data 2.数据在json中的位置 3.title 4.colomn_arrange 默认two 5.titlePlay 默认false */}
    {/* 两种排列方式
      两列:5*2 可换页 默认
      三列:n*3 不可换页 */}
    {/* <NewSongs /> */}
    <RecommendPanel data={songData} title='新歌首发' titlePlay={true} />
    {/* 推荐MV */}
    <NewMV>
      ...
    </NewMV>
  </Content>
  <Content>
    {/* <NewBooks /> */}
    <RecommendPanel data={bookData} title='听书精选' arrange='three' />
  </Content>
  <Footer />
</>
)

你可以看到这里我已经复用了 RecommendPanel 组件了,在复用时我传入了一些参数,具体是怎么使用这些参数的呢,又怎么实现不同的 RecommendPanel 呢,继续看下去吧。

子组件封装与数据联动

RecommendPanel首先接收到的数据是data 、title、titlePlay 和 arrange

  1. 在 RecommendPanel 中需要封装两个组件 ListTitleListBox
  2. 经过上面的分析,在点击 ListTitle 种类时,Listbox 相应地需要切换数据。
  3. 另外 ListTitle 右侧分页按钮点击后也需要切换数据展示。ListTitle 和 ListBox 的联动,我们可以配置两个参数实现这些功能。
  4. 所以数据传递过程中,我们在 RecommendPanel 中开个分支,使用useReducer保存两种状态:
    1. pagination 数据展示的当前页
    2. activeIndex 当前 data 中详细种类数据索引,也就是 ListTitle 中部的分类
- RecommendPanel -> index.jsx
import React, { useReducer } from 'react'
import ListBox from './ListBox'
import ListTitle from './Title'

const RecommendPanel = (props) => {

    const reducer = (state, action) => {
        switch (action.type) {
            case 'updatePage':    //更新页数
                return { ...state, pagination: action.data }
            case 'activeIndex':   //更新类别索引
                return { ...state, activeIndex: action.data }
            default:
                break
        }
    }
    const [panelConfig, dispatch] = useReducer(reducer, {
        pagination: 1,  //初始页数为1
        activeIndex: 0  //初始种类索引为下标0
    });

    return (
        <div style={{ minWidth: '660px' }}>
            <ListTitle
                args={props}
                dispatch={dispatch}   //在ListTitle中需要更改state传入dispatch
                config={{ pagination: panelConfig.pagination, activeIndex: panelConfig.activeIndex }} />
            <ListBox
                args={props}
                config={{ pagination: panelConfig.pagination, activeIndex: panelConfig.activeIndex }} />
        </div>
    )
}
export default RecommendPanel

ListTitle 子组件

  1. 拿到数据
    • title:标题,直接使用
    • arrange:判断右侧是否分页,两列数据需要分页功能,三列数据不需要分页功能
    • titlePlay:判断是否展示小播放按钮
  2. 实现分页功能:
    1. 给切换按钮添加点击事件
    2. 在回调函数中使用dispatch方法传入action对象,修改 pagination 。
    3. 修改完后自动将新的 pagiation 返回到 useReducer 的状态库中。
    4. 这样 ListBox 就能使用新的 pagination 更新相应数据
- index.jsx

const ListTitle = (props) => {
    //连续解构
    const {
        args: { title, data, arrange = 'two', titlePlay = false },
        dispatch,
        config
    } = props
    
// 点击切换歌曲页面
const operatePage = e => {
    const { target } = e
    const { pagination } = config
    if (target.className.includes('pre')) {//点击的是前一页
        if (pagination !== 1) {//当前页数不为1时才能修改
            dispatch({ type: 'updatePage', data: pagination - 1 })
        }
    } else {//点击的是下一页
        if (pagination !== 3) {//当前页数不为3时才能修改
            dispatch({ type: 'updatePage', data: pagination + 1 })
        }
    }
}

return (
    <Title>
        {/* title左侧地区列表 */}
        <div className='songs-title_d1'>
            <h3 className='songs-title_h3'>{title}</h3>
            <ul className='songs-title_ul'>
                {/* 添加active类,显示背景和播放按钮图标 */}
                {
                    data?.map((item, index) => (
                        //同样的,使用classnames库 动态设置 active 类名
                        <li className={classnames('songs-title_li', config.activeIndex === index ? 'active' : '')}
                            key={index}
                            onClick={() => {
                                //把当前点击item的索引 index 设置为 activeIndex,展示效果
                                dispatch({ type: 'activeIndex', data: index });
                                //每次切换不同 category 的数据时,将 pagination 设置成初始1
                                dispatch({ type: 'updatePage', data: 1 })
                            }
                            } >
                            {item.category}
                            {/* books的title不显示小播放按钮 */}
                            {titlePlay ? <span className='songs-title_playnow'></span> : null}
                        </li>
                    ))
                }
            </ul>
        </div>
        {/* title右侧: 两列数据Title右侧为切换按钮,三列数据Title右侧显示“更多” */}
        {//这里使用三目运算符
            arrange == 'two' ?
                //切换页面图标
                <div className='songs-title_d2'>
                    <span className='newsong-title_d2_sp newsong-pre' onClick={e => operatePage(e)}></span>
                    <span>{config.pagination}</span>
                    <span>/</span>
                    <span>3</span>
                    <span className='newsong-title_d2_sp newsong-next' onClick={e => operatePage(e)}></span>
                </div>
                :
                // 更多按钮 
                <div className='songs-title_more'>
                    <a href="">更多</a>
                </div>
        }
    </Title>
)

ListBox 子组件

在这里我们终于使用到了CardItem组件了,这里拿到了 state 和数据,我们需要做什么呢?

根据arrange:

  1. 如果是三列展示,直接将数据全部用 CardItem 展示即可
  2. 如果是两列展示,根据 pagination 每页展示10条数据 具体看下面
- ListBox -> index.jsx
import { Content } from './style'
import CardItem from '@/components/common/CardItem'

const ListBox = (props) => {

    const { //解构
        args: { data, arrange = 'two' },
        config: { activeIndex, pagination }
    } = props

    return (
        <Content>
            <div className='newsongslist_main active'>
                <ul className='newsongslist_ul active'>
                    {/* 一条新歌数据 */}
                    {
                        arrange == 'three' ?
                            data[activeIndex]?.list.map(item => (
                                <CardItem data={item} key={item.id} />
                            )) :
                            //data[activeIndex]? 判断数据是否拿到,没有则不执行后面
                            //每一页展示10条数据,注意slice方法,左闭右开
                            data[activeIndex]?.list.slice(10 * (pagination - 1), 10 * pagination).map(item => (
                                <CardItem data={item} key={item.id} />
                            ))
                    }
                </ul>
            </div>
        </Content>
    )
}
export default ListBox

这样,我们就实现了推荐卡片的全部功能啦。

27 - 副本.jpg

最后

至此,这个案例就算完成了,感谢你能看到最后。这个小项目开发过程虽有曲折,但收获颇丰,同时感谢好兄弟帮忙,解决了好多bug。

如果后续学习中遇到更好的写法和性能优化的点,将会更新完善这个项目。

各位大佬看完不妨点个赞,如有问题欢迎指正,期待你的评论。

在线演示git page(这里要使用电脑查看)

项目地址gitee