阅读 4614

React全家桶构建一款Web音乐App实战(三):推荐页开发及公用组件封装

接着上一节内容,这一节抓取QQ音乐移动Web端推荐页面接口和PC端最新专辑接口数据。通过这些接口数据开发推荐页面。首先看一下效果图

页面结构

推荐页面主要分轮播和最新专辑两块,其中轮播图片来自QQ音乐移动Web端推荐页面的接口,最新专辑则从PC端抓取的,整个推荐页面超出屏幕是可以滚动的

轮播图和最新专辑数据抓取

用chrome浏览器打开手机调试模式,输入QQ音乐移动端地址:m.y.qq.com。打开后点击Network,然后点击XHR,可以看到有一个ajax请求。点开后,选择preview,红色框内就是我们最后需要的轮播数据

在chrome浏览器输入QQ音乐pc官网:y.qq.com

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});
});
复制代码

实现的效果如下图

底部有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-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分支

后续更新中...

关注下面的标签,发现更多相似文章
评论