这都拿不下你?React + Redux 让你一眼就爱上的哔哩哔哩会员购

我正在参加「创意开发 投稿大赛」详情请看:掘金创意开发大赛来了!

继上一篇文章React项目开发-仿哔哩哔哩移动端首页之后,我经过一段时间的学习(主要是对Redux的学习)之后,写下了这篇关于 React + Redux 项目的文章,新增了会员购界面,对首页进行了改进,优化了用户体验,使用 Redux 对状态统一进行管理。

前言

1. Redux 概述

Redux 是一个使用叫做 action 的事件来管理和更新应用状态的模式和工具库,它以集中式Store(centralized store)的方式对整个应用中使用的状态进行集中管理,其规则确保状态只能以可预测的方式更新。

2. 为什么要使用 Redux?

Redux 提供的模式和工具让我们更容易理解应用程序中的状态何时、何地、为什么以及如何更新,以及当这些更改发生时应用程序逻辑将如何表现。

3. 我们应该如何使用 Redux?

Redux 的应用场景:

  • 在应用的大量地方,都存在大量的状态
  • 应用状态会随着时间的推移而频繁更新
  • 更新该状态的逻辑可能很复杂
  • 中型和大型代码量的应用,很多人协同开发

Redux 工作流

为了更好的理解,我们把 Redux 工作流比作图书馆借书流程,当我们(Component)向管理员(Store)发出一个借书行为(Action)时,管理员接收到后,对照借书记录本(Reducers)查看,管理员拿到新书(一个新的状态)后交给我们。

image.png

项目预览:Github Pages

项目准备

安装依赖

在使用 redux 之前,我们需要在之前的基础上安装以下依赖(默认安装最新版本):

npm i redux
npm i react-redux
npm i redux-thunk
npm i redux-logger

redux-thunk 主要的功能就是让我们可以 dispatch 一个函数,applyMiddlewareRedux 的一个原生方法,可将所有中间件组成一个数组,依次执行。

import { createStore, compose, applyMiddleware } from 'redux'
import reducer from './reducer'
import thunk from 'redux-thunk'  // 异步数据管理
import logger from 'redux-logger'  // 让 redux 调试更优秀

const composeEnhancers = 
  window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose  // 激活 redux devtools

const store = createStore(reducer, 
  // 合并成一个中间件对象
  compose(
    composeEnhancers(applyMiddleware(thunk)),
    applyMiddleware(logger)
  )
)

export default store

安装插件

为了能够看到效果,我们还需在浏览器中(建议使用Chrome)安装插件:Redux DevTools,Redux DevTools插件下载地址

当我们激活了 redux devtools 后,切换到浏览器的 Redux 界面可以看到当前仓库状态:

Snipaste_2022-07-24_20-52-39.png

redux-logger 会在 dispatch 改变仓库状态的时候打印出旧的仓库状态、当前触发的action以及新的仓库状态。

Snipaste_2022-07-24_20-54-54.png

实现功能

用户体验方面

1. 瀑布流布局 + 图片懒加载

multi-column 布局中子元素的排列顺序是先从上往下再从左至右的,让上下相邻的子元素分开使用 margin-bottom 即可。

  • 瀑布流的优点如下:
  1. 节省空间,外表美观,更有艺术性。
  2. 对于触屏设备非常友好,通过向上滑动浏览。
  3. 用户浏览时的观赏和思维不容易被打断,留存更容易。

实现代码:

/* 父容器 */
.container {
  column-count: 2;  // 两列布局
  column-gap: 10px;  // 列间距为 10px
}
/* 子元素 */
.good-box { 
  width: 100%;
  break-inside: avoid;  // 元素不能中断,auto 可以中断 
}

实现效果:

Snipaste_2022-07-24_23-58-20.png

当从远程请求过来的图片还没加载出来时,使用默认图片进行占位,优化用户体验。这里我勾选浏览器中的禁用缓存模拟了一下效果:

Snipaste_2022-07-25_00-16-13.png

2. 页面切换

页面切换的效果我使用了 CSSTransition 对子元素进行包裹,它会将过渡类型给到子元素,添加动画效果。

实现代码:

import { CSSTransition } from 'react-transition-group'
...

<CSSTransition
  in={show}  // 控制动画的开关
  timeout={300}  // 动画执行时间
  appear={true}  // 第一次加载该组件时启用相应的动画渲染
  classNames="fly"
  unmountOnExit  // 动画效果消失时,该标签会从 dom 树上移除
>
<Wrapper>
...
</Wrapper>
</CSSTransition>
import styled from "styled-components"

export const Wrapper = styled.div`
  ...
  /* CSSTransition 过度类型给children  */
  &.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);
  }
`

效果如下:

chrome-capture-2022-6-25.gif

3. 加载动画

当我们首次进入到首页或会员购页面时,图片资源是不能瞬间得到的,这里我使用了 antd-mobile 的动态骨架屏,让页面更丰富,填补了等待时间段,提升用户体验。

在搜索界面,我仿造了神三元写的 loading 组件,这是一个在等待请求过程中的动画效果。loading 动画主要实现代码如下:

import React from 'react';
import styled, { keyframes } from 'styled-components';

const loading = keyframes`
  0%, 100% {
    transform: scale(0.0);
  }
  50% {
    transform: scale(1.0);
  }
`
const LoadingWrapper = styled.div`
    >div {
      position: absolute;
      top: 0; left: 0; right: 0; bottom: 0;
      margin: auto;
      width: 60px;
      height: 60px;
      opacity: 0.6;
      border-radius: 50%;
      background-color: rgba(0, 150, 250, 0.8);  // 这里可以选择自己喜欢的颜色
      animation: ${loading} 1.4s infinite ease-in;  // 动画持续时间
    }
    >div:nth-child(2) {
      animation-delay: -0.7s;  // 跳过 0.7s 进入动画周期
    }
`

function Loading()  {
  return (
    <LoadingWrapper>
      <div></div>
      <div></div>
    </LoadingWrapper>
  );
}
 
export default React.memo(Loading);

效果如下:

chrome-capture-2022-6-25.gif

业务方面

1. 商品收藏

chrome-capture-2022-6-24.gif

这里可能会遇到的问题,点击收藏某件商品时,把列表中的所有商品都收藏了,解决方法:

把商品组件进行单独封装作为子组件,父组件将 good 传递给子组件,子组件拿到单独的 id,在进行之后的操作时,就不会对其它的子组件造成影响。

import React from 'react'
import propTypes from "prop-types";
import { Wrapper } from './style'
import GoodsItem from '@/components/GoodsItem';

export default function GoodsList({goodsList}) {
  
  return (
    <Wrapper>
      <div className="container">
        {
          goodsList && goodsList.map(good => (
            <GoodsItem key={good.id} good={good} />
          ))
        }
      </div>
    </Wrapper>
  )
}

GoodsList.propTypes = {
  goodsList: propTypes.array.isRequired
}

子组件 GoodsItem 中收藏效果的实现代码如下:

import React, { useState } from "react"
import classnames from 'classnames'

const GoodsItem = ({good}) => {
  const [isColl, setIsColl] = useState(false)  // 定义收藏状态

  const changeColl = () => {  // 对状态进行取反
    setIsColl(!isColl)
  }

  return (
    <div className="good-box" key={good.id}>
      ...
      <div className="price_coll">
        ...
        <span>
          {/* 当 isColl 为 true 时,使用 classnames 添加相应的样式,否则为默认样式 */}
          <i
            className={classnames(
              'iconfont', 
              {'icon-aixin3': !isColl},  
              {'icon-aixin1': isColl}, 
              {'active': isColl}
            )}
            onClick={() => changeColl()}
          >
          </i>
          {/* isColl 为 true 时,收藏量+1,否则不变 */}
          <span>{isColl ? good.collection + 1 : good.collection}</span>
        </span>
      </div>
    </div>
  )
}

// 性能优化
export default React.memo(GoodsItem)

2. 防抖搜索功能

首页搜索功能由父组件 HomeSearch 和子组件 SearchBox 实现。

首页搜索

chrome-capture-2022-6-24 (1).gif

会员购搜索功能的功能由父组件 VipSearch 和子组件 SearchBox 实现。

会员购搜索

chrome-capture-2022-6-24 (2).gif

在搜索上我加上了防抖功能,防抖函数和其他函数放到 util 文件夹下的 index.js 下作为工具使用。

// 防抖函数
export const debounce = (func, delay) => {
  let timer
  return function (...args) {
    if(timer) {
      clearTimeout(timer)
    }
    timer = setTimeout(() => {
      func.apply(this, args)
      clearTimeout(timer)
    }, delay)
  }
}

在子组件 SearchBox 中修改 query,进行防抖处理,每隔500毫秒执行一次 handleQuery 去更新父组件 VipSearch 中的 query,并通过 dispatch 对状态进行修改。

// useMomo 可以缓存 上一次函数计算的结果
let handleQueryDebounce = useMemo(() => {
  return debounce(handleQuery, 500)  // 每隔 0.5s 执行一次
}, [handleQuery])
// 使用 useEffect 去更新
useEffect(() => {
  handleQueryDebounce(query)
}, [query])
// 父组件
const VipSearch = (props) => {
  ...
  // 输入时每隔 0.5s 执行一次
  useEffect(() => {
    if (query.trim()) {
      changeEnterLoadingDispatch(true)
      getGoodsListDispatch(query)
    }
  }, [query])
  // 对商品标题进行模糊查询,将搜索到的商品进行渲染
  const renderGoodsList = () => {
    return (
      <Wrapper>
        <h3 style={{"margin": "5px"}}>商品列表</h3>
        <div className="container">
        {
          goodsList.filter(good => 
            good.title.indexOf(query) != -1
          ).map(good => {
            return (
              <GoodsItem key={good.id} good={good} />
            )
          })
        }
        </div>
      </Wrapper>
    )
  }

  return (
    <Container>
      <HeaderWrapper>
        <SearchBox
          newQuery={query}
          handleQuery={handleQuery}>
        </SearchBox>
        <span onClick={() => navigate(-1)}>取消</span>
      </HeaderWrapper>
      ...
      { enterLoading && <EnterLoading><Loading></Loading></EnterLoading> }
    </Container>
  )
}

const mapStateToProps = (state) => {
  return {
    enterLoading: state.vipsearch.enterLoading,
    goodsList: state.vipsearch.goodsList
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    changeEnterLoadingDispatch(data) {
      dispatch(changeEnterLoading(data))
    },
    getGoodsListDispatch(query) {
      dispatch(getGoodsList(query))
    }
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(React.memo(VipSearch))

优化

1. 封装网络请求

api 文件夹下,新添加了 config.js 文件,用来对对象 axiosInstance 进行封装,当接口数量较多时,能够减少代码量,使页面更简洁:

import axios from 'axios'
export const baseUrl = "https://www.fastmock.site/mock/059647e88be0d33ef58d6ab4bf009dd9/bilibili"
// 单例设计模式
const axiosInstance = axios.create({
  baseURL: baseUrl
})

// 添加响应拦截,拿到数据时对数据做处理,或抛出错误
axiosInstance.interceptors.response.use(
  res => res.data,
  err => {
    console.log(err, '网络错误~')
  }
)

export { axiosInstance }

2. 骨架屏占位

因为大多数图片资源是从 fastmock 中请求过来的,受网络影响需一些时间,用户在等待的过程中页面出现空白状态很影响体验,引入骨架屏让页面更丰富,填补了等待时间段,优化了用户体验。此项目中我使用了 antd-mobile 中的动态骨架屏,Skeleton 骨架屏

3. 图片懒加载

图片懒加载也叫“按需加载”,也就是当图片资源出现在视口区域内,才会被加载,使用懒加载能大大节省网站的流量,对于有大量图片资源的网站来说显得尤为重要。

这里我使用了 LazyLoad,当网络图片还没加载出来时,使用本地默认图片进行占位,主要代码如下:

import LazyLoad from 'react-lazyload'
import bilibili from '@/assets/images/bilibili.jpeg'

...
<LazyLoad
  placeholder={<img width="100%" 
  height="100%" src={bilibili}/>}
>
  <img src={good.img} />
</LazyLoad>
...

4. memo性能优化

如果你的组件在相同 props 的情况下渲染相同的结果,那么你可以通过将其包装在 React.memo 中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现。这意味着在这种情况下,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果。但React.memo 仅检查 props 的变更。

5. 全局样式风格文件

在项目开发过程中,我们在不同的页面中可能会用到相同的样式,例如背景颜色,字体大小,边框等等,将相同的样式抽离出来放到 assets 文件夹下的 global-style.js 中,便于对样式进行统一管理。

export default {
  "background-color": "rgba(50, 50, 50, 0.06)",
  "search_bar-color": "rgba(50, 50, 50, 0.08)",
  "border-color": "rgba(50, 50, 50, 0.2)",
  "loading-color": "rgba(0, 150, 250, 0.8)"
}

定义了全局样式风格文件后,我们就可以在其他的样式文件中进行引用,如下:

import styled from "styled-components"
import style from '@/assets/global-style'

export const Wrapper = styled.div`
  background: ${style["background-color"]};
  ...
`

最后

在这个项目中,我借鉴了神三元大佬的网易云音乐项目中的 CSSTransition 组件, loading 组件和 debounce 防抖函数等,项目地址如下:github源码地址,如果有兴趣的小伙伴也可以去瞧瞧他写的掘金小册React Hooks 与 Immutable 数据流实战。 本项目在后期仍会继续改进,实现更多功能,谢谢大家!未完待续......

源码地址:bilibili-page

项目预览:GitHub Pages

猜你喜欢

转载自juejin.im/post/7124197279598116871