React + Redux,浅写一下bilibili游戏移动端

1,115 阅读8分钟

前言

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情 >>

前段时间学习了react,也在掘金上发布了一篇仿写bilibili游戏移动端的文章。这段时间笔者学习了一下redux和神三元大佬网易云音乐的项目 。大佬封装的Scroll、SearchInput组件都很厉害,值得新手(比如我)学习一下,有兴趣的可以看看大佬的小册。

一开始的学习是比较的困惑有不理解的地方,后来慢慢的理解了就开始动手写项目啦!这次的项目还是bilibili游戏移动端的仿写,只不过做了一下redux数据管理的升级和搜索页的按名字搜索功能。

项目介绍

  • react
  • redux 进行数据管理
  • styled-components 编写样式组件
  • react-transition-group 动画过渡库,在切换路由时展示动画效果
  • axios与fastmock 请求数据
  • antd-mobile 蚂蚁团队推出开源组件库
  • react-lazyload 延时加载图片

项目展示

1.gif

2.gif

3.gif

Redux

什么是Redux呢?

redux是一个独立专门用于做状态管理的JS库,集中式管理react应用中多个组件共享的状态。当某个状态需要跨页面共享时,我们就可以用redux来做,若状态只存在于某个页面,可以不用redux继续用useState来做。

Redux的工作流程

Inked5.jpg

用户进行操作 ---> ActionCreators获取需求 派送action(特定的type属性)到Store中 ---> Store再将Stateaction 传给Reducers ---> Reducers通过辨别actiontype属性,重新计算,返回一个新的stateStore ---> Store获取新的state重新渲染页面。


Reducers不仅可以重新计算状态,还可以初始化状态哦!

什么情况下我们使用Redux呢?

  1. 能不用就不用, 如果不用比较吃力才考虑使用
  2. 某个组件的状态,需要共享
  3. 一个组件需要改变全局状态
  4. 一个组件需要改变另一个组件的状态

这里我以搜索页为例子讲述redux的使用。

搜索页的实现

Redux的引入

  1. 在main.js入口文件中,引入react-redux的Provider组件,用Provider将其他组件包裹,给项目提供仓库服务
ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </Provider>
)
  1. 在src文件夹下创建store文件夹,完善项目架构。

6.png

  1. 初始化总仓库
  • createStore: 创建仓库
  • composeEnhancers: 启用redux-dev-tool调试工具、合并中间件
  • applyMiddleware: 启用中间件、如redux-thunk提供的thunk异步数据管理的中间件
import { createStore, compose,applyMiddleware } from "redux";
import reducer from "./reducer"; // 总Reducer
import thunk from "redux-thunk"; // 提供异步数据管理中间件
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; //启用redux-dev-tool调试工具
const store = createStore // 创建仓库
    (reducer,
        composeEnhancers( // 整合中间件
            applyMiddleware(thunk) // 启用中间件
    )    
);
export default store;
  1. 总Reducer
  • combineReducers: 整合分仓中的reducer,实现模块化
import { combineReducers } from "redux";
import { reducer as chooseReducer } from '@/pages/Choose/store/index'
import { reducer as searchReducer } from '@/pages/Search/store/index'
export default combineReducers({
    choose: chooseReducer,
    search: searchReducer
});

页面级组件连接仓库

  1. 在Search目录下建立分仓store文件夹

7.png

  1. index.js
  • 清单文件,向外输出分仓下的内容,将所有数据交给总仓库
import reducer from './reducer';
import * as actionCreators from './actionCreators';
import * as constants from './constants'
export {
    reducer,
    actionCreators,
    constants
}
  1. constants.js
  • 设置type常量,方便管理
export const CHANGE_HOT_LIST = 'CHANGE_HOT_LIST';
export const CHANGE_HOT_TAG = 'CHANGE_HOT_TAG';
export const CHANGE_SEARCH_RESULT = 'CHANGE_SEARCH_RESULT';
  1. actionCreators.js
  • 向reducer分发特定的action
  • 一般有成对的action,同步action与异步action
  • 同步action: 生产带type和data的action准备派发给reducer
  • 异步action: 获取数据data给同步action
import { getHotListRequest,getHotTagRequest,getSearchResultRequest } from "@/api/request";
import * as actionTypes from './constants'

export const changeHotList = (data) => ({
    type: actionTypes.CHANGE_HOT_LIST,
    data
})
export const getHotList = () => {
    return (dispatch) => {
        getHotListRequest()
            .then(data => {
                dispatch(changeHotList(data))
            })
    }
}
export const changeHotTag = (data) => ({
    type: actionTypes.CHANGE_HOT_TAG,
    data
})
export const getHotTag = () => {
    return (dispatch) => {
        getHotTagRequest()
            .then(data => {
                dispatch(changeHotTag(data))
            })
    }
}
export const changeSearchResult = (data) => ({
    type: actionTypes.CHANGE_SEARCH_RESULT,
    data
})
export const getSearchResult = () => {
    return (dispatch) => {
        getSearchResultRequest()
            .then(data => {
                dispatch(changeSearchResult(data))
            })
    }
}
  1. reducer.js
  • 可以理解为仓库管理员

  • 是纯函数,返回一个新的state

  • 根据type属性值的不同返回不同的state

  • 也可以用 Object.assign()  方法返回状态state

    例如:

    return Object.assign({}, state, {hotList: action.data})
    
import * as actionTypes from './constants'

const defaultState = {
    hotList: [],
    hotTag: [],
    serachResult: []
}

export default (state = defaultState,action) => {
    switch(action.type){
        case actionTypes.CHANGE_HOT_LIST:
            return {
                ...state,
                hotList: action.data
            }
        case actionTypes.CHANGE_HOT_TAG:
            return {
                ...state,
                hotTag: action.data
            }
        case actionTypes.CHANGE_SEARCH_RESULT:
            return {
                ...state,
                serachResult: action.data
            }
        default :
            return state;
    }
}

  1. 组件连接仓库index.jsx
import { connect } from 'react-redux';
...
const Search = (props) => {
    ...
return (
    ...
)
}
// 读操作
const mapStateToProps = (state) => {
    return {
        hotList: state.search.hotList,
        hotTag: state.search.hotTag,
        serachResult: state.search.serachResult
    }
}
// 写操作
const mapDispatchToProps = (dispatch) => {
    return {
        getHotListDispatch(){
            dispatch(actionCreators.getHotList());
        },
        getHotTagDispatch(){
            dispatch(actionCreators.getHotTag());
        },
        getSearchResultDispatch() {
            dispatch(actionCreators.getSearchResult());
        }
    }
}
// connect连接
export default connect(mapStateToProps,mapDispatchToProps)(memo(Search))
  1. 在调试的过程中可以打开浏览器,使用redux-dev-tool进行查看数据状态
  • 初始状态:

8.png

  • 数据到达后状态:

9.png

Search页面的编写

Search页面从首页右上角的放大镜图标点击进入,进入search时会有一个过渡动画效果,这个效果用的是react-transition-group中CSSTransition组件,也是向神三元大佬学习的。

CSSTransition的使用方法

<CSSTransition
    in={show} // 过渡动画效果的显示与隐藏
    timeout={800} // 过渡动画效果的执行时间
    appear={true} // 是否第一次加载该组件时启用相应的动画渲染
    classNames="fly" // 给对应的样式命名,与后面的样式保持一致
    unmountOnExit // 当动画效果为隐藏时,该标签会从dom树上移除
    onExit={() => {
        navigate('/');
    }}  
>
...... // 用CSSTransition包裹着整个页面
</CSSTransition>
/* 设置动画出场的方向 */
transform-origin: right bottom;
/* CSSTransition 过渡类型给children */
    &.fly-enter,&.fly-appear{
        opacity: 0;
        /* 启用GPU加速 */
        transform: translate3d(100%,0,0);
    }
    &.fly-enter-active,&.fly-apply-active{
        opacity: 1;
        transition: all 0.3s;
        transform: translate3d(0,0,0);
    }
    &.fly-exit{
        opacity: 1;
        transform: translate3d(0,0,0);
    }
    &.fly-exit-active{
        opacity: 0;
        transition: all 0.3s;
        transform: translate3d(100%,0,0);
    }
  • enter和appear表示动画进场 exit表示动画出场
  • 点击进入页面时,整个div的className为fly-enter,经过一小段时间(很短)变为fly-enter-active,动画激活,动画执行完后,className变为fly-enter-done,表示动画已经完成。
  • exit退出亦是如此

search页面由SearchBox组件、热门游戏、热门标签和搜索结果列表四个模块组成,

SearchBox组件

父组件Search传值,newquery、back(修改CSSTransition的in属性的值,实现页面切换动画效果)、handleQuery(修改父组件的query的值)。

子组件SearchBox

  1. 将handleQuery函数进行防抖封装(handleQueryDebounce),并且性能优化升级,用useMemo缓存上一次计算的结果,没变,不重新渲染。
  2. 用useRef绑定DOM元素进行操作(此处为input),组件挂载时自动聚焦,点击×时,清空文字。
  3. 当input输入框输入时,onChange事件触发handleChange函数,设置子组件的query值。
  4. query值发生改变,触发生命周期useEffect更新,useEffect(() => { handleQueryDebounce(query); },[query]),handleQueryDebounce函数向父组件传回输入的query值。
  5. 父组件的query值更新,传给子组件的newquery值也更新,此时生命周期再更新,将父子组件query值同步。
const SearchBox = (props) => {
    const queryRef = useRef();
    // 解构父组件props 分两部分
    // 读取props状态
    const { newQuery } = props;
    // 读取方法
    const { handleQuery,back } = props;
    const [ query,setQuery ] = useState('');

    // 父组件传过来的函数进行封装
    // 优化再升级
    // useMemo 可以缓存上一次函数计算的结果
    let handleQueryDebounce = useMemo(() => {
        return debounce(handleQuery,1000);
    },[handleQuery]);

    const displayStyle = query ? {display:"block"} : {display:"none"};

    const clearQuery = () => {
        setQuery('');
        queryRef.current.value = "";
    }
    const handleChange = (e) => {
        let val = e.currentTarget.value;
        setQuery(val);
    }

    // mount时的生命周期
    useEffect(() => {
        // 挂载后 生命周期
        queryRef.current.focus(); // 聚焦input
    },[]);

    // 使用useEffect去更
    useEffect(() => {
        // query 更新
        // console.log(query);
        // console.log(queryRef);
        // let curQuery = query;
        handleQueryDebounce(query);
    },[query]);

    useEffect(() => {
        // mount时执行生命周期 父组件 newQuery -> input query
        // newQuery更新时执行
        let curQuery = query;
        if(newQuery !== query){
            curQuery = newQuery;
            queryRef.current.value = newQuery;
        }
        setQuery(curQuery);
    },[newQuery]);

    return (
        <SearchBoxWrapper>
            <i className="fa fa-arrow-left icon-back" onClick={() => back()}></i>
            <input type="text" 
                className="box"
                placeholder="搜索游戏或攻略"
                ref={queryRef}
                onChange={handleChange}
                
            />
            <i className="fa fa-close icon-delete" 
                style={displayStyle}
                onClick={clearQuery}
            >
            </i>
            <i className="fa fa-search icon-search" onClick={() => {}}></i>
        </SearchBoxWrapper>
    )
}

export default memo(SearchBox);

热门游戏、热门标签

在父组件Search中解构出hotList、hotTag、serachResult等数据,useEffect挂载时,加载数据。再用函数renderHotList、renderHotTag遍历输出获得的数据。页面的展示由是否有query值判定,不存在query则显示热门游戏与热门标签。进行搜索时,显示出搜索列表,而隐藏热门游戏。

const {
        hotList,
        hotTag,
        serachResult
    } = props;
const {
        getHotListDispatch,
        getHotTagDispatch,
        getSearchResultDispatch
    } = props;
const navigate = useNavigate();
const [query,setQuery] = useState('');
const [show,setShow] = useState(false);
const searchBack = () => {
    setShow(false);
}
    const handleQuery = (q) => {
        // console.log(q);
        setQuery(q);
    }
    useEffect(() => {
        setShow(true);
        if(!hotList.length){
            getHotListDispatch();
        }
        if(!hotTag.length){
            getHotTagDispatch();
        }
        if(!serachResult.length){
            getSearchResultDispatch();
        }
    },[]);
    
        const renderHotList = () => {
        let list = hotList ? hotList : [];
        return (
            <ul className='bui-tag-list'>
                {
                    list.map(item => {
                        return(
                            <li className="bui-item" 
                                key={item.id}
                                onClick={() => setQuery(item.title)}
                            >
                                <span>{item.title}</span>
                            </li>
                        )
                    })
                }
            </ul>
        )
    }

    const renderHotTag = () => {
        let list = hotTag ? hotTag : [];
        return (
            <ul className='bui-tag-list'>
                {
                    list.map(item => {
                        return(
                            <li className="bui-item" 
                                key={item.id}
                                onClick={() => setQuery(item.tag)}
                            >
                                <span>{item.tag}</span>
                            </li>
                        )
                    })
                }
            </ul>
        )
    }
<ContentWrapper show={!query}>
    <Scroll>
        <HotList>
            <h1 className="bui-title">热门游戏</h1>
                {renderHotList()}
        </HotList>
        <HotTag>
            <h1 className="bui-title">热门标签</h1>
                {renderHotTag()}
        </HotTag>
    </Scroll>
</ContentWrapper>

搜索结果列表

通过query值对searchresult进行筛选,再对筛选后的值进行遍历。

const renderSearchResult = () => {
        let list = serachResult ? serachResult : [];
        return (
            <div className='bui-mod-wrap'>
                {
                    list
                    .filter((item) => {
                        return true == new RegExp('^.*' + query + '.*$').test(item.name)
                    })
                    .map(item => {
                        return (
                            <div className='crad-game' key={item.id}>
                                <div className="game-img">
                                    <img src={item.pic} className='item-pic'/>
                                </div>
                                <div className="game-info-wrap">
                                    <h4 className="game-name">{item.name}</h4>
                                    <span className="game-info">
                                        <span className="tag-name">{item.tag1}</span>
                                        <span className="tag-name">{item.tag2}</span>
                                        <span className="tag-name">{item.tag3}</span>
                                    </span>
                                    <div className="bui-rate">
                                        <span className="rate-num">评分:{item.mark}</span>
                                    </div>
                                </div>
                            </div>
                        )
                    })
                }
            </div>
        )
    }

其他页面的编写

Home,Mine等页面的布局与上一篇文章没有进行过多的改变,Home页面也加入的redux,过程与上面相似,可以参考上一篇文章或者文末的git地址。

优化

实现路由的懒加载

使用Suspense,lazy对路由懒加载,减少进入应用首页的响应时间。

import React, { Suspense,lazy } from "react";
import { Route,Routes,Navigate } from 'react-router-dom'
const Choose = lazy(() => import('@/pages/Choose'));
const Find = lazy(() => import('@/pages/Find'));
const Mine = lazy(() => import('@/pages/Mine'));
const Search = lazy(() => import('@/pages/Search'));

const RouteConfig = () => {
    return (
        <Suspense fallback={null}>
            <Routes>
                <Route path='/' element={<Choose/>}></Route>
                <Route path='/choose' element={<Choose/>}></Route>
                <Route path='/find' element={<Find/>}></Route>
                <Route path='/mine' element={<Mine/>}></Route>
                <Route path='/search' element={<Search/>}></Route>
            </Routes>
        </Suspense>
    )
}


export default RouteConfig;

api请求优化

设置baseUrl与拦截器,优化request.js代码与显示错误提示。

// api/config.js
import axios from 'axios';
export const baseUrl = "https://www.fastmock.site/mock/477f993fb8b86e1e7fa9aa8ca719a766/bilibili-game";

const axiosInstance = axios.create({
    baseURL: baseUrl
});

axiosInstance.interceptors.response.use(
    res => res.data,
    err => {
        console.log(err,'网络错误ovo');
    }
);

export { axiosInstance };

优化后的request.js

import { axiosInstance } from './config';

export const getGameListRequest  = () =>
    axiosInstance.get(`/gamelist`)

export const getVideoListRequest  = () =>
    axiosInstance.get('/videoinfo')

alias配置

vite.config.js里添加alias配置,将引用路径简短化。

从import('../pages/Search')变为import('@/pages/Search')

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    port: 1234
  },
  base: './',
  alias: {
    "@" : path.resolve(__dirname,"src")
  }
})

memo

给相对独立的组件加memo,只要外界给他的props没有变则不会重新编译,memo会记忆上次编译的结果没有发生改变就继续使用上次编译的结果,减少页面不必要的渲染的次数,优化性能。

// 例如
export default memo(SearchBox);

写在最后

本项目,bilibili游戏移动端,在经过不断的学习react+redux终于写出来部分了,当然其中还学习了神三元大佬的项目中很多的知识点,useMemo、useRef、CSSTransition等与思想(函数命名风格),大家也可以去看看大佬写的小册,真的很不错。

现在笔者正在学习Typescript,下一个项目,也会是一个全新的项目,会用ts + node + redux,也请大家多多期待啦。如果喜欢这篇文章,不妨点个小心心!这对我真的很重要! 下次见!

源码地址 项目预览