React全家桶構建一款Web音樂App實戰(六):排行榜及歌曲本地持久化

上一節使用Redux管理歌曲相關數據,實現核心播放功能,播放功能是本項目最複雜的一個功能,涉及各個組件之間的數據交互,播放邏輯控制。這一節繼續開發排行榜列表和排行榜詳情,以及把播放歌曲和播放歌曲列表的持久化到本地。步入主題react

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

使用chrome瀏覽器切換到手機模式輸入QQ音樂移動端網址https://m.y.qq.com。進入後切換到Network,先把全部的請求清除掉,點擊排行榜而後查看請求git

點開第一個請求,點擊Preview。排行榜列表數據以下圖,github

接着選擇一個排行榜點擊進去(先清除全部請求列表),就能夠查看到排行榜詳情的請求,點擊請求的連接選擇Preview查看排行榜詳情數據web

接口請求方法

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

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,用來存放接口請求方法json

ranking.jsredux

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

上訴代碼提供了兩個接口請求方法,稍後會調用這兩個方法api

接下來爲排行榜創建一個模型類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倉庫中經過預覽地址體驗

完整項目地址:https://github.com/code-mcx/mango-music

本章節代碼在chapter6分支

後續更新中...

相關文章
相關標籤/搜索