Vue音乐播放器(二)

推荐页面开发

源码

完成轮播图时

完成歌单列表时



better-scroll插件

中文文档

1.x Docs

2.x Docs

之后的轮播图组件和列表滚动组件都会用到这个插件。这个插件还挺牛逼的,9400+stars。



QQ音乐的api



【重点难点一】recommend页面jsonp相关文件的编写

src/common/js/jsonp.js promise封装

本项目该部分使用jsonp技术获取数据,解决跨域问题。

这里使用一个封装好的jsonp插件。这是它的API :

1560407149676

利用这个插件里面提供的api进行Promise的改写—-> jsonp.js的创建。

npm i jsonp进行jsonp插件的安装。

之后会经常用到它,把他放在js文件夹下,然后编写src/common/js/jsonp.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import originJsonp from 'jsonp'
/* jsonp2这个函数的三个参数,url是一个干净的不带参数的url地址,data表示随同url传过去的参数,
因为jsonp这个插件要求的url是不支持object类型的的data参数的,所以我们需要先将data里面的数据拼接到url上。
第三个参数option对应jsonp插件api里的opts参数
*/
export default function jsonp2(url, data, option) {
url += (url.indexOf('?') < 0 ? '?' : '&') + param(data)

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

export function param(data) {
let url = ''
for (var k in data) {
let value = data[k] !== undefined ? data[k] : ''
url += '&' + k + '=' + encodeURIComponent(value)/* encodeURIComponent(value)进行字符转义,如encodeURIComponent('百度')转为"%E7%99%BE%E5%BA%A6" */
}
return url ? url.substring(1) : ''
}


我们把请求api数据相关的js放在src/api目录下


src/api/config.js

这部分主要就是模拟qq音乐api的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
export const commonParams = {
g_tk: 5381,
inCharset: 'utf-8',
outCharset: 'utf-8',
notice: 0,
format: 'jsonp'
}

export const options = {
param: 'jsonpCallback'
}

export const ERR_OK = 0 /* 语义化一下 */


src/api/recommend.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import jsonp from 'common/js/jsonp'
import {commonParams, options} from './config'
// import axios from 'axios'

export function getRecommend() {
const url = 'https://c.y.qq.com/musichall/fcgi-bin/fcg_yqqhomepagerecommend.fcg'

const data = Object.assign({}, commonParams, {
platform: 'h5',
uin: 0,
needNewCode: 1
})

return jsonp(url, data, options)
}


webpack.base.conf.js

添加多两个别名:

1
2
3
4
5
6
7
alias: {
'@': resolve('src'),
'common': resolve('src/common'),
'components': resolve('src/components'),
'base': resolve('src/base'),
'api': resolve('src/api')
}


recommend.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<template>
<div class="recommend" ref="recommend">

<router-view></router-view>
</div>
</template>

<script type="text/ecmascript-6">
import {getRecommend} from 'api/recommend'
import {ERR_OK} from 'api/config'

export default {
data() {
return {
recommends: [],
discList: []
}
},
created() {
this._getRecommend()
},
methods: {
_getRecommend() {
getRecommend().then((res) => {
if (res.code === ERR_OK) {
this.recommends = res.data.slider
console.log(res);
}
})
},
},
}
</script>

<style scoped lang="stylus" rel="stylesheet/stylus">

看一下这时候有没有抓取到数据:

1560411184376



轮播图组件

安装依赖

这里用到一个插件better-scroll

better-scroll文档



插播一个小技巧:抽出一个“添加类名”组件dom.js

在轮播图组件的编写过程中,编写 _setSliderWidth() 方法的代码中,要遍历某个节点的子节点,给它加上一个类名,这是一个常见的操作。比起直接在 _setSliderWidth() 里面实现,最好的方法是把该操作抽离成为一个js文件。

于是新建了src/common/js/dom.js,在里面实现了添加类名的操作并暴露出去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* dom操作相关的代码
*/
export function hasClass(el, className) {
let reg = new RegExp('(^|\\s)' + className + '(\\s|$)')
return reg.test(el.className)
}

export function addClass(el, className) {
if (hasClass(el, className)) {
return
}

let newClass = el.className.split(' ')
newClass.push(className)
el.className = newClass.join(' ')
}

而这个dom.js以后就专门来存放常见的dom操作。



踩坑记录

  1. recommend.vue中引用slider组件,而slider.vue中的 _setSliderWidth()方法里面循环渲染dom节点。但是我一开始的recommend.vue的代码中没有实现判断是否成功获取到了数据并渲染了节点,因为获取的是真实数据存在异步过程,如果没有加上这句v-if="recommends.length"的话,可能存在recommend.vue页面还没有成功获取数据渲染节点,而引用slider组件导致slider中 _setSliderWidth()方法里面循环渲染dom节点出错,最终导致页面报销的状况。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <div class="recommend" ref="recommend">
    <div class="recommend-content">
    <div v-if="recommends.length" class="slider-wrapper"><!-- 注意这里用了v-if来确保只有当成功获取到数据后才将slider组件添加上,从而保证slider中的循环渲染成功 -->
    <slider>
    <div v-for="item in recommends" :key="item.id">
    <a :href="item.linkUrl">
    <img :src="item.picUrl" alt="">
    </a>
    </div>
    </slider>
    </div>
    </div>
    </div>


  2. better-scroll默认会复制轮播图的首尾两张图(这里说成“图”不太准确,姑且这么说),因此通过api获得的轮播图图片只有5张,但是在使用better-scroll的时候生成了7个节点。相应的在slider.vue的_setSliderWidth中要设置增加两张图的宽度:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    _setSliderWidth(isResize) {
    this.children = this.$refs.sliderGroup.children;

    let width = 0;
    let sliderWidth = this.$refs.slider.clientWidth;
    for (let i = 0; i < this.children.length; i++) {
    let child = this.children[i];
    addClass(child, "slider-item");

    child.style.width = sliderWidth + "px";
    width += sliderWidth;
    }
    if (this.loop && !isResize) {
    width += 2 * sliderWidth; /* 加多左右两个轮播图的宽度,为了实现无缝流畅滚动,不会噌的一下跳过去 */
    }
    this.$refs.sliderGroup.style.width = width + "px";
    }


  3. 有个bug

    无法实现自动的无缝轮播,轮播到最后一张的时候就不动了,必须手动划一下才能无缝轮播。暂未解决,怀疑是better-scroll的问题。目前的妥协是最后一张直接跳转到第一张,就很难看。。。


  4. 实现窗口改变的时候自适应

    在mounted钩子函数中监听一下窗口的改变事件

    1
    2
    3
    4
    5
    6
    7
    8
      /* 监听窗口改变事件 */
    window.addEventListener('resize', () => {
    if (!this.slider) {/* 如果slider还未初始化,直接退出 */
    return
    }
    this._setSliderWidth()
    this.slider.refresh()
    })


  5. 优化:减少重复的数据请求

    打开项目的时候轮播图会发起数据请求,但是当从首页推荐页切换路由到其他页面,再从其他页面切换回轮播图所在的首页推荐页面时,它又重新刷新发起了数据请求,这是没必要的,而且降低了性能。

    可以使用<keepalive>标签将<router-view>包裹起来,将其dom缓存到内存中。这就避免了上述的情况。


  6. 优化:beforeDestroy清除计时器释放内存

    路由切换的时候会触发vue的destroy,那么最好时把slider的计算器清除了以释放内存

    1
    2
    3
    beforeDestroy() {
    clearTimeout(this.timer)
    },



【重点难点二】axios与后端接口代理

先安装axios依赖

修改代理

在使用QQ音乐推荐页歌单列表的api时,发现无法通过ajax获取到。关键点是QQ音乐设置了请求头中的Host和Referer进行了限制:

1561392420142

因此需要解决这个跨域的问题。方法是修改代理


修改webpack.dev.conf.js

【相关参考】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  devServer: {
before(app) {
app.get('/api/getDiscList', function (req, res) {
const url = 'https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg'
axios.get(url, {
headers: {
referer: 'https://c.y.qq.com/',
host: 'c.y.qq.com'
},
params: req.query
}).then((response) => {
res.json(response.data)
}).catch((e) => {
console.log(e)
})
})

...
}
}


src/api/recommend.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import jsonp from 'common/js/jsonp'
import { commonParams, options } from './config'
import axios from 'axios'

export function getRecommend() {
const url = 'https://c.y.qq.com/musichall/fcgi-bin/fcg_yqqhomepagerecommend.fcg'

const data = Object.assign({}, commonParams, {
platform: 'h5',
uin: 0,
needNewCode: 1
})

return jsonp(url, data, options)
}

export function getDiscList() {
const url = '/api/getDiscList'

const data = Object.assign({}, commonParams, {
platform: 'yqq',
hostUin: 0,
sin: 0,
ein: 29,
sortId: 5,
needNewCode: 0,
categoryId: 10000000,
rnd: Math.random(),
format: 'json'
})

return axios.get(url, {
params: data
}).then((res) => {
return Promise.resolve(res.data)
})
}

然后在recommend.vue中调用getDiscList()方法就能成功获取到数据了。


这时候启动项目,可以在chrome Network中看到发送了Ajax请求并成功获得了数据:

1560566255358



歌单列表组件开发

通过上一步改变qq音乐api的接口代理,在recommend.vue调用getDiscList方法获取到数据,并渲染到页面上。

1560578229727

但是到这一步为止,页面尚无法上下滑动。还是需要better-scroll组件来创建一个歌单列表滚动组件。


src/base/scroll/scroll.vue

创建/src/base/scroll/scroll.vue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<template>
<div ref="wrapper">
<slot></slot>
</div>
</template>

<script type="text/ecmascript-6">
import BScroll from 'better-scroll'

export default {
props: {
probeType: {
type: Number,
default: 1
},
click: {
type: Boolean,
default: true
},
data: {
type: Array,
default: null
}
},
mounted() {
setTimeout(() => {
this._initScroll()
}, 20)
},
methods: {
_initScroll() {
if (!this.$refs.wrapper) {
return
}
this.scroll = new BScroll(this.$refs.wrapper, {
probeType: this.probeType,
click: this.click
})
},
enable() {
this.scroll && this.scroll.enable()/* better-scroll的api,启用 better-scroll, 默认 开启 */
},
disable() {
this.scroll && this.scroll.disable()/* better-scroll的api,禁用 better-scroll,DOM 事件(如 touchstart、touchmove、touchend)的回调函数不再响应。 */
},
refresh() {
this.scroll && this.scroll.refresh()/* 重新计算 better-scroll,当 DOM 结构发生变化的时候务必要调用确保滚动的效果正常。 */
}
},
watch: {
data() { /* 监听数据改变立即刷新 */
setTimeout(() => {
this.refresh()
}, 20)
}
}
}

</script>

<style scoped lang="stylus" rel="stylesheet/stylus">

</style>


src/components/recommend/recommend.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
<div class="recommend" ref="recommend">
<scroll ref="scroll" class="recommend-content" :data='discList'><!-- 注意这里的:data如果没有加的话依旧是不能上下滑动的,因为scroll是在mounted时候初始化,由于数据获取存在异步性,可能mounted的时候尚未取到数据,整个列表dom的高度是没有被撑开的以至于better-scroll无法正确初始化。所以需要传入一个discList数据(discList有说明数据已经获取到了)触发scroll的watch里面的refresh -->
<div> <!-- 注意,better-scroll的scroll标签下只能是一个div包裹全部,如果这个div不把轮播图和列表都包裹起来的话,效果是只有歌单列表滚动,轮播图是固定的,那就糟了 -->
<div v-if="recommends.length" class="slider-wrapper">
<!-- 注意这里用了v-if来确保只有当成功获取到数据后才将slider组件添加上,从而保证slider中的循环渲染成功 -->
<slider>
<div v-for="item in recommends" :key="item.id">
<a :href="item.linkUrl">
<img @load="loadImage" :src="item.picUrl"><!-- 通过@load事件监听图片加载完成了没有,一旦获加载完就refresh scroll -->
</a>
</div>
</slider>
</div>
<div class="recommend-list">
<h1 class="list-title">热门歌单推荐</h1>
<ul>
<li class="item" v-for="item in discList" :key="item.dissid">
<div class="icon">
<img :src="item.imgurl" width="60" height="60" alt>
</div>
<div class="text">
<h2 class="name" v-html="item.creator.name"></h2>
<p class="desc" v-html="item.dissname"></p>
</div>
</li>
</ul>
</div>
</div>
</scroll>
</div>
</template>


这里遇到的坑

  1. 在上述recommend.vue中提到scroll标签中:data如果没有加的话依旧是不能上下滑动的,因为scroll是在mounted时候初始化,由于数据获取存在异步性,可能mounted的时候尚未取到数据,整个列表dom的高度是没有被撑开的。所以需要传入一个discList数据(discList有说明数据已经获取到了)触发scroll的refresh

  2. 那为什么传discList就行?传recommends行不行?答案是不行的。虽然他们都是异步获取数据,但是discList获取的速度比recommends的获取速度慢。如果传入的是recommends,虽然recommends已经获取到了,轮播图的高度也已经撑开了,但是下方的列表discList还没有完全获取到,所以scroll在初始化的时候页面的高度计算是有误的。

  3. 还有一个问题,拿到recommends就万事大吉了吗?不是。

    对于discList歌单列表,里面的列表项是给定宽高的,所以一拿到discList就能确定宽高撑开dom。但是recommend部分的轮播图图片是没有给定宽高的,是按照百分比来设置的宽高,所以即使获得了recommends的数据,但是由于图片是通过链接引入的,这又是一个异步的过程,所以不能保证拿到了recommends的数据就能立刻加载出图片撑起宽高,在此完成之前就初始化scroll还是会出问题的。所以,可以给img标签监听load事件,一旦load就refresh scroll。

    1
    2
    3
    4
    5
    6
    loadImage() {
    if (!this.checkloaded) { /* 无中生有搞个checkloaded变量来监测加载完成与否,一开始默认为undefined,只要有一张加载成功就改值为true,不用每张图都触发这个方法,加载得到一张图就可以确定宽高了 */
    this.checkloaded = true
    this.$refs.scroll.refresh()
    }
    },

使用checkloaded也是常见的一种优化


这里总结一下better-scroll的渲染原理吧

它根据初始化的时机或者refresh的时机,那个时候scroll的父元素的高度和子元素的高度之差,算到scroll可以滚动的位置,所以我们去实例化或者refresh这个scroll的时候一定要保证dom是渲染完成的,才能正确计算他们的高度。一旦我们有数据变化引起dom变化或者其他dom结构变化,一定要重新refresh这个scroll。



懒加载vue-lazyload

vue-lazyload


代码编写

  1. main.js

    1
    2
    3
    4
    5
    import VueLazyLoad from 'vue-lazyload'

    Vue.use(VueLazyLoad, {
    loading: require('./common/image/default-ldt.png') //loading时显示的图片
    })
  2. 将recommend.vue中img标签的:src属性替换成v-lazy计即可

    1561427554510


loading组件

效果:

1561428758666


要搞个这个东西,提升用户体验。

src/base/loading.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<template>
<div class="loading">
<img width="24" height="24" src="./loading.gif">
<p class="desc">{{title}}</p>
</div>
</template>
<script type="text/ecmascript-6">
export default {
props: {
title: {
type: String,
default: '正在载入...'
}
}
}
</script>
<style scoped lang="stylus" rel="stylesheet/stylus">
@import "~common/stylus/variable"

.loading
width: 100%
text-align: center
.desc
line-height: 20px
font-size: $font-size-small
color: $color-text-l
</style>

然后在recommend.vue中引用并嵌入即可。