综合分析大神三元网易云音乐React+钩子
吧啦吧啦
自从弄懂了神三元大神的作品《云音悦》,整个人神清气爽,飘飘欲仙~,本着学习应该共同进步的精神,我将三元项目的一个页面流程进行了一个细致的讲解。是的,你没有听错,是流程。我相信有很多人拿到一个开源项目希望能够弄懂,但是一个项目肯定是很大的,不知从何下手,每个组件与每个组件之间有着千丝万缕的联系,于是,想想还是算了吧。
在这里我将从零开始搭建这个项目,一步一步的将项目的真面目露出来,需要说明的是,我在这里仅仅只是对三元项目的代码进行了 cv大战(粘贴复制),当然cv不是说每个文件一次性全部复制,而是有选择性的将流程能够走通。
如下图就是我们要完成的一个页面,其他的页面也是类似,完成这个页面其实能说明很多问题了,这个页面有从后端请求数据,有redux管理数据,有轮播图。。。 不多说了,进入正题吧!
技术栈
- react
- styled.components 使得css在js文件可以书写
- hooks react新特性
- redux 状态管理
- immutable 优化性能
- better-scroll 一个比较好的滚动下拉组件
- react-router-config 将路由全部统一管理
注意: 这些知识点你都要掌握(至少要了解他们是什么),不然食起来可能会有点痛苦哦~## 项目目录结构
目录
src
- api 前后端数据接口 以及一些配置文件
- application 页面组件以及每个组件的store
- assets 静态资源目录,主要放iconfonts以及全局样式
- baseUI 功能性组件 如 加载组件 滚动条等
- components 公共组件 如轮播图
- layouts 涉及到路由,组件嵌套
- routes 路由,使用react-router-config
- store 全局 redux 仓库
开始工作
1. 配置路由
src下创建 routes 文件夹 建index.js文件
index.js
import React, { lazy, Suspense } from "react";
import { Redirect } from "react-router-dom";
import HomeLayout from "../layouts/HomeLayout";
import BlankLayout from "../layouts/BlankLayout";
// 路由懒加载
const SuspenseComponent = Component => props => {
return (
<Suspense fallback={null}>
<Component {...props}></Component>
</Suspense>
)
}
// 组件懒加载
const RecommendComponent = lazy(() => import("../application/Recommend/"));
export default [
{
component: BlankLayout,
routes: [
{
path: "/",
component: HomeLayout,
routes: [
{
path: "/",
exact: true,
render: () => <Redirect to={"/recommend"} />,
},
{
path: "/recommend",
component: SuspenseComponent(RecommendComponent),
},
],
},
],
},
];
这种配置路由的方式与之前普通的区别可能有点大,不了解的建议充个电(react-router-config)
在这里BlankLayout是全局组件,在它下面放置所有路由级别组件,HomeLayout下所有路由级别组件也都归HomeLayout管理,这里我们看到的只有recommend组件,这也就是逐级嵌套
。所有的路由都放在这里统一管理,之前普通路由是哪个组件需要路由才到哪个组件下面写,管理不方便。
2. 创建layouts文件夹
建 BlankLayout.js 以及 HomeLayout.js
BlankLayout.js下是全局组件,HomeLayout下放的是recommend singers,rank 三大组件,由于整个项目过多,这里我们只介绍recommend页面,当然,如果recommend页面会写了,那么其他页面其实都是类似的操作
BlankLayout.js
import React from "react";
import { renderRoutes } from "react-router-config";
// 从我们刚刚配置的路由可知 这个组件是最外层组件 所有路由级别组件都在这
const Layout = ({ route }) => <>{renderRoutes(route.routes)};
export default Layout
HomeLayout.js
```js
import React from "react";
import { renderRoutes } from "react-router-config";
function Home(props) {
const { route } = props;
return (
{/* 将HomeLayout的子组件都放到这里 */}
{renderRoutes(route.routes)}
)
}
export default React.memo(Home);
```
3. 创建application目录
创建Recommend子目录,Recomend下创建index.js
index.js
现在还不能运行,因为在app.js中没有导入路由(由于div会被编辑器吃掉????,某些地方只能上图片)
4. 来到App.js
App.js
```js
import React from "react";
import routes from "./routes/index.js";
import { renderRoutes } from "react-router-config";
import { HashRouter } from "react-router-dom";
function App() {
return (
<HashRouter>
{renderRoutes(routes)}
</HashRouter>
);
}
export default App;
```
我们将 routes里面的文件导入到了renderRoutes,renderRoutes就可以根据routes里面的组件在其他组件下放入其他组件了(就问你绕不绕,此其他非彼其他????)。这个理解了其实用起来感觉会很方便。 到这我们安装两个包
yarn add react-router-config
yarn add react-router-dom
这个时候项目就可以运行起来了 运行结果如下
5. 配置 global-style.js 文件 以及全局样式
src下创建 assets目录,创建该文件,该文件是通用样式文件(不要与全局样式混淆),比如,该app的主题颜色(你懂的颜色)在许多地方都会用到
global-style.js
```js
const extendClick = () => {
return `
position: relative;
&:before{
content: '';
position: absolute;
top: -10px; bottom: -10px; left: -10px; right: -10px;
};
`;
};
const noWrap = () => {
return `
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
`;
};
const bgFull = () => {
return `
background-position: 50%;
background-size: contain;
background-repeat: no-repeat;
`
};
export default {
"theme-color": "#d44439", //今天有绿吗
"theme-color-shadow": "rgba(212, 68, 57, .5)",
"font-color-light": "#f1f1f1",
"font-color-light-shadow": "rgba(241, 241, 241, 0.6)",//略淡
"font-color-desc": "#2E3030",
"font-color-desc-v2": "#bba8a8", //略淡
"font-size-ss": "10px",
"font-size-s": "12px",
"font-size-m": "14px",
"font-size-l": "16px",
"font-size-ll": "18px",
"border-color": "#e4e4e4",
"border-color-v2": "rgba(228, 228, 228, 0.1)",
"background-color": "#f2f3f4",
"background-color-shadow": "rgba(0, 0, 0, 0.3)",
"highlight-background-color": "#fff",
"official-red": "#E82001",
extendClick,
noWrap,
bgFull
};
```
style.js
```js
import { createGlobalStyle } from 'styled-components'
export const GlobalStyle = createGlobalStyle`
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
html, body{
background: #f2f3f4;;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
a{
text-decoration: none;
color: #fff;
}
`
```
6. 开始写头部
这里我们将用到 比较新的知识styled.components
以及需要iconfont
, 来到HomeLayout下,
HomeLayout.js
这里我们导入了HomeLayout.style.js(styled.componets)以及
(iconfont),未加iconfont类似于‘口’,因此我们需要创建它们。styled.components就类似于在js文件里面写样式,并且该容器的class会随机生成。
从如下代码我们可以看到Top就是一个容器,它的子容器的样式也可以在这里写,然后导入上面的HomeLayout.js.
7. 在同级目录下创建HomeLayout.style.js
HomeLayout.style.js
```js
import styled from "styled-components";
import style from "../assets/global-style";
export const Top = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 5px 10px;
background: ${style["theme-color"]};
& > span {
line-height: 40px;
color: #f1f1f1;
font-size: 20px;
&.iconfont {
font-size: 25px;
}
}
`
```
将这段代码引入之后就可以看到效果了 此时运行结果如下 因为还没有配置iconfont,所以是如上效果
8. 配置iconfont
可以到icontfont官网下载所需要的文件,放在assets目录下,目录如下 在全局App.js引入icontfont文件,加上这几行代码
import { IconStyle } from "./assets/iconfont/iconfont";
<HashRouter>
<IconStyle></IconStyle>
{renderRoutes(routes)}
</HashRouter>
运行效果如下,图标出来了
9. 接着在 HomeLayout.js 写布局
在原来的的基础上加上如下代码下面写
HomeLayout.js
上面引入了 ,在HomeLayout.style.js写
HomeLayout.style.js
```js
export const Tab = styled.div`
height: 44px;
display: flex;
flex-direction: row;
justify-content: space-around;
background: ${style["theme-color"]};
a {
flex: 1;
padding: 2px 0;
font-size: 14px;
color: #e4e4e4;
&.selected {
span {
padding: 3px 0;
font-weight: 700;
color: #f1f1f1;
border-bottom: 2px solid #f1f1f1;
}
}
}
`;
export const TabItem = styled.div`
height: 100%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
`;
```
页面结果如下
10. 来到了难点重点部分。
前面的都是一些基础的不需要从后端获取数据就可以完成的,但是接下来的内容轮播图,以及列表都是需要从后端接口请求数据渲染到页面。
数据拿到了也需要人来管理,那么谁呢,就是你又爱又恨的redux
。
接下来首先得准备好接口数据
src下创建api文件夹,在该文件夹下配置config.js
文件, 该文件一般是用来配置默认后端请求接口路径
config.js
```js
import axios from "axios";
export const baseUrl = "http://neteasecloudmusicapi.zhaoboy.com";
// 创建一个axios的实例及拦截器配置
const axiosInstance = axios.create({
baseURL: baseUrl
});
// 响应拦截器,在获取响应之前处理数据
axiosInstance.interceptors.response.use(
res => res.data,
err => {
console.log(err, "网络错误");
}
);
export { axiosInstance };
```
11. 同路径api下创建request.js
该文件用来封装 请求接口,通过调用该函数可以请求数据
import { axiosInstance } from "./config";
export const getBannerRequest = () => {
return axiosInstance.get("/banner");
};
12. 创建redux 仓库
需要说明的是,神三元项目中每个对应的路由组件都有一个store,然后将所有的store(该store中没有store文件,一个项目只应该有一个仓库)全部引入到最外面的store,这样写的理由是逻辑清晰。如果不这样写很多action放到一起,很多reducer放到一起,文件数量可能会较大,头都大了。
那么用redux怎么管理数据呢?简单说就是dispatch(action)向store请求数据,store又找reducer,reducer对请求的数据进行一定的操作,存到store,在组件中connect一下,再通过props获取数据
我们在Recommend下创建store文件夹,分别创建constants.js
actionCreators.js
reducer.js
index.js
12.1 constants.js
该文件只有一行代码,作用是区分不同的actions
export const CHANGE_BANNER = 'home/recommend/CHANGE_BANNER';
12.2 actionCreators.js
该文件中有 changeBannerList 用来改变状态,也就是传进来什么数据就修改成什么数据,但是为了支持异步,我们需要通过下面一个getBannerList来获取数据
actionCreators.js
```js
import * as actionTypes from './constants';
import { fromJS } from 'immutable';
import { getBannerRequest } from '../../../api/request';
export const changeBannerList = (data) => ({
type: actionTypes.CHANGE_BANNER,
data: fromJS(data)
});
// 获取 轮播图数据
export const getBannerList = () => {
return (dispatch) => {
getBannerRequest().then(data => {
const action = changeBannerList(data.banners);
dispatch(action);
}).catch(() => {
console.log("轮播图数据传输错误");
})
}
};
```
注意,我们在这里使用了immutable,以后的文件会大量用到immutable,简单介绍下作用: 使用旧数据创建新数据时可以保证新数据不变,并且创建新数据的过程中使用了结构共享,如下图所示
在上面的代码中我们导入getBannerRequest的请求,getBannerList 返回的是一个方法,我们需要在store导入redux-thunk包,这是后面写到的。
12.3 reducer.js
reducer.js 首先初始化bannerList的数据,然后switch一顿狂操作
reducer.js
```js
import * as actionTypes from './constants';
import { fromJS } from 'immutable';
const defaultState = fromJS({
bannerList: [],
})
export default (state = defaultState, action) => {
switch(action.type) {
case actionTypes.CHANGE_BANNER:
return state.set('bannerList', action.data);
default:
return state;
}
}
```
12.4 index.js
import reducer from './reducer'
import * as actionCreators from './actionCreators'
import * as constants from './constants'
export { reducer, actionCreators, constants };
将三个文件整合,并导出 以上是Recommend 里面的store,(再次说明: 里面的store是没有仓库的,统一放到外面的仓库管理),我们需要创建 外面最大的store
13. 在src 创建 store文件夹(最外面的仓库)
store 下创建 reducer.js 集合所有reducer, index.js 仓库
reducer.js
import { combineReducers } from "redux-immutable";
import { reducer as recommendReducer } from "../application/Recommend/store/index";
export default combineReducers({
recommend: recommendReducer,
});
index.js, 这里我们安装了 redux-thunk,支持action返回方法
import { createStore, compose, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import reducer from "./reducer";
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(reducer, composeEnhancers(applyMiddleware(thunk)));
export default store;
- 在App.js 引入store,使之成为全局仓库 现在App.js文件长这样
代码
```js
import React from "react";
import routes from "./routes/index.js";
import { renderRoutes } from "react-router-config";
import { HashRouter } from "react-router-dom";
import { IconStyle } from "./assets/iconfont/iconfont";
import { Provider } from "react-redux";
import store from "./store/index";
function App() {
return (
<Provider store={store}>
<HashRouter>
<IconStyle></IconStyle>
{renderRoutes(routes)}
</HashRouter>
</Provider>
);
}
export default App;
```
需要导入一个Provider,使得全局都可以使用store里面的数据 到这里我们终于把Recommend的数据管理给搞定了,下载必要的包 yarn add axios redux react-reudx immutable redux-immutable redux-thunk (可能不全) 接下里就是如何将数据渲染到页面了。
15. 来到HomeLayout的子组件Recommend下的 index.js 中
首先我们先通过connect拿到数据
index.js
connect 之后怎么拿数据呢? 通过props可以获取数据,接下来我们来写如何通过props获取数据
index.js
```js
function Recommend(props) {
const { bannerList} = props;
const { getBannerDataDispatch } = props;
useEffect(() => {
if(!bannerList.size){
getBannerDataDispatch();
console.log(bannerList);
}
}, []);
return (
Recommend
)
}
```
只要connect之后,我们就可以通过props拿到数据了,props之后解构刚刚上面的bannerList数据和getBannerDataDispatch方法,调用该方法之后就可以获取数据,所以我们把它放到useEffect,中,判断bannerList是否为空,不用每次执行都请求一次数据。打印之后我们可以看到如下结果 数据拿到了,现在要做轮播图,因为轮播图可能多个地方都会用到,所以我们将它放到components目录下
16. 创建slider
在src下创建components文件夹,接着创建slider文件夹,在里面创建 index.js和style.js
style.js, 这是给swiper加的样式,引入了Global-style里面的主题颜色
style.js
```js
import styled from 'styled-components';
import style from '../../assets/global-style';
export const SliderContainer = styled.div`
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
margin: auto;
background: white;
.before{
position: absolute;
top: -300px;
height: 400px;
width: 100%;
background: ${style["theme-color"]};
z-index: 1;
}
.slider-container{
position: relative;
width: 98%;
height: 160px;
overflow: hidden;
margin: auto;
border-radius: 6px;
.slider-nav{
position: absolute;
display: block;
width: 100%;
height: 100%;
}
.swiper-pagination-bullet-active{
background: ${style["theme-color"]};
}
}
`
```
代码
```js
import React, { useEffect, useState } from 'react';
import "swiper/swiper-bundle.min.css"; //这里引用的方式已经发生了变化
import Swiper from "swiper";
import { SliderContainer } from './style';
function Slider(props) {
const [sliderSwiper, setSliderSwiper] = useState(null);
const { bannerList } = props;
useEffect(() => {
if(bannerList.length && !sliderSwiper){
let sliderSwiper = new Swiper(".slider-container", {
loop: true,
autoplay: {
delay: 3000,
disableOnInteraction: false,
},
pagination: {el:'.swiper-pagination'},
});
setSliderSwiper(sliderSwiper);
}
}, [bannerList.length, sliderSwiper])
return (
<SliderContainer>
<div className="before"></div>
<div className="slider-container">
<div className="swiper-wrapper">
{
bannerList.map(slider => {
return (
<div className="swiper-slide" key={slider.imageUrl}>
<div className="slider-nav">
<img src={slider.imageUrl} width="100%" height="100%" alt="推荐" />
</div>
</div>
)
})
}
</div>
<div className="swiper-pagination"></div>
</div>
</SliderContainer>
)
}
export default React.memo(Slider);
```
这里主要是使用了swiper api, 具体用法可以看swiper官网有详细介绍
17. 导入slider
swiper 写完了之后,来到,recommend 下的index.js, 首先为index.js写个style.js
style.js
```js
import styled from 'styled-components';
export const Content = styled.div`
position: fixed;
top: 94px;
left: 0;
bottom: ${props => props.play > 0?"60px": 0};
width: 100%;
`
```
在index.js中导入Slider
import { Content } from "./style";
import Slider from "../../components/slider/";
const bannerListJS = bannerList ? bannerList.toJS() : [];
return (
<Content >
<div><Slider bannerList={bannerListJS}></Slider></div>
</Content>
);
}
在 index.js加上上面这几句代码之后就可以看到效果了,但是你会发现header被覆盖了,这里其实是 Slider组件里面的before搞的鬼, 如果要改过来也很简单,在它的父容器加个overflow:hidden就可以解决问题,但是现在因为我们要做滚动(需要上下可以滚动),滚动会做slider的父容器,也会有overflow:hidden,所以这里就先不处理。 但是我们发现这个轮播图是不能滚动的,因为我们现在用的swiper 是swiper6最新版本,具体的可以看官网如何使用
18. loading组件
讲解scroll组件又会牵扯到 loading 组件(加载)和 debounce函数(防抖),所以得先建loading组件和防抖函数 baseUI文件下分别创建loading和loading-v2文件夹
loading中的文件
index.js
```js
import React from 'react';
import styled, { keyframes } from 'styled-components';
import style from '../../assets/global-style';
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: ${style["theme-color"]};
animation: ${loading} 1.4s infinite ease-in;
}
>div:nth-child(2) {
animation-delay: -0.7s;
}
`
function Loading() {
return (
<LoadingWrapper>
<div></div>
<div></div>
</LoadingWrapper>
);
}
export default React.memo(Loading);
```
index.js
```js
import React from 'react';
import styled, {keyframes} from 'styled-components';
import style from '../../assets/global-style'
const dance = keyframes`
0%, 40%, 100%{
transform: scaleY(0.4);
transform-origin: center 100%;
}
20%{
transform: scaleY(1);
}
`
const Loading = styled.div`
height: 10px;
width: 100%;
margin: auto;
text-align: center;
font-size: 10px;
>div{
display: inline-block;
background-color: ${style["theme-color"]};
height: 100%;
width: 1px;
margin-right:2px;
animation: ${dance} 1s infinite;
}
>div:nth-child(2) {
animation-delay: -0.4s;
}
>div:nth-child(3) {
animation-delay: -0.6s;
}
>div:nth-child(4) {
animation-delay: -0.5s;
}
>div:nth-child(5) {
animation-delay: -0.2s;
}
`
function LoadingV2() {
return (
<Loading>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<span>拼命加载中...</span>
</Loading>
);
}
export default React.memo(LoadingV2);
```
这两个文件都是用animation做的加载动画,具体就不介绍了,篇幅过长~
19. debounce 函数
防抖函数建在 api 的 utils.js 中 什么是防抖
***举个栗子:***有个scroll事件,滚动会不停触发回滚动事件,从而调用绑定的回调函数,我们希望当我们停止滚动的时,才触发一次回调,这时可以使用函数防抖。
代码
```js
const debounce = (func, delay) => {
let timer;
return function (...args) {
if(timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
func.apply(this, args);
clearTimeout(timer);
}, delay);
};
};
export { debounce }
```
20. scroll
好了,预备工作完成了,就可以写了,这里再说明一下,我现在是尽可能的帮助大家梳理如何能够看懂一个比较好的项目,实现可以自己动手实践
index.js
```js
import React, {
forwardRef,
useState,
useEffect,
useRef,
useImperativeHandle,
useMemo,
} from "react";
import PropTypes from "prop-types";
import BScroll from "better-scroll";
import styled from "styled-components";
import { debounce } from "../../api/utils";
import Loading from '../loading/index';
import Loading2 from '../loading-v2/index';
const ScrollContainer = styled.div`
width: 100%;
height: 100%;
overflow: hidden;
`;
const PullUpLoading = styled.div`
position: absolute;
left: 0;
right: 0;
bottom: 5px;
width: 60px;
height: 60px;
margin: auto;
z-index: 100;
`;
export const PullDownLoading = styled.div`
position: absolute;
left: 0;
right: 0;
top: 0px;
height: 30px;
margin: auto;
z-index: 100;
`;
const Scroll = forwardRef((props, ref) => {
const [bScroll, setBScroll] = useState();
const scrollContaninerRef = useRef();
const {
direction,
click,
refresh,
pullUpLoading,
pullDownLoading,
bounceTop,
bounceBottom,
} = props;
const { pullUp, pullDown, onScroll } = props;
let pullUpDebounce = useMemo(() => {
return debounce(pullUp, 500);
}, [pullUp]);
let pullDownDebounce = useMemo(() => {
return debounce(pullDown, 500);
}, [pullDown]);
useEffect(() => {
const scroll = new BScroll(scrollContaninerRef.current, {
scrollX: direction === "horizental",
scrollY: direction === "vertical",
probeType: 3,
click: click,
bounce: {
top: bounceTop,
bottom: bounceBottom,
},
});
setBScroll(scroll);
return () => {
setBScroll(null);
};
// eslint-disable-next-line
}, []);
useEffect(() => {
if (!bScroll || !onScroll) return;
bScroll.on("scroll", onScroll);
return () => {
bScroll.off("scroll", onScroll);
};
}, [onScroll, bScroll]);
useEffect(() => {
if (!bScroll || !pullUp) return;
const handlePullUp = () => {
//判断是否滑动到了底部
if (bScroll.y <= bScroll.maxScrollY + 100) {
pullUpDebounce();
}
};
bScroll.on("scrollEnd", handlePullUp);
return () => {
bScroll.off("scrollEnd", handlePullUp);
};
}, [pullUp, pullUpDebounce, bScroll]);
useEffect(() => {
if (!bScroll || !pullDown) return;
const handlePullDown = (pos) => {
//判断用户的下拉动作
if (pos.y > 50) {
pullDownDebounce();
}
};
bScroll.on("touchEnd", handlePullDown);
return () => {
bScroll.off("touchEnd", handlePullDown);
};
}, [pullDown, pullDownDebounce, bScroll]);
useEffect(() => {
if (refresh && bScroll) {
bScroll.refresh();
}
});
useImperativeHandle(ref, () => ({
refresh() {
if (bScroll) {
bScroll.refresh();
bScroll.scrollTo(0, 0);
}
},
getBScroll() {
if (bScroll) {
return bScroll;
}
},
}));
const PullUpdisplayStyle = pullUpLoading
? { display: "" }
: { display: "none" };
const PullDowndisplayStyle = pullDownLoading
? { display: "" }
: { display: "none" }<