腾讯掌上道聚城第二弹🏆React Hooks + Redux

1,535 阅读7分钟

前言

最近学习了ReduxReact Hooks,经过这一段时间的学习,React 全家桶基本上算是熟悉了,,就用ReduxReact Hooks续写对腾讯掌上道聚城的热情。这里主要分享一下ReduxReact Hooks的使用方法,希望对您有所帮助。上一篇文章

项目实现

项目介绍

  • 使用 Redux 集中管理数据,fastmock 模拟后端数据接口
  • 坚守 MVVM 、组件化、模块化思想,函数式组件编写页面 React-Router v6 编写路由
  • 使用 react-transition-group动画过渡库,完成游戏搜索列表的渲染效果
  • 使用 styled-components 样式组件编写样式
  • 参考 三元大佬 纯手写的 debouce 函数和搜索框组件,实现搜索框功能
  • 使用 antd-mobile 提供的 Tabs SideBar组件 完成横向导航切换和侧边竖屏游戏分类列表的组件
  • 使用 antd-mobile 提供的 Popup 弹出层组件 完成GameList组件

项目展示

此次项目完成了对Home、LOLHome及GameList等页面级别组件的数据状态集中管理(Redux),并实现了GameList页面和Home主页添加游戏的交互功能。 话不多说,先看项目实现的效果。

首页(即精选页面)效果图

chrome-capture-2022-6-15.gif

GameList 游戏列表页面效果图

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

添加和删除游戏功能及与主页的交互效果图

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

chrome-capture-2022-6-15 (3).gif

相关游戏搜索及添加功能效果图

chrome-capture-2022-6-15 (5).gif

chrome-capture-2022-6-15 (6).gif

具体实现

使用 React-Router v6 编写路由,为了性能优化我们引入 lazy 实现懒加载,只有在此组件被加载时,内部资源才被引用。

// 独立配置路由文件
import { lazy } from 'react'
import { Routes, Route } from 'react-router-dom'
//  推迟加载,运行,按需加载
// 当切换到这个路由后 加载
import Home from '@/pages/Home'
import LOLHome from '@/pages/LOLHome'
const Find = lazy(() => import('@/pages/Find'))
const Mine = lazy(() => import('@/pages/Mine'))
const Judou = lazy(() => import('@/pages/Judou'))

const RoutesConfig = () => {
    return (
        <Routes>
            <Route path='/' element={<Home />}></Route>
            <Route path='/lol' element={<LOLHome />}></Route>
            <Route path='/cf' element={<Home />}></Route>
            <Route path='/home' element={<Home />}></Route>
            <Route path="/judou" element={<Judou />}></Route>
            <Route path="/find" element={<Find />}></Route>
            <Route path="/mine" element={<Mine />}></Route>
        </Routes>
    )
}
export default  RoutesConfig

实现GameList页面(注:具体代码可在文章结尾链接中查看)

1. 实现弹出层

这里用到了antd-mobile的 Popup 组件,弹出GameList页面

const [visible, setVisible] = useState(false)
const onMaskClick = () => {
    setVisible(false)
  }
      <div>
          <i className='iconfont icon-jiahao icon-right'
            onClick={() => {
              setVisible(true)
            }}>

          </i>
          <Space direction='vertical'>
            <Popup
              visible={visible}
              position='bottom'
              onMaskClick={onMaskClick}
              bodyStyle={{ minHeight: '100%' }}
            >
              <GameList onMaskClick={onMaskClick} />
            </Popup>
          </Space>
      </div>

效果图如下:

chrome-capture-2022-6-15 (9).gif

2. 游戏分类部分

使用 `antd-mobile` 提供的 `Tabs` `SideBar`组件 完成横向导航切换和侧边竖屏游戏分类列表的组件
      // 侧边竖屏游戏分类
     <div className="side">
        <SideBar activeKey={activeKey} onChange={setActiveKey}
          style={{ '--width': '65px', '--background-color': '#fff' }}>
          {
            tabs2.map(item => (
              <SideBar.Item key={item.key}
                title={<span style={{ fontSize: "0.7rem" }}>{item.title}</span>
                } />
            ))
          }
        </SideBar>
     </div>
     // 横向导航切换
     <Tabs>
       <Tabs.Tab title='端游' key='key1'>
         {
           renderTabs()
         }
       </Tabs.Tab>
       <Tabs.Tab title='手游' key='key2'>
         {
           renderTabs2()
         }
       </Tabs.Tab>
     </Tabs>

效果图如下:

chrome-capture-2022-6-15 (8).gif

3. 游戏搜索部分

  • 参考 三元大佬 纯手写的 debouce 函数,完成搜索框的防抖效果
const debounce = (func, delay) => {
   let timer;
   return function (...args) {
       if (timer) {
           clearTimeout(timer);
       }
       timer = setTimeout(() => {
           func.apply(this, args);
           clearTimeout(timer);
       }, delay);
   };
};

export { debounce };
  • 搜索框完成边输入边搜索(具有 防抖 功能),使用 React Hooks 封装 debounce 函数,使用CSSTransition 页面交互组件,带来页面的动画效果
    const [value, setValue] = useState('')
    const [query, setQuery] = useState('');
    const [show, setShow] = useState(false)
    const queryRef = useRef()
    
    const onSetQuery = (query) => {
        setQuery(query)
    }
    // useMemo 可以缓存上一次函数计算的结果
    // 封装debounce函数
    let onSetQueryDebounce = useMemo(() => {
        return debounce(onSetQuery, 500)
    }, [onSetQuery])

    useEffect(() => {
        // query更新
        onSetQueryDebounce(query)
    }, [value])

    // mounted 挂载 聚焦输入框
    useEffect(() => {
        queryRef.current.focus() 
    }, [])
    useEffect(() => {
        if(query.trim()){
            getSearchResultDispatch(query)  
        }
    }, [query])
    const onAdd = (value) => {
        onSetQuery(value)
        setShow(true)
     }
     
      <div className='search'>
        <i className='iconfont icon-guanbi icon-right' onClick={() => {
          onMaskClick()
          setShow(false)
          setValue('')
          queryRef.current.focus()
        }}></i>
        <SearchBar
          ref={queryRef}
          placeholder='搜索想要添加的游戏'
          className='search1'
          value={value}
          onChange={e => {
            setValue(e)
            onAdd(e)
            console.log(query)
            if (e == '' || !e) {
              setShow(!show)
              queryRef.current.focus()
            }
          }}
          style={{
            '--border-radius': '5rem',
            '--height': '1.5rem',
            '--padding-left': '0.6rem',
          }}
        />
      </div>
      // 搜索后的结果列表
      {query && <CSSTransition
        in={show}   // 控制动画开启关闭
        timeout={1000}  // 为动画执行时间
        appear={true}  // 是否第一次加载该组件时启用相应的动画渲染
        classNames="fly"
        unmountOnExit  // 当动画效果为隐藏时该标签会从dom树上移除类似js操作
      >
        <Container>
          {searchResult == false ? renderNull() : renderSearchList()}
        </Container>
      </CSSTransition>
      }

效果图如下:

chrome-capture-2022-6-15 (7).gif

4. 头部header的编辑部分

此部分完成游戏添加后及取消选中该游戏后,展示已选择的游戏列表的功能

    const [activekey, setActivekey] = useState(false)
      <div className='header'>
        <h2>我的游戏</h2>
        <span onClick={() => setActivekey(!activekey)}
          className={classnames({ span2: true }, { active: activekey === true })}
        >
          { activekey ? '完成' : '编辑' }
        </span>
      </div>

效果图如下:

chrome-capture-2022-6-15 (10).gif

5. 主页导航栏部分

添加和删除已选择的游戏,主页导航栏相应的变化,完成了页面间的交互。

效果图如下:

chrome-capture-2022-6-15 (11).gif chrome-capture-2022-6-15 (12).gif

至此腾讯掌上道聚城GameList和之前的组件全部完成Redux集中管理数据的改善和优化,接下来将详细介绍Redux,别走,精彩继续!

数据流管理Redux

Redux 是一个用来管理管理数据状态和UI状态的JavaScript应用工具。随着单页应用(SPA)开发日趋复杂,组件间需要管理的 state(状态) 比任何时候都要多,这时候react的层级传递数据机制是非常繁琐的,这时候就需要一直新的机制改变这种现状, Redux 就此诞生。把所有的state集中到组件顶部,能够灵活的将所有state各取所需的分发给所有的组件,这就是Redux管理数据状态的机制。

简介和工作流程

  • Redux的诞生是为了给 React 应用提供「可预测化的状态管理」机制。

  • Redux 会将整个应用状态(其实也就是数据)存储到到一个地方,称为 store

  • 这个 store 里面保存一棵 状态树(state tree)

  • 组件改变 state 的唯一方法是通过调用 storedispatch 方法,触发一个 action ,这个 action 被对应的 reducer 处理,于是 state 完成更新

  • 组件可以派发 (dispatch) 行为 (action)store ,而不是直接通知其它组件

  • 其它组件可以通过获取 store 中的状态 (state) 来更新本身所需的状态 (MVVM)

使用方法

1. 创建store总仓库 并合并每个子模块的store

使用 Redux 提供 createStore方法 创建一个store仓库

import { createStore,compose,applyMiddleware } from 'redux'
import reducer from './reducer'
import thunk from 'redux-thunk'

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose

const store = createStore(
    reducer,
    composeEnhancers(
        applyMiddleware(thunk)
    )
)

export default store

使用 Redux 提供 combineReducers 整合其子模块的store

import { combineReducers } from 'redux'
import { reducer as HomeReducer } from '@/pages/Home/store'
import { reducer as LOLHomeReducer } from '@/pages/LOLHome/store'
import { reducer as GameListReducer } from '@/pages/GameList/store'

export default combineReducers ({
    home: HomeReducer,  // Home 子仓store
    lolhome: LOLHomeReducer,  // LOLHome 子仓store
    gamelist: GameListReducer  // GameList 子仓store
})

2. 顶层组件Provider声明式的添加数据管理功能

把store直接集成到React应用的顶层props里面,这样各个子组件能访问到顶层props了。

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

3. 搭建子模块的store (此处只介绍GameList模块)

  • index.js 这是子模块的store文件 ,旨在将子模块的store暴露给 store(主仓库),以便在store(主仓库)中合并供组件使用。
import  reducer  from "./reducer";
import * as actionCreators from './actionCreators'
export {
    reducer,
    actionCreators
}
  • actionCreators.js 该文件通过同步的api(如changeGameList())完成对action对象的创建,并通过异步api(如getGameList())完成对数据的请求及dispatch相应的action。
import * as actionTypes from './constants'
import { 
    getGameListsRequest,
    getSelectedGameListsRequest
} from '@/api/request'

const changeGameList = (data) => ({
    type: actionTypes.CHANGE_GAMELIST,
    data
})

const changeSelectedGameList = (data) => ({
    type: actionTypes.CHANGE_SELECTEDGAMELIST,
    data
})

const addList = (data) => ({
    type: actionTypes.ADD_LIST,
    data
})

const deleteList = (data) => ({
    type: actionTypes.DETELE_LIST,
    data
})

const deleteSearchList = (data) => ({
    type: actionTypes.DETELE_SEARCH_LIST,
    data
})

const searchResult = (data) => ({
    type: actionTypes.GET_SEARCHRESULT,
    data
})

const changeLoading = (data) => ({
    type: actionTypes.CHANGE_GAMELISTLOADING,
    data
})

export const getSelectedGameList = () => {
    return (dispatch) => {
        getSelectedGameListsRequest()
            .then(data => {
                dispatch(changeSelectedGameList(data.data))
                
            })
    }
}

export const getGameList = () => {
    return (dispatch) => {
        getGameListsRequest()
            .then(data => {
                dispatch(changeGameList(data.data))
                dispatch(changeLoading(false))  
            })
    }
}

export const AddListData = (data) => {
    return (dispatch) => {
       dispatch(addList(data))
    }
}

export const DeleteListData = (data) => {
    return (dispatch) => {
       dispatch(deleteList(data))
    }
}

export const DeleteSearchListData = (data) => {
    return (dispatch) => {
       dispatch(deleteSearchList(data))
    }
}

export const getSearchResult = (query) => {
    return (dispatch) => {
        dispatch(searchResult(query))
    }
}
  • reducer.js dispatch(action) 发出命令后将state放入reucer加工函数中,匹配相应的action的类型完成对应的数据状态的更新,对state进行加工处理,并同步给组件,完成MVVM数据状态更新。
import * as actionTypes from './constants'

const defaultState = {
    games: [],
    selectedgamelist: [],
    searchresult: [],
    loading: true
}
export default (state = defaultState,action) => {
    switch (action.type) {
        case actionTypes.CHANGE_GAMELIST:
            return {
                ...state,
                games: action.data
            }
        case actionTypes.CHANGE_SELECTEDGAMELIST:
            return {
                ...state,
                selectedgamelist: action.data
            }
        case actionTypes.ADD_LIST:
                let addedGame = state.games.find(item =>  item.cid == action.data)
                let games = state.games.filter(item => item.cid != action.data)
                let newSelectedGameList = [
                    ...state.selectedgamelist,
                    addedGame
                ]
                return {
                    ...state,
                    games,
                    selectedgamelist: newSelectedGameList,
                }
            case actionTypes.DETELE_LIST:
                let deleteGame = state.selectedgamelist.find(item => item.cid == action.data)
                let selectedgamelist = state.selectedgamelist.filter(item => item.cid != action.data)
                let newGameList = [
                    ...state.games,
                    deleteGame
                ]
                let newSearchResult = [
                    ...state.searchresult,
                    deleteGame
                ]   
            return {
                ...state,
                games: newGameList,
                selectedgamelist,
                searchresult: newSearchResult

            }
        case actionTypes.GET_SEARCHRESULT:
                let result = state.games.filter(
                    todo => todo.desc.includes(action.data) || todo.classify.includes(action.data)
                    ) 
                return {
                    ...state,
                    searchresult: result
                }
            case actionTypes.DETELE_SEARCH_LIST:
                let addedGame1 = state.games.find(item =>  item.cid == action.data)
                let deletesearchResult = state.searchresult.filter(item => item.cid != action.data)
                let games1 = state.games.filter(item => item.cid != action.data)
                let newSelectedGameList1 = [
                    ...state.selectedgamelist,
                    addedGame1
                ]
                return {
                    ...state,
                    games: games1,
                    selectedgamelist: newSelectedGameList1,
                    searchresult: deletesearchResult
                }
        case actionTypes.CHANGE_GAMELISTLOADING:
            return {
                ...state,
                loading: action.data
            }
        default:
            return state
    }
}
  • constants.js 用于存放action的type类型常量,方便后期代码的修改和维护。
export const CHANGE_GAMELIST = 'CHANGE_GAMELIST'
export const CHANGE_GAMELISTLOADING = 'CHANGE_GAMELISTLOADING'
export const CHANGE_SELECTEDGAMELIST = 'CHANGE_SELECTEDGAMELIST'
export const GET_SEARCHRESULT = 'GET_SEARCHRESULT'
export const DETELE_SEARCH_LIST = 'DETELE_SEARCH_LIST'
export const ADD_LIST = 'ADD_LIST'
export const DETELE_LIST = 'DETELE_LIST'

4. GameList页面连接项目的store总仓库

至此 Redux 数据管理功能就完成了,我们只需要在组件通过 react-redux 提供的connect方法连接到store,并通过 mapStateToProps方法 把Redux中的数据映射到React中的props中去 和使用 mapDispatchToProps方法 把各种dispatch也变成了props让你可以直接使用

const mapStateToProps = (state) => {
    return {
        games:state.gamelist.games,
        selectedgamelist: state.gamelist.selectedgamelist,
        searchResult: state.gamelist.searchresult,
        loading:state.gamelist.loading
    }
}
const mapDispatchToProps = (dispatch) => {
    return {
        getGameListDispatch() {
            dispatch(getGameList())
        },
        getSelectedGameListDispatch() {
            dispatch(getSelectedGameList())
        },
        getSearchResultDispatch(query) {
            dispatch(getSearchResult(query))
        },
        AddList(data) {
            // console.log(data)
            dispatch(AddListData(data))
        },
        DeleteList(data) {
            dispatch(DeleteListData(data))
        },
        DeleteSearchList(data) {
            dispatch(DeleteSearchListData(data))
        }
    }
}
export default connect(mapStateToProps, mapDispatchToProps)(React.memo(GameList))

最后

以上就是整个项目的分享啦,如果对你有帮助,感谢点赞🤗。

源码地址: 传送门👀

项目上线地址 点这里立即康康😍