我正在参加「创意开发 投稿大赛」详情请看:掘金创意开发大赛来了!
前言
前两周写了个米游社的小demo,最近学习了Redux,加之对米游社的首页挺感兴趣(coser她们不美吗) ,所以就诞生了这个米游社的另一个demo,使用React-Hooks + Redux完善了更为完整的米游社。 上成果:
进入正题。
准备阶段
使用工具
vite
: 脚手架,初始化react项目
redux
: 状态管理
redux-thunk
: 处理异步逻辑的redux中间件
styled-components
: css in js,吸顶之后的样式改变大部分依靠他,官方文档
classnames
: 动态类名,官方文档
axios
: 请求后端api数据,中文文档
ahooks
:一套高质量可靠的 React Hooks 库,会用到其中的useScroll
,监听滚动,制作吸顶,官方文档
react-photo-view
:实现图片预览,官方文档
1. 数据获取
1.1 方式
- 数据量庞大,自己mock显然不理智,还是借用一下官方的接口比较好。
1.2 问题
- 米游社的内容集中在移动端,而在App端获取数据无非就是打开虚拟机打开小黄鸟(HttpCanary一个移动端抓包工具)) 一波操作,打开米游社,向上一滑,
好家伙,寄,面具中刷了相关校验模块,小黄鸟的证书也添加到了信任区,SSL证书校验依旧寄了,思来想去各种求解,在社区得到的答案是反编译一下,我总不可能为了个接口数据去对App进行反编译吧,就算法律允许,我也没那技术啊!
1.3 解决方法
- 移动端我获取不了这不还有网页端嘛,数据大差不差,熟练的打开米游社网页F12
- 这熟悉的面板,可比移动端的好解决多了,很常见的反爬措施,断点死循环,停用断点调试,恢复脚本执行就可以正常访问了。
1.4 拿取数据
- 调试面板点击网络,筛选Fetch/XHR ,剩下的那些就是我们需要的接口,一个个右键打开查看,找到需要的。
2. 数据分析及处理
数据分析
篇幅太长,就不在这里展示了,感兴趣可到我的仓库readme:查看
UNIX 时间戳处理
米有社api 返回的数据其中时间很多都是用的UNIX 时间戳的格式,而显示到页面上的却是几小时前,超过一天的显示日期,超过一年的显示年份,所以这里我在utils下写了个工具函数对其进行处理:
// 时间戳转日期
const getGMT = function (dateTime) {
if (dateTime === null) {
return '';
}
let date = new Date(parseInt(dateTime) * 1000);
let now = new Date().getTime();
let second = Math.floor((now - date) / 1000);
let minute = Math.floor(second / 60);
let hour = Math.floor(minute / 60);
let day = Math.floor(hour / 24);
let month = Math.floor(day / 31);
let year = Math.floor(month / 12);
let Year = date.getFullYear();
let Moth = date.getMonth() + 1 < 10 ? `0${date.getMonth() + 1}` : date.getMonth() + 1;
let Day = date.getDate() < 10 ? `0${date.getDate()}` : date.getDate();
if (year > 0) {
return `${Year}-${Moth}-${Day}`;
}else if (day > 0) {
return `${Moth}-${Day}`;
} else if (hour > 0) {
return hour + '小时前';
} else if (minute > 0) {
return minute + '分钟前';
} else if (second > 0) {
return second + '秒前';
} else {
return '刚刚';
}
};
3. 跨域处理
- 因为用了官方的接口,和本地开发环境不在同一个协议,域名和端口下,触发了浏览器的同源策略限制,所以需要对项目做跨域处理
- 本次项目使用了vite 作为脚手架,所以只需要在vite 的配置文件下添加如下代码:
server: {
"proxy": {
"/api": {
"target": "https://bbs-api.mihoyo.com",
"changeOrigin": true,
"pathRewrite": {
"^/api": ""
}
}
},
将官方的接口地址代理到本地即可。
4. axios 封装数据请求
- axios 官方文档写的相当详细,这里就分享下我的大概配置:
// 配置请求对象
import axios from 'axios';
export const baseUrl = 'http://localhost:3000';
const axiosInstance = axios.create({
baseURL: baseUrl,
timeout: 5000,
});
// 拦截器
axiosInstance.interceptors.request.use(
req => {
...
},
err => {
...
}
)
axiosInstance.interceptors.response.use(
res => {
...
},
err => {
...
}
)
export { axiosInstance };
请求拦截和相应拦截中写入自己的成功和失败的操作即可。
5. 给项目添加 redux
从数据分析可以看见,这次官方接口返回的数据量是相当多的,数据量大了,项目趋于复杂,很多数据是需要在组件之间共享的,所以还是请上我们的redux
5.1 创建store
- 在
src
目录下创建一个store
文件夹,然后在文件夹下创建一个index.js
文件和reducer.js
文件。 (这里是store相当于总仓 汇总多个reducer 合并子仓的store
- index.js文件 (创建整个项目的
store
)
// 组件 - 中间件redux-thunk - 数据
import { createStore, compose, applyMiddleware } from 'redux';
import thunk from 'redux-thunk'; // 支持异步数据管理(接口请求)
import reducer from './reducer';
// 这里是启用Redux DevTools,我用的官方文档的第一种方式
// 也可下载插件,那样代码更优雅,我懒,就先这样用着
// compose 合并中间件
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
// 创建仓库管理数据流 createStore
const store = createStore(reducer,
composeEnhancers(
// 异步
applyMiddleware(thunk)
)
)
export default store;
- reducer.js文件 (集合各个子数据仓库)
import { combineReducers } from "redux";
import { reducer as homeReducer } from "@/pages/Home/store/index";
import { reducer as yuanshenReducer } from "@/pages/Home/Yuanshen/store/index";
import { reducer as dabieyeReducer } from "@/pages/Home/Dabieye/store/index";
import { reducer as searchReducer } from "@/pages/Search/store/index";
import { reducer as selectReducer } from "@/pages/SelectChannel/store/index";
...
// 引入并合并分仓
export default combineReducers({
home: homeReducer,
yuanshen: yuanshenReducer,
dabieye: dabieyeReducer,
search: searchReducer,
select: selectReducer
...
})
5.2 首页main.jsx配置
main.jsx
作为容器组件,引入Provider
组件 声明式引入数据管理功能
import { Provider } from 'react-redux'
import store from './store'
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
)
页面级别组件下创建分仓store
5.2.1 创建分仓
页面级别组件创建自己的自仓,方便管理每个页面的状态和数据,store下分别创建四个文件:
- index.js文件为子仓库的核心,暴露子仓库的
reducer
和actionCreators
、constants
import reducer from './reducer';
import * as actionCreators from './actionCreators';
import * as constants from './constants';
export {
reducer,
actionCreators,
constants
}
- reducer.js 文件是redux最关键的地方,操作数据在这里进行,给状态设置一个默认值,没有发生改变时,redux会把这个默认值传给组件,一旦有状态发生改变,接收
action
,去匹配相应的type
并执行相应的操作,同步给组件,完成MVVM操作
这里需要注意Reducer 里只能接受state,不能改变state,所以一般在更改数据时都是运用以下方式:
// 克隆
let newState = JSON.parse(JSON.stringify(state));
// 或者使用对象展开运算符
case actionTypes.GET_LIST:
return {
...state,
List: action.data
}
// 又或者使用Object.assign() 创建一个副本
case actionTypes.CHECK_LIST:
// state 旧状态 保全
let checkList = state.list
// 新状态
// 必须把第一个参数设置为空对象,因为它会改变第一个参数的值
return Object.assign({}, state, {list:[...checkList]})
- actionCreators.js这里暴露相应的函数,数据通过
dispatch action
来更新,拉取数据
import * as actionTypes from './constants'
import {
getGameListRequest,
} from '@/api/request'
// 获取游戏分区列表
const changeGameList = (data) => ({
type: actionTypes.SET_GAME_LIST,
data
})
export const getGameList = () => {
return (dispatch) =>{
getGameListRequest().then(data => {
let list = data.data.list
dispatch(changeGameList(list))
})
}
}
- contains.js 这里是配置文件 给
type
取一个别名(常量),方便引用
export const SET_GAME_LIST = 'SET_GAME_LIST'
5.2.2 页面连接仓库
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import {
getGameList,
} from './store/actionCreators'
const Home = (props) => {
// 解构出全局的state和dispatch
const {
gameList,
} = props
const {
getGameListDispatch,
} = props
// dispatch
useEffect(() => {
getGameListDispatch();
},[])
return (
...
)
}
// 映射Redux全局的state到组件的props上
const mapStateToProps = (state) => {
return {
gameList: state.home.gameList,
}
}
// 映射dispatch到props上
const mapDispatchToProps = (dispatch) => {
return {
getGameListDispatch(){
dispatch(getGameList())
},
}
}
// 将ui组件包装成容器组件
export default connect(mapStateToProps, mapDispatchToProps)(Home)
5.2.3 Redux使用顺序
按照: 声明常量 -> 编写action -> 编写reducer -> 组件使用,的顺序来进行
6. 首页
6.1 页面分析
打开米游社,可以看到米游社首页的页面组件和布局几乎一模一样,所以首页的组件可以全部拆分成公共组件,高度复用,看看官方页面:
可以看到官方的页面大体可以分为以下几个部分:
- 底部导航栏,一级路由
- Home顶部Tab导航栏,二级路由
- 活动导航List组件
- 套路区组件
- 官方资讯组件
- 推荐文章组件(含轮播图)
6.2 底部导航栏及路由设计
6.2.1 路由
直接上代码,没什么好多说的:
import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom';
import Home from '@/pages/Home';
const Dynamic = lazy(() => import('@/pages/Dynamic'))
const Information = lazy(() => import('@/pages/Information'))
const Mypage = lazy(() => import('@/pages/Mypage'))
const Search = lazy(() => import('@/pages/Search'))
const SelectChannel = lazy(() => import('@/pages/SelectChannel'))
const Yuanshen = lazy(() => import('@/pages/Home/Yuanshen'))
const Dabieye = lazy(() => import('@/pages/Home/Dabieye'))
const RouterConfig = ({gamename}) => {
return (
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<Navigate to={`/home/${gamename}`}/>} replace={true} />
<Route path="/home" element={<Navigate to={`/home/${gamename}`}/>} replace={true} />
<Route path="/dynamic" element={<Dynamic />} />
<Route path="/information" element={<Information />} />
<Route path="/mypage" element={<Mypage />} />
<Route path="/search" element={<Search />} />
<Route path="/select" element={<SelectChannel/>} />
<Route path="/home" element={<Home />} >
<Route path="/home/yuanshen" element={<Yuanshen/>} />
<Route path="/home/dabieye" element={<Dabieye/>} />
</Route>
</Routes>
</Suspense>
);
};
export default RouterConfig;
6.2.2 底部导航栏
导航栏每个图标为一级路由,中间的添加按钮,弹出层使用了antd-mobile 中的Popup
,代码有点长,看这儿,效果:
6.2.3 顶部导航栏
顶部导航栏中的数据是变动的,因为每一个页面都是一个游戏的资讯页,所以我将其每一个都作为了二级路由,并给了每一个二级路由页面一个分仓store 方便管理数据(这里也可以不用设置分仓,可以直接使用主页的store,但是每一个二级路由也是页面级别,而且内部组件也有细微区别,所以我还是给了每个二级路由一个store),部分代码:
const SelectTop = () => {
return (
<SelectItem searchHidden={searchHidden}>
<div className="swiper-container">
<div className="swiper-wrapper">
{
data.map((item) => {
if(item.has_wiki || item.en_name == 'dby'){
return(
<div key={item.id} className="swiper-slide">
<NavLink to={`/home/${selectGame(item)}`}>
<span>
{item.name}
</span>
</NavLink>
</div>
)
}
})
}
</div>
</div>
</SelectItem>
)
}
return (
<Wrapper>
...
<Outlet />
</Wrapper>
)
完整的看这儿
6.3 吸顶
先看看效果
- 一共两个吸顶,分别是最上方的二级路由以及下方页面中的讨论区,都要做到吸顶后改变样式。
ahooks + useRef + styled-components
- 吸顶的实现我最开始是想的找一个现成的库或者使用css的
sticky
,可是前者找来找去发现github 里面知名的那几个都已经几年没维护了,后者又有兼容性问题,无奈还是得自己来。 - 我这里使用的是
ahooks + useRef
来完成的,利用ahooks
中的useScroll
监听屏幕滚动,Ref 来获取DOM 元素,当组件超出范围时吸顶
导航栏代码如下:
// searchHidden 传给样式组件用,实现吸顶时改变字体颜色
const [searchHidden, setSearchHidden] = useState(false);
const searchRef = useRef(null)
const scroll = useScroll()
// 监听屏幕滚动,超出顶部,组件吸顶
useEffect(() => {
if(scroll && scroll.top > 0){
if(!searchHidden && searchRef.current){
setSearchHidden(true);
searchRef.current.style.position = 'fixed'
searchRef.current.style.backgroundColor ='white'
searchRef.current.style.zIndex = '9999'
searchRef.current.style.opacity = '0.5'
}
}else {
if(searchHidden && searchRef.current){
searchRef.current.style = ''
setSearchHidden(false);
}
}
if(scroll && scroll.top > 100){
searchRef.current.style.backgroundColor ='rgb(242, 243, 244)'
searchRef.current.style.opacity = '1'
}
}, [JSON.stringify(scroll)])
// 传递参数给样式组件
<SelectItem searchHidden={searchHidden}>
- 使用组件中传入的
searchHidden
在样式组件中实现吸顶之后的样式改变:
&.active {
font-weight: 500;
font-size: 1rem;
color: ${props => (props.searchHidden ? 'black' : 'white')};
}
讨论区代码如下:
const [searchHidden, setSearchHidden] = useState(false);
const searchRef = useRef(null)
const scroll = useScroll()
// 监听屏幕滚动,超出顶部,组件吸顶
useEffect(() => {
if(scroll && scroll.top > 144){
if(!searchHidden && searchRef.current){
setSearchHidden(true);
searchRef.current.style.position = 'fixed'
searchRef.current.style.backgroundColor ='white'
searchRef.current.style.marginTop = '-2.98rem'
searchRef.current.style.zIndex = '9999'
}
}else {
if(searchHidden && searchRef.current){
searchRef.current.style = ''
setSearchHidden(false);
}
}
}, [JSON.stringify(scroll)])
- 样式组件
height: ${props => (props.searchHidden ? '2rem' : '4rem')};
display: flex;
align-items: center;
justify-content: center;
a{
position: relative;
display: inline-block;
display: flex;
align-items: center;
justify-content: center;
width: ${props => (props.searchHidden ? '100%' : '90%')};
height: ${props => (props.searchHidden ? '2rem' : '3rem')};
color: black;
background: white;
border: 1px solid rgba(0,0,0,0.05);
border-radius: ${props => (props.searchHidden ? '0' : '8px')};
img {
position: absolute;
height: ${props => (props.searchHidden ? '1.3rem' : '1.5rem')};
width: ${props => (props.searchHidden ? '1.3rem' : '1.5rem')};
margin-left: ${props => (props.searchHidden ? '-16rem' : '-14rem')};
}
>p {
position: absolute;
font-size: 0.6rem;
margin-right: ${props => (props.searchHidden ? '-14rem' : '-12.5rem')};
}
}
- 可以看到,这里我大量的使用了
styled-components
的css in js 的特性,像写js 一样写css
6.4 资讯栏组件
老规矩先上效果
下拉刷新
下拉刷新使用了antd-mobile
中的 PullToRefresh
组件,现成的轮子,可以自定义下拉时的显示内容,每次下拉触发函数重新拉取数据:
async function doRefresh() {
await sleep(1000);
// dispatch 拉取数据
getOfficialListDispatch(2);
getCarouselsListDispatch(2);
Toast.show({
content: '推荐已更新'
})
}
return (
<PullToRefresh
onRefresh={doRefresh}
refreshingText={<DotLoading color='#2df4fe'/>}
completeText={ <h3> </h3>}
>
...
</PullToRefresh>
)
资讯数据处理
米游社的api 返回的资讯数据是固定的,不管刷新多少次固定的时间段都是那些,所以要实现每次下拉更新需要对返回的数组进行处理,这里我写了个小工具函数:
//截取打乱后的数组的前num 位
const getRandomArr = (arr, num) => {
//打乱数组顺序
const getArrRandomly = arr => {
let len = arr.length;
for (let i = len - 1; i >= 0; i--) {
let randomIndex = Math.floor(Math.random() * (i + 1));
let itemIndex = arr[randomIndex];
arr[randomIndex] = arr[i];
arr[i] = itemIndex;
}
return arr;
};
const tmpArr = getArrRandomly(arr);
let arrList = [];
for (let i = 0; i < num; i++) {
arrList.push(tmpArr[i]);
}
return arrList;
};
6.5 推荐文章组件
6.5.1 整体布局
米游社推荐文章页面api 一页返回二十个数据,通过api 返回数据中的top 字段判断是否置顶,没有指定就切割前两个显示在最上方,下面插入轮播图滑动展示组件,切割剩余的文章继续在轮播图下面展示
const frontPost = suggestPostList.slice(0,2)
const lastPost = suggestPostList.slice(2)
6.5.2 作者文章信息栏及弹出层
在数据方面头像和头像框,发布时间和作者信息都有现成数据,这里就变成简单的切图了,下方的标题和内容预览也是切图,这里主要分享下内容显示两行多余省略的写法:
.post_content {
display: block;
font-size: 0.7rem;
color: #482929;
letter-spacing: 0;
line-height: 1.1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
opacity: 0.5;
}
这对非谷歌内核的浏览器可能有兼容问题,我的知识量也没想到更好的办法,有更好方法的小伙伴可以分享下。
弹出层用了和底部导航栏的处理方法一样用了antd-mobile的Popup
6.5.3 图片布局
米游社api 返回的图片是多样的,封面图、内容图、长图,的显示都不一样,仔细翻看米游社发现他也没做到真正的适配每一张图的内容,所以这里就对其进行了一个简单的判断,图片以背景的形式呈现方便布局,大致如下:
<div className="cover_container">
// 是否有封面图,有则展示,并将封面图数据传给样式组件
{
Post.post.cover &&
<CoverImg
coverUrl={Post.post.cover}
viewType={Post.post.view_type}
/>
}
// 没有封面时,是否有内容图,有就展示
{
Post.post.images && !Post.post.cover &&
Post.post.images.map((item, index) =>(
<ImageItem imgUrl={item} key={index}/>
))
}
{
Post.post.images && !Post.post.cover &&
<div className="label">
<i className='iconfont icon-gengduotupian'></i>
+{Post.post.images.length}
</div>
}
</div>
然后再在css 中进行一些处理,比如判断是否是长图片,根据判断显示不同样式,再次吹一波styled-components
,部分代码如下:
export const CoverImg = styled.div`
/* 图片以背景的形式呈现 */
background-image: url(${props => props.coverUrl});
/* 判断是否是长图片,根据判断显示不同样式 */
width: ${props => (props.viewType == 1 ? '100%' : '10rem')};
height: ${props => (props.viewType == 1 ? '10rem' : '12rem')};
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 38.2%;
border-radius: 0.2rem;
`;
export const ImageItem = styled.div`
background-image: url(${props => props.imgUrl});
height: 8.7rem;
width: 8.7rem;
background-size: cover;
background-position: 50% 38.2%;
display: inline-block;
margin-right: 0.5rem;
border-radius: 0.2rem;
vertical-align: top;
position: relative;
`;
6.5.4 图片预览
- 图片预览方面在掘金论坛找到了
MinJie
大佬制作的一款超精致的 React 图片预览组件(官方文档就是这样写的,也确实好用),感兴趣的小伙伴可以看看MinJie
大佬的文章:2022强力之作:一款超精致的图片预览组件
使用后效果如下
- 使用相当便捷,只需要下载后在入口引入,在需要的地方用
PhotoProvider
包裹图片组件,以PhotoProvider
为界限,里面所有的PhotoView
图片会按照运行顺序提取为一组图片预览画廊。当某个<img />
被点击,则会定位到指定的图片并打开预览,所加入图片预览后上面的代码变成了下面这样:
<PhotoProvider>
<div className="cover_container">
{
Post.post.cover &&
<PhotoView src={Post.post.cover}>
<CoverImg
coverUrl={Post.post.cover}
viewType={Post.post.view_type}
/>
</PhotoView>
}
{
Post.post.images && !Post.post.cover &&
Post.post.images.map((item, index) =>(
<PhotoView key={index} src={item}>
<ImageItem imgUrl={item} />
</PhotoView>
))
}
{
Post.post.images && !Post.post.cover &&
<div className="label">
<i className='iconfont icon-gengduotupian'></i>
+{Post.post.images.length}
</div>
}
</div>
</PhotoProvider>
开箱即用,真的便捷好用。
6.5.5 轮播图
轮播图使用了swiper
,不自动轮播,不循环,这里就直接写在了推荐文章的组件里,封装成一个函数
let swiper = null;
useEffect(() => {
if(swiper) return;
swiper = new Swiper('.swiper-container',{
observer: true,
observerParants: true,
slidesPerView : 'auto',
freeMode: {
enabled: true,
},
})
},[])
const carousels = () => {
return (
<SwiperItem>
<div className="swiper-container mySwiper">
<div className="swiper-wrapper">
{
carouselsList.map((item,index) =>
<div key={index} className="swiper-slide">
<img src={item.cover} />
</div>
)
}
</div>
</div>
</SwiperItem>
)
}
7. 搜索
搜索页面参考了神三元大佬的云音悦项目,Search
组件嵌套Search-box
组件,实现效果如下:
Search-box
组件实现输入数据并对数据进行防抖处理,将输入的值传入父组件,父组件根据传过来的值dispatch
并对返回的搜索结果进行渲染。
使用
useRef
在操作清除按钮和进入页面后,输入框中没有数据自动聚焦。
这里就不放代码了,具体的实现代码可以到三元大佬的云音悦项目中查看,或者到我的仓库,查看简化版。
8. 性能优化
memo
- import { memo } from 'react'
- export default memo(xxxx)
- 实现减少未变数据重复渲染
useMemo
也是从三元大佬那里学来的 useMemo
可以缓存 上一次函数计算的结果,搜索的防抖就是放那里面,当handleQuery
发生改变了才重新计算
let handleQueryDebounce = useMemo(() => {
return debounce(handleQuery, 500)
},[handleQuery])
lazyLoad 懒加载
还是三元大佬那里学来的,大概用法:
<LazyLoad
// 占位图片
placeholder={<img
src={placeholderImg}
className='m-bfs-pic pic'
/>}
>
<img src={pic}
className={classnames("m-bfs-pic pic", { notfond: !pic })} />
</LazyLoad>
-
路由懒加载
- import { lazy, Suspense } from "react"
- const XXX = lazy(() => import('@/pages/XXX'))
const Dynamic = lazy(() => import('@/pages/Dynamic')) <Suspense fallback={null}> <Routes> <Route path="/dynamic" element={<Dynamic />} /> </Routes> </Suspense>
最后
遗憾:
- 有点赶,首页频道设置的功能,也就是上篇文章的功能还没完整迁移过来
- 文章互动的数据拿不到,米游社的文章和文章互动数据(点赞,评论,收藏数)是分两个不同的接口传过来的,但是当我筛选出文章id 通过axios 去拿数据却一直404,看了下请求头才发现不支持除米游社之外的跨域请求,若是想拿得通过nginx或者自己写后端cross,很是遗憾
- 二级路由切换时改变背景延迟太高,相信看了效果的小伙伴也注意到了,当从原神切换到大别野或者其他游戏的时候其他数据都到了,首页的背景数据还没到,而这个背景是写在home 的页面中去拿取并设置的,在其他页面也得不到这种显示效果,但是延迟就是高,没找到方法解决
最后的最后
这个项目还将继续完善(毕竟还有那么多东西没写),有问题和可优化点,欢迎大佬评论区指正