admin 管理员组

文章数量: 1086061

React全家桶构建一款Web音乐App实战(七):歌手列表及详情开发

本节继续开发歌手列表和歌手详情

接口数据抓取

1.歌手列表

用chrome浏览器打开QQ音乐官网,进入QQ音乐官网后打开开发者工具选择Network选项,点击js选项,在QQ音乐官网点击歌手

点开上图红框中的请求,在右侧点击Preview,下方就是歌手列表数据,具体的请求链接参数在Headers选项中查看

2.歌手详情

选择歌手列表中的任意一个歌手点击,在左边的Network中查看具体请求数据

接口具体说明见QQ音乐api接口梳理中的歌手列表和歌手详情

接口请求方法

在api目录下面的config.js中加入接口url配置

config.js

const URL = {.../*歌手列表*/singerList: ".fcg",/*歌手详情*/singerInfo: ".fcg"
};
复制代码

在api下面新建singer.js,编写接口请求方法

singer.js

import jsonp from "./jsonp"
import {URL, PARAM, OPTION} from "./config"export function getSingerList(pageNum, key) {const data = Object.assign({}, PARAM, {g_tk: 5381,loginUin: 0,hostUin: 0,platform: "yqq",needNewCode: 0,channel: "singer",page: "list",key,pagenum: pageNum,pagesize: 100});return jsonp(URL.singerList, data, OPTION);
}export function getSingerInfo(mId) {const data = Object.assign({}, PARAM, {g_tk: 5381,loginUin: 0,hostUin: 0,platform: "yqq",needNewCode: 0,singermid: mId,order: "listen",begin: 0,num: 100,songstatus: 1});return jsonp(URL.singerInfo, data, OPTION);
}
复制代码

接下来新建歌手模型类Singer,在model目录下新建singer.js,属性如下

export class Singer {constructor(id, mId, name, img) {this.id = id;this.mId = mId;this.name = name;this.img = img;}
}
复制代码

根据歌手列表和歌手详情返回的数据编写两个对象创建函数,在singer.js中编写如下代码

export function createSingerBySearch(data) {return new Singer(data.singerid,data.singermid,data.singername,`${data.singermid}.jpg?max_age=2592000`);
}export function createSingerByDetail(data) {return new Singer(data.singer_id,data.singer_mid,data.singer_name,`${data.singer_mid}.jpg?max_age=2592000`);
}
复制代码

歌手列表开发

先来看一下效果图

歌手页分两块,上部分是歌手分类,下部分就是对应的歌手列表。在歌手列表接口中有一个key参数,改参数就是对应的歌手分类,它是由第一栏分类和第二栏分类拼接而成的。在QQ音乐官网的歌手列表页面中通过浏览器调试工具查看DOM结构可以查看到分类对应的key值

其中data-key就是对应分类的key值

接下来初始化这些key值,回到componens下singer目录中的SingerList.js,在SingerList.js中使用构造函数初始化分类所需要的key值

constructor(props) {super(props);this.types = [{key:"all_all", name:"全部"},{key:"cn_man", name:"华语男"},{key:"cn_woman", name:"华语女"},{key:"cn_team", name:"华语组合"},{key:"k_man", name:"韩国男"},{key:"k_woman", name:"韩国女"},{key:"k_team", name:"韩国组合"},{key:"j_man", name:"日本男"},{key:"j_woman", name:"日本女"},{key:"j_team", name:"日本组合"},{key:"eu_man", name:"欧美男"},{key:"eu_woman", name:"欧美女"},{key:"eu_team", name:"欧美组合"},{key:"other_other", name:"其它"}];this.indexs = [{key:"all", name:"热门"},{key:"A", name:"A"},{key:"B", name:"B"},{key:"C", name:"C"},{key:"D", name:"D"},{key:"E", name:"E"},...];}
复制代码

省内部分代码,完整代码请在源码中查看

然后初始化一些默认的state,继续在constructor中增加以下代码

this.state = {loading: true,typeKey: "all_all",indexKey: "all",singers: [],refreshScroll: false
}
复制代码

其中typeKey是第一栏默认选中的分类key,indexKey是第二栏默认选择的分类key,singer存放歌手列表

在效果图中每个分类都是一行显示,超出屏幕是可以滚动的,这里同样使用第三节封装的Scroll组件,为什么不使用浏览器自带的overflow: scroll,当然是因为原生的滚动效果体验太差,在有些浏览器自带右滑后退,左滑前进,这个时候冲突就很鸡肋了~_~。现在需要左右滚动,这时原来封装的Scroll组件不满足需求,接下来对Scroll组件进行改造

Scroll组件是基于better-scroll封装的,better-scroll默认支持纵向滚动,它也支持横向滚动,纵向滚动将scrollY设置为true,横向滚动将scrollX设置为true,有了这个配置后给Scroll组件增加一个direction属性表示滚动方向,它有两个值vertical(垂直方向)和horizontal(水平方向),默认值为vertical,然后用prop-types限制direction属性的值

代码如下

Scroll.js

Scroll.defaultProps = {direction: "vertical",...
};Scroll.propTypes = {direction: PropTypes.oneOf(['vertical', 'horizontal']),...
};
复制代码

better-scroll配置参数修改如下

this.bScroll = new BScroll(this.scrollView, {scrollX: this.props.direction === "horizontal",scrollY: this.props.direction === "vertical",//实时派发scroll事件probeType: 3,click: this.props.click
});
复制代码

修改Scroll组件后,在SingerList.js的render方法中增加以下代码

let tags = this.types.map(type => (<a key={type.key}className={type.key === this.state.typeKey ? "choose" : ""}>{type.name}</a>
));
let indexs = this.indexs.map(type => (<a key={type.key}className={type.key === this.state.indexKey ? "choose" : ""}>{type.name}</a>
));
return (<div className="music-singers"><div className="nav"><div className="tag" ref="tag">{tags}</div><div className="index" ref="index">{indexs}</div></div></div>
);
复制代码

使用Scroll组件包装分类元素,传入direction

import Scroll from "@/common/scroll/Scroll"
复制代码
<Scroll direction="horizontal"><div className="tag" ref="tag">{tags}</div>
</Scroll>
<Scroll direction="horizontal"><div className="index" ref="index">{indexs}</div>
</Scroll>
复制代码

此时Scroll组件的第一个子元素的宽度并没有超过屏幕,需要设置为它下面的所有子元素占的宽度才能滚动,编写一个初始化Scroll第一个子元素宽度的方法,并在componentDidMount中调用

import ReactDOM from "react-dom"
复制代码
initNavScrollWidth() {let tagDOM = ReactDOM.findDOMNode(this.refs.tag);let tagElems = tagDOM.querySelectorAll("a");let tagTotalWidth = 0;Array.from(tagElems).forEach(a => {tagTotalWidth += a.offsetWidth;});tagDOM.style.width = `${tagTotalWidth}px`;let indexDOM = ReactDOM.findDOMNode(this.refs.index);let indexElems = indexDOM.querySelectorAll("a");let indexTotalWidth = 0;Array.from(indexElems).forEach(a => {indexTotalWidth += a.offsetWidth;});indexDOM.style.width = `${indexTotalWidth}px`;
}
复制代码
componentDidMount() {//初始化导航元素总宽度this.initNavScrollWidth();
}
复制代码

样式代码请在源码中查看

接下来调用歌手列表接口并渲染到页面上

导入需要的模块

import Loading from "@/common/loading/Loading"
import {getSingerList} from "@/api/singer"
import {CODE_SUCCESS} from "@/api/config"
import * as SingerModel from "@/model/singer"
复制代码

在SingerList.js中增加以下方法

getSingers() {getSingerList(1, `${this.state.typeKey + '_' + this.state.indexKey}`).then((res) => {//console.log("获取歌手列表:");if (res) {//console.log(res);if (res.code === CODE_SUCCESS) {let singers = [];res.data.list.forEach(data => {let singer = new SingerModel.Singer(data.Fsinger_id, data.Fsinger_mid, data.Fsinger_name,`${data.Fsinger_mid}.jpg?max_age=2592000`);singers.push(singer);});this.setState({loading: false,singers}, () => {//刷新scrollthis.setState({refreshScroll:true});});}}});
}
复制代码

在render方法return语句前增加以下代码

let singers = this.state.singers.map(singer => {return (<div className="singer-wraper" key={singer.id}><div className="singer-img"><img src={singer.img} width="100%" height="100%" alt={singer.name}onError={(e) => {e.currentTarget.src = require("@/assets/imgs/music.png");}}/></div><div className="singer-name">{singer.name}</div></div>);
});
复制代码

return语句后的代码如下

return (<div className="music-singers">...<div className="singer-list"><Scroll refresh={this.state.refreshScroll} ref="singerScroll"><div className="singer-container">{singers}</div></Scroll></div><Loading title="正在加载..." show={this.state.loading}/></div>
);
复制代码

使用react-lazylaod优化图片加载,导入reat-lazyload,在歌手图片外层使用Lazyload组件包裹,同时监听Scroll组件滚动调用forceCheck方法检测图片是否出现在屏幕内

import LazyLoad, { forceCheck } from "react-lazyload"
复制代码
<LazyLoad height={50}><img src={singer.img} width="100%" height="100%" alt={singer.name}onError={(e) => {e.currentTarget.src = require("@/assets/imgs/music.png");}}/>
</LazyLoad>
复制代码
<Scroll refresh={this.state.refreshScroll}onScroll={() => {forceCheck();}} ref="singerScroll"><div className="singer-container">{singers}</div>
</Scroll>
复制代码

图片加载更多说明见第三节优化图片加载

在分类点击的时候改变typeKeyindexKey的值,调用setState触发组件更新,让对应点击的分栏选中,组件更新后再调用getSingers方法获取歌手数

给分类添加点击事件处理

handleTypeClick = (key) => {this.setState({loading: true,typeKey: key,indexKey: "all",singers: []}, () => {this.getSingers();});
}
handleIndexClick = (key) => {this.setState({loading: true,indexKey: key,singers: []}, () => {this.getSingers();});
}
复制代码
<a key={type.key} className={type.key === this.state.typeKey ? "choose" : ""}onClick={() => {this.handleTypeClick(type.key);}}>{type.name}</a>
复制代码
<a key={type.key}className={type.key === this.state.indexKey ? "choose" : ""}onClick={() => {this.handleIndexClick(type.key);}}>{type.name}</a>
复制代码

歌手详情开发

在compontents中的singer目录下新建Singer.jssinger.styl

Singer.js

import React from "react"import "./singer.styl"class Singer extends React.Component {render() {return (<div className="music-singer"></div>);}
}export default Singer
复制代码

singer.styl代码请在源码中查看

为Singer编写容器组件Singer,在container目录下新建Singer.js

import {connect} from "react-redux"
import {showPlayer, changeSong, setSongs} from "../redux/actions"
import Singer from "../components/singer/Singer"const mapDispatchToProps = (dispatch) => ({showMusicPlayer: (status) => {dispatch(showPlayer(status));},changeCurrentSong: (song) => {dispatch(changeSong(song));},setSongs: (songs) => {dispatch(setSongs(songs));}
});export default connect(null, mapDispatchToProps)(Singer)
复制代码

在歌手列表页中加入子路由,和对应的点击事件,点击歌手进入歌手详情页。回到SingerList.js中,导入Route组件和Singer容器组件

import {Route} from "react-router-dom"
import Singer from "@/containers/Singer"
复制代码

将Route组件放置如下位置

render() {let {match} = this.props;...return (<div className="music-singers">...<Loading title="正在加载..." show={this.state.loading}/><Route path={`${match.url + '/:id'}`} component={Singer}/></div>);
}
复制代码

给列表的.singer-wrapper元素增加点击事件

toDetail = (url) => {this.props.history.push({pathname: url});
}
复制代码
<div className="singer-wrapper" key={singer.id}onClick={() => {this.toDetail(`${match.url + '/' + singer.mId}`)}}>...
</div>
复制代码

继续编写Singer组件。在Singer组件的constructor构造函数中初始化以下state

constructor(props) {super(props);this.state = {show: false,loading: true,singer: {},songs: [],refreshScroll: false}
}
复制代码

show用来控制组件进入动画、singer存放歌手信息、songs存放歌曲列表。组件进入动画使用第四节实现动画中使用的react-transition-group

导入react-transition-group

import {CSSTransition} from "react-transition-group"
复制代码

当组件挂载后将status修改为true

componentDidMount() {this.setState({show: true});
}
复制代码

然后使用CSSTransition组件包裹Singer的根元素

<CSSTransition in={this.state.show} timeout={300} classNames="translate"><div className="music-singer"></div>
</CSSTransition>
复制代码

导入HeaderLoaddingScroll三个公用组件,接口请求方法getSingerInfo,接口成功CODE码,歌手和歌曲模型类

import Header from "@/common/header/Header"
import Scroll from "@/common/scroll/Scroll"
import Loading from "@/common/loading/Loading"
import {getSingerInfo} from "@/api/singer"
import {getSongVKey} from "@/api/song"
import {CODE_SUCCESS} from "@/api/config"
import * as SingerModel from "@/model/singer"
import * as SongModel from "@/model/song"
复制代码

render方法中编写以下代码

let singer = this.state.singer;
let songs = this.state.songs.map((song) => {return (<div className="song" key={song.id}><div className="song-name">{song.name}</div><div className="song-singer">{song.singer}</div></div>);
});
return (<CSSTransition in={this.state.show} timeout={300} classNames="translate"><div className="music-singer"><Header title={singer.name} ref="header"></Header><div style={{position:"relative"}}><div ref="albumBg" className="singer-img" style={{backgroundImage: `url(${singer.img})`}}><div className="filter"></div></div><div ref="albumFixedBg" className="singer-img fixed" style={{backgroundImage: `url(${singer.img})`}}><div className="filter"></div></div><div className="play-wrapper" ref="playButtonWrapper"><div className="play-button"><i className="icon-play"></i><span>播放全部</span></div></div></div><div ref="albumContainer" className="singer-container"><div className="singer-scroll" style={this.state.loading === true ? {display:"none"} : {}}><Scroll refresh={this.state.refreshScroll}><div className="singer-wrapper"><div className="song-count">歌曲 共{songs.length}首</div><div className="song-list">{songs}</div></div></Scroll></div><Loading title="正在加载..." show={this.state.loading}/></div></div></CSSTransition>
);
复制代码

在componentDidMount中初始化.singer-container的top值,值设置为.singer-img高度。然后getSingerInfo方法请求接口数据,请成功后更新singer和songs

let albumBgDOM = ReactDOM.findDOMNode(this.refs.albumBg);
let albumContainerDOM = ReactDOM.findDOMNode(this.refs.albumContainer);
albumContainerDOM.style.top = albumBgDOM.offsetHeight + "px";getSingerInfo(this.props.match.params.id).then((res) => {console.log("获取歌手详情:");if (res) {console.log(res);if (res.code === CODE_SUCCESS) {let singer = SingerModel.createSingerByDetail(res.data);singer.desc = res.data.desc;let songList = res.data.list;let songs = [];songList.forEach(item => {if (item.musicData.pay.payplay === 1) { return }let song = SongModel.createSong(item.musicData);//获取歌曲vkeythis.getSongUrl(song, song.mId);songs.push(song);});this.setState({loading: false,singer: singer,songs: songs}, () => {//刷新scrollthis.setState({refreshScroll:true});});}}
});
复制代码

getSongUrl

getSongUrl(song, mId) {getSongVKey(mId).then((res) => {if (res) {if(res.code === CODE_SUCCESS) {if(res.data.items) {let item = res.data.items[0];song.url =  `/${item.filename}?vkey=${item.vkey}&guid=3655047200&fromtag=66`}}}});
}
复制代码

监听Scroll组件滚动实现上滑和往下拉伸效果

scroll = ({y}) => {let albumBgDOM = ReactDOM.findDOMNode(this.refs.albumBg);let albumFixedBgDOM = ReactDOM.findDOMNode(this.refs.albumFixedBg);let playButtonWrapperDOM = ReactDOM.findDOMNode(this.refs.playButtonWrapper);if (y < 0) {if (Math.abs(y) + 55 > albumBgDOM.offsetHeight) {albumFixedBgDOM.style.display = "block";} else {albumFixedBgDOM.style.display = "none";}} else {let transform = `scale(${1 + y * 0.004}, ${1 + y * 0.004})`;albumBgDOM.style["webkitTransform"] = transform;albumBgDOM.style["transform"] = transform;playButtonWrapperDOM.style.marginTop = `${y}px`;}
}
复制代码
<Scroll refresh={this.state.refreshScroll} onScroll={this.scroll}>...
</Scroll>
复制代码

详细说明请看第四节实现动画列表滚动和图片拉伸效果

给歌曲增加点击播放功能,这里有两个地方一个是点击单个歌曲播放,另一个是点击全部播放

selectSong(song) {return (e) => {this.props.setSongs([song]);this.props.changeCurrentSong(song);};
}
playAll = () => {if (this.state.songs.length > 0) {//添加播放歌曲列表this.props.setSongs(this.state.songs);this.props.changeCurrentSong(this.state.songs[0]);this.props.showMusicPlayer(true);}
}
复制代码
<div className="song" key={song.id} onClick={this.selectSong(song)}>...
</div>
复制代码
<div className="play-button" onClick={this.playAll}><i className="icon-play"></i><span>播放全部</span>
</div>
复制代码

和上一节一样复制第5节的initMusicIcostartMusicIcoAnimation两个函数,然后在componentDidMount中调用initMusicIco

this.initMusicIco();
复制代码

selectSong函数中调用startMusicIcoAnimation启动动画

selectSong(song) {return (e) => {this.props.setSongs([song]);this.props.changeCurrentSong(song);this.startMusicIcoAnimation(e.nativeEvent);};
}
复制代码

音符下落动画具体请看歌曲点击音符下落动画

效果

总结

这一节主要内容是根据新的滚动需求改造了Scroll基础组件,在实际开发中,封装了一些基础组件,前期能够满足需求,随着新的功能出现可能会对基础组件进行改造以满足新的需求。详情在几个页面中都是非常相似的,其实这里是可以把它做为一个公用的组件,获取数据后封装成其要求的数据格式传入。我最近使用vue开发这个web音乐app,其中详情页就已经抽取出来了

完整项目地址:github/code-mcx/ma…

本章节代码在chapter7分支

后续更新中...

本文标签: React全家桶构建一款Web音乐App实战(七)歌手列表及详情开发