使用Vue做的一个音乐webapp

432 阅读9分钟

前言

在慕客网上买了黄轶老师的Vue2.0开发企业级移动端音乐WebApp,在这里做个学习感受和经验分享。

代码地址:github.com/shlroland/E…

预览地址:http://47.103.123.83/music/

概览

技术栈

Vue:用于构建用户界面的 MVVM 框架。它的核心是响应的数据绑定和组系统件

vue-router:为单页面应用提供的路由系统,项目上线前使用了 Lazy Loading Routes 技术来实现异步加载优化性能

vuex:Vue 集中状态管理,在多个组件共享某些状态时非常便捷

vue-lazyload:第三方图片懒加载库,优化页面加载速度

better-scroll:iscroll 的优化版,使移动端滑动体验更加流畅

stylus:css 预编译处理器

ES6:ECMAScript 新一代语法,模块化、解构赋值、Promise、Class 等方法非常好用

Node.js:利用 Express 起一个本地测试服务器

jsonp:服务端通讯。抓取 QQ音乐(移动端)数据

axios:服务端通讯。结合 Node.js 代理后端请求,抓取 QQ音乐(PC端)数据

目录结构

├── api //封装的请求api
├── assets
│   ├── fonts //字体图标
│   ├── image 
│   ├── js // 公用JS方法
│   └── stylus // 公用 stylus 样式与方法
├── base // 通用组件
│   ├── confirm 
│   ├── listview
│   ├── loading
│   ├── no-result
│   ├── progress-bar
│   ├── progress-circle
│   ├── scroll
│   ├── search-box
│   ├── search-list
│   ├── slider
│   ├── song-list
│   ├── switches
│   └── top-tip
├── components // APP组件
│   ├── add-song // 添加歌曲页面
│   ├── disc // 专辑页面
│   ├── m-header 
│   ├── music-list // 歌曲列表
│   ├── player // 播放器内核页面
│   ├── playlist // 播放歌曲列表
│   ├── rank // 排名列表
│   ├── recommend // 推荐主页面
│   ├── search // 搜索页面
│   ├── singer // 歌手列表
│   ├── singer-detail // 歌手详情页面
│   ├── suggest // 推荐页面
│   ├── tab // 导航栏
│   ├── top-list 
│   └── user-center // 个人中心
└── store

通用组件学习经验

slide组件

使用better-scroll辅助实现slide轮播组件。

better-scroll初始化相关api:

     this.slider = new BScroll(this.$refs.slider, {
        scrollX: true, // 横向的轮播图
        scrollY: false,
        momentum: false, // /关闭动量动画,能提升效能
        snap: {
          loop: this.loop,// 是否无缝轮播
          threshold: 0.3,// 拉取当前图片的多少,作为播放下一张图片一句
          speed: 400 // 播放间隔
        }
      })

达成better-scroll滚动,需要满足一下条件:

  • 必须包含两个大的div,外层和内层div
  • 外层div设置可视的大小(宽或者高)-有限制宽或高
  • 内层div,包裹整个可以滚动的部分
  • 内层div高度一定大于外层div的宽或高,才能滚动 所以初始化完成后,须要设置完成内部图片的总宽度:
    _setSliderWidth (isResize) {
      this.children = this.$refs.sliderGroup.children
      let width = 0
      let sliderWidth = this.$refs.slider.clientWidth // slider 可见宽度
      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 // 无缝循环的原理在于首尾隔添加一个slider,所以总宽度是多两个slider宽度的
      }
      this.$refs.sliderGroup.style.width = width + 'px'
    },

值得注意的是需要控制显示的时机,是由于slider.vue中设置html的宽度等是在mounted(即已完成模板渲染后执行),而recommend.vue 当还未获取数据的时候,mounted 已经执行,为了确保元素的存在再渲染,所以添加判断

v-if="recommneds.length" // 即在获取到图片后,再进行渲染。

这样基本的轮播图就构建完成。 继续完善细节部分。

  1. dots
<span class="dot" :class="{active: currentPageIndex === index }" v-for="(item,index) in dots":key="index"></span>
// 只有到达了对应的图片,dot样式才发生变化
   _initDots () {
      this.dots = new Array(this.children.length)// 先进行初始化
    },
    this.slider.on('scrollEnd', this._onScrollEnd)
    _onScrollEnd () {
        let pageIndex = this.slider.getCurrentPage().pageX // 获取当前图片的index
        this.currentPageIndex = pageIndex
      }
    },
  1. autoplay 设置自动播放功能,在mounted中设置方法。
 if (this.autoPlay) {
    this._play()
}
    _play () {
      clearTimeout(this.timer) //清除上一个闹钟
      this.timer = setTimeout(() => {
        this.slider.next() // 新版BS组件已经提供跳转下一页api
      }, this.interval)
    }

后面在播放一次后,需要再触发scrollEnd事件后再次设置this._play()。同时如果手动拖拽,也需要设置touchEnd事件后的this._play()

  1. resize 窗口大小改变后,会导致slider宽度与窗口宽度不一致此时需要刷新slider重新设置。在mounted添加回调
    window.addEventListener('resize', () => {
      if (!this.slider) {
        return
      }
      this._setSliderWidth(true) // 因为设置宽度时会自动拓展两个slider宽度,重新计算尺寸是不需要在进行此操作,加入一个判断
      this.slider.refresh()
    })
  1. 优化 加入两个生命周期钩子
 destroyed() {
  clearTimeout(this.timer) // 生命周期destroyed销毁清除定时器,有利于内存释放
 },
 deactivated () {
    this.slider.disable()
    clearTimeout(this.timer)// 配合keep-alive,将DOM缓存到内存中,切换不会重新请求,并且没有一闪而过的画面,重置闹钟。
  },

Tips:

  • slider组件的父元素必须给他一个100%的宽度且定义overflow:hidden,否则整个页面会被撑开,整个页面都能横向滚动
  • 添加class时使用了两个工具函数
export function hasClass(el, clssName) {
 let reg = new RegExp('(^|\\s)' + className + '(\\s|$)')    //判断className 的开头或结尾无字符或者是空格
 return reg.test(el.className)    
}

export function addClass(el, className) {
 if (hasClass(el, className)) {    //有这个类名就返回
   return
 }

 let newClass = el.className.split(' ')  //split() 将原本的className字符串按空格分割成数组
 newClass.push(className)        //将新的className 添加到上面的数组中
 el.className = newClass.join(' ')        //join() 以空格为连接符链接成class字符串
}

scroll组件

better-scroll封装一个通用的scroll组件。

  <div ref="wrapper">
   <slot></slot>
 </div>
 // 使用的vue的slot插槽,但要注意的是插槽内容如果是异步加载的,需要进行刷新。
props: {
     probeType: {
       type: Number, 
       default: 1
       // 作用:有时候我们需要知道滚动的位置。当 probeType 为 1 的时候,会非实时(屏幕滑动超过一定时间后)派发scroll 事件;当 probeType 为 2 的时候,会在屏幕滑动的过程中实时的派发 scroll 事件;当 probeType 为 3 的时候,不仅在屏幕滑动的过程中,而且在 momentum 滚动动画运行过程中实时派发 scroll 事件。
     },
     click: {
       type: Boolean,
       default: true
       // 设置scroll可点击
     },
     data: {
       type: Array,
       default: null
       // 设置数据,即数据发生变动时,scroll组件刷新。
     },
     listenScroll: {
       type: Boolean,
       default: false
     },
     pullup: {
       type: Boolean,
       default: false
     },
     beforeScroll: {
       type: Boolean,
       default: false
     },
     refreshDelay: {
       type: Number,
       default: 20
     },
     direction: {
       type: String,
       default: DIRECTION_V
     },
     pullup: {
       type: Boolean,
       default: false
     },
     beforeScroll: {
       type: Boolean,
       default: false
     }
   },
    watch: {
     data () {
       setTimeout(() => {
         this.refresh()
       }, this.refreshDelay)
     }
   }
   // 监听data数据变化
     enable () {
        this.scroll && this.scroll.enable()
      },
      disable () {
        this.scroll && this.scroll.disable()
      },
      refresh () {
        this.scroll && this.scroll.refresh()
      },
      scrollTo () {
        this.scroll && this.scroll.scrollTo.apply(this.scroll, arguments) //滚动到制定位置
      },
      scrollToElement () {
        this.scroll && this.scroll.scrollToElement.apply(this.scroll, arguments)//滚动到制定元素
      }

最后初始化better-scroll

      _initScroll () {
       if (!this.$refs.wrapper) {
         return
       }
       this.scroll = new BScroll(this.$refs.wrapper, {
         probeType: this.probeType, //设置两个属性
         click: this.click
       })
       if (this.listenScroll) {
         let me = this
         this.scroll.on('scroll', (pos) => {
           me.$emit('scroll', pos) //触发scroll方法,向组件传递pos位置信息
         })
       }
       if (this.pullup) {
         this.scroll.on('scrollEnd', () => {
           if (this.scroll.y <= this.scroll.maxScrollY + 50) {
             this.$emit('scrollToend')
           }
         })
       }
       if (this.beforeScroll) {
         this.scroll.on('beforeScrollStart', () => {
           this.$emit('beforeScroll')
         })
       }

list-view组件

一个类似于通讯录的组件 功能:

  • 右侧快速入口滑动或点击,左侧可以快速定位到相关区域。左侧滚动,右侧高亮显示。
  • 吸顶效果 首先完成基础的滚动,就是使用scroll组件,渲染数据即可。 然后做右侧的list-shortcut。
top: 50%
transform: translateY(-50%)
//  设置绝对定位到右侧

传入数据列表渲染即可得到静态效果。 下面做滑动效果: 绑定touchstarttouchmove两个事件

@touchstart.stop.prevent="onShortcutTouchStart"
@touchmove.stop.prevent="onShortcutTouchMove"
// 加.stop .prevent修饰符阻止冒泡和默认事件

在onShortcutTouchStart默认会传入事件event,获取其中data-index属性,这个属性的值即为元素的Index。之后触发betterscroll的scrollToElement函数即可。

export function getData(el, name, val) {
  const prefix = 'data-'
  name = prefix + name
  if (val) {
    return el.setAttribute(name, val)
  } else {
    return el.getAttribute(name)
  }
}
//  工具函数

为了实现在右侧提示的滚动页面相应滚动,需要记住滑动的开始和结束坐标,以及开始坐标所在的列表索引,根据开始索引加上移动的索引数即可得到滑动后的索引数,再次触发scrollToElement函数即可。 移动索引数可以用滑动结束y轴坐标减去开始y轴坐标除以每一个索引高度即可。

onShortcutTouchStart (e) {
        let anchorIndex = getData(e.target, '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 // 计算得移动索引数
        let anchorIndex = parseInt(this.touch.anchorIndex) + delta 
        this._scrollTo(anchorIndex)
      },

如果要实现左侧滚动,右侧高亮,那么必须记住页面滚动的位置,算出滚动到哪个区间,再得到这个区间对应右端哪个索引,使其高亮。要监听页面滚动到哪,需要在scroll组件中添加listenScroll事件。

if (this.listenScroll) {
        let me = this
        this.scroll.on('scroll', (pos) => {
            //监听到后向上派发scroll事件
            me.$emit('scroll', pos)
          })
      }

在listview中添加@scroll接收派发的scroll方法,触发listview组件中的scroll方法

<scroll class="listview" :data="data"
  ref="listview"
  :listenScroll="listenScroll"
  //@scroll为接收派发的scroll方法,
  @scroll="scroll"
  :probeType="probeType">

//scroll方法
scroll(pos) {
      this.scrollY = pos.y
    },

本地的scroll方法得到scroll组件中传入的y坐标,监听y坐标的值的变化进行相应操作。

      _calculateHeight () {
        this.listHeight = []
        const list = this.$refs.listGroup
        let height = 0
        this.listHeight.push(height)
        for (let i = 0; i < list.length; i++) {
          height += list[i].clientHeight
          this.listHeight.push(height)
        }
      }
      // 计算每个scroll区间的累计高度
      
           scrollY (newY) { // 监听scrollY 达到 联动效果
        const listHeight = this.listHeight
        // 当滚动到顶部时
        if (newY > 0) {
          this.currentIndex = 0
          return
        }
        // 中间部分移动
        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
            this.diff = height2 + newY
            return
          }
        }
        // 当滚动到底部时,且-new Y大于最后元素的最大高度
        this.currentIndex = listHeight.length - 2
      },

下面完成吸顶固定的效果

   <div class="list-fixed" ref="fixed" v-show="fixedTitle">
      <h1 class="fixed-title">{{fixedTitle}}</h1>
    </div>
----------------------------------------------------------------------------------
      fixedTitle () {
        if (this.scrollY > 0) {
          return ''
        }
        return this.data[this.currentIndex] ? this.data[this.currentIndex].tilte : ''
      }
以上完成基本的样式。

但此时,又一个问题,就是顶上去的动画效果未完成,需要监控区块与顶部的差:

this.diff = height2 + newY

      diff (newVal) {
        let fixedTop = (newVal > 0 && newVal < TITLE_HEIGHT) ? newVal - TITLE_HEIGHT : 0 // 判断此时的距离
        if (this.fixedTop === fixedTop) {
          return
        } // 未顶到顶上时,不做任何改动。
        this.fixedTop = fixedTop
        this.$refs.fixed.style.transform = `translate3d(0,${fixedTop}px,0)`
      }

各个页面的学习总结

推荐页面

推荐页面主要在于slider组件和scroll组件的组合,其他无特殊难点。

歌手列表页面

歌手列表页面主要在于listview组件的运用。

歌手详情页面

歌手详情页面,最主要的是music-list组件的开发应用。

    .bg-image
      position: relative
      width: 100%
      height: 0
      padding-top: 70%
      transform-origin: top
      // 用这样把背景图片位置占住

添加一个bg-layer层,并监听滚动的距离,达到歌曲列表向上背景消失的效果。

 <scroll :data="songs"
            class="list"
            ref="list"
            :listen-scroll="listenScroll" // 监听scroll事件
            :probe-type="probeType" // 设置probetype为3,实时触发事件
            @scroll="scroll">
      <div class="song-list-wrapper">
        <song-list :rank="rank" @select="selectItem" :songs="songs"></song-list>
      </div>
      <div v-show="!songs.length" class="loading-container">
        <loading></loading>
      </div>
    </scroll>
      scrollY (newY) {
        let translateY = Math.max(this.minTranslateY, newY) //layer 向上偏移的高度,未高于背景图片则使用newY,高于背景图片则最高为minTranslateY
        let zIndex = 0
        let scale = 1
        let blur = 0
        const percent = Math.abs(newY / this.imageHeight) // 下拉,背景图片可以按比例放大,这里求出newY和背景图片高度比值来算比例
        if (newY > 0) {
          scale = 1 + percent// 设置放大倍数
          zIndex = 10 // 改变z-index值,保证图片放大不会遮住
        } else {
          blur = Math.min(20, percent * 20)// 向上则设置模糊效果
        }
        this.$refs.layer.style[transform] = `translate3d(0,${translateY}px,0)` // 修改样式 发生偏移
        this.$refs.filter.style[backdrop] = `blur(${blur}px)`
        if (newY < this.minTranslateY) {
          zIndex = 10
          this.$refs.bgImage.style.paddingTop = 0
          this.$refs.bgImage.style.height = `${RESERVED_HEIGHT}px`
          this.$refs.playBtn.style.display = 'none'
        } else {
          this.$refs.bgImage.style.paddingTop = '70%'
          this.$refs.bgImage.style.height = 0
          this.$refs.playBtn.style.display = ''
        }
        // 此处判断在于滚动条向上滚动时,图片可以遮住多余的滚动字幕。当newY < this.minTranslateY,修改zindex值,取消padding-top高度,不再保持比例,保留标题高度,用来完成效果。反之则恢复。
        this.$refs.bgImage.style[transform] = `scale(${scale})`
        this.$refs.bgImage.style.zIndex = zIndex
      }

播放器组件

两个播放器的切换存在一个动画效果,先从动画效果讲起。

    <transition name="normal"
                @enter="enter"
                @after-enter="afterEnter"
                @leave="leave"
                @after-leave="afterLeave">
                // 利用vue的transition组件提供的事件钩子 完成动画
      enter (el, done) {
        const { x, y, scale } = this._getPosScale() // 获取动画参数
        let animation = {
          0: {
            transform: `translate3d(${x}px,${y}px,0) scale(${scale})`
          },
          60: {
            transform: `translate3d(0,0,0) scale(1.1)`
          },
          100: {
            transform: `translate3d(0,0,0) scale(1)`
          } // 动画效果
        }
        animations.registerAnimation({ // 使用create-animation-keyframe 注册动画
          name: 'move',
          animation,
          presets: {
            duration: 400,
            easing: 'linear'
          }
        })
        animations.runAnimation(this.$refs.cdWrapper, 'move', done) // 运行动画,加载的dom,注册的动画名,执行完成后的回调
      },
      afterEnter () {
        animations.unregisterAnimation('move') // 注销动画
        this.$refs.cdWrapper.style.animation = ''
      },
      leave (el, done) {
        this.$refs.cdWrapper.style.transition = 'all 0.4s' // 添加css 过渡效果
        const { x, y, scale } = this._getPosScale() // 获取目标位置
        this.$refs.cdWrapper.style[transform] = `translate3d(${x}px,${y}px,0) scale(${scale})` 
        const timer = setTimeout(done, 400)
        this.$refs.cdWrapper.addEventListener('transitioned', () => { // 监听到transitionend事件,触发done
          clearTimeout(timer)
          done()
        })
      },
      afterLeave () {
        this.$refs.cdWrapper.style.transition = '' // 卸载动画
        this.$refs.cdWrapper.style[transform] = ''
      },
      _getPosScale () {
        const targetWidth = 40 // 小圆的宽度
        const paddingLeft = 40 // 小圆圆心的左偏移
        const paddingBottom = 30 // 小圆圆心的底部偏移
        const paddingTop = 80 // 大圆圆心到顶部的宽度
        const width = window.innerWidth * 0.8 // 大圆的宽度
        const scale = targetWidth / width // 放大倍数
        const x = -(window.innerWidth / 2 - paddingLeft) // x轴 偏移量
        const y = window.innerHeight - paddingTop - width / 2 - paddingBottom // y轴 偏移量
        return {
          x,
          y,
          scale
        }

动画效果搞定,来实现核心的播放歌曲的功能

    <audio ref="audio" @canplay="ready" @error="error" @timeupdate="updateTime"
           @ended="end"></audio>
           // 核心就是html5的audio实现音频播放功能,由vuex定义全局的播放状态

控制播放状态

    togglePlaying () { 
        if (!this.songReady) {
          return
        }
        this.setPlayingState(!this.playing) // 由修改vuex中的状态
        if (this.currentLyric) {
          this.currentLyric.togglePlay() // 控制歌曲播放状态的同时也要控制歌词的滚动状态
        }
      },
    cdCls () {
        return this.playing ? 'play' : '' // cd背景图片不断旋转效果直接动态切换class实现
      },
    syncWrapperTransform (wrapper, inner) { // 这里需要记录切换class时,cd的位置以保证后面的同步
        if (!this.$refs[wrapper]) {
          return
        }
        let imageWrapper = this.$refs[wrapper]
        let image = this.$refs[inner]
        let wTransform = getComputedStyle(imageWrapper)[transform]
        let iTransform = getComputedStyle(image)[transform]
        imageWrapper.style[transform] = wTransform === 'none' ? iTransform : iTransform.concat(' ', wTransform)
      },
      playing (newPlaying) {
        const audio = this.$refs.audio
        this.$nextTick(() => {
          newPlaying ? audio.play() : audio.pause() // nextTick来保重不会报错
        })
        if (!newPlaying) { // 同步cd旋转的位置
          this.syncWrapperTransform('imageWrapper', 'image')
        } else {
          this.syncWrapperTransform('miniWrapper', 'miniImage')
        }
      }

切换歌曲,在vuex中添加currentIndex来控制当前歌曲在歌单中的位置

      prev () {
        if (!this.songReady) {
          return // 只有audio已经准备完毕,触发canplay钩子,则可以切换
        }
        if (this.playlist.length === 1) {
          this.loop() // 循环播放
        } else {
          let index = this.currentIndex - 1 // 前一首需要-1
          if (index === -1) {
            index = this.playlist.length - 1
          }
          this.setCurrentIndex(index) // vuex状态设置
          if (!this.playing) {
            this.togglePlaying() // 若暂停状态在播放
          }
        }
      },
      next () {
        if (!this.songReady) {
          return
        }
        if (this.playlist.length === 1) {
          this.loop()
        } else {
          let index = this.currentIndex + 1
          if (index === this.playlist.length) {
            index = 0
          }
          this.setCurrentIndex(index)
          if (!this.playing) {
            this.togglePlaying()
          }
        }
      },

进度条组件的实现

利用audiotimeupdateapi实现获取播放进度

      setProgressOffset (percent) {
        if (percent >= 0 && !this.touch.initiated) {
          const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth // 减去小球宽度
          const offsetWidth = percent * barWidth // 获取百分比宽度
          this._offset(offsetWidth)// 设置进度条样式变化
        }
      },
      _offset (offsetWidth) {
        this.$refs.progress.style.width = `${offsetWidth}px`
        this.$refs.progressBtn.style[transform] = `translate3d(${offsetWidth}px,0,0)`
      },
          watch: { // 通过watch监控percent变化来进行改变
      percent (newVal) {
        this.setProgressOffset(newVal)
      }
    }

实现拖动进度条控制播放进度

 progressTouchStart (e) {
        this.touch.initiated = true // 初始化状态
        this.touch.startX = e.touches[0].pageX // 记录小圆点的初始位置
        this.touch.left = this.$refs.progress.clientWidth // 记录小圆点的做偏移位置
      },
      progressTouchMove (e) {
        if (!this.touch.initiated) {
          return 
        }
        const deltaX = e.touches[0].pageX - this.touch.startX // 计算小圆点移动差值
        const offsetWidth = Math.min(this.$refs.progressBar.clientWidth - progressBtnWidth, Math.max(0, this.touch.left + deltaX))// 获取小于进度条长度的总偏移量
        this._offset(offsetWidth)// 设置偏移样式
        this.$emit('percentChanging', this._getPercent())
      },
      progressTouchEnd () {
        this.touch.initiated = false
        this._triggerPercent() // 将百分比返回给父组件
      },
         _triggerPercent () {
        this.$emit('percentChange', this._getPercent())
      },
      _getPercent () {
        const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
        return this.$refs.progress.clientWidth / barWidth
      },

实现点击切换进度

      progressClick (e) {
        const rect = this.$refs.progressBar.getBoundingClientRect()
        const offsetWidth = e.pageX - rect.left
        this._offset(offsetWidth)
        this._triggerPercent()
      },

播放模式的切换 播放分随机播放,列表循环,单曲循环

export function shuffle (arr) {
  let _arr = arr.slice()
  for (let i = 0; i < _arr.length; i++) {
    let j = getRandomInt(0, i)
    let t = _arr[i]
    _arr[i] = _arr[j]
    _arr[j] = t
  }
  return _arr
}
 function getRandomInt (min, max) {
   return Math.floor(Math.random() * (max - min + 1) + min)
 }
 // 随机播放需要用的的洗牌函数
      const mode = (this.mode + 1) % 3
      this.setPlayMode(mode)
      let list = null
      if (mode === playMode.random) {
        list = shuffle(this.sequenceList) // 重新打乱顺序播放,但currentSong不能改变
      } else {
        list = this.sequenceList
      }
      this.resetCurrentIndex(list) // 重置currentIndex
      this.setPlaylist(list)
      --------------------------------------------
      resetCurrentIndex (list) {
      let index = list.findIndex((item) => {
        return item.id === this.currentSong.id
      })
      this.setCurrentIndex(index)
    },