前言
携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情 >>
前段时间学习了react,也在掘金上发布了一篇仿写bilibili游戏移动端的文章。这段时间笔者学习了一下redux和神三元大佬网易云音乐的项目 。大佬封装的Scroll、SearchInput组件都很厉害,值得新手(比如我)学习一下,有兴趣的可以看看大佬的小册。
一开始的学习是比较的困惑有不理解的地方,后来慢慢的理解了就开始动手写项目啦!这次的项目还是bilibili游戏移动端的仿写,只不过做了一下redux数据管理的升级和搜索页的按名字搜索功能。
项目介绍
- react
- redux 进行数据管理
- styled-components 编写样式组件
- react-transition-group 动画过渡库,在切换路由时展示动画效果
- axios与fastmock 请求数据
- antd-mobile 蚂蚁团队推出开源组件库
- react-lazyload 延时加载图片
项目展示
Redux
什么是Redux呢?
redux是一个独立专门用于做状态管理的JS库,集中式管理react应用中多个组件共享的状态。当某个状态需要跨页面共享时,我们就可以用redux来做,若状态只存在于某个页面,可以不用redux继续用useState来做。
Redux的工作流程
用户进行操作 ---> ActionCreators获取需求 派送action(特定的type属性)到Store中 ---> Store再将State与action 传给Reducers ---> Reducers通过辨别action中type属性,重新计算,返回一个新的state给Store ---> Store获取新的state重新渲染页面。
Reducers不仅可以重新计算状态,还可以初始化状态哦!
什么情况下我们使用Redux呢?
- 能不用就不用, 如果不用比较吃力才考虑使用
- 某个组件的状态,需要共享
- 一个组件需要改变全局状态
- 一个组件需要改变另一个组件的状态
这里我以搜索页为例子讲述redux的使用。
搜索页的实现
Redux的引入
- 在main.js入口文件中,引入react-redux的Provider组件,用Provider将其他组件包裹,给项目提供仓库服务
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
)
- 在src文件夹下创建store文件夹,完善项目架构。
- 初始化总仓库
- 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;
- 总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
});
页面级组件连接仓库
- 在Search目录下建立分仓store文件夹
- index.js
- 清单文件,向外输出分仓下的内容,将所有数据交给总仓库
import reducer from './reducer';
import * as actionCreators from './actionCreators';
import * as constants from './constants'
export {
reducer,
actionCreators,
constants
}
- 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';
- 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))
})
}
}
- 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;
}
}
- 组件连接仓库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))
- 在调试的过程中可以打开浏览器,使用redux-dev-tool进行查看数据状态
- 初始状态:
- 数据到达后状态:
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
- 将handleQuery函数进行防抖封装(handleQueryDebounce),并且性能优化升级,用useMemo缓存上一次计算的结果,没变,不重新渲染。
- 用useRef绑定DOM元素进行操作(此处为input),组件挂载时自动聚焦,点击×时,清空文字。
- 当input输入框输入时,onChange事件触发handleChange函数,设置子组件的query值。
- query值发生改变,触发生命周期useEffect更新,useEffect(() => { handleQueryDebounce(query); },[query]),handleQueryDebounce函数向父组件传回输入的query值。
- 父组件的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,也请大家多多期待啦。如果喜欢这篇文章,不妨点个小心心!这对我真的很重要! 下次见!