项目打包脚本配置针对生产环境已经做了修改,增加了对样式的压缩,把样式统一打包到样式文件中。详细请看第一节配置Stylus预处理语言。本节所有内容紧接上一节,上一节地址:juejin.im/post/5a3a6c…
上一节开发了推荐页面,这一节实现专辑页面开发、进入动画和图片拉伸动画。话不多说,先看效果图
头部是一个很常见的标题加一个返回按钮(标准app的做法~~~),上部分是专辑背景图片,图片下面就是专辑的歌曲列表,最底下就是专辑的简介
数据抓取
打开chrome浏览器,地址栏输入QQ音乐官网:y.qq.com。打开后点击专辑
点击后,如下图
打开开发者工具(按F12或CTRL+SHIFT+I),然后任意选一张专辑点击
这个时候回弹出一个新的窗口,直接关闭它。回到刚才的开发者工具,可以看到有一个请求,这个请求就是获取专辑详情的
点开preview,这里面就是我们需要的数据
接下来编写获取接口的代码
在api目录下的config.js中,添加专辑详情的url配置
config.js
const URL = {
/*推荐轮播*/
carousel: "https://c.y.qq.com/musichall/fcgi-bin/fcg_yqqhomepagerecommend.fcg",
/*最新专辑*/
newalbum: "https://u.y.qq.com/cgi-bin/musicu.fcg",
/*专辑信息*/
albumInfo: "https://c.y.qq.com/v8/fcg-bin/fcg_v8_album_info_cp.fcg"
};
复制代码
在api下的recommend.js中添加获取专辑请求的方法
recommend.js
export function getAlbumInfo(albumMid) {
const data = Object.assign({}, PARAM, {
albummid: albumMid,
g_tk: 1278911659,
hostUin: 0,
platform: "yqq",
needNewCode: 0
});
return jsonp(URL.albumInfo, data, OPTION);
}
复制代码
推荐页子路由
为了进入专辑详情页面,需要在推荐页面中实现点击专辑项跳转到专辑详情的路由。先创建专辑页面组件Album.js 在src下的components下面新建album文件夹,然后在album下面新建Album.js和album.styl
Album.js
import React from "react"
import "./album.styl"
class Album extends React.Component {
constructor(props) {
super(props);
}
componentDidMount() {
}
render() {
return (
<div className="music-album">
Album
</div>
);
}
}
export default Album
复制代码
album.styl
.music-album
position: fixed
top: 0
left: 0
right: 0
bottom: 0
background-color: #212121
z-index: 100
复制代码
点开Recommend.js(src下面components中的recommend目录下面)。导入Route和Album.js
Recommend.js
import {Route} from "react-router-dom"
import Album from "../album/Album"
复制代码
在render方法第一行增加
let {match} = this.props;
复制代码
match是路由通过props传递给组件的包含了url、参数等相关信息。然后在根元素下面添加子路由
<div className="music-recommend">
<Scroll refresh={this.state.refreshScroll}
onScroll={(e) => {
/*检查懒加载组件是否出现在视图中,如果出现就加载组件*/
forceCheck();}}>
...
</Scroll>
<Loading title="正在加载..." show={this.state.loading}/>
<Route path={`${match.url + '/:id'}`} component={Album} />
</div>
复制代码
最后给每一个专辑包裹元素添加点击事件
let albums = this.state.newAlbums.map(item => {
//通过函数创建专辑对象
let album = AlbumModel.createAlbumByList(item);
return (
<div className="album-wrapper" key={album.mId}
onClick={this.toAlbumDetail(`${match.url + '/' + album.mId}`)}>
...
</div>
);
});
复制代码
toAlbumDetail(url) {
/*scroll组件会派发一个点击事件,不能使用链接跳转*/
return () => {
this.props.history.push({
pathname: url
});
}
}
复制代码
这里使用react路由提供的history对象来实现编程路由跳转,使用闭包函数把每次循环传入的url作为局部变量。这样每次点击item获取到的都是对应传递的url
Header组件封装
在整个项目中标题是很常见的,这里把头部标题和返回按钮封装成一个公用的Header组件。Header组件接受一个title标题,返回按钮点击的时候具有返回的功能,其实就是路由的返回,这里在Header组件内部处理这个点击事件 在src下面的common目录下新建header文件夹,在header文件夹下面新建Header.js和header.styl
Header.js
import React from "react"
import "./header.styl"
class MusicHeader extends React.Component {
handleClick() {
window.history.back();
}
render() {
return (
<div className="music-header">
<span className="header-back" onClick={this.handleClick}>
<i className="icon-back"></i>
</span>
<div className="header-title">
{this.props.title}
</div>
</div>
);
}
}
export default MusicHeader
复制代码
上诉代码的handleClick函数中也可以使用history.goBack()来实现路由的回退。返回按钮使用的是一个字体图标,在App.js中引入字体图标样式,作为全局引入,这样所有的组件都可以使用字体图标样式
import "../assets/stylus/font.styl"
复制代码
header.styl
.music-header
position: fixed
width: 100%
height: 55px
line-height: 55px
color: #FFFFFF
text-align: center
font-size: 18px
.header-back
position: absolute
left: 10px
font-size: 22px
.header-title
margin: 0 40px
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
复制代码
专辑页开发
在上一节已经为专辑数据创建了一类模型,对于专辑详情接口只需要一个创建对象的函数即可。在src下面的model目录中的album.js中新增以下代码
/**
* 通过专辑详情数据创建专辑对象函数
*/
export function createAlbumByDetail(data) {
return new Album(
data.id,
data.mid,
data.name,
`http://y.gtimg.cn/music/photo_new/T002R300x300M000${data.mid}.jpg?max_age=2592000`,
data.singername,
data.aDate
);
}
复制代码
专辑列表中有很多歌曲数据,这里为歌曲数据创建一个Song类,方便后续使用。同样在src下的model中新建song.js,编写一个创建Song类对象的函数
song.js
/**
* 歌曲类模型
*/
export class Song {
constructor(id, mId, name, img, duration, url, singer) {
this.id = id;
this.mId = mId;
this.name = name;
this.img = img;
this.duration = duration;
this.url = url;
this.singer = singer;
}
}
/**
* 创建歌曲对象函数
*/
export function createSong(data) {
return new Song(
data.songid,
data.songmid,
data.songname,
`http://y.gtimg.cn/music/photo_new/T002R300x300M000${data.albummid}.jpg?max_age=2592000`,
data.interval,
"",
filterSinger(data.singer)
);
}
function filterSinger(singers) {
let singerArray = singers.map(singer => {
return singer.name;
});
return singerArray.join("/");
}
复制代码
专辑页中需要用到上一节封装的Scroll组件和Loading组件以及封装的Header组件。回到Album.js中导入这个三个组件
import Header from "@/common/header/Header"
import Scroll from "@/common/scroll/Scroll"
import Loading from "@/common/loading/Loading"
复制代码
导入专辑请求函数,接口成功状态码常量,专辑和歌曲模型类
import {getAlbumInfo} from "@/api/recommend"
import {CODE_SUCCESS} from "@/api/config"
import * as AlbumModel from "@/model/album"
import * as SongModel from "@/model/song"
复制代码
Album.js主要代码如下
class Album extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: true,
album: {},
songs: [],
refreshScroll: false
}
}
componentDidMount() {
let albumBgDOM = ReactDOM.findDOMNode(this.refs.albumBg);
let albumContainerDOM = ReactDOM.findDOMNode(this.refs.albumContainer);
albumContainerDOM.style.top = albumBgDOM.offsetHeight + "px";
getAlbumInfo(this.props.match.params.id).then((res) => {
console.log("获取专辑详情:");
if (res) {
console.log(res);
if (res.code === CODE_SUCCESS) {
let album = AlbumModel.createAlbumByDetail(res.data);
album.desc = res.data.desc;
let songList = res.data.list;
let songs = [];
songList.forEach(item => {
let song = SongModel.createSong(item);
songs.push(song);
});
this.setState({
loading: false,
album: album,
songs: songs
}, () => {
//刷新scroll
this.setState({refreshScroll:true});
});
}
}
});
}
render() {
let album = this.state.album;
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 (
<div className="music-album">
<Header title={album.name} ref="header"></Header>
<div style={{position:"relative"}}>
<div ref="albumBg" className="album-img" style={{backgroundImage: `url(${album.img})`}}>
<div className="filter"></div>
</div>
<div ref="albumFixedBg" className="album-img fixed" style={{backgroundImage: `url(${album.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="album-container">
<div className="album-scroll" style={this.state.loading === true ? {display:"none"} : {}}>
<Scroll refresh={this.state.refreshScroll}>
<div className="album-wrapper">
<div className="song-count">专辑 共{songs.length}首</div>
<div className="song-list">
{songs}
</div>
<div className="album-info" style={album.desc? {} : {display:"none"}}>
<h1 className="album-title">专辑简介</h1>
<div className="album-desc">
{album.desc}
</div>
</div>
</div>
</Scroll>
</div>
<Loading title="正在加载..." show={this.state.loading}/>
</div>
</div>
);
}
}
复制代码
上诉代码在componentDidMount中通过match.params.id获取参数id,再发送请求获取到数据后先创建Album对象再创建Song列表,然后调用setState更新ui。此时歌曲还缺少文件地址,歌曲文件地址接口获取见juejin.im/post/5a3522…
在api目录下的config中添加歌曲vkey地址,然后新建song.js,编写用来获取歌曲vkey请求
config.js
/*歌曲vkey*/
songVkey: "https://c.y.qq.com/base/fcgi-bin/fcg_music_express_mobile3.fcg"
复制代码
song.js
import jsonp from "./jsonp"
import {URL, PARAM} from "./config"
export function getSongVKey(songMid) {
const data = Object.assign({}, PARAM, {
g_tk: 1278911659,
hostUin: 0,
platform: "yqq",
needNewCode: 0,
cid: 205361747,
uin: 0,
songmid: songMid,
filename: `C400${songMid}.m4a`,
guid: 3655047200
});
const option = {
param: "callback",
prefix: "callback"
};
return jsonp(URL.songVkey, data, option);
}
复制代码
在Album.js中导入上述方法
import {getSongVKey} from "@/api/song"
复制代码
编写一个获取歌曲vkey的方法
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 = `http://dl.stream.qqmusic.qq.com/${item.filename}?vkey=${item.vkey}&guid=3655047200&fromtag=66`
}
}
}
});
}
复制代码
对歌曲进行遍历的时候调用getSongUrl获取歌曲文件地址
songList.forEach(item => {
let song = SongModel.createSong(item);
//获取歌曲vkey
this.getSongUrl(song, item.songmid);
songs.push(song);
});
复制代码
song是一个对象,对象是引用类型,把song传递给getSongUrl的第一个参数,他们指向的是同一块内存,也就是说他们是同一个的实例,那么他们的url属性也是一样的。在getSongUrl中修改了url也就修改了传递进去的song对象的url属性
实现动画
- 专辑页进入动画
在很多app中页面进入时都会有平移动画,这样看起来界面跳转不会显得很生硬。这里使用react-transition-group动画库来实现动画。
注意:这里使用的是2.x版本,1.x和2.x版本api相差很大。详细请看github
react-transition-group提供了三个组件
- Transition(过渡动画组件。允许从一种状态到另一种状态的改变。默认跟踪组件的进入和离开状态)
- TransitionGroup(管理Transition组件集合)
- CSSTransition(使用css过渡和动画的Transition组件)
详细用法请看:reactcommunity.org/react-trans…
这里使用CSSTransition组件来做动画。先安装react-transition-group
npm install react-transition-group --save
复制代码
在Album.js中导入CSSTransition
import {CSSTransition} from "react-transition-group"
复制代码
给Album组件添加一个show属性用来控制动画的状态
this.state = {
show: false,
loading: true,
album: {},
songs: [],
refreshScroll: false
}
复制代码
使用CSSTransition组件包裹Album组件的根元素
<CSSTransition in={this.state.show} timeout={300} classNames="translate">
<div className="music-album">
...
</div>
</CSSTransition>
复制代码
CSSTransition接受in、timeout、classNames三个props。其中in控制组件的状态。当in为true时,组件的子元素会应用translate-enter、translate-enter-active样式,当in为false时,组件的子元素会应用translate-exit、translate-exit-active样式。timeout指定过渡时间
这里只实现组件进入动画,组件离开动画可通过Header组件的返回按钮点击事件结合动画钩子函数实现
这个动画使用的样式会被用在多处,我们把样式写在App.styl中
.translate-enter
transform: translate3d(100%, 0, 0)
&.translate-enter-active
transition: transform .3s
transform: translate3d(0, 0, 0)
复制代码
在组件挂载完成后,也就是componentDidMount函数中将show设置为true,让组件应用translate-enter、translate-enter-active样式从而实现过渡动画
componentDidMount() {
this.setState({
show: true
});
...
}
复制代码
- 列表滚动和图片拉伸效果
先来看gif图
上图中列表往上滚动会覆盖图片,当超过头部高度的时候会隐藏,图片上半部分Header高度的区域显示在列表上方。向下拉伸时图片会跟着放大。向上滚动效果主要利用元素的position定位,和z-index设置层级关系,图片这里做了两个同样的元素,它们都相对父元素进行定位,一个是用来默认显示,另外一个隐藏并且高度只有Header高,隐藏的元素层级比列表要高。当里列表往上滚动超过Header的底部时就显示隐藏的图片。向下滚动利用监听Scroll组件的滚动事件,根据滚动的高度给图片设置对应的scale值,同时给按钮设置margin-top
先给滚动列表设置溢出隐藏,覆盖Scroll组件的样式
.scroll-view
overflow: visible
复制代码
监听Scroll组件的滚动,判断y是否小于0,小于0表示向上滚动。当滚动y值的绝对值加上Header的高度大于图片高度的时候此时已经超过了Header的底部,这个时候显示隐藏的图片,向下滚动没有达到Header的底部时隐藏图片(这里使用了两张图片,其实也可以使用一张图片,当滚动到Header组件的底部的时候设置图片的高度和z-index即可)
/**
* 监听scroll
*/
scroll = ({y}) => {
let albumBgDOM = ReactDOM.findDOMNode(this.refs.albumBg);
let albumFixedBgDOM = ReactDOM.findDOMNode(this.refs.albumFixedBg);
if (y < 0) {
if (Math.abs(y) + 55 > albumBgDOM.offsetHeight) {
albumFixedBgDOM.style.display = "block";
} else {
albumFixedBgDOM.style.display = "none";
}
}
}
复制代码
<Scroll refresh={this.state.refreshScroll} onScroll={this.scroll}>
...
</Scroll>
复制代码
接下来处理图片拉伸,在if (y < 0)增加else块,当y大于0时表示向下滚动
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`;
}
}
复制代码
album.styl完整代码见结尾源码地址
总结
这一节使用history对象来实现编程子路由跳转,简单的介绍了react-transition-group做过渡动画,后面会介绍使用react-transition-group结合钩子函数实现动画效果。还利用上一节封装的Scroll组件的滚动事件实现了列表滚动和图片拉伸效果,主要是明白如何通过滚动的y值判断是上拉还是下拉、图片的布局设计以及如何判断列表滚动到了Header组件底部
完整项目地址:github.com/code-mcx/ma…
本章节代码在chapter4分支
后续更新中...