React全家桶構建一款Web音樂App實戰(七):歌手列表及詳情開發

code_mcx發表於2018-01-19

本節繼續開發歌手列表和歌手詳情

介面資料抓取

1.歌手列表

用chrome瀏覽器開啟QQ音樂官網,進入QQ音樂官網後開啟開發者工具選擇Network選項,點選js選項,在QQ音樂官網點選歌手

React全家桶構建一款Web音樂App實戰(七):歌手列表及詳情開發

點開上圖紅框中的請求,在右側點選Preview,下方就是歌手列表資料,具體的請求連結引數在Headers選項中檢視

React全家桶構建一款Web音樂App實戰(七):歌手列表及詳情開發

2.歌手詳情

選擇歌手列表中的任意一個歌手點選,在左邊的Network中檢視具體請求資料

React全家桶構建一款Web音樂App實戰(七):歌手列表及詳情開發

React全家桶構建一款Web音樂App實戰(七):歌手列表及詳情開發

介面具體說明見QQ音樂api介面梳理中的歌手列表歌手詳情

介面請求方法

在api目錄下面的config.js中加入介面url配置

config.js

const URL = {
    ...
    /*歌手列表*/
    singerList: "https://c.y.qq.com/v8/fcg-bin/v8.fcg",
    /*歌手詳情*/
    singerInfo: "https://c.y.qq.com/v8/fcg-bin/fcg_v8_singer_track_cp.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,
        `http://y.gtimg.cn/music/photo_new/T001R68x68M000${data.singermid}.jpg?max_age=2592000`
    );
}

export function createSingerByDetail(data) {
    return new Singer(
        data.singer_id,
        data.singer_mid,
        data.singer_name,
        `http://y.gtimg.cn/music/photo_new/T001R300x300M000${data.singer_mid}.jpg?max_age=2592000`
    );
}
複製程式碼

歌手列表開發

先來看一下效果圖

React全家桶構建一款Web音樂App實戰(七):歌手列表及詳情開發

歌手頁分兩塊,上部分是歌手分類,下部分就是對應的歌手列表。在歌手列表介面中有一個key引數,改引數就是對應的歌手分類,它是由第一欄分類和第二欄分類拼接而成的。在QQ音樂官網的歌手列表頁面中通過瀏覽器除錯工具檢視DOM結構可以檢視到分類對應的key值

React全家桶構建一款Web音樂App實戰(七):歌手列表及詳情開發

React全家桶構建一款Web音樂App實戰(七):歌手列表及詳情開發

其中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,
                `https://y.gtimg.cn/music/photo_new/T001R150x150M000${data.Fsinger_mid}.jpg?max_age=2592000`);
                    singers.push(singer);
                });
                this.setState({
                    loading: false,
                    singers
                }, () => {
                    //重新整理scroll
                    this.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);
                //獲取歌曲vkey
                this.getSongUrl(song, song.mId);
                songs.push(song);
            });
            this.setState({
                loading: false,
                singer: singer,
                songs: songs
            }, () => {
                //重新整理scroll
                this.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 =  `http://dl.stream.qqmusic.qq.com/${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);
    };
}
複製程式碼

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

效果

React全家桶構建一款Web音樂App實戰(七):歌手列表及詳情開發

總結

這一節主要內容是根據新的滾動需求改造了Scroll基礎元件,在實際開發中,封裝了一些基礎元件,前期能夠滿足需求,隨著新的功能出現可能會對基礎元件進行改造以滿足新的需求。詳情在幾個頁面中都是非常相似的,其實這裡是可以把它做為一個公用的元件,獲取資料後封裝成其要求的資料格式傳入。我最近使用vue開發這個web音樂app,其中詳情頁就已經抽取出來了

完整專案地址:github.com/code-mcx/ma…

本章節程式碼在chapter7分支

後續更新中...

相關文章