【音乐App】—— Vue2.0开发移动端音乐WebApp项目爬坑(二)

前言:上一篇总结了项目概况、项目准备、页面骨架搭建、推荐页面开发,这一篇重点梳理歌手页面开发、歌手详情页。项目github地址:https://github.com/66Web/ljq_vue_music,欢迎Star。


一、歌手页面开发--singer

       歌手页面布局与设计

  • 需求:联系人列表形式、左右联动的滚动列表、顶部标题随列表滚动而改变
歌手列表 快速入口列表


     
  歌手数据接口抓取

  • api目录下创建 singer.js  :  同recommend.js,依赖jsonp和一些公共参数
    import jsonp from '@/common/js/jsonp'
    import {commonParams, options} from '@/api/config'
    
    export function getSingerList() {
           const url = 'https://c.y.qq.com/v8/fcg-bin/v8.fcg'
    
           const data = Object.assign({}, commonParams, {
                   channel: 'singer',
                   page: 'list',
                   key: 'all_all_all',
                   pagesize: 100,
                   pagenum: 1,
                   hostUin: 0,
                   needNewCode: 0,
                   platform: 'yqq',
                   g_tk: 1664029744, //会变,以实时数据为准
          })
    
          return jsonp(url, data, options)
    }
  • singer.vue :  数据结构与需求不同,需要两层数组结构
  1. 第一层数组:将所有歌手以姓名开头字母Findex——ABCD顺序排列
  2. 第二层数组:在每一个字母歌手数组中,按顺序再将歌手进行排列
  3. 热门数据:简单将前十条数据取出来

       歌手数据处理和Singer类的封装

  • 定义_normalizeSinger()方法,规范化singer数据,接收参数list,即数据singers
    const HOT_NAME = '热门'
    const HOT_SINGER_LEN = 10
    
    _normalizeSinger(list){
         let map = {
               hot: {
                    title: HOT_NAME,
                    items: []
               }
        }
    
        list.forEach((item, index) => {
              if(index < HOT_SINGER_LEN) {
                 map.hot.items.push({
                         id: item.Fsinger_mid,
                         name: item.Fsinger_name,          
    avatar:`https:
    //y.gtimg.cn/music/photo_new/T001R300x300M000${item.Fsinger_mid}.jpg?max_age=2592000` }) } //根据Findex作聚类 const key = item.Findex if(!map[key]) { map[key] = { title: key, items: [] } } map[key].items.push({ id: item.Fsinger_mid, name: item.Fsinger_name, avatar:`https://y.gtimg.cn/music/photo_new/T001R300x300M000${item.Fsinger_mid}.jpg?max_age=2592000` }) }) // console.log(map) }
  • 问题:avatar需要的数据是通过id计算得到的,且重复多次,重复代码太多
  • common->js目录下创建singer.js: 用面向对象的方法,构造一个Singer类
    //JavaScript constructor 属性返回对创建此对象的数组函数的引用
    //语法:object.constructor
    
    export default class Singer {
         constructor({id, name}) { 
              this.id = id
              this.name = name
              this.avatar =`https://y.gtimg.cn/music/photo_new/T001R300x300M000${id}.jpg?
              max_age=2592000`
         }
    }
  • 引入Singer: 
    import Singer from '@/common/js/singer'

    使用new Singer({ id: item.Fsinger_mid, name: item.Fsinger_name})代替前面的大段代码,减少avatar这样重复的大段代码

  • 为了得到有序列表,需要处理map
    let hot = [] //title是热门的歌手
    let ret = [] //title是A-Z的歌手
    for(let key in map){
         let val = map[key]
         if(val.title.match(/[a-zA-Z]/)) {
            ret.push(val)
         }else if(val.title === HOT_NAME) {
            hot.push(val)
         }
    }
  • 为ret数组进行A-Z排序
    ret.sort((a, b) => {
         return a.title.charCodeAt(0) - b.title.charCodeAt(0)
    })
  • 最后将ret数组拼接在hot数组后返回
    return hot.concat(ret)

       listview.vue类通讯录组件开发

【滚动列表实现】

  • base->listview目录下:创建listview.vue
  • 引用scroll组件,在<scroll>根标签中传入data数据,当data发生变化时,强制BScroll重新计算
  • props参数:
    props:{
        data: {
            type: Array,
            default: []
        }
    }
  • DOM布局:
    <scroll class="listview" :data="data">
         <ul>
            <li v-for="(group, index) in data" :key="index" class="list-group">
                 <h2 class="list-group-title">{{group.title}}</h2> 
                 <ul>
                      <li v-for="(item, index) in group.items" :key="index" class="list-group-item">
                            <img :src="item.avatar" class="avatar">
                           <span class="name">{{item.name}}</span>
                      </li>
                 </ul> 
            </li>
         </ul>
    </scroll>
  • singer.vue
  1. 引入并注册listview组件,传入参数data,绑定singers数据:
    <div class="singer">
          <listview :data="singers"></listview>
    </div>
  2. 修改_getSingerList()中的singers为重置数据结构后的singers
    this.singers = this._normalizeSinger(res.data.list)
  3. 优化:使用图片懒加载技术处理<img>,:src替换为v-lazy
    <img v-lazy="item.avatar" class="avatar">

【右侧快速入口实现】

  • listview.vue中通过computed定义shortcutList(),获得title的集合数组
    computed: {
        shortcutList() { //得到title的集合数组,‘热门’取1个字
              return this.data.map((group) => {
                     return group.title.substr(0, 1) 
              })
        }
    } 
  • 在<scroll>内层,与歌手列表同级编写布局DOM:
    <div class="list-shortcut">
        <ul>
           <li v-for="(item, index) in shortcutList" :key="index" class="item">{{item}}</li>
       </ul>
    </div>
  • CSS样式:
    .list-shortcut
       position: absolute //绝对定位到右侧 
       right: 0
       top: 50%

【点击定位实现】

  • 关键: 监听touchstart事件
  • 为<li class="item">扩展一个属性变量
    :data-index="index"
  • dom.js中封装一个getData函数,得到属性data-val的值
    export function getData(el, name, val){
           const prefix = 'data-'
           name = prefix + name
           if(val){
              return el.setAttribute(name, val)
           }else{
              return el.getAttribute(name)
           }
    }
  • scroll.vue 中扩展两个方法:
    scrollTo() {
         // 滚动到指定的位置;这里使用apply 将传入的参数,传入到this.scrollTo()
         this.scroll && this.scroll.scrollTo.apply(this.scroll, arguments)
    },
    scrollToElement() {
         // 滚动到指定的目标元素
         this.scroll && this.scroll.scrollToElement.apply(this.scroll, arguments)
    }
  • listview.vue
  1. 引入getData方法:
    import {getData} from '@/common/js/dom'
  2. <scroll>根标签中添加引用: ref="listview"
  3. <li class="list-group">中添加引用: ref="listGroup"
  4. 给快速入口列表 监听touchstart事件
    <div class="list-shortcut" @touchstart="onShortcutTouchStart">
    onShortcutTouchStart(e) {
           let anchorIndex = getData(e.target, 'index')//获取data-index的值 index
          _scrollTo(anchorIndex)
    }
    _scrollTo(index){
          this.$refs.listview.scrollToElement(this.$refs.listGroup[index], 0)//列表滚动定位
    }

【滑动联动实现】

  • 关键:监听touchmove事件
  • 需求:滑动右侧快速入口列表,左侧歌手列表随之滚动
  • 坑:快速入口列表下方就是歌手列表,同样可以滚动,需要避免滑动快速入口列表时,也使歌手列表受到影响
  • 解决:阻止事件冒泡,阻止浏览器的延伸滚动 @touchmove.stop.prevent
  • 思路:
  1. 在touchstart事件触发时,记录touch处的y值y1和anchorIndex,存储到this.touch对象中
  2. 在touchmove事件触发时,同样记录touch处的y值y2,计算(y2-y1)/每个列表项的像素高 | 0 向下取整,
  3. 得到两次touch位置列表项的差值delta,使touchmove时的anchorIndex = touch对象的anchorIndex + delta
  4. 调用封装好的_scrollTo方法,传入anchorIndex,使歌手列表滚动到对应位置
  • 实现:
    const ANCHOR_HEIGHT = 18 //通过样式设置计算得到
    
    created() {
            this.touch = {} //在created中定义touch对象,而不在data或computed中定义,是因为touch对象不用进行监测
    }
    
    methods: {
           onShortcutTouchStart(e) {
                 let anchorIndex = getData(e.target, 'index')//获取data-index的值 index 得到的是字符串
                 let firstTouch = e.touches[0]
                 this.touch.y1 = firstTouch.pageY
                 this.touch.anchorIndex = anchorIndex
                 this._scrollTo(anchorIndex)
           },
           onShortcutTouchMove(e) {
                 let firstTouch = e.touches[0]
                 this.touch.y2 = firstTouch.pageY
                 let delta = (this.touch.y2 - this.touch.y1) / ANCHOR_HEIGHT | 0 //获取列表项差值,| 0 向下取整 = Math.floor()
                 let anchorIndex = parseInt(this.touch.anchorIndex) + delta
                 this._scrollTo(anchorIndex)
           },
          _scrollTo(index){
                //第二个参数表示:要不要滚动动画缓动时间; 0 瞬间滚动
                this.$refs.listview.scrollToElement(this.$refs.listGroup[index], 0)//列表滚动定位
          }
    }
  • 坑:在touchstart时通过getData获得的anchorIndex是字符串,如果直接和delta相加得到的还是字符串,这样滚动的位置就不对
  • 解决:let anchorIndex = parseInt(this.touch.anchorIndex) + delta

【联动效果实现】

  • 需求:滚动歌手列表时,快速入口列表对应的title项高亮显示
  • 思路:
  1. 监听scroll事件,拿到pos对象,定义一个变量scrollY,「实时记录」歌手列表Y轴滚动的位置pos.y,
  2. 监测数据data,每次发生改变时,都重新计算每个group元素的高度height,存在listHeight数组中
  3. 监测scrollY,保留计算高度后的listHeight数组,遍历得到每个group元素的「高度区间」上限height1和下限height2,
  4. 对比scrollY和每个group元素的高度区间height2-height1,确定当前滚动位置「currentIndex」,映射到DOM中
  • 实现:
  1. scroll.vue中添加一个props参数,决定要不要监听BScroll的滚动事件scroll
    listenScroll: {
         type: Boolean,
         default: false
    }
  2. _initScroll方法中:
    if(this.listenScroll) {
        let me = this //箭头函数中代理this
        this.scroll.on('scroll', (pos) => { //监听scroll事件
             me.$emit('scroll', pos) //派发一个scroll事件,传递pos位置对象:有x和y属性
       })
    }
  3. listview.vue中:created()中添加两个属性值
    this.listenScroll = true
    this.listHeight = []
  4. <scroll>根标签中传值   :listenScroll="listenScroll"    监听scroll事件   @scroll="scroll"
  5. 常见习惯:私有方法如_scrollTo()一般放在下面,公共方法或绑定事件的方法如scroll()放在上面
  6. data中观测两个数据:   
    scrollY: -1 //实时滚动的Y轴位置
    currentIndex: 0 //当前显示的第几个title项
  7. methods中添加scroll方法,传入接收的pos对象:
    scroll(pos) {
        this.scrollY = pos.y //实时获取BScroll滚动的Y轴距离
    }
  8. 添加_calculateHeight私有方法,计算每个group的高度height
    _calculateHeight() {
        this.listHight = [] //每次重新计算每个group高度时,恢复初始值
        const list = this.$refs.listGroup
        let height = 0 //初始位置的height为0
        this.listHeight.push(height)
        for(let i=0; i<list.length; i++){
             let item = list[i] //得到每一个group的元素
             height += item.clientHeight //DOM元素可以用clientHeight获取元素高度
             this.listHeight.push(height) //得到每一个元素对应的height
        }
    } 
  9. watch:{}监测data的变化,使用setTimeout延时调用_calculateHeight,重新计算每个group的高度;监测scrollY的变化,遍历listHeight数组得到每个group元素的高度上限height1和下限height2;对比scrollY,确定当前滚动位置对应的title项currentIndex
    watch: {
        data() {
           setTimeout(() => { //使用setTimeout延时:因为数据的变化和DOM的变化还是间隔一些时间的
               this._calculateHeight()
           }, 20)
        },
        scrollY(newY) {
           listHeight = this.listHeight
           //当滚动到顶部,newY>0
           if(newY > 0) {
              this.currentIndex = 0
              return
           }
           //在中间部分滚动,遍历到最后一个元素,保证一定有下限,listHeight中的height比元素多一个
           for(let i = 0; i < listHeight.length-1; i++){
                let height1 = listHeight[i]
                let height2 = listHeight[i+1]
                if(-newY >= height1 && -newY < height2) { 
                     this.currentIndex = i
                    // console.log(this.currentIndex)
                     return
                }
          }
         //当滚动到底部,且-newY大于最后一个元素的上限
         //currentIndex 比listHeight中的height多一个, 比元素多2个
         this.currentIndex = listHeight.length - 2
        }
    }
  10. 快速入口列表的title项<li class="item">  动态绑定current class,将currentIndex映射到DOM中:
    :class="{'current': currentIndex === index}"
  11. CSS样式:
    &.current
       color: $color-theme
  • 坑:scroll组件中设置了probeType的默认值为1:滚动的时候会派发scroll事件,会截流,只能监听缓慢的滚动,监听不到swipe快速滚动
  • 解决:需要在<scroll>中传递 :probeType="3"  除了实时派发scroll事件,在swipe的情况下仍然能实时派发scroll事件
  • 坑:点击快速入口列表时,歌手列表会快速滚动,但点击的列表项没有高亮显示
  • 原因:高亮没有依赖点击的点,而是通过scrollY计算得到的,但目前_scrollTo中只是使列表滚动,没有派发scroll事件,改变scrollY
  • 解决:在_scrollTo中,手动改变scrollY的值,为当前元素的上限height
    this.scrollY = -this.listHeight[index]
  • 坑:touch事件都是加在父元素<div class="list-shortcut">上的,点击头尾--“热”“Z”之前和之后的边缘区块,会发现也是可以点击的,但它没有对应显示的歌手列表,这个点击是没有意义的
  • 解决:console.log(index)得知边缘区块的index都是null,在_scrollTo中设置如果是边缘区块,不执行任何操作,直接返回
    if(!index && index !== 0){
       return
    }
  • 坑:console.log(index)时发现滑动时滑到头部以上时是一个负值,滑到尾部以下时是一个很大的值
  • 原因:touchmove一直在执行,这个事件一直没有结束,它的Y值就会变大,这样算出来的delta加上之前的touch.anchorIndex得到的值就可能会超
  • 解决:在_scrollTo中处理index的边界情况
    if(index < 0){
       index = 0
    }else if(index > this.listHeight.length - 2){
       index = this.listHeight.length - 2
    }
  • 补充:scrollToElement(this.$refs.listGroup[index], 0)中的index没有出现问题,是因为BScroll中已经做了边界的处理

      滚动固定标题实现--fixed title

  • 需求:当滚动到哪个歌手列表,顶部就显示当前歌手列表的title, 且固定不动,直到滚动到下一个歌手列表,再显示下一个title
  • 布局DOM: 当fixedTitle不为" "的时候显示
    <div class="list-fixed" v-show="fixedTitle">
          <div class="fixed-title">{{fixedTitle}}</div>
    </div>
  • computed中计算fixedTitle:
    fixedTitle() { 
        if(this.scrollY > 0){ //判断边界,‘热门’往上拉时,不显示
            return ''
        }
        //初始时,data默认为空,此时this.data[this.currentIndex]为undefinded
        return this.data[this.currentIndex] ? this.data[this.currentIndex].title : ''
    }
  • CSS样式:
    .list-fixed
       position: absolute //绝对定位到顶部
       top: 0
       left: 0
       width: 100%
  • 坑:只有在歌手列表的title从底部穿过fixed title后,fixed title的内容才会发生改变,两个title没有过渡效果,体验不好
  • 解决:当歌手列表的title上边界滚动到fixed title下边界时,给fixed title添加一个上移效果,使两个title过渡顺滑
  1. 定义一个数据:
    diff: -1 //fixed title的偏移位置
  2. 在scrollY(newY)中实时得到diff: 
    this.diff = height2 + newY
    //得到fixed title上边界距顶部的偏移距离 = 歌手列表title height下限 + newY(上拉为负值)
  3. 给<div class="list-fixed">添加引用: 
    ref="fixedTitle"
  4. 通过样式设置得到并定义fixed title的div高度: 
    const TITLE_HEIGHT = 30
  5. 在watch:{}中观测diff : 判断diff范围,数据改变DOM
    diff(newVal) {
         let fixedTop = (newVal>0 && newVal<TITLE_HEIGHT) ? newVal - TITLE_HEIGHT : 0
         if(this.fixedTop === fixedTop){
            return 
         }
         this.fixedTop = fixedTop
         this.$refs.fixedTitle.style.transform = `translate3d(0, ${fixedTop}px, 0)`
    }
  • 优化:listview歌手组件也是异步请求的数据,所以也加一个loading,引入loading组件注册
  1. 布局DOM: 
    <div class="loading-container" v-show="!data.length">
         <loading></loading> 
    </div>
  2. CSS样式:
    .loading-container
        position: absolute
        width: 100%
        top: 50%
        transform: translateY(-50%)
二、歌手详情页开发--singer-detail
歌曲列表 歌曲播放

       子路由配置以及转场动画实现

  • components->singer-detail目录下:创建singer-detai.vue
  • route->index.js 中引入并配置Singer子路由SingerDetail:
    import SingerDetail from '@/components/singer-detail/singer-detail' 
    
    {
        path: '/singer',
        component: Singer,
        children: [
                {
                     path: ':id',
                     component: SingerDetail
                }
        ]
    }
  • singer.vue中:添加路由容器
    <router-view></router-view>
  • listview.vue 中:
  1. 给<li class="list-group-item">添加点击事件:
    @click="selectItem(item)"
  2. methods中定义selectItem方法,将item作为事件参数,派发出去:
    selectItem(item){
        this.$emit('select', item)
    }
  • singer.vue中的<listview>监听select事件,触发selectSinger,执行业务逻辑:
    @select="selectSinger"
    selectSinger(singer){
        this.$router.push({ //动态添加路由地址
                path: `/singer/${singer.id}`
        })
    }
  • 注意:子路由并不是一个页面,只是一个层,使用z-index将之前的层全部盖住
  • CSS样式:
    .singer-detail
        position: fixed
        z-index: 100
        top: 0
        bottom: 0
        left: 0
        right: 0
        background: $color-background
  • 转场动画 : 从右向左滑动
  1.  给singer-detail添加transition:
    <transition name="slide">
          <div class="singer-detail"></div>
    </transition>
  2. CSS样式:
    .slide-enter-active, .slide-leave-active
        transition: all 0.3s
    .slide-enter, .slide-leave-to
        transform: translate3d(100%, 0, 0)   //100% 完全移动到屏幕右侧 动画开始后向左滑入

       Vuex

  • 问题:子路由SingerDetail需要从父路由页面Singer获取很多数据,都用参数获取内容太多
  • 解决: 使用Vuex实现路由之间参数数据的获取
  • Vuex GitBook地址 : https://vuex.vuejs.org/zh/
  • 什么是Vuex : Vuex 是一个专为 Vue.js 应用程序开发的【状态管理模式】。
  1. 它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化
  2. 适用情况:构建一个中大型单页应用,考虑如何更好地在组件外部管理状态时,使用Vuex

       Vuex初始化

  • 安装Vuex: 
    npm install vuex --save
  • src->store目录下新建:
  1. index.js:入口文件
  2. state.js : 管理所有状态 state
  3. mutations.js : 管理所有mutation -- 更改 Vuex 的 store 中状态state的唯一方法
  4. mutation-types.js:管理所有mutation 事件类型(type)-- 字符串常量
  5. actions.js:处理异步操作和修改、以及对mutation的封装
  6. getters.js: 对获取的state 做一些映射
  • Vuex 中的 mutation 非常类似于事件:
  1. 每个 mutation 都有一个字符串的 事件类型 (type) 和 一个 回调函数 (handler)
  2. 这个回调函数就是我们实际进行状态更改的地方,并且它会接受 state 作为第一个参数

       歌手数据配置

  • state.js 中定义singer数据:
    const state = {
         singer: {}
    }
    
    export default state
  • mutation-types.js 中定义设置singer数据的字符串常量:
    export const SET_SINGER = 'SET_SINGER'
  • mutations.js 中对state进行修改:引入mutation-types作关联
    import * as types from './mutation-types'
    
    const mutations = {
       [types.SET_SINGER](state, singer){
             state.singer = singer
        }
    }
    
    export default mutations
  • getter.js 中对state进行包装和输出,获得state.singer:
    export const singer = state => state.singer
    //state => state.singer 箭头函数的简写,state是一个function,return返回一个state.singer
  • 同步修改,只需要通过mutation修改,不需要action进行异步操作
  • 初始化 index.js 入口文件:
    import Vue from 'vue'
    import Vuex from 'vuex'
    
    // * as 是es6的新import语法
    import * as actions from './actions'
    import * as getters from './getters'
    import state from './state'
    import mutations from './mutations'
    
    //Vuex 内置日志插件用于一般的调试
    import createLogger from 'vuex/dist/logger'
    
    Vue.use(Vuex)
    
    //只在开发环境时启动严格模式
    const debug = process.env.NODE_ENV !== 'production'
    
    //工厂方法输出一个单例Vuex.Store模式
    export default new Vuex.Store({
              actions,
              getters,
              state,
              mutations,
              strict: debug,
              plugins: debug ? [createLogger()] : []
    })
在严格模式下,无论何时发生了状态变更且不是由 mutation 函数引起的,将会抛出错误。
这能保证所有的状态变更都能被调试工具跟踪到。

**不要在发布环境下启用严格模式!**
严格模式会深度监测状态树来检测不合规的状态变更——请确保在发布环境下关闭严格模式,以避免性能损失。
  • main.js 中引入Store , 并在new Vue实例中注入:
    import store from './store'
  • singer.vue 中:
  1. 引用vuex提供的【写入数据】语法糖
    import {mapMutations} from 'vuex'
  2. 在methods属性中调用mapMutations作对象映射:把mutation的修改映射为一个方法名setSinger
    ...mapMutations({
       setSinger: 'SET_SINGER'  //对应mutation-types中定义的常量
    })
  3. 在selectSinger(singer)方法中将singer传入this.setSinger():
    selectSinger(singer){
        this.$router.push({
              path: `/singer/${singer.id}`
        })
        this.setSinger(singer) //实现对mutation的提交,向state【写入数据】
    }
  • singer-detail.vue
  1. 引用vuex提供的【取出数据】语法糖:
     import {mapGetters} from 'vuex'
  2. 在computed中通过mapGetters 挂载singer属性
    computed: {
       ...mapGetters([
           'singer' //拿到getters.js中的singer
       ])
    } 
  3. 在created()中打印出 this.singer,查看vuex中数据的传递是否成功
    created() {
        console.log(this.singer)
    }

       歌手详情数据抓取

  • api->singer.js中:
    export function gerSingerDetail(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)
    }
  • singer-detail.vue中:
  1. 引入getSingerDetail方法和ERR_OK常量:
    import {getSingerDetail} from '@/api/singer'
    import {ERR_OK} from '@/api/config'
  2. 在methods中定义_getDetail()私有方法,通过调用getSingerDetail()返回promise对象,获取singer数据
    _getDetail() {
        getSingerDetail(this.singer.id).then((res) => {
              if(res.code === ERR_OK){
                 console.log(res.data.list)
              }
        })
    }
  • 坑:只有从singer页面选择歌手跳转到对应singer-detail路由中,才能得到singer数据;在singer-detail路由页面刷新时不会得到数据,这样也是没有意义的
  • 解决: 在_getDetail()中添加判断,当获取不到singer.id时,调用this.$route.push,使页面回退到singer路由
    if(!this.singer.id){
         this.$router.push('/singer')
         return
    }

       歌手详情数据处理和Song类的封装

  • api目录下创建song.js : 使用JavaScript constructor 属性构造一个Song类
    export default 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
          }
    }
  • 这样就可通过遍历res.data.list数据,得到经过Song类封装的对象

    设计为类而不是对象的好处

  • 可以把代码集中的一个地方维护
  • 类的扩展器比对象的扩展器强很多,而且它是一种面向对象的编程方式
  • 歌手详情数据处理 : singer-detail.vue
  1.  data中维护一个数据  songs : [ ]
  2. 在song.js中处理musicData数据抽象出工厂方法,返回song实例:
    //抽象出一个工厂方法:传入musicData对象参数,实例化一个Song
    export function createSong(musicData){
          return new Song({
                id: musicData.songid,
                mid: musicData.songmid,
                singer: filterSinger(musicData.singer),
                name: musicData.songname,
                album: musicData.albumname,
                duration: musicData.interval, //歌曲时长s
                image: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${musicData.albummid}.jpg?max_age=2592000`,
                //url: `http://ws.stream.qqmusic.qq.com/C100${musicData.songmid}.m4a?fromtag=0&guid=126548448`
                //注意guid以实时数据为主
                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 ''
         }
         singer.forEach((s) => {
             ret.push(s.name)
         })
         return ret.join('/')
    }

    vue.js最新版获取QQ音乐播放源

  • 问题:课程中的旧版播放源地址已经获取不到了,因此不能再用过去的方法拼接url了
  • 分析正确的播放源地址:http://dl.stream.qqmusic.qq.com/C400001apXAh2mHRub.m4a?guid=6319873028&vkey=6DAE080C291DECFDC9A3C532879658439F66EBA6C588813C8A1C12917030FA050C2352C15343CCCAC8FDE731383C2489026145978797D513&uin=0&fromtag=66
  • 对比参数可知,正确的拼接url为:http://dl.stream.qqmusic.qq.com/C400${musicData.songmid}.m4a?vkey=${songVkey}&guid=6319873028&uin=0&fromtag=66
  • 注意:guid是会变化的,以自己抓取的实际值为准,需改动的有两处:①song.js中拼接的url ②singer.js中参数guid

抓取详解地址:【蚂蚁农场的博客

  • 获取正确url需要反向代理的方式请求vkey : webpack.dev.config.js中配置
    app.get('/api/music', function(req, res){
           var url="https://c.y.qq.com/base/fcgi-bin/fcg_music_express_mobile3.fcg"
    
           axios.get(url, {
               headers: { //通过node请求QQ接口,发送http请求时,修改referer和host
                     referer: 'https://y.qq.com/',
                     host: 'c.y.qq.com'
               },
               params: req.query //把前端传过来的params,全部给QQ的url
            }).then((response) => { 
               res.json(response.data)
            }).catch((e) => {
              console.log(e)
           })
    })
  • 注意:webpack配置完之后必须重新启动
  • api->singer.js 中定义getMusic方法获取vkey:
    export function getMusic(songmid) {
         const url = '/api/music'
         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 axios.get(url, {
                 params: data
        }).then((res) => {
                 return Promise.resolve(res.data)
        })
    } 
  • methods中定义方法 _normallizeSongs(list) 按需求重新处理数据
    _normallizeSongs(list){
          let ret = [] //返回值
          list.forEach((item) => {
               let {musicData} = item //得到music对象
               // console.log(musicData)
               //createSong必传两个参数
               if(musicData.songid && musicData.albummid){ 
                   // console.log(getMusic(musicData.songmid))
                   getMusic(musicData.songmid).then((res) => {  //异步获取vkey
                   // console.log(res)
                           if(res.code === ERR_OK){
                              // console.log(res.data)
                              const svkey = res.data.items
                              const songVkey = svkey[0].vkey
                              const newSong = createSong(musicData, songVkey)
                              ret.push(newSong)
                           }
                  })
              }
        })
       // console.log(ret)
       return ret
    }
  • _getDetail()中:将处理好的数据赋给songs
    this.songs = this._normallizeSongs(res.data.list)

       music-list组件开发

  • 在components->music-lict目录下:创建music-list.vue
  1. 布局DOM:
    <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>
  2. 需要从父组件接收的props参数:
    props: {
        bgImage: {
             type: String,
             default: ''
        },
        songs: {
             type: Array,
             default: []
        },
        title: {
            type: String,
            default: ''
       }
    }
  • singer-detail.vue 中应用music-list组件:
  1. 将<div class="singer-detail">及其样式删掉,替换为<music-list>:
    <music-list :songs="songs" :title="title" :bg-image="bgImage"></music-list>
  2. title和bgImage数据通过computed计算得到:
    title() {
        return this.singer.name
    },
    bgImage() {
        return this.singer.avatar
    }
  • music-list.vue中将获得的数据填入DOM,bgStyle样式属性通过computed计算得到:
    bgStyle() {
        return `background-image: url(${this.bgImage})`
    }
  • 歌曲列表抽象为song-list组件:base->song-list目录下:创建song-list.vue
  1. 布局DOM:
    <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>
  2. CSS样式:
    .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
    View Code
  3. 需要从父组件接收props参数songs
    props: {
       songs: {
           type: Array,
           default: []
       }
    }
  4. 将得到的数据填入DOM,其中desc通过methods定义getDesc(song)得到:
    methods: {
        getDesc(song){
              return `${song.singer} 。${song.album}`
        }
    }
  • music-list.vue中应用song-list组件
  1. 引用并注册scroll和song-list组件:
    import Scroll from '@/base/scroll/scroll'
    import SongList from '@/base/song-list/song-list'
  2. 布局DOM:
    <scroll :data="songs" class="list" ref="list">
         <div class="song-list-wrapper">
               <song-list :songs="songs"></song-list>
         </div>
    </scroll>
  3. CSS样式:
    .list
         position: fixed
         top: 0
         bottom: 0
         width: 100%
         overflow: hidden
         background: $color-background
         .song-list-wrapper
               padding: 20px 30px
    View Code
  • 坑:<scroll class="list">的top值不能写死,因为不同浏览器不同视口中bgImage的高度是不同的
  • 解决:给bgImage和list都添加ref引用,在mounted中得到当前加载好的bgImage的高度,动态赋值给top
    <div class="bg-image" :style="bgStyle" ref="bgImage">
    <scroll :data="songs" class="list" ref="list">
    mounted() {
         this.$refs.list.$el.style.top = `${this.$refs.bgImage.clientHeight}px`
    }

       歌手详情页交互效果

  • 需求:允许列表可以往上滚动
  1. music-list.vue 中去掉list的样式 :overflow: hidden
  2. 需要一个在列表文字下面的层,随着列表的滚动实现往上推<scroll>前添加布局DOM:
    <div class="bg-layer" ref="layer"></div>
  3. CSS样式:

    .bg-layer
        position: relative
        height: 100%  //屏幕高度的100%
        background: $color-background 
  4. create()中添加属性,监听滚动:

    created() {
         this.probeType = 3
         this.listenScroll = true
    }
  5. 将属性传入<scroll>中,并监听scroll事件,实时监听scroll位置
    <scroll :data="songs"   class="list"   ref="list" 
            :probe-type="probeType"  :listen-scroll="listenScroll"  @scroll="scroll">
  6. 同歌手列表: data中维护一个scrollY数据
    data() {
        return{
             scrollY: 0
        }
    }
  7. 在methods中定义scroll(),实时给scrollY赋值:
    scroll(pos) {
         this.scrollY = pos.y
    }
  8. watch:{}中监测scrollY,为layer添加引用,设置layer的transform:
    watch: {
       scrollY(newY) {
            this.$refs.layer.style['transform'] = `translate3d(0, ${newY}px, 0)`
            this.$refs.layer.style['webkitTransform'] = `translate3d(0, ${newY}px, 0)`
       }
    }
  • 坑:bg-layer的高度只有屏幕高度的100%,并不能无限滚动,当超出屏幕高度后下面的内容会露出来
  • 解决:限制bg-layer的滚动位置,最远只能滚动到标题以下,再往上滚动列表时,bg-layer固定不再滚动
  • 实现:
  1. mounted中记录imageHeight,计算得到最小滚动Y:
    this.imageHeight = this.$refs.bgImage.clientHeight
    this.minTranslateY = -this.imageHeight + RESERVED_HEIGHT //最远滚动位置,不超过minTranslateY
  2. 定义顶部以下偏移常量:
    const RESERVED_HEIGHT = 40 //滚动偏移距离
  3. scrollY(newY)中得到最大滚动量,修改transform替换newY
    watch: {
       scrollY(newY) {
           let translateY = Math.max(this.minTranslateY, newY) //最大滚动量
           this.$refs.layer.style['transform'] = `translate3d(0, ${translateY}px, 0)`
           this.$refs.layer.style['webkitTransform'] = `translate3d(0, ${translateY}px, 0)`
      }
    } 
  • 坑:当滚动到顶部时,列表文字会遮住图片,需要图片遮住文字
  • 解决:scrollY(newY)中添加判断,当滚到顶部时,改变图片的z-index和高度,否则,重置回初始位置
    //滚动到顶部时,图片遮住文字
    let zIndex = 0
    if(newY < this.minTranslateY) {
         zIndex = 10
         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.zIndex = zIndex
  • 需求:列表从初始位置向下滚动时,图片随着滚动实现缩小放大
    let scale = 1
    const percent = Math.abs(newY / this.imageHeight)
    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.zIndex = zIndex

    图片从顶部放大缩小,关键样式:transform-origin: top

  • 需求:列表滚动到顶部时,(iphone手机中)图片有一个高斯模糊的变化
    <div class="bg-image" :style="bgStyle" ref="bgImage">
         <div class="filter" ref="filter"></div>
    </div>
    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['webkitBackdrop-filter'] = `blur(${blur}px)`

       优化 -- 封装JS的prefixStyle

  • CSS中不用写prefix:vue-loader用到了autoprefix插件自动添加
  • JS中没有,需要自己封装: 利用浏览器的能力检测特性
  • dom.js中扩展一个方法:
    //能力检测: 查看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)
    }
  • music-list.vue中引用prefixStyle,并定义常量代替原始属性,删掉手动添加prefix的语句
    import {prefixStyle} from '@/common/js/dom'
    
    const transform = prefixStyle('transform')
    const backdrop = prefixStyle('backdrop-filter')
    
    this.$refs.layer.style[transform] = `translate3d(0, ${translateY}px, 0)`
    this.$refs.bgImage.style[transform] = `scale(${scale})`
    this.$refs.filter.style[backdrop] = `blur(${blur}px)`

       歌手详情页剩余功能

  • 返回按钮:@click="back"
    back(){
         this.$router.back() //回退到上一级路由
    }
  • 随机播放全部按钮:
    <div class="play-wrapper">
           <div class="play">
                <i class="icon-play"></i>
                <span class="text">随机播放全部</span>
           </div>
    </div>
  • 坑:只有当列表数据都加载完成后,播放按钮才会显示
  • 解决:设置按钮显示时机 v-show="songs.length>0"
  • 坑:当列表滚动到顶部时,播放按钮因为绝对定位还在,体验不好,应该消失
  • 解决:给按钮添加引用ref="playBtn",在scrollY(newY)中判断滚动到顶部时修改display为none,正常显示时重置为空
    if(newY < this.minTranslateY) {
       this.$refs.playBtn.style.display = 'none'
    }else{
       this.$refs.playBtn.style.display = ''
    }
  • 优化:异步获取的歌曲数据显示之前,添加loading
    <div class="loading-container" v-show="!songs.length">   
         <loading></loading>
    </div>
    .loading-container
        position: absolute
        width: 100%
        top: 50%
        transform: translateY(-50%)
    View Code

版权声明:本文原创,未经本人允许不得转载

猜你喜欢

转载自www.cnblogs.com/ljq66/p/10090690.html