React全家桶構建一款Web音樂App實戰(三):推薦頁開發及公用元件封裝

code_mcx發表於2017-12-21

接著上一節內容,這一節抓取QQ音樂移動Web端推薦頁面介面和PC端最新專輯介面資料。通過這些介面資料開發推薦頁面。首先看一下效果圖

React全家桶構建一款Web音樂App實戰(三):推薦頁開發及公用元件封裝

頁面結構

推薦頁面主要分輪播和最新專輯兩塊,其中輪播圖片來自QQ音樂移動Web端推薦頁面的介面,最新專輯則從PC端抓取的,整個推薦頁面超出螢幕是可以滾動的

輪播圖和最新專輯資料抓取

用chrome瀏覽器開啟手機除錯模式,輸入QQ音樂移動端地址:m.y.qq.com。開啟後點選Network,然後點選XHR,可以看到有一個ajax請求。點開後,選擇preview,紅色框內就是我們最後需要的輪播資料

React全家桶構建一款Web音樂App實戰(三):推薦頁開發及公用元件封裝

在chrome瀏覽器輸入QQ音樂pc官網:y.qq.com

React全家桶構建一款Web音樂App實戰(三):推薦頁開發及公用元件封裝

JSONP使用

這裡介面用的是ajax請求,用這種方式存在跨域限制,前端是不能直接請求的,好在QQ音樂還是很人性化的基本上大部分介面都支援jsonp請求。jsonp原理具體不做過多解釋了。為了使用jsonp,這裡使用一款jsonp外掛,首先安裝jsonp依賴

npm install jsonp --save
複製程式碼

安裝完成後開始編寫程式碼。為了養成好的程式設計習慣呢,通常會把介面請求程式碼存放到api目錄下面,很多人會介面的url一同寫在請求的程式碼中,這裡呢,我們把url抽取出來放到單獨的一個檔案裡面便於管理。

說明:這一章節是在上一章節的基礎上繼續開發的,上一章節傳送門:juejin.im/post/5a3738…,輪播資料介面和最新專輯介面說明見:juejin.im/post/5a3522…

src目錄下面新建api目錄,然後新建config.js檔案,在config.js檔案中編寫URL、一些介面公用引數、jsonp參象、介面code碼等常量

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

const PARAM = {
    format: "jsonp",
    inCharset: "utf-8",
    outCharset: "utf-8",
    notice: 0
};

const OPTION = {
    param: "jsonpCallback",
    prefix: "callback"
};

const CODE_SUCCESS = 0;

export {URL, PARAM, OPTION, CODE_SUCCESS};
複製程式碼

在ES6以前寫ajax的時候各種函式回撥程式碼,ES6提供了Promise物件,它可以將非同步程式碼以同步的形式編寫具體用法請看阮老師的教程Promise物件。我們這裡使用Promise物件將jsonp程式碼封裝成同步程式碼形式。在api目錄下面新建jsonp.js檔案 jsonp.js

import originJsonp from "jsonp"

let jsonp = (url, data, option) => {
    return new Promise((resolve, reject) => {
        originJsonp(buildUrl(url, data), option, (err, data) => {
            if (!err) {
                resolve(data);
            } else {
                reject(err);
            }
        });
    });
};

function buildUrl(url, data) {
    let params = [];
    for (var k in data) {
        params.push(`${k}=${data[k]}`);
    }
    let param = params.join("&");
    if (url.indexOf("?") === -1) {
        url += "?" + param;
    } else {
        url += "&" + param;
    }
    return url;
}

export default jsonp
複製程式碼

上述程式碼大致說明下,在Promise建構函式內呼叫jsonp,當然請求成功的時候會呼叫resolve函式把data的值傳出去,請求錯誤的時候會呼叫reject函式將err的值傳出去。buildUrl函式是把json物件的引數拼接到url後面最後變成xxxx?引數名1=引數值1&引數名2=引數值2這種形式

為了方便管理,我們把請求的程式碼都模組化。在api目錄下面新建recommend.js對應Recommend頁面元件用到的相關請求 recommend.js

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

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

export function getNewAlbum() {
	const data = Object.assign({}, PARAM, {
		g_tk: 1278911659,
		hostUin: 0,
		platform: "yqq",
		needNewCode: 0,
		data: `{"albumlib":
		{"method":"get_album_by_tags","param":
		{"area":1,"company":-1,"genre":-1,"type":-1,"year":-1,"sort":2,"get_tags":1,"sin":0,"num":50,"click_albumid":0},
		"module":"music.web_album_library"}}`
	});
	const option = {
		param: "callback",
		prefix: "callback"
	};
	return jsonp(URL.newalbum, data, option);
}
複製程式碼

在上述程式碼中使用Object.assign()函式把物件進行合併,相同的屬性值會被覆蓋。注意第一個引數使用一個空物件目的是為了不干擾PARAM物件的資料,如果把PARAM作為第一個引數,那麼後面使用這個PARAM物件它裡面的屬性就會擁有上一次合併之後的屬性,其實有些屬性我們是不需要的

推薦頁面開發和資料介面呼叫

在React元件中有很多生命週期函式,幾個生命週期函式如下

函式名 觸發時間點
componentDidMount 在第一次DOM渲染後呼叫
componentWillReceiveProps 在元件接收到一個新的prop時被呼叫。在初始化render時不會被呼叫
shouldComponentUpdate 在元件接收到新的props或者state時被呼叫。在初始化時或者使用forceUpdate時不被呼叫
componentWillUpdate 元件接收到新的props或者state但還沒有render時被呼叫。在初始化時不會被呼叫
componentDidUpdate 元件完成更新後立即呼叫。在初始化時不會被呼叫
componentWillUnmount 元件從 DOM 中移除的時候立刻被呼叫

一般的我們會在componentDidMount函式中獲取DOM,對DOM進行操作。React每次更新都會呼叫render函式,使用shouldComponentUpdate可以幫助我們控制元件是否更新,返回true元件會更新,返回false就會阻止更新,這也是效能優化的一種手段。componentWillUnmount通常用來銷燬一些資源,比如setInterval、setTimeout函式呼叫後可以在該周期函式內進行資源釋放

那麼我們應該在那個生命週期函式裡面傳送介面請求?

答案是componentDidMount

我們應該在元件掛載完成後面進行請求,防止異部操作阻塞UI

回到專案中繼續編寫Recommend元件。推薦頁面輪播我們使用swiper外掛來實現,swiper更多用法見官網:www.swiper.com.cn

安裝swiper

npm install swiper@3.4.2 --save
複製程式碼

注意:這裡使用3.x的版本。4.0的版本目前在移動端有問題,筆者在手機端訪問後一片空白。

使用swiper

Recommend.js中匯入swiper和相關樣式

import Swiper from "swiper"
import "swiper/dist/css/swiper.css"
複製程式碼

Recommend.js

import React from "react"
import Swiper from "swiper"
import {getCarousel} from "@/api/recommend"
import {CODE_SUCCESS} from "@/api/config"
import "./recommend.styl"
import "swiper/dist/css/swiper.css"


class Recommend extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            sliderList: []
        };
    }
    componentDidMount() {
        getCarousel().then((res) => {
            console.log("獲取輪播:");
            if (res) {
                console.log(res);
                if (res.code === CODE_SUCCESS) {
                    this.setState({
                        sliderList: res.data.slider
                    }, () => {
                        if(!this.sliderSwiper) {
                            //初始化輪播圖
                            this.sliderSwiper = new Swiper(".slider-container", {
                                loop: true,
                                autoplay: 3000,
                                autoplayDisableOnInteraction: false,
                                pagination: '.swiper-pagination'
                            });
                        }
                    });
                }
            }
        });

    }
    toLink(linkUrl) {
        /*使用閉包把引數變為區域性變數使用*/
        return () => {
            window.location.href = linkUrl;
        };
    }
    render() {
        return (
            <div className="music-recommend">
                <div className="slider-container">
                    <div className="swiper-wrapper">
                        {
                            this.state.sliderList.map(slider => {
                                return (
                                    <div className="swiper-slide" key={slider.id}>
                                        <a className="slider-nav" onClick={this.toLink(slider.linkUrl)}>
                                            <img src={slider.picUrl} width="100%" height="100%" alt="推薦"/>
                                        </a>
                                    </div>
                                );
                            })
                        }
                    </div>
                    <div className="swiper-pagination"></div>
                </div>
            </div>
        );
    }
}

export default Recommend
複製程式碼

上述程式碼在componentDidMount方法中傳送jsonp請求,請求成功後呼叫setState更新ui,setState第二個引數是一個回撥函式,當元件更新完成後會立即呼叫,這個時候我們在回撥函式裡面初始化swiper

接下來開發最新專輯列表,在constructor建構函式的state中增加一個newAlbums屬性存放最新專輯列表

this.state = {
    sliderList: [],
    newAlbums: []
};
複製程式碼

然後從recommend.js中匯入getNewAlbum

import {getCarousel, getNewAlbum} from "@/api/recommend"
複製程式碼

針對專輯資訊我們封裝一個類模型。使用類模型的好處可以使程式碼重複利用,方便後續繼續使用,ui對應的資料清晰,把ui需要的欄位統一作為類的屬性,根據屬性就能很清楚的知道ui需要哪些資料

模型類統一放置在model目錄下面。在src目錄下新建model目錄,然後新建album.js檔案

album.js

/**
 *  專輯類模型
 */
export class Album {
    constructor(id, mId, name, img, singer, publicTime) {
        this.id = id;
        this.mId = mId;
        this.name = name;
        this.img = img;
        this.singer = singer;
        this.publicTime = publicTime;
    }
}

/**
 *  通過專輯列表資料建立專輯物件函式
 */
export function createAlbumByList(data) {
    return new Album(
        data.album_id,
        data.album_mid,
        data.album_name,
        `http://y.gtimg.cn/music/photo_new/T002R300x300M000${data.album_mid}.jpg?max_age=2592000`,
        filterSinger(data.singers),
        data.public_time
    );
}

function filterSinger(singers) {
    let singerArray = singers.map(singer => {
        return singer.singer_name;
    });
    return singerArray.join("/");
}
複製程式碼

上述程式碼album類通過建構函式給屬性初始化值,在每個介面獲取的專輯資訊欄位都不一樣,所以針對每個介面的請求使用一個物件建立函式來建立album物件

Recommend.js中import這個檔案

import * as AlbumModel from "@/model/album"
複製程式碼

comentDidMount中增加以下程式碼

getNewAlbum().then((res) => {
    console.log("獲取最新專輯:");
    if (res) {
        console.log(res);
        if (res.code === CODE_SUCCESS) {
            //根據釋出時間降序排列
            let albumList = res.albumlib.data.list;
            albumList.sort((a, b) => {
                return new Date(b.public_time).getTime() - new Date(a.public_time).getTime();
            });
            this.setState({
                newAlbums: albumList
            });
        }
    }
});
複製程式碼

render方法中增加以下程式碼

let albums = this.state.newAlbums.map(item => {
    //通過函式建立專輯物件
    let album = AlbumModel.createAlbumByList(item);
    return (
        <div className="album-wrapper" key={album.mId}>
            <div className="left">
                <img src={album.img} width="100%" height="100%" alt={album.name}/>
            </div>
            <div className="right">
                <div className="album-name">
                    {album.name}
                </div>
                <div className="singer-name">
                    {album.singer}
                </div>
                <div className="public—time">
                    {album.publicTime}
                </div>
            </div>
        </div>
    );
});
複製程式碼

return塊中的程式碼如下

<div className="music-recommend">
    <div className="slider-container">
        <div className="swiper-wrapper">
            {
                this.state.sliderList.map(slider => {
                    return (
                        <div className="swiper-slide" key={slider.id}>
                            <a className="slider-nav" onClick={this.toLink(slider.linkUrl)}>
                                <img src={slider.picUrl} width="100%" height="100%" alt="推薦"/>
                            </a>
                        </div>
                    );
                })
            }
        </div>
        <div className="swiper-pagination"></div>
    </div>
    <div className="album-container">
        <h1 className="title">最新專輯</h1>
        <div className="album-list">
            {albums}
        </div>
    </div>
</div>
複製程式碼

樣式recommend.styl檔案沒有列出,可在原始碼中檢視

到此介面及資料渲染已經完成

使用Better-Scroll封裝Scroll元件

在推薦頁面中最新專輯列表已經超出了螢幕高度,而外層定位的元素並沒有設定overflow: scroll,這個時候是不能滾動的。這裡我們使用一款better-scroll(一位國人大牛黃軼寫的)外掛來實現列表的滾動,在專案中會有很多列表需要滾動所以把滾動列表抽象成一個公用的元件

better-scroll是一個移動端滾動外掛,基於iscroll重寫的。普通的網頁滾動效果是很死板的,better-scroll具有拉伸、回彈的效果並且滾動的時候具有慣性,很接近原生體驗。better-scroll更多相關內容見github地址:github.com/ustbhuangyi…。相信很多人在vue中都用過better-scroll,因為better-scroll的作者很好的把它運用在了vue中,幾乎一說到better-scroll大家就會想到vue(2333~~~)。其實better-scroll是利用原生js編寫的,所以在所有使用原生js的框架中幾乎都能使用它,這裡我將在React中的運用better-scroll

首先在src目錄下新建一個common目錄用來存放公用的元件,新建scroll資料夾,然後在scroll資料夾下新建Scroll.jsscroll.styl檔案。先來分析一下怎麼設計這個Scroll元件,better-scroll的原理就是外層一個固定高度的元素,這個元素有一個子元素,當子元素的高度超過父元素時就可以發生滾動,那麼子元素裡面的內容從何而來?React為我們提供了一個props的children屬性用來獲取元件的子元件,這樣就可以用Scroll元件去包裹需要滾動的內容。在Scroll元件內部的列表,會隨著增加或減少原生而發生變化,這個時候元素的高度也會發生變化,better-scroll需要重新計算高度,better-scroll為我們提供了一個refresh方法用來重新計算以保證正常滾動,元件發生變化會觸發React的componentDidUpdate周期函式,所以我們在這個函式裡面對better-scroll進行重新整理操作,同時需要一個props來告訴Scroll是否重新整理。某些情況下我們需要手動呼叫Scroll元件去重新整理better-scroll,這裡對外暴露一個Scroll元件的refresh方法。better-scroll預設是禁止點選的,需要提供一個控制是否點選的props,為了監聽滾動Scroll需要對外暴露一個函式,便於使用Scroll的元件監聽滾動進行其他操作。當元件銷燬時我們把better-scroll繫結的事件取消以及better-scroll例項給銷燬掉,釋放資源

安裝better-scroll

npm install better-scroll@1.5.5 --save
複製程式碼

這裡使用1.5.5的版本,在開發的時候使用的版本。寫這個篇文章的時候已經更新到1.6.x了,作者還是很勤快的

對元件的props進行型別檢查,這裡使用prop-types庫。型別檢查是為了提早發現開發問題,避免一些bug產生

安裝prop-types

npm install prop-types --save
複製程式碼

編寫Scroll元件

Scroll.js

import React from "react"
import ReactDOM from "react-dom"
import PropTypes from "prop-types"
import BScroll from "better-scroll"
import "./scroll.styl"

class Scroll extends React.Component {
    componentDidUpdate() {
        //元件更新後,如果例項化了better-scroll並且需要重新整理就呼叫refresh()函式
        if (this.bScroll && this.props.refresh === true) {
            this.bScroll.refresh();
        }
    }
    componentDidMount() {
        this.scrollView = ReactDOM.findDOMNode(this.refs.scrollView);
        if (!this.bScroll) {
            this.bScroll = new BScroll(this.scrollView, {
                //實時派發scroll事件
                probeType: 3,
                click: this.props.click
            });

            if (this.props.onScroll) {
                this.bScroll.on("scroll", (scroll) => {
                    this.props.onScroll(scroll);
                });
            }

        }
    }
    componentWillUnmount() {
        this.bScroll.off("scroll");
        this.bScroll = null;
    }
    refresh() {
        if (this.bScroll) {
            this.bScroll.refresh();
        }
    }
    render() {
        return (
            <div className="scroll-view" ref="scrollView">
                {/*獲取子元件*/}
                {this.props.children}
            </div>
        );
    }
}

Scroll.defaultProps = {
    click: true,
    refresh: false,
    onScroll: null
};

Scroll.propTypes = {
    //是否啟用點選
    click: PropTypes.bool,
    //是否重新整理
    refresh: PropTypes.bool,
    onScroll: PropTypes.func
};

export default Scroll
複製程式碼

上訴程式碼中ref屬性來標記div元素,使用ReactDOM.findDOMNode函式來獲取dom物件,然後傳入better-scroll建構函式中初始化。在Scroll元件中呼叫外部元件的方法只需要把外部元件的函式通過props傳入即可,這裡就是onScroll函式

scroll.styl

.scroll-view
  width: 100%
  height: 100%
  overflow: hidden
複製程式碼

scroll.styl中就是一個匹配父容器寬高的樣式

接下來在Recommend元件中加入Scroll元件,匯入Scroll元件

import Scroll from "@/common/scroll/Scroll"
複製程式碼

在state中增加refreshScroll用來控制Scroll元件是否重新整理

this.state = {
    sliderList: [],
    newAlbums: [],
    refreshScroll: false
};
複製程式碼

使用Scroll元件包裹Recommend元件的內容,Scroll元件增加一個根元素


<div className="music-recommend">
    <Scroll refresh={this.state.refreshScroll}>
        <div>
        <div className="slider-container">
            <div className="swiper-wrapper">
                {
                    this.state.sliderList.map(slider => {
                        return (
                            <div className="swiper-slide" key={slider.id}>
                                <a className="slider-nav" onClick={this.toLink(slider.linkUrl)}>
                                    <img src={slider.picUrl} width="100%" height="100%" alt="推薦"/>
                                </a>
                            </div>
                        );
                    })
                }
            </div>
            <div className="swiper-pagination"></div>
        </div>
        <div className="album-container">
            <h1 className="title">最新專輯</h1>
            <div className="album-list">
                {albums}
            </div>
        </div>
        </div>
    </Scroll>
</div>
複製程式碼

在獲取最新專輯資料更新專輯列表後呼叫setState讓Scroll元件重新整理

this.setState({
    newAlbums: albumList
}, () => {
    //重新整理scroll
    this.setState({refreshScroll:true});
});
複製程式碼

實現的效果如下圖

React全家桶構建一款Web音樂App實戰(三):推薦頁開發及公用元件封裝

React全家桶構建一款Web音樂App實戰(三):推薦頁開發及公用元件封裝

底部有52px的bottom是為了後面miniplayer元件預留

Loading元件的封裝

此時Recommend頁面元件還是不夠完善的,當網路請求耗費很多時間的時候介面什麼都沒有,體驗很不好。一般在網路請求的時候都會加一個loading效果,告訴使用者此時正在載入資料。這裡把Loading元件抽取成公用的元件

common下新建loading目錄,然後在loading目錄下新建Loading.jsloading.styl,另外在loading下面放入一張loading.gif圖片 Loading.js

import React from "react"
import loadingImg from "./loading.gif"
import "./loading.styl"

class Loading extends React.Component {
    render() {
        let displayStyle = this.props.show === true ?
            {display:""} : {display:"none"};
        return (
            <div className="loading-container" style={displayStyle}>
                <div className="loading-wrapper">
                    <img src={loadingImg} width="18px" height="18px" alt="loading"/>
                    <div className="loading-title">{this.props.title}</div>
                </div>
            </div>
        );
    }
}

export default Loading
複製程式碼

Loading元件只接受一個show屬性明確當前元件是否顯示,title是顯示的文字內容

loading.styl

.loading-container
  position: absolute
  top: 0
  left: 0
  width: 100%
  height: 100%
  z-index: 999
  display: flex
  justify-content: center
  align-items: center
  .loading-wrapper
    display: inline-block
    font-size: 12px
    text-align: center
    .loading-title
      margin-top: 5px
複製程式碼

回到Recommend元件中。匯入Loading元件

import Loading from "@/common/loading/Loading"
複製程式碼

在state中增加loading屬性

this.state = {
    loading: true,
    sliderList: [],
    newAlbums: [],
    refreshScroll: false
};
複製程式碼

當專輯列表載入完成後隱藏Loading元件,只需要將loading狀態值修改為false

this.setState({
    loading: false,
    newAlbums: albumList
}, () => {
    //重新整理scroll
    this.setState({refreshScroll:true});
});
複製程式碼

優化圖片載入

專輯列表中有很多圖片,一個螢幕放不下列表中的所有圖片並且使用者不一定就會看滾動檢視所有的資料,這個時候需要使用圖片懶載入功能,當使用者滾動列表,圖片顯示出來時才載入,幫助使用者節省流量,這也是為什麼移動端需要使用體積小的庫進行開發的原因。這裡使用一個react-lazyload庫github地址:github.com/jasonslyvia…,它其實是元件的懶載入,用它來實現圖片懶載入

安裝react-lazyload

npm install react-lazyload --save
複製程式碼

在Recommend.js中匯入react-lazyload

import LazyLoad from "react-lazyload"
複製程式碼

使用LazyLoad元件包裹圖片

<LazyLoad>
    <img src={album.img} width="100%" height="100%" alt={album.name}/>
</LazyLoad>
複製程式碼

這個時候執行發現一個問題,當滾動專輯列表的時候,從螢幕外進入螢幕內的圖沒有了

React全家桶構建一款Web音樂App實戰(三):推薦頁開發及公用元件封裝

這是因為react-lazylaod庫監聽的是瀏覽器原生的scroll和resize事件,當出現在螢幕的時候才會載入。而這裡使用的是better-scroll的滾動,better-scroll是基於css3的transform實現的,所以當圖片出現在螢幕內時自然無法被載入

解決辦法

通過查閱react-lazyload的github的使用說明,發現提供了一個forceCheck函式,當元素沒有通過scroll或者resize事件載入時強制檢查元素位置,這個時候如果出現在螢幕內就會被立即載入。藉助Scroll元件暴露的onScroll屬性就可以監聽到Scroll元件的滾動

此時修改import

import LazyLoad, { forceCheck } from "react-lazyload"
複製程式碼

在Scroll元件上增加onScroll,在處理函式中呼叫forceCheck

<Scroll refresh={this.state.refreshScroll} 
	onScroll={(e) => {
		/*檢查懶載入元件是否出現在檢視中,如果出現就載入元件*/
		forceCheck();}}>
	...
</Scroll>
複製程式碼

總結

這一節主要介紹了介面請求程式碼的合理規劃、推薦介面和最新專輯介面呼叫、better-scroll在React中的運用(應better-scroll作者要求)、公用元件Scroll和Loading元件的封裝。在做圖片懶載入優化的時候,剛開始考慮到一般的懶載入都是通過監聽原生scroll或reset事件來實現的。這裡使用了better-scroll,需要一個適當的時候手動進行載入,恰好react-lazyload提供了forceCheck方法,結合better-scroll的refresh方法就可以到達這個需求

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

本章節程式碼在chapter3分支

後續更新中...

相關文章