包括子路由的配置及转场动画实现、Vuex 的介绍、Vuex 初始化歌手数据的配置、歌手详情页数据抓取和处理、Song 类的封装、music-list 组件开发。
6-1 歌手详情页布局和设计详解
6-2 子路由配置以及转场动画实现
src\router\index.js
{
path:'/singer',
component:singer,
name:'歌手',
children:[
{
path:'/singer/:id',
component:singerDetail
}
]
},
给歌手项绑定点击事件,派发数据
src\components\base\listview\listview.vue
<li class="list-group-item" v-for="singer in item.items" :key="singer.id" @click="selectItem(singer)">
<img class="avatar" v-lazy="singer.avatar">
<span class="name">{{singer.name}}</span>
</li>
selectItem(singer){
this.$emit('select',singer)
},
src\components\singer\singer.vue
<listview :data="singerList" @select="selectSinger"></listview>
singer-detail.vue监听到点击的事件,改变路由
//监听到点击的事件,改变路由
selectSinger(singer){
this.$router.push({
path:`/singer/${singer.id}`
})
},
注意:子路由并不是一个页面,只是一个层,使用z-index将之前的层全部盖住
转场动画:从右向左滑动。添加transition
<transition name="slide">
<router-view class="singer-detail"></router-view>
</transition>
.slide-enter-active,.slide-leave-active{
transition: all .3s
}
.slide-enter,.slide-leave-to{
transform: translate3d(-100%,0,0);
}
6-3 初识 Vuex
vuex是什么
vue是一种设计思想,我们把组件的数据放在同一个内存空间去管理,这个内存空间我门称它为state。state的数据能非常方便映射到组件上,来渲染组件。
当组件的数据发生变化的时候,它可以dispatch一个action(action通常是一些异步操作,和后端的一些交互,或者同时改变多个mutation时,对mutation的封装)
action可以commit一个mutation(注意也可以直接在组件中commit一个mutation的)。除了这两种方式,其他的改变state的途径都时非法的。
然后在mutations里面修改state
vuex这样设计的目的就是为了state的状态可以预测,当state被修改之后,它又会直接映射到组件上,这样就形成了一个闭环
在vuex中,我们必须要通过dispatch来commit一个mutation。或者直接在组件上commit一个mutation来修改state的数据,这样比起不用vuex直接在组件上修改数据复杂多了,那么我们为什么还需要使用vuex呢
vuex的应用场景
- 解决多个组件之间的状态共享问题(比如多个兄弟组件,多级组件之间的通讯)
- 解决路由之间复杂的数据传递(当在路由中,需要传递的参数过多的时候)
vuex的文件结构
文件名称 | 描述 |
---|---|
index.js | 入口文件 |
state.js | 管理多个组件公用的数据状态 |
mutation | 更改 Vuex 的 store 中状态state的唯一方法 |
mutation-types.js | 管理所有mutation 事件类型(type)–字符串常量 |
actions.js | 处理异步操作和修改、以及对mutation的封装 |
getters.js | 对获取的state 做一些映射 |
为什么要用mutation-types呢
- 便于书写方便
- IDE能够够帮我们检测出没我们写的对不对。通常mutations和mutation-types和actions是关联的
6-4 Vuex 初始化及歌手数据的配置
这里我们在需要在上一个路由中获取到歌手的相关信息,歌手的相关信息较多,我们不应该直接放在路由参数中传递过来,而是,我们应该通过vuex来管理singer数据
(1)确定状态
src\store\state.js
const state={
singer:{}
}
export default state
(2)映射state里面的数据,做一层getters的封装,这样我们可以直接从getters拿到数据到组件上
src\store\getters.js
export const singer=state=>state.singer
(3)在确定mutation之前,我们需要先确定mutation-types,在这里定义一些常量
src\store\mutation-types.js
export const SET_SINGER='SET_SINGER'
(4)在mutations里定义一些修改state的操作
src\store\mutations.js
每个 mutation 都有一个字符串的 事件类型 (type) 和 一个 回调函数 (handler)
import * as types from './mutation-types'
const mutations={
[types.SET_SINGER](state,singer){
state.singer=singer
}
}
export default mutations
(5)export store 出去,并且需要在main.js里面注册,这样每个组件都可以访问到vuex管理的状态了
src\store\index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use( Vuex)
import * as getters from './getters'
import * as actions from './actions'
import state from './state'
import mutations from './mutations'
export default new Vuex.Store({
state,
getters,
actions,
mutations
})
(6)应用vuex
这里我们使用的是vuex的语法糖mapMutations,引入之后,我们需要在methods里面获取到SET_SINGER这个mutation。并且在组件上发起一个mutation
src\components\singer\singer.vue
import {mapMutations} from 'vuex'
...mapMutations({
set_singer:'SET_SINGER'
})
selectSinger(singer){
this.$router.push({
path:`/singer/${singer.id}`
})
this.set_singer(singer);
},
6-5 歌手详情数据抓取
//获取到歌手详情页面列表数据,根据id来获取数据,所以要传入singerId
export function getSingerDetail(singerId){
const url = 'https://c.y.qq.com/v8/fcg-bin/fcg_v8_singer_track_cp.fcg'
const data=Object.assign({},commonParams,{
hostUin: 0,
needNewCode: 0,
platform: 'yqq',
order: 'listen',
begin: 0,
num: 100,
songstatus: 1,
singermid: singerId
})
return jsonp(url,data,options)
}
src\components\singer-detail\singer-detail.vue
import {mapGetters} from 'vuex'
export default {
computed: {
...mapGetters([
'singer'
])
},
created() {
console.log(this.singer);
},
}
得到vuex的singer的相关数据,我们需要获取到详情页的数据,当在当前页面刷新的时候,this.singer.id是取不到的,我们在这里需要做路由处理,当我们在当前页面刷新的时候,让页面回到歌手页
created() {
this._getSingerDetail();
},
_getSingerDetail(){
//当在当前页面刷新的时候,跳转回singer页
if(!this.singer.id){
this.$router.push('/singer')
return
}
//获取到列表
getSingerDetail(this.singer.id).then(res=>{
if(res.ERR_OK){
//对得到的数据进行处理
this._normallizeSingerDetail(res.data.list)
}
}).catch(err=>{
console.log(err);
})
}
6-6 歌手详情数据处理和Song类的封装(上)
src\common\js\song.js
export default class Song{
//因为参数过多,我们把参数放在一个对象中去
constructor({id, mid, singer, name, album, duration, image, url}){
this.id=id,
this.mid=mid,
this.singer=singer,
this.name=namem,
this.album=album,
this.duration=duration,
this.image=image,
this.url=url
}
}
在singer-detail\singer-detail.vue中维护一个song引入song类文件,我们将musicData的每一项都抽象成Song类,但是传入的参数过多了,而且每次使用的时候都需要new Song。我们希望能做到的是直接传入musicData返回一个Song实例数组。
_normallizeSingerDetail(list){
let ret=[];//返回值
list.forEach(musicData => {
ret.push(new Song({
//...传入的参数过多
}))
});
return ret
}
6-7 歌手详情数据处理和Song类的封装(下)
在song.js中:处理musicData数据抽象出工厂方法,返回song实例
class Song{
//因为参数过多,我们把参数放在一个对象中去
constructor({id, mid, singer, name, album, duration, image, url}){
this.id=id,
this.mid=mid,
this.singer=singer,
this.name=name,
this.album=album,
this.duration=duration,
this.image=image,
this.url=url
}
}
//抽象出一个工厂方法:传入musicData对象参数,实例化一个Song
export default function createSong(musicData,songVkey){
return new Song({
id: musicData.songid,
mid: musicData.songmid,
singer: filterSinger(musicData.singer),
name: musicData.songname,
album: musicData.albumname,
duration: musicData.interval, //歌曲时长
image: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${musicData.albummid}.jpg?max_age=2592000`,
//播放源这里的songVkey需要动态获取。
url: `http://dl.stream.qqmusic.qq.com/C400${musicData.songmid}.m4a?vkey=${songVkey}&guid=6319873028&uin=0&fromtag=66`
})
}
//格式化处理singer数据
function filterSinger(singer) {
let ret=[]
if(!singer){
return ''
}else{
singer.forEach(item => {
ret.push(item);
});
}
return ret.join('/');
}
播放源songVkey的获取
config\index.js配置代理
//获取到播放源地址
'/api/getMusicVkey':{
target:'https://c.y.qq.com/base/fcgi-bin/fcg_music_express_mobile3.fcg',
secure:false,
changeOrigin:true,
bypass:function(req,res,proxyOptions){
req.headers.referer='https://y.qq.com/'
req.headers.host='c.y.qq.com'
},
pathRewrite:{
'^/api/getMusicVkey': ''
}
}
src\api\singer.js
//获取到播放源的url
import axios from 'axios'
export function getMusicVkey(songmid) {
const url = '/api/getMusicVkey'
const data = Object.assign({}, commonParams, {
songmid: songmid,
filename: 'C400' + songmid + '.m4a',
guid: 6319873028, //会变,以实时抓取的数据为准
platform: 'yqq',
loginUin: 0,
hostUin: 0,
needNewCode: 0,
cid:205361747,
uin: 0,
format: 'json'
})
return new Promise(function(resolve,reject){
axios.get(url,{ params: data}).then(res=>{
resolve(res.data)
}).catch(err=>{
reject(err)
})
})
}
src\components\singer-detail\singer-detail.vue
import createSong from 'common/js/song.js'
_normallizeSingerDetail(list){
let ret=[];//返回值
list.forEach(item => {
let {musicData} = item //得到每一项的musicData对象
if(musicData.songid && musicData.albummid){
//在这里要获取到valkey
getMusicVkey(musicData.songmid).then(res=>{
if(res.code==ERR_OK){
let vkey=res.data.items[0].vkey
//传入musicData和vkey
ret.push(createSong(musicData,vkey))
}
})
}
});
return ret
}
拿到数据
_getSingerDetail(){
//当在当前页面刷新的时候,跳转回singer页
if(!this.singer.id){
this.$router.push('/singer')
return
}
//获取到列表
getSingerDetail(this.singer.id).then(res=>{
if(res.code==ERR_OK){
//对得到的数据进行处理
this.songs=this._normallizeSingerDetail(res.data.list)
}
}).catch(err=>{
console.log(err);
})
},
拿到需要的数据之后我们可以进行开发了啦
6-8 music-list 组件发-基本布局(1)
因为歌手详情和歌单页布局上是极为相似的,在这里我们可以在singer-detail抽取出来一个music-list的业务组件,业务组件我们放在components中,当是基础组件我们放在base中
src\components\music-list\music-list.vue
<template>
<div class="music-list">
<div class="back">
<i class="icon-back"></i>
</div>
<h1 class="title" v-html="title"></h1>
<div class="bg-image" :style="bgStyle">
<div class="filter"></div>
</div>
</div>
</template>
props:{
bgImage:{
type:String,
default:''
},
songs:{
type:Array,
default:[]
},
title:{
type:String,
default:''
}
},
computed: {
bgStyle(){
return `background-image:url(${this.bgImage})`
}
},
6-9 music-list 组件开发-抽取基本歌曲基本组件(2)
我们发现设计稿的好多页面都反复使用了歌曲列表的布局样式,所以在这我们将歌曲列表项抽取出来,作为基础组件
src\components\base\song-list\song-list.vue
<template>
<div class="song-list">
<ul>
<li v-for="(song, index) in songs" :key="index" class="item">
<div class="content">
<h2 class="name">{{song.name}}</h2>
<p class="desc">{{getDesc(song)}}</p>
</div>
</li>
</ul>
</div>
</template>
<script>
export default {
props:{
songs:{
type:Array,
default:[]
}
},
methods: {
getDesc(song){
return `${song.singer} 。${song.album}`
}
},
}
</script>
<style scoped lang="stylus" rel="stylesheet/stylus">
@import "~common/stylus/variable"
@import "~common/stylus/mixin"
.song-list
.item
display: flex
align-items: center
box-sizing: border-box
height: 64px
font-size: $font-size-medium
.content
flex: 1
line-height: 20px
overflow: hidden
.name
no-wrap()
color: $color-text
.desc
no-wrap()
margin-top: 4px
color: $color-text-d
</style>
src\components\music-list\music-list.vue
在music-list引入scroll组件,song-list组件
在mounted中我们计算scroll的top值=图片的高度,让scroll可以滚动
<template>
<div class="music-list">
<div class="back">
<i class="icon-back"></i>
</div>
<h1 class="title" v-html="title">{{title}}</h1>
<div class="bg-image" :style="bgStyle" ref="bgImage">
<div class="filter"></div>
</div>
<scroll :data="songs" class="list" ref="list">
<div class="song-list-wrapper">
<song-list :songs="songs"></song-list>
</div>
</scroll>
</div>
</template>
<script>
import SongList from 'components/base/song-list/song-list'
import scroll from 'components/base/scroll/scroll'
export default {
props:{
bgImage:{
type:String,
default:''
},
songs:{
type:Array,
default:[]
},
title:{
type:String,
default:''
}
},
components:{
SongList,
scroll
},
computed: {
bgStyle(){
return `background-image:url(${this.bgImage})`
}
},
mounted() {
//因为这个得到的是VueComponent,而不是元素,所以我们这里还需要加上$el
this.$refs.list.$el.style.top=this.$refs.bgImage.clientHeight+'px'
}
}
</script>
<style scoped lang="stylus" rel="stylesheet/stylus">
@import "~common/stylus/variable"
@import "~common/stylus/mixin"
.music-list
position: fixed
z-index: 100
top: 0
left: 0
bottom: 0
right: 0
background: $color-background
.back
position absolute
top: 0
left: 6px
z-index: 50
.icon-back
display: block
padding: 10px
font-size: $font-size-large-x
color: $color-theme
.title
position: absolute
top: 0
left: 10%
z-index: 40
width: 80%
no-wrap()
text-align: center
line-height: 40px
font-size: $font-size-large
color: $color-text
.bg-image
position: relative
width: 100%
height: 0
padding-top: 70%
transform-origin: top
background-size: cover
.play-wrapper
position: absolute
bottom: 20px
z-index: 50
width: 100%
.play
box-sizing: border-box
width: 135px
padding: 7px 0
margin: 0 auto
text-align: center
border: 1px solid $color-theme
color: $color-theme
border-radius: 100px
font-size: 0
.icon-play
display: inline-block
vertical-align: middle
margin-right: 6px
font-size: $font-size-medium-x
.text
display: inline-block
vertical-align: middle
font-size: $font-size-small
.filter
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background: rgba(7, 17, 27, 0.4)
.bg-layer
position: relative
height: 100%
background: $color-background
.list
position: fixed
top: 0
bottom: 0
width: 100%
overflow hidden
background: $color-background
.song-list-wrapper
padding: 20px 30px
.loading-container
position: absolute
width: 100%
top: 50%
transform: translateY(-50%)
</style>
6-10 music-list 滚动效果优化
当我们在滚动页面的时候,我们只能滚动蓝色区域的列表,这并不是我们期望的效果,我们希望的是列表可以往上滚动,列表文字下面的黑色的层,随着列表的滚动往上推
首先我们得让列表可以滚动到顶部,这时,我们需要将 .list的overflow hidden
样式去掉,这样列表可以滚动了,但是文字底部的黑色的层我们也想跟着文字滚动,我们可以通过监听scrollY的值来获得滚动的距离,让文字底部的黑色的层跟着滚动
那么怎么让文字底部的黑色的跟着列表滚动呢?
create()中初始化probeType,和listenScroll
created() {
this.probeType =3,
this.listenScroll =true
},
将probeType,和listenScroll等传递给子组件,监听滚动
<scroll :data="songs" class="list" ref="list" :probeType='probeType' :listenScroll='listenScroll' @scroll="scroll">
<div class="song-list-wrapper">
<song-list :songs="songs"></song-list>
</div>
</scroll>
得到scrollY
methods: {
scroll(pos){
this.scrollY=pos.y//获取scroll滚动的值
}
}
给页面加上黑色的层bg-layer
<!-- 加上黑色的层 -->
<div class="bg-layer" ref="layer"></div>
<scroll :data="songs" class="list" ref="list" :probeType='probeType' :listenScroll='listenScroll' @scroll="scroll">
<div class="song-list-wrapper">
<song-list :songs="songs"></song-list>
</div>
</scroll>
bg-layer设置了样式position: relative;
接着我们watch scrollY的变化。动态改变 bg-layer的值
scrollY(newY){
console.log(newY)
this.$refs.layer.style['transform']=`translate3d(0,${newY}px,0)`
this.$refs.layer.style['-webkit-transform']=`translate3d(0,${newY}px,0)`
}
这样黑色的层可以跟着列表滚动了,但是,当滚动一定的距离之后,黑色的层并不能无限滚动了,因为它的高度只有屏幕高度的100%,超过屏幕高度的部分会露出来
解决:我们需要限制bg-layer的滚动范围,当滚动都标题以下的范围就不让它滚动了
const RESERVED_HEIGHT = 40 //滚动偏移距离,顶部标题的距离
最大的滚动的距离,就是背景图片的距离-头部标题的距离
在mouted中
this.imageHeight = this.$refs.bgImage.clientHeight
this.minTranslateY = -this.imageHeight + RESERVED_HEIGHT //最远滚动位置,不超过
watch: {
//监听scrollY的变化
scrollY(newY){
//让文字底部黑色的层跟着滚动
let translateY=Math.max(newY,this.minTranslateY)//获取到最大滚动量
this.$refs.layer.style['transform']=`translate3d(0,${translateY}px,0)`
this.$refs.layer.style['webkitTransform']=`transilate3d(0,${translateY}px,0)`
}
},
这样黑色的层可以跟着文字滚动而滚动了
但是当滚动到顶部的时候,我们希望白色的字不要盖着背景图片,我们应该怎么处理呢、
6-11 music-list 组件开发-顶部图片处理
档滚动到超过顶部40px的时候将图片的z-index变成大,将图片的高度变为40px
const RESERVED_HEIGHT=40
scrollY(newY){
//...省略
//对顶部样式进行处理
let zIndex=0;//对图片的zindex做处理
if(newY<this.minTranslateY){
zIndex=10;//让z-index变大
this.$refs.bgImage.style['paddingTop']='0%'
this.$refs.bgImage.style['height']=`${RESERVED_HEIGHT}px`
}else{
this.$refs.bgImage.style['paddingTop']='70%'
this.$refs.bgImage.style['height']=`0`
}
this.$refs.bgImage.style['z-index']=zIndex;
}
6-12 music-list 组件开发-图片放大处理
列表从初始位置向下滚动时,图片随着滚动实现放大
scrollY(newY){
//...省略
// //当往下拖的时候,让图片跟着等比放大
let scale=1;
let percent=Math.abs(newY/this.$refs.bgImage.clientHeight)//计算比例
if(newY>0){
scale=1+percent
zIndex = 10
}
this.$refs.bgImage.style['transform']=`scale(${scale})`
this.$refs.bgImage.style['webkitTransform']= `scale(${scale})`
this.$refs.bgImage.style['z-index']=zIndex
}
6-13 music-list 组件开发-高斯模糊效果实现
在bg-image里加上
<!-- iphone手机下高斯模糊效果 -->
<div class="filter" ref="filter"></div>
.filter
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background: rgba(7, 17, 27, 0.4)
高斯模糊下,必须要给当前的高斯的图层加上透明的背景,然后再back-filter:blur(20px)
在监听scrollY的时候
当往上推的时候,希望图片出现高斯模糊效果,给ref="filter"加上高斯模糊效果
backdrop-filter CSS 属性可以让你为一个元素后面区域添加图形效果(如模糊或颜色偏移)。 因为它适用于元素背后的所有元素,为了看到效果,必须使当前元素透明。
let blur = 0
if(newY<0){
blur = Math.min(20 * percent, 20)
}
this.$refs.filter.style['backdrop-filter']=`blur(${blur}px)`
this.$refs.filter.style['-webkit-backdrop-filter']=`blur(${blur}px)`
6-14 music-list 播放按钮
在图片部分bg-image里面加上播放按钮
<!-- 播放按钮 -->
<div class="play-wrapper">
<div class="play">
<i class="icon-play"></i>
<span class="text">随机播放全部</span>
</div>
</div>
这个播放按钮需要当有数据的时候才展示,所以我们需要加上
<div class="play-wrapper" v-show="songs.length">
但是当我们滚动到顶部的时候,播放按钮也跟着滚动上去了,当我们滚动到上方的时候,我们应该让播放按钮隐藏
if(newY < this.minTranslateY) {
this.$refs.playBtn.style.display = 'none'
}else{
this.$refs.playBtn.style.display = ''
}
优化:封装JS的prefixStyle
CSS中不用写prefix是因为vue-loader用到了autoprefix插件自动添加
jS中没有,需要自己封装:利用浏览器的能力检测特性
在dom.js中封装一个prefixStyle方法
src\common\js\dom.js
//封装一个prefixStyle的方法
//能力检测: 查看elementStyle支持哪些特性
let elementStyle= document.createElement('div').style;
//供应商: 遍历查找浏览器的前缀名称,返回对应的当前浏览器
let vendor=(()=>{
let transformNames ={
webkit: 'webkitTransform',
Moz: 'MozTransform',
O: 'OTransform',
ms: 'msTransform',
standard: 'transform'
}
for(let key in transformNames){
if(elementStyle[transformNames[key]]!=undefined){
return key
}
}
return false
})()
export function prefixStyle(style) {
if(vendor===false){
return false
}
if(vendor === 'standard'){
return style
}
return vendor+ style.charAt(0).toUpperCase() + style.substr(1)
}
在scrollY中将,将transform的部分替换掉
const transform = prefixStyle('transform')
this.$refs.layer.style[transform]=`translate3d(0,${translateY}px,0)`
this.$refs.bgImage.style[transform]=`scale(${scale})`
const backdrop = prefixStyle('backdrop-filter') this.$refs.filter.style[backdrop]=`blur(${blur}px)`
loading处理
因为songs的是异步获取的在获取到songs之前我们加上loading
<scroll :data="songs" class="list" ref="list" :probeType='probeType' :listenScroll='listenScroll' @scroll="scroll">
<div class="song-list-wrapper">
<song-list :songs="songs"></song-list>
</div>
<!-- loading部分 -->
<div class="loading-container" v-show="!songs.length">
<loading></loading>
</div>
</scroll>
.loading-container
position: absolute
width: 100%
top: 50%
transform: translateY(-50%)