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

code_mcx發表於2018-01-11

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

排行榜列表和詳情介面抓取

使用chrome瀏覽器切換到手機模式輸入QQ音樂移動端網址m.y.qq.com。進入後切換到Network,先把所有的請求清除掉,點選排行榜然後檢視請求

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

點開第一個請求,點選Preview。排行榜列表資料如下圖,

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

接著選擇一個排行榜點選進去(先清除所有請求列表),就可以檢視到排行榜詳情的請求,點選請求的連結選擇Preview檢視排行榜詳情資料

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

介面請求方法

在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,
        []
    );
}
複製程式碼

歌曲列表給一個空陣列

排行榜列表開發

先來看一下效果圖

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

在排行榜列表中每一個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);
    };
}
複製程式碼

音符下落動畫具體請看上一節歌曲點選音符下落動畫

效果如下

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

歌曲本地持久化

當每次進入網頁的時候退出頁面前播放的歌曲以及播放列表都會消失,為了實現上一次播放的歌曲以及歌曲列表在下一次開啟網頁還會繼續存在,使用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還沒掛載到頁面

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

報錯程式碼片段

//從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/code-mcx/ma…

本章節程式碼在chapter6分支

後續更新中...

相關文章