React全家桶构建一款Web音乐App实战(六):排行榜及歌曲本地持久化

2,600 阅读8分钟

上一节使用Redux管理歌曲相关数据,实现核心播放功能,播放功能是本项目最复杂的一个功能,涉及各个组件之间的数据交互,播放逻辑控制。这一节继续开发排行榜列表和排行榜详情,以及把播放歌曲和播放歌曲列表的持久化到本地。步入主题

排行榜列表和详情接口抓取

使用chrome浏览器切换到手机模式输入QQ音乐移动端网址m.y.qq.com。进入后切换到Network,先把所有的请求清除掉,点击排行榜然后查看请求

点开第一个请求,点击Preview。排行榜列表数据如下图,

接着选择一个排行榜点击进去(先清除所有请求列表),就可以查看到排行榜详情的请求,点击请求的链接选择Preview查看排行榜详情数据

接口请求方法

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

const URL = {
    ...
    /*排行榜*/
    rankingList: "https://c.y.qq.com/v8/fcg-bin/fcg_myqq_toplist.fcg",
    /*排行榜详情*/
    rankingInfo: "https://c.y.qq.com/v8/fcg-bin/fcg_v8_toplist_cp.fcg",
    ...
};

在api目录下新建ranking.js,用来存放接口请求方法

ranking.js

import jsonp from "./jsonp"
import {URL, PARAM, OPTION} from "./config"

export function getRankingList() {
    const data = Object.assign({}, PARAM, {
        g_tk: 5381,
        uin: 0,
        platform: "h5",
        needNewCode: 1,
        _: new Date().getTime()
    });
    return jsonp(URL.rankingList, data, OPTION);
}

export function getRankingInfo(topId) {
    const data = Object.assign({}, PARAM, {
        g_tk: 5381,
        uin: 0,
        platform: "h5",
        needNewCode: 1,
        tpl: 3,
        page: "detail",
        type: "top",
        topid: topId,
        _: new Date().getTime()
    });
    return jsonp(URL.rankingInfo, data, OPTION);
}

上诉代码提供了两个接口请求方法,稍后会调用这两个方法

接下来为排行榜建立一个模型类ranking,在model目录下面新建ranking.js。ranking类拥有的属性如下

export class Ranking {
    constructor(id, title, img, songs) {
        this.id = id;
        this.title = title;
        this.img = img;
        this.songs = songs;
    }
}

ranking包含songs歌曲列表,在ranking.js首行导入同目录下的song.js

import * as SongModel from "./song"

针对排行榜列表接口返回的数据创编写一个创建ranking对象函数

export function createRankingByList(data) {
    const songList = [];
    data.songList.forEach(item => {
        songList.push(new SongModel.Song(0, "", item.songname, "", 0, "", item.singername));
    });
    return new Ranking (
        data.id,
        data.topTitle,
        data.picUrl,
        songList
    );
}

这里接口只返回songname和singernam字段,把歌曲其它信息赋值上空字符串或者0

同样对于排行榜详情接口编写一个创建ranking对象函数

export function createRankingByDetail(data) {
    return new Ranking (
        data.topID,
        data.ListName,
        data.pic_album,
        []
    );
}

歌曲列表给一个空数组

排行榜列表开发

先来看一下效果图

在排行榜列表中每一个item中都对应一个ranking对象,item中的前三个歌曲信息对应ranking对象中的songs数组,后面把接口获取的数据进行遍历创建ranking数组,ranking对象中再创建song数组,在组件的render函数中进行遍历渲染ui

回到原来的Ranking.js。在constructor构造函数中定义rankingListloadingrefreshScroll三个state,分别表示Ranking组件中的排行榜列表、是否正在进行接口请求、是否需要刷新Scroll组件

constructor(props) {
    super(props);

    this.state = {
        loading: true,
        rankingList: [],
        refreshScroll: false
    };
}

导入刚刚编写的接口请求函数,接口请求成功的CODE码和ranking模型类。在组件Ranking组件挂载完成后,发送接口请求

import {getRankingList} from "@/api/ranking"
import {CODE_SUCCESS} from "@/api/config"
import * as RankingModel from "@/model/ranking"
componentDidMount() {
    getRankingList().then((res) => {
        console.log("获取排行榜:");
        if (res) {
            console.log(res);
            if (res.code === CODE_SUCCESS) {
                let topList = [];
                res.data.topList.forEach(item => {
                    if (/MV/i.test(item.topTitle)) {
                        return;
                    }
                    topList.push(RankingModel.createRankingByList(item));
                });
                this.setState({
                    loading: false,
                    rankingList: topList
                }, () => {
                    //刷新scroll
                    this.setState({refreshScroll:true});
                });
            }
        }
    });
}

上述代码中(/MV/i.test(item.topTitle)用来过滤mv排行榜,获取数据后将loading更新为false,最后当列表数据渲染完成后更改refreshScroll状态为true,使Scroll组件重新计算列表高度

在这个组件中依赖Scroll和Loading组件,导入这两个组件

import Scroll from "@/common/scroll/Scroll"
import Loading from "@/common/loading/Loading"

render方法代码如下

render() {
    return (
        <div className="music-ranking">
            <Scroll refresh={this.state.refreshScroll}>
                <div className="ranking-list">
                    {
                        this.state.rankingList.map(ranking => {
                            return (
                                <div className="ranking-wrapper" key={ranking.id}>
                                    <div className="left">
                                        <img src={ranking.img} alt={ranking.title}/>
                                    </div>
                                    <div className="right">
                                        <h1 className="ranking-title">
                                            {ranking.title}
                                        </h1>
                                        {
                                            ranking.songs.map((song, index) => {
                                                return (
                                                    <div className="top-song" key={index}>
                                                        <span className="index">{index + 1}</span>
                                                        <span>{song.name}</span>
                                                        &nbsp;-&nbsp;
                                                        <span className="song">{song.singer}</span>
                                                    </div>
                                                );
                                            })
                                        }
                                    </div>
                                </div>
                            );
                        })
                    }

                </div>
            </Scroll>
            <Loading title="正在加载..." show={this.state.loading}/>
        </div>
    );
}

ranking.styl请在源码中查看

这个列表中有图片,同样需要对图片加载进行优化,导入第三节优化图片加载使用的react-lazyload插件

import LazyLoad, { forceCheck } from "react-lazyload"

使用LazyLoad组件包裹图片,并传入height

<div className="ranking-wrapper" key={ranking.id}>
    <div className="left">
        <LazyLoad height={100}>
            <img src={ranking.img} alt={ranking.title}/>
        </LazyLoad>
    </div>
    ...
</div>

监听Scroll组件的onScroll,滚动的时候检查图片是否出现在屏幕内,如果可见立即加载图片

<Scroll refresh={this.state.refreshScroll}
    onScroll={() => {forceCheck();}}>
    ...
</Scroll>

排行榜详情开发

在ranking目录下新建RankingInfo.jsrankinginfo.styl

RankingInfo.js

import React from "react"

import "./rankinginfo.styl"

class RankingInfo extends React.Component {
    render() {
        return (
            <div className="ranking-info">

            </div>
        );
    }
}

export default RankingInfo

rankinginfo.styl请在最后的源码中查看

RankingInfo组件需要操作Redux中的歌曲和歌曲列表,为RankingInfo编写对应的容器组件Ranking,在container目录下新建Ranking.js

import {connect} from "react-redux"
import {showPlayer, changeSong, setSongs} from "../redux/actions"
import RankingInfo from "../components/ranking/RankingInfo"

const mapDispatchToProps = (dispatch) => ({
    showMusicPlayer: (show) => {
        dispatch(showPlayer(show));
    },
    changeCurrentSong: (song) => {
        dispatch(changeSong(song));
    },
    setSongs: (songs) => {
        dispatch(setSongs(songs));
    }
});

export default connect(null, mapDispatchToProps)(RankingInfo)

进入排行榜详情的入口在排行榜列表页中,所以先在排行榜中增加子路由和点击跳转事件。导入route组件和Ranking容器组件

import {Route} from "react-router-dom"
import RankingInfo from "@/containers/Ranking"

将Route组件放置在如下位置

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

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

toDetail(url) {
    return () => {
        this.props.history.push({
            pathname: url
        });
    }
}
<div className="ranking-wrapper" key={ranking.id}
    onClick={this.toDetail(`${match.url + '/' + ranking.id}`)}>
</div>

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

constructor(props) {
    super(props);

    this.state = {
        show: false,
        loading: true,
        ranking: {},
        songs: [],
        refreshScroll: false
    }
}

其中show用来控制组件进入动画、ranking存放排行榜信息、songs存放歌曲列表。组件进入动画继续使用第四节实现动画中使用的react-transition-group,导入CSSTransition组件

import {CSSTransition} from "react-transition-group"

在组件挂载以后,将show状态改为true

componentDidMount() {
    this.setState({
        show: true
    });
}

用CSSTransition组件包裹RankingInfo的根元素

<CSSTransition in={this.state.show} timeout={300} classNames="translate">
    <div className="ranking-info">
    </div>
</CSSTransition>

关于CSSTransition的更多说明见第四节实现动画

导入HeaderLoaddingScroll三个公用组件,接口请求方法getRankingInfo,接口成功CODE码,排行榜和歌曲模型类等

import ReactDOM from "react-dom"
import Header from "@/common/header/Header"
import Scroll from "@/common/scroll/Scroll"
import Loading from "@/common/loading/Loading"
import {getRankingInfo} from "@/api/ranking"
import {getSongVKey} from "@/api/song"
import {CODE_SUCCESS} from "@/api/config"
import * as RankingModel from "@/model/ranking"
import * as SongModel from "@/model/song"

componentDidMount中增加以下代码

let rankingBgDOM = ReactDOM.findDOMNode(this.refs.rankingBg);
let rankingContainerDOM = ReactDOM.findDOMNode(this.refs.rankingContainer);
rankingContainerDOM.style.top = rankingBgDOM.offsetHeight + "px";

getRankingInfo(this.props.match.params.id).then((res) => {
    console.log("获取排行榜详情:");
    if (res) {
        console.log(res);
        if (res.code === CODE_SUCCESS) {
            let ranking = RankingModel.createRankingByDetail(res.topinfo);
            ranking.info = res.topinfo.info;
            let songList = [];
            res.songlist.forEach(item => {
                if (item.data.pay.payplay === 1) { return }
                let song = SongModel.createSong(item.data);
                //获取歌曲vkey
                this.getSongUrl(song, item.data.songmid);
                songList.push(song);
            });

            this.setState({
                loading: false,
                ranking: ranking,
                songs: songList
            }, () => {
                //刷新scroll
                this.setState({refreshScroll:true});
            });
        }
    }
});

获取歌曲文件函数

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`
                }
            }
        }
    });
}

组件挂载完成以后调用getRankingInfo函数去请求详情数据,请求成功后调用setState设置ranking和songs的值触发render函数重新调用,在对歌曲列表遍历的时候调用getSongUrl去获取歌曲地址

render方法代码如下

render() {
    let ranking = this.state.ranking;
    let songs = this.state.songs.map((song, index) => {
        return (
            <div className="song" key={song.id}>
                <div className="song-index">{index + 1}</div>
                <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="ranking-info">
                <Header title={ranking.title}></Header>
                ...
                <div ref="rankingContainer" className="ranking-container">
                    <div className="ranking-scroll" style={this.state.loading === true ? {display:"none"} : {}}>
                        <Scroll refresh={this.state.refreshScroll}>
                            <div className="ranking-wrapper">
                                <div className="ranking-count">排行榜 共{songs.length}首</div>
                                <div className="song-list">
                                    {songs}
                                </div>
                                <div className="info" style={ranking.info ? {} : {display:"none"}}>
                                    <h1 className="ranking-title">简介</h1>
                                    <div className="ranking-desc">
                                        {ranking.info}
                                    </div>
                                </div>
                            </div>
                        </Scroll>
                    </div>
                    <Loading title="正在加载..." show={this.state.loading}/>
                </div>
            </div>
        </CSSTransition>
    );
}

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

scroll = ({y}) => {
    let rankingBgDOM = ReactDOM.findDOMNode(this.refs.rankingBg);
    let rankingFixedBgDOM = ReactDOM.findDOMNode(this.refs.rankingFixedBg);
    let playButtonWrapperDOM = ReactDOM.findDOMNode(this.refs.playButtonWrapper);
    if (y < 0) {
        if (Math.abs(y) + 55 > rankingBgDOM.offsetHeight) {
            rankingFixedBgDOM.style.display = "block";
        } else {
            rankingFixedBgDOM.style.display = "none";
        }
    } else {
        let transform = `scale(${1 + y * 0.004}, ${1 + y * 0.004})`;
        rankingBgDOM.style["webkitTransform"] = transform;
        rankingBgDOM.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-wrapper" ref="playButtonWrapper">
    <div className="play-button" onClick={this.playAll}>
        <i className="icon-play"></i>
        <span>播放全部</span>
    </div>
</div>

此时还缺少音符动画,复制上一节的initMusicIcostartMusicIcoAnimation两个函数在componentDidMount中调用initMusicIco

this.initMusicIco();

selectSong函数中调用startMusicIcoAnimation启动动画

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

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

效果如下

歌曲本地持久化

当每次进入网页的时候退出页面前播放的歌曲以及播放列表都会消失,为了实现上一次播放的歌曲以及歌曲列表在下一次打开网页还会继续存在,使用H5的本地存储localStorage对象来实现歌曲持久化到。localStorage有setItem()和 getItem()两个方法,前者存储用来一个键值对的数据,后者通过key获取对应的值,localStorage会在当前域名下存储数据,更多用法请戳这里

在util目录下新建一个歌曲持久化工具类storage.js

storage.js

let localStorage = {
    setCurrentSong(song) {
        window.localStorage.setItem("song", JSON.stringify(song));
    },
    getCurrentSong() {
        let song = window.localStorage.getItem("song");
        return song ? JSON.parse(song) : {};
    },
    setSongs(songs) {
        window.localStorage.setItem("songs", JSON.stringify(songs));
    },
    getSongs() {
        let songs = window.localStorage.getItem("songs");
        return songs ? JSON.parse(songs) : [];
    }
}

export default localStorage

上诉代码中有设置当前歌曲、获取当前歌曲、设置播放列表和获取播放列表四个方法。在使用localStorage存储数据的时候,借助JSON.stringify()将对象转化成json字符串,获取数据后再使用JSON.parse()将json字符串转化成对象

在Redux中,初始化的song和songs从localStorage中获取

import localStorage from "../util/storage"
const initialState = {
	showStatus: false,  //显示状态
	song: localStorage.getCurrentSong(),  //当前歌曲
	songs: localStorage.getSongs()  //歌曲列表
};

修改歌曲的reducer函数song调用时将歌曲持久化到本地

function song(song = initialState.song, action) {
    switch (action.type) {
        case ActionTypes.CHANGE_SONG:
            localStorage.setCurrentSong(action.song);
            return action.song;
        default:
            return song;
    }
}

添加歌曲列表或删除播放列表中的歌曲的时将歌曲列表持久化到本地

function songs(songs = initialState.songs, action) {
    switch (action.type) {
        case ActionTypes.SET_SONGS:
            localStorage.setSongs(action.songs);
            return action.songs;
        case ActionTypes.REMOVE_SONG_FROM_LIST:
            let newSongs = songs.filter(song => song.id !== action.id);
            localStorage.setSongs(newSongs);
            return newSongs;
        default:
            return songs;
    }
}

在所有的组件触发修改歌曲或歌曲列表的reducer函数时都会进行持久化操作。这样修改之后Player组件需要稍作修改,当选择播放歌曲后退出重新进入时,会报如下错误,这是因为第一次调用Player组件的render方法歌曲已经存在,此时if判断成立访问audioDOM时dom还没挂载到页面

报错代码片段

//从redux中获取当前播放歌曲
if (this.props.currentSong && this.props.currentSong.url) {
    //当前歌曲发发生变化
    if (this.currentSong.id !== this.props.currentSong.id) {
        this.currentSong = this.props.currentSong;
        this.audioDOM.src = this.currentSong.url;
        this.audioDOM.load();
    }
}

增加一个if判断

if (this.audioDOM) {
    this.audioDOM.src = this.currentSong.url;
    this.audioDOM.load();
}

playOrPause方法修改如下

playOrPause = () => {
    if(this.state.playStatus === false){
        //表示第一次播放
        if (this.first === undefined) {
            this.audioDOM.src = this.currentSong.url;
            this.first = true;
        }
        this.audioDOM.play();
        this.startImgRotate();

        this.setState({
            playStatus: true
        });
    }else{
       ...
    }
}

总结

这一节相对于上一节比较简单,大部分动画效果在上几节都已经做了说明,另外在最近刚刚新增了歌手功能,可以在github仓库中通过预览地址体验

完整项目地址:github.com/dxx/mango-m…

本章节代码在chapter6分支