基于Vue2.x全家桶的Web音乐App学习总结

Vue-music项目概述

这是一个基于vue2.x全家桶和真实线上数据接口开发的一个媲美原生的移动端音乐App,已完成页面包括有歌单推荐页,歌手列表页,歌手详情页,播放器页面,排行榜页面和搜索页面。

要点总结

Vue

  • <router-link>:通过to属性指定目标地址;配置tag属性生成别的标签,当目标路由成功激活时,将自动在tag生成的标签上自动设置router-link-active类名

  • <router-view>:用于构建单页应用时,渲染指定路由对应的组件,可当作是要匹配组件的容器

  • <keep-alive>:在组件切换过程中将状态保留在内存中,防止重复渲染DOM

  • <slot>:Vue的内容分发机制,即可以将父组件的内容分发到子组件的指定位置中,<slot>则作为承载分发内容的出口

JS

  • Promise:异步编程的一种解决方案,在项目中的运用:
export default function jsonp(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)
            }
        })
    })
}
  • Object.assign():用于对象的合并,将源对象的所有可枚举属性,复制到目标对象。第一个参数是目标对象,后面的参数都是源对象
const data = Object.assign({}, commonParams, {
    platform: 'h5',
    uin: 0,
    needNewCode: 1
})
  • setTimeout(fn, 20):JS线程执行完毕后一个Tick的时间约17ms内DOM就有可以渲染完毕,所以setTimeout(fn, 20)是非常稳妥的写法

CSS

  • Stylus:编写模块化的CSS
  • Flex:弹性布局,实现常见的移动设备的响应式布局

库的使用

  • better-scroll
    • 易错better-scroll只会处理容器的第一个子元素的滚动;且子元素的宽度(或高度)一定要大于容器的宽度(或高度)才可以滚动;当一个页面出现不同滚动需求时(例如歌单推荐页即有横向又有纵向的滚动),可进行嵌套使用,但必须用一个容器将两部分滚动包裹在一起
    • slider组件:需配置参数值snap:true,另外设置snapLoop:true表示无缝循环轮播,snapThreshold:0.3表示手指滑动大于这个阈值则滑动到下一页,snapSpeed:400表示轮播图切换的动画时间
    • 事件
      • scrollEnd表示滚动结束时触发
    • 函数
      • refresh():强制scroll重新计算,当better-scroll中的元素发生变化的时候调用此方法
      • getCurrentPage():获取滚动的当前页,返回对象结构为{x, y, pageX, pageY},x,y代表滚动横向和纵向的位置;pageX,pageY表示横向和纵向的页面索引。用法如:getCurrentPage().pageX
      • goToPage(x, y, time, easing):滚动到对应的页面,x表示横向页面索引,y表示纵向页面索引,time表示动画,easing表示缓动函数(可省略)
  • jsonp:获取轮播图数据时用到,传入的url需做字符串处理,data需进行encodeURIComponent()编码处理,再结合new Promise()异步获取数据

  • axios:基于promise的HTTP库,可以用在浏览器和node.js中,项目中用的最多的通过代理后端服务器获取数据的手段

// 歌单数据获取
before(app) {
    app.get('/api/getDiscList', function(req, res) {
        var url = "https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg"
        axios.get(url, {
            header: {
                referer: 'http://y.qq.com',
                host: 'c.y.qq.com'
            },
            params: req.query
        }).then((response) => {
            res.json(response.data)
        }).catch((e) => {
            console.log(e)
        })
    })
}

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

    const data = Object.assign()

    return axios.get(url, {
        params: data
    }).then((res) => {
        return Promise.resolve(res.data)
    })
}
  • vue-lazyload:懒加载图片,优化用户体验

轮播图

  • 设置轮播宽度Bscroll设置snapLoop时会自动克隆两个轮播图插在前后位置,为了保证轮播图无缝切换,需要增加两个宽度;同时加入窗口变化时不再增加宽度的限制
if (this.loop && !isResize) {
    width += 2*sliderWidth
}
  • 初始化dots:dots的数量由轮播图数量决定,因此需在设置克隆插入轮播之前进行

  • 初始化滚动:通过getCurrentPage()获取当前滑动页的索引赋值给currentPageIndex,与dots的index比较是否相等,以此添加active

  • 自动播放:自定义定时器结合goToPage()跳转到相应的页面

_play() {
    let pageIndex = this.currentPageIndex + 1
    if (this.loop) {
        pageIndex += 1
    }
    this.timer = setTimeout( ()=> {
        this.slider.goToPage(pageIndex, 0, 400)
    }, this.interval)
}
  • 清除定时器:每次手指滑动时都应清除定时器,避免索引值的获取发生错乱;组件销毁时都应清除定时器达到释放内存的目的

  • 监听窗口变化:为了保证窗口变化时依旧能够正常轮播,应监听resize事件来重新渲染轮播图并计算宽度

window.addEventListener('resize', ()=> {
    if (!this.slider) {
        return
    }
    this._setSliderWidth(true)
    this.slider.refresh()
})

歌手列表数据聚合

// 定义Singer类(相当于ES5的构造函数)创建属性,处理avatar字段
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`
    }
}

_normalizeSinger(list) {
    let map = {
        hot: {
            title: HOT_NAME,
            items: []
        }
    }
    // 获取需要用到的数据字段
    // 将前10条数据push到hot对象的items中
    list.forEach((item, index) => {
        if (index < HOT_SINGER_LEN) {
            map.hot.items.push(new Singer({
                id: item.Fsinger_mid,
                name: item.Fsinger_name
            }))
        }
        const key = item.Findex
        // 创建以字母为键值的对象
        if (!map[key]) {
            map[key] = {
                title: key,
                items: []
            }
        }
        // 如果存在该字母名的对象,则将数据push到该对象的items中
        map[key].items.push(new Singer({
            id: item.Fsinger_mid,
            name: item.Fsinger_name
        }))
    })
    // 为了得到有序列表,需要处理map
    let hot = []
    let ret = []
    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.sort((a, b) = {
        return a.title.charCodeAt(0) -b.title.charCodeAt(0)
    })
    // 最后拼接数组
    return hot.concat(ret)
}

左右列表的联动

  • 左滑右动:通过监听滚动组件派发过来的scrollY的值
scrollY(newY) {
    // this.listHeight为左侧各部分列表的clientHeight值组成的数组
    const listHieght = this.listHeight

    // 当滚动到顶部时,newY>0
    if (newY > 0) {
        this.currentIndex = 0
        return 
    }

    // 在中间部分滚动
    // 高度数组比列表数组多一个元素(height=0),因此要减去1
    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
        }
    }

    // 当滚动到底部时,且-newY大于最后一个元素的上限
    this.currentIndex = listHeight.length - 2
}
  • 右滑左动:此处的核心为Vue的手指触摸事件和动态绑定属性值
onShortcutTouchStart(e) {
    // 获取动态绑定的属性值,getData为自定义的获取属性值的函数
    let anchorIndex = getData(e.target, 'index')
    // 记录触摸屏幕的第一个手指以及pageY值
    let firstTouch = e.touches[0]
    this.touch.y1 = firstTouch.pageY
    // 记录触摸当前li的index
    this.touch.anchorIndex = anchorIndex
    this._scrollTo(anchorIndex)
}

onShortcutTouchMove(e) {
    // 记录滑动后的手指以及pageY的值
    let firstTouch = e.touches[0]
    this.touch.y2 = firstTouch.pageY
    // 滑动了多少个li = 滑动的距离/li的高度
    let delta = (this.touch.y2 - this.touch.y1) / ANCHOR_HEIGHT | 0
    // 计算滑动结束时当前li的index
    let anchorIndex = parseInt(this.touch.anchorIndex) + delta
    this._scorllTo(anchorIndex)
}

_scrollTo(index) {

    // 滚动边界值处理
    if (!index && index !== 0) {
        return
    }
    if (index < 0) {
        index = 0
    } else if (index > this.listHieght.length - 2) {
        index = this.listHeight.length -2
    }
    this.scrollY = -this.listHeight[index]
    // 左侧滑动到对应位置
    this.$refs.listview.scrollToElement(this.$refs.listGroup[index], 0)
}
  • 固定标题栏:计算列表clientHeight与滚动的scollY值的和(newVal),判断这个值是否大于0且小于固定栏的高度值,是的话则让固定栏发生(newVal-TITLE_HEIGHT)的translate变化
// 在scrollY中计算diff的变化
this.diff = height2 + newY

diff(newVal) {
    let fixedTop = (newVal > 0 && newVal < TITLE_HEIGHT) ? newVal - TITLE_HEIGHT : 0
    // diff是动态变化的,所以这里要提前计算fixedTop的值,减少transform的DOM操作,达到性能优化效果
    if (this.fixedTop == fixedTop) {
        return
    }
    this.fixedTop = fixedTop
    this.$refs.fixed.style.transform = `translate3d(0, ${fixedTop}px, 0)`
}

歌曲数据的获取和处理

  • vuex管理歌手数据 —— 通过歌手id获取相应的歌手歌曲数据 —— 遍历数据通过结构赋值获取每首歌曲的数据musicData —— 再处理该数据取得需要用到的字段

// 通过vuex的mapGetters方法从store中获取歌手数据

computed: {
    ..mapGetters([
        'singer'
    ])
}
methods: {
    _getDetail() {
        // 边界处理,刷新回退操作
        if (!this.singer.id) {
            this.$router.push('/singer')
            return
        }
        getSingerDetail(this.singer.id).then((res) => {
            if (res.code === ERR_OK) {
                this.songs = this._normalizeSongs(res.data.list)
            }
        })
    },
    // 数据标准化
    _normalizeSongs(list) {
        let ret = []
        list.forEach((item) => {
            // 对象的结构赋值,取得数据中的musicData对象
            let {musicData} = item
            if (musicData.songid && musicDta.albummid) {
                // 通过歌曲id获取歌曲数据
                getMusic(musicData.songmid).then(res => {
                    if (res.code === ERR_OK) {
                        // 获取歌曲音频链接需要用到的songVkey字段
                        const svkey = res.data.items
                        const songVkey = svkey[0].vkey
                        // 保留需要用到的一些字段
                        const newSong = createSong(musicData, songVkey)
                        ret.push(newSong)
                    }
                })
            }
        })
        return ret
    }
}
// song.js
export 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`,
        url: `http://dl.stream.qqmusic.qq.com/C400${musicData.songmid}.m4a?vkey=${songVkey}&guid=2908953575&uin=0&fromtag=66`
    })
}

// 这里作歌手名称的处理
function filterSinger(singer) {
    let ret = []
    if (!singer) {
        return ''
    }
    singer.forEach((s) => {
        ret.push(s.name)
    })
    return ret.join('/')
}

播放器状态管理(vuex的使用)

|—— components
|—— App.vue
|—— main.js
|—— store
    |—— index.js            // 将各个模块组装并导出store
    |—— state.js            // 初始化状态
    |—— mutation-types.js   // 将所有状态别名放在这里
    |—— mutations.js        // 定义监听状态的函数
    |—— actions.js          // 异步提交状态
    |—— getters.js          // 获取状态并映射到组件上
  • index.js
import Vue from 'vue'
import Vuex from 'vuex'
import * as actions from './actions'
import * as getters from './getters'
import state from './state'
import mutations from './mutations'
import createLogger from 'vuex/dist/logger'

Vue.use(Vuex)

const debug = process.env.NODE_ENV !== 'prodycution'

export default new Vuex.Store({
    actions,
    getters,
    state,
    mutations,
    strict: debug,
    plugins: debug ? [createLogger()] : []
})
  • state.js
import {playMode} from 'common/js/config'

const state = {
    singer: {},
    playing: false,
    fullScreen: false,
    playlist: [],
    sequenceList: [],
    mode: playMode.sequence,
    currentIndex: -1
}

export default state
  • mutation-types.js
export const SET_SINGER = 'SET_SINGER'

export const SET_PLAYING_STATE = 'SET_PLAYING_STATE'

...
  • mutations.js
import * as types from './mutation-types'

const mutations = {
    [types.SET_SINGER](state, singer) {
        state.singer = singer
    },
    [types.SET_PLAYING_STATE](state, flag) {
        state.playing = flag
    }
    ...
}
  • actions.js
import * as types from './mutation-types'
import {playMode} from 'common/js/config'
import {shuffle} from 'common/js/util'

function findIndex(list, song) {
    return list.findIndex((item) => {
        return item.id === song.id
    })
}

export const selectPlay = function({commit, state}, {list, index}) {
    commit(types.SET_SEQUENCE_LIST, list)
    if (state.mode === playMode.random) {
        // 打乱歌曲列表顺序
        let randomList = shuffle(list)
        commit(types.SET_PLAYLIST, randomList)
        // 找到顺序播放时的当前歌曲id与随机播放列表id相等时歌曲的
        index值并代替它
        index = findIndex(randomList, list[index])
    } else {
        commit(types.SET_PLAYLIST, list)
    }
    commit(types.SET_CURRENT_INDEX, index)
    commit(types.SET_FULL_SCREEN, true)
    commit(types.SET_PLAYING_STATE, true)
}

export const randomPlay = function({commit}, {list}) {
    commit(types.SET_PLAY_MODE, playMode.random)
    commit(types.SET_SEQUENCE_LIST, list)
    let randomList = shuffle(list)
    commit(types.SET_PLAYLIST, randomList)
    commit(types.SET_CURRENT_INDEX, 0)
    commit(types.SET_FULL_SCREEN, true)
    commit(types.SET_PLAYING_STATE, true)
}
  • getters.js
export const singer = state => state.singer

export const playing = state => state.playing
...
export const currentSong = (state) = > {
    return state.playlist[state.currenIndex] || {}
}
  • 点击某一歌曲后song-list组件将派发事件和数据给父组件,父组件再根据派发过来的事件定义selectItem函数,此时可以通过vuex的辅助函数mapActions提交状态,并且在selectItem函数中将歌曲的数据传入actions的状态函数(该函数可以异步批量提交状态)中,这些状态可以在组件中通过vuex的辅助函数mapGetter来获得(由于状态是动态的,所以函数需定义在computed属性中)。
  • 在mutations中同样可以提交状态,在组件中可通过vuex辅助函数mapMutations来分别改变状态并映射到vuex中提交,注意这样做不是异步操作
selectItem(item, index) {
    this.selectPlay({
        list: this.songs,
        index
    })
}

...mapActions([
    'selectPlay',
    'randomPlay'
])
computed: {
    // 获取变化的状态
    ...mapGetters([
        'fullScreen',
        'playlist',
        ...
    ])
}
methods: {
    ...mapMutations({
        setFullScreen: 'SET_FULL_SCREEN',
        setPlayingState: 'SET_PLAYING_STATE',
        ...
    })
    // 此时就可以在需要的时候以`this.setFullScreen(false)`的方式来改变状态
}

唱片飞出飞入动画

  • 计算位移变化和缩放值(此处应注意位移的变化是取中心点到中心点的x,y距离变化。)
_getPosAndScale() {
    // 缩小版唱片的宽度
    const targetWidth = 40
    // 缩小版唱片中心到左边距离
    const paddingLeft = 40
    // 缩小版唱片中心到底部的距离
    const paddingBottom = 30
    // 放大版唱片中心到顶部的距离
    const paddingTop = 80
    const width = window.innerWidth * 0.8
    const scale = targetWidth / width
    cosnt x = -(window.innerWidth / 2 - paddingLeft)
    // 注意translate3d变化的坐标系:纵轴y向下为正值
    const y = window.innerHeight - paddingTop - width / 2 - paddingBottom
    return {
        x,
        y,
        scale
    }
}
  • 结合vue的动画钩子函数和create-keyframe-animation动画库定义不同时机的动画行为(在enterleave中必须使用done进行回调,否则它们会被同步调用,过渡会立即完成)

歌曲播放操作

  • 快速切换歌曲引发的错误:该错误是由于快速切换时歌曲播放地址没有及时获取而引发的,此处可以利用H5的apicanplay来解决,在audio监听canplay事件表示歌曲已经可以播放

  • 时间戳格式处理

format(interval) {
    interval = interval | 0
    const minute = interval / 60
    const second = this._pad(interval % 60)
    return `${minute}:${second}`
}

_pad(num, n = 2) {
    let len = num.toString().length
    while (len < n) {
        // 当传入的数字字符小于2时,在前面补0
        num = '0' + num
        len++
    }
    return num
}
  • 圆形进度条:利用SVG实现,由两个圆组成,一个是背景圆,一个是已播放的圆形进度
<div class="progress-circle">
    <svg :width="radius" :height="radius" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
        <circle class="progress-background" r="50" cx="50" cy="50" fill="transparent"/>
        <circle class="progress-bar" r="50" cx="50" cy="50" fill="transparent" :stroke-dasharray="dashArray" :stroke-dashoffset="dashOffset"/>
    </svg>
    <slot></slot>
</div>
  • 切换播放模式:默认为顺序播放,用的是原歌曲列表;随机播放的原理是定义了一个洗牌函数将原歌曲列表打乱来实现
function getRandomInt(min, max) {
    return Math.floor(Math.random() * (max - min + 1) + min)
}

export function shuffle(arr) {
    // 不改变原数组
    let _arr = arr.slice()
    for (var i = 0; i < _arr.length; i++) {
        let j = getRandomInt(0, i)
        let t = _arr[i]
        _arr[i] = _arr[j]
        _arr[j] = t
    }
    return _arr
}

bug: 切换模式时歌曲发生改变,原因是由于currentSong是由playlist和currentIndex计算而来,切换模式后列表发生变化,当前播放歌曲也会随之发生变化

resetCurrentIndex(list) {
    // 获取当前播放歌曲在列表中的索引并重新设置
    let index = list.findIndex((item) => {
        return item.id === this.currenSong.id
    })
    this.setCurrentIndex(index)
}

watch: {
    currentSong(newSong, oldSong) {
        if (newSong.id === oldSong.id) {
            return
        }
    }
}

歌词相关

  • 歌词数据解析:第三方库js-base64(解码base64字符串)和lyric-parser(解析歌词,处理播放状态)
app.get('/api/lyric', function(req, res) {
    ...
    axios.get(url, ...).then((response) => {
        var ret = response.data
        // 获取到的是jsonp字符串,需做正则处理
        if (typeof ret === 'string') {
            var reg = /^\w+\(({[^()]})\)$/
            var matches = ret.match(reg)
            if (matches) {
                ret = JSON.parse(matches[1])
            }
        }
        res.json(ret)
    })
})

getLyric() {
    this.currentSong.getLyric().then((lyric) => {
        this.currentLyric = new Lyric(lyric, this.handleLyric)
        if (this.playing) {
            // 调用lyric-parser的api播放歌词
            this.currentLyric.play()
        }
    }).catch(() => {
        this.currentLyric = null
        this.playingLyric = ''
        this.currentLineNum = 0
    })
},
// 让歌词的滚动保持居中状态
headleLyric({lineNum, txt}) {
    this.currentLineNum = lineNum
    if (lineNum > 5) {
        let lineEl = this.$refs.lyricLine[lineNum - 5]
        this.$refs.lyriclist.scrollToElement(lineEl, 1000)
    } else {
        this.$refs.lyricList.scrollTo(0, 0, 1000)
    }
    // 展示当前播放的歌词
    this.playingLyric = txt
}
  • 唱片和歌词滑动切换
// 手指滑动y的值大于x的值,则判断为歌词的滚动
if (Math.abs(deltaY) > Math.abs(deltaX)) {
    return
}

// 需定义一个开关来判断当前是否是滑动状态
middleTouchStart(e) {
    this.touch.moved = false
}
middleTouchMove(e) {
    if (!this.touch.moved) {
        this.touch.moved = true
    }
}
middleTouchEnd(e) {
    // 判断为非滑动状态,不执行后面的操作
    if (!this.touch.moved) {
        return
    }
}

// lyric-parser播放歌词的原理是定义了一个定时器,当歌曲变化时,需调用它的stop()方法清除掉定时器,防止出现歌词播放的闪动的Bug
watch: {
    currentSong {
        ...
        if (this.currentLyric) {
            this.currentLyric.stop()
        }
        // 解决微信从后台切换到前台无法正常播放的问题
        setTimeout(() => {
            this.$refs.audio.play()
            this.getLyric()
        }, 1000)
    }
}

迷你播放器自适应

  • 混入(mixins): 分发Vue组件中可复用功能的方式
// mixin.js
import {mapGetters} from 'vuex'

export const playlistMixin = {
    computed: {
        ...mapGetters([
            'playlist'
        ])
    },
    mouted() {
        this.handlePlaylist(this.playlist)
    },
    activated() {
        this.handlePlaylist(this.playlist)
    }
    watch: {
        playlist(newVal) {
            this.handlePlaylist(newVal)
        }
    },
    methods: {
        // 在组件中定义覆盖该函数,不然则报错
        handlePlaylist() {
            throw new Error('...')
        }
    }
}

// music-list.vue
import {playlistMixin} from 'common/js/mixin'
methods: {
    handlePlaylist(playlist) {
        const bottom = playlist.length > 0 ? '60px' : ''
        this.$refs.list.$el.style.bottom = bottom
        this.$refs.list.refresh()
    }
}

结语

文章记录了作者在学习该项目过程中遇到的一些要点的总结,由于Vue版本的变化和线上数据接口的改变导致踩了不少的坑,以此记录下来方便自己日后查看,同时也希望能帮助到在学习该项目遇到困难的小伙伴

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值