詞條html
目前市面上尚未一個Vue 2.0 的高級教學,都是一些基礎的入門課程,你很難找到一個基於Vue.js的複雜應用的教學, 可是,咱們爲你準備了這門獨一無二的Vue 2.0 高級實戰課程vue
src簡單的介紹webpack
入口文件ios
import 'babel-polyfill' //寫在第一位 import Vue from 'vue' import App from './App' import router from './router' import fastclick from 'fastclick' import VueLazyload from 'vue-lazyload' import store from './store' import 'common/stylus/index.styl' /* eslint-disable no-unused-vars */ // import vConsole from 'vconsole' fastclick.attach(document.body) Vue.use(VueLazyload, { loading: require('common/image/default.png') //傳一個默認參數 }) /* eslint-disable no-new */ new Vue({ el: '#app', router, store, render: h => h(App) })
babel-polyfill是es6底層鋪墊即支持一些API,好比promise
Tab頁面git
<template> <div class="tab"> <router-link tag="div" class="tab-item" to="/recommend"> <span class="tab-link">推薦</span> </router-link> <router-link tag="div" class="tab-item" to="/singer"> <span class="tab-link">歌手</span> </router-link> <router-link tag="div" class="tab-item" to="/rank"> <span class="tab-link">排行 </span> </router-link> <router-link tag="div" class="tab-item" to="/search"> <span class="tab-link">搜索</span> </router-link> </div> </template> <script type="text/ecmascript-6"> export default {} </script>
`router-link默認是a標籤,咱們經過tag指定爲div
.router-link-active這個class是組件自帶的`
APP.vuees6
<template> <div id="app" @touchmove.prevent> <m-header></m-header> <tab></tab> <keep-alive> <router-view></router-view> </keep-alive> <player></player> </div> </template> <script type="text/ecmascript-6"> import MHeader from 'components/m-header/m-header' import Player from 'components/player/player' import Tab from 'components/tab/tab' export default { components: { MHeader, Tab, Player } } </script>
仔細的看一下引入的組件Tab以及一個佈局方式
jsonp的封裝github
import originJsonp from 'jsonp' //jsonp 結合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) } }) }) } export function param(data) { let url = '' for (var k in data) { let value = data[k] !== undefined ? data[k] : '' url += '&' + k + '=' + encodeURIComponent(value) //視頻代碼 //url += `&${k}=${encodeURIComponent(value)}` es6語法 } return url ? url.substring(1) : '' }
重點關注一下URL的拼接能夠用到項目中
API/recommend.js 使用jsonp 調取輪播圖的數據web
import jsonp from 'common/js/jsonp' import {commonParams, options} from './config' export function getRecommend() { const url = 'https://c.y.qq.com/musichall/fcgi-bin/fcg_yqqhomepagerecommend.fcg' const data = Object.assign({}, commonParams, { //assign es6語法 platform: 'h5', uin: 0, needNewCode: 1 }) return jsonp(url, data, options) }
用到了es6對象的合併方法Object.assign
config.jsvuex
export const commonParams = { g_tk: 1928093487, inCharset: 'utf-8', outCharset: 'utf-8', notice: 0, format: 'jsonp' } export const options = { param: 'jsonpCallback' } export const ERR_OK = 0
定義一些公共參數,不用每次再去重寫
components/recommend.vue 在組件中調用接口express
<div v-if="recommends.length" class="slider-wrapper" ref="sliderWrapper"> <slider> <div v-for="item in recommends"> <a :href="item.linkUrl"> <img class="needsclick" @load="loadImage" :src="item.picUrl"> <!-- 若是fastclick監聽到有class爲needsclick就不會攔截 --> </a> </div> </slider> </div>
`這裏用到了slider組件以及slot的知識,也遇到了一個坑,由於數據響應
必須肯定有數據v-if="recommends.length"才能保證插槽的正確顯示`
export default { data() { return { recommends: [] } }, created() { this._getRecommend() }, methods: { _getRecommend() { getRecommend().then((res) => { if (res.code === ERR_OK) { this.recommends = res.data.slider } }) } }, components: { Slider } }
<div class="recommend-list"> <h1 class="list-title">熱門歌單推薦</h1> <ul> <li @click="selectItem(item)" v-for="item in discList" class="item"> <div class="icon"> <img width="60" height="60" v-lazy="item.imgurl"> </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>
<script type="text/ecmascript-6"> import Slider from 'base/slider/slider' import Loading from 'base/loading/loading' import Scroll from 'base/scroll/scroll' import {getRecommend, getDiscList} from 'api/recommend' import {ERR_OK} from 'api/config' export default { data() { return { recommends: [], discList: [] } }, created() { this._getRecommend() this._getDiscList() //熱門歌單獲取 }, methods: { _getRecommend() { getRecommend().then((res) => { if (res.code === ERR_OK) { this.recommends = res.data.slider } }) }, _getDiscList() { getDiscList().then((res) => { if (res.code === ERR_OK) { this.discList = res.data.list } }) }, }, components: { Slider, Loading, Scroll } } </script>
在這裏沒有用jsonp而是用了axios,是由於接口有host、referer校驗不得使用後端代理接口的方式去處理
bulid目錄下dev-server.js處理代理
require('./check-versions')() var config = require('../config') if (!process.env.NODE_ENV) { process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV) } var opn = require('opn') var path = require('path') var express = require('express') var webpack = require('webpack') var proxyMiddleware = require('http-proxy-middleware') var webpackConfig = require('./webpack.dev.conf') var axios = require('axios') //第一步 // default port where dev server listens for incoming traffic var port = process.env.PORT || config.dev.port // automatically open browser, if not set will be false var autoOpenBrowser = !!config.dev.autoOpenBrowser // Define HTTP proxies to your custom API backend // https://github.com/chimurai/http-proxy-middleware var proxyTable = config.dev.proxyTable var app = express() var apiRoutes = express.Router() //如下是後端代理接口 第二步 apiRoutes.get('/getDiscList', function (req, res) { var 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) //輸出到瀏覽器的res }).catch((e) => { console.log(e) }) }) apiRoutes.get('/lyric', function (req, res) { //這是另外一個接口下節將用到 var url = 'https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg' axios.get(url, { headers: { referer: 'https://c.y.qq.com/', host: 'c.y.qq.com' }, params: req.query }).then((response) => { var ret = response.data if (typeof ret === 'string') { var reg = /^\w+\(({[^()]+})\)$/ var matches = ret.match(reg) if (matches) { ret = JSON.parse(matches[1]) } } res.json(ret) }).catch((e) => { console.log(e) }) }) app.use('/api', apiRoutes) //最後一步 var compiler = webpack(webpackConfig) var devMiddleware = require('webpack-dev-middleware')(compiler, { publicPath: webpackConfig.output.publicPath, quiet: true }) var hotMiddleware = require('webpack-hot-middleware')(compiler, { log: () => {} }) // force page reload when html-webpack-plugin template changes compiler.plugin('compilation', function (compilation) { compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { hotMiddleware.publish({ action: 'reload' }) cb() }) }) // proxy api requests Object.keys(proxyTable).forEach(function (context) { var options = proxyTable[context] if (typeof options === 'string') { options = { target: options } } app.use(proxyMiddleware(options.filter || context, options)) }) // handle fallback for HTML5 history API app.use(require('connect-history-api-fallback')()) // serve webpack bundle output app.use(devMiddleware) // enable hot-reload and state-preserving // compilation error display app.use(hotMiddleware) // serve pure static assets var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory) app.use(staticPath, express.static('./static')) var uri = 'http://localhost:' + port var _resolve var readyPromise = new Promise(resolve => { _resolve = resolve }) console.log('> Starting dev server...') devMiddleware.waitUntilValid(() => { console.log('> Listening at ' + uri + '\n') // when env is testing, don't need open it if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') { opn(uri) } _resolve() }) var server = app.listen(port) module.exports = { ready: readyPromise, close: () => { server.close() } }
API/recommend.js 使用jsonp 調取熱門歌單的數據
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) }) }
接下來開發推薦頁面滾動列表--由於不少頁面都支持滾動,因此抽出來一個公用組件Scroll.vue
<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 }, listenScroll: { type: Boolean, default: false }, data: { type: Array, default: null }, pullup: { type: Boolean, default: false }, beforeScroll: { type: Boolean, default: false }, refreshDelay: { type: Number, default: 20 } }, 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 }) if (this.listenScroll) { let me = this //注意這塊 this.scroll.on('scroll', (pos) => { me.$emit('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') }) } }, disable() { this.scroll && this.scroll.disable() }, enable() { this.scroll && this.scroll.enable() }, 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) } }, watch: { data() { setTimeout(() => { this.refresh() }, this.refreshDelay) } } } </script>
recommend.vue
可能會遇到一個問題,初始化後不能滾動,是由於高度的問題,因此給img加了一個方法,這裏提到了vuex的使用,那怎麼給vuex提交數據細心的同窗可能會發現↓↓↓↓↓
<template> <div class="recommend" ref="recommend"> <scroll ref="scroll" class="recommend-content" :data="discList"> <div> <div v-if="recommends.length" class="slider-wrapper" ref="sliderWrapper"> <slider> <div v-for="item in recommends"> <a :href="item.linkUrl"> <img class="needsclick" @load="loadImage" :src="item.picUrl"> <!-- 若是fastclick監聽到有class爲needsclick就不會攔截 --> </a> </div> </slider> </div> <div class="recommend-list"> <h1 class="list-title">熱門歌單推薦</h1> <ul> <li @click="selectItem(item)" v-for="item in discList" class="item"> <div class="icon"> <img width="60" height="60" v-lazy="item.imgurl"> </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> <div class="loading-container" v-show="!discList.length"> <loading></loading> </div> </scroll> </div> </template> <script type="text/ecmascript-6"> import Slider from 'base/slider/slider' import Loading from 'base/loading/loading' import Scroll from 'base/scroll/scroll' import {getRecommend, getDiscList} from 'api/recommend' import {ERR_OK} from 'api/config' import {mapMutations} from 'vuex' export default { data() { return { recommends: [], discList: [] } }, created() { this._getRecommend() this._getDiscList() }, methods: { loadImage() { if (!this.checkloaded) { this.checkloaded = true this.$refs.scroll.refresh() } }, selectItem(item) { this.$router.push({ path: `/recommend/${item.dissid}` }) this.setDisc(item) }, _getRecommend() { getRecommend().then((res) => { if (res.code === ERR_OK) { this.recommends = res.data.slider } }) }, _getDiscList() { getDiscList().then((res) => { if (res.code === ERR_OK) { this.discList = res.data.list } }) }, ...mapMutations({ setDisc: 'SET_DISC' }) }, components: { Slider, Loading, Scroll } } </script>
接下來是歌手頁面,因爲考慮到二級路由要跳到歌手詳情,因此抽出一個獨立組件listview.vue,涉及到數據結構處理、類的建立、es6的字符拼接、數組map方法、自定義data屬性獲取方法的封裝
<template> <scroll @scroll="scroll" :listen-scroll="listenScroll" :probe-type="probeType" :data="data" class="listview" ref="listview"> <ul> <li v-for="group in data" class="list-group" ref="listGroup"> <h2 class="list-group-title">{{group.title}}</h2> <uL> <li @click="selectItem(item)" v-for="item in group.items" class="list-group-item"> <img class="avatar" v-lazy="item.avatar"> <span class="name">{{item.name}}</span> </li> </uL> </li> </ul> <div class="list-shortcut" @touchstart.stop.prevent="onShortcutTouchStart" @touchmove.stop.prevent="onShortcutTouchMove" @touchend.stop> <ul> <li v-for="(item, index) in shortcutList" :data-index="index" class="item" :class="{'current':currentIndex===index}">{{item}} </li> </ul> </div> <div class="list-fixed" ref="fixed" v-show="fixedTitle"> <div class="fixed-title">{{fixedTitle}} </div> </div> <div v-show="!data.length" class="loading-container"> <loading></loading> </div> </scroll> </template> <script> import Scroll from 'base/scroll/scroll' import Loading from 'base/loading/loading' import {getData} from 'common/js/dom' const TITLE_HEIGHT = 30 const ANCHOR_HEIGHT = 18 //樣式的高度 export default { props: { data: { type: Array, default: [] } }, computed: { shortcutList() { return this.data.map((group) => { return group.title.substr(0, 1) }) }, fixedTitle() { if (this.scrollY > 0) { return '' } return this.data[this.currentIndex] ? this.data[this.currentIndex].title : '' } }, data() { return { scrollY: -1, currentIndex: 0, diff: -1 } }, created() { this.probeType = 3 this.listenScroll = true this.touch = {} this.listHeight = [] }, methods: { selectItem(item) { this.$emit('select', item) }, 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 //或0 至關於向下取整 let anchorIndex = parseInt(this.touch.anchorIndex) + delta this._scrollTo(anchorIndex) }, refresh() { this.$refs.listview.refresh() }, scroll(pos) { this.scrollY = pos.y }, _calculateHeight() { this.listHeight = [] const list = this.$refs.listGroup let height = 0 this.listHeight.push(height) for (let i = 0; i < list.length; i++) { let item = list[i] height += item.clientHeight this.listHeight.push(height) } //獲取到從第一個到最後一個每個的height }, _scrollTo(index) { if (!index && index !== 0) { return } if (index < 0) { index = 0 } else if (index > this.listHeight.length - 2) { index = this.listHeight.length - 2 } this.scrollY = -this.listHeight[index] this.$refs.listview.scrollToElement(this.$refs.listGroup[index], 0) } }, watch: { data() { setTimeout(() => { this._calculateHeight() }, 20) }, scrollY(newY) { const listHeight = this.listHeight // 當滾動到頂部,newY>0 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) { //newY往上滑是負值 --變正 this.currentIndex = i this.diff = height2 + newY return } } // 當滾動到底部,且-newY大於最後一個元素的上限 this.currentIndex = listHeight.length - 2 }, 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)` } }, components: { Scroll, Loading } } </script>
singer.vue
引入listview組件,有一個20毫秒的定時器,關鍵在於左右聯動的思路很重要,以及關於diff的處理加強用戶體驗
<template> <div class="singer" ref="singer"> <list-view @select="selectSinger" :data="singers" ref="list"></list-view> <router-view></router-view> </div> </template> <script> import ListView from 'base/listview/listview' import {getSingerList} from 'api/singer' import {ERR_OK} from 'api/config' import Singer from 'common/js/singer' import {mapMutations} from 'vuex' //對Mutations的封裝 import {playlistMixin} from 'common/js/mixin' const HOT_SINGER_LEN = 10 const HOT_NAME = '熱門' export default { mixins: [playlistMixin], data() { return { singers: [] } }, created() { this._getSingerList() }, methods: { handlePlaylist(playlist) { const bottom = playlist.length > 0 ? '60px' : '' this.$refs.singer.style.bottom = bottom this.$refs.list.refresh() }, selectSinger(singer) { this.$router.push({ path: `/singer/${singer.id}` }) this.setSinger(singer) }, _getSingerList() { getSingerList().then((res) => { if (res.code === ERR_OK) { this.singers = this._normalizeSinger(res.data.list) } }) }, _normalizeSinger(list) { let map = { hot: { title: HOT_NAME, items: [] } } list.forEach((item, index) => { if (index < HOT_SINGER_LEN) { map.hot.items.push(new Singer({ name: item.Fsinger_name, id: item.Fsinger_mid })) } const key = item.Findex if (!map[key]) { map[key] = { title: key, items: [] } } map[key].items.push(new Singer({ name: item.Fsinger_name, id: item.Fsinger_mid })) }) // 爲了獲得有序列表,咱們須要處理 map let ret = [] let hot = [] 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) }, ...mapMutations({ setSinger: 'SET_SINGER' }) }, components: { ListView } } </script>
歌手詳情頁,爲了組件重用抽出來一個music-list.vue,在此基礎又抽出來一個song-list.vue,用到了v-html來轉義字符、計算屬性裏返回對象的某幾個key好比只傳入name或者頭像、mapGetters獲取vuex的數據
<template> <div class="song-list"> <ul> <li @click="selectItem(song, index)" class="item" v-for="(song, index) in songs"> <div class="rank" v-show="rank"> <span :class="getRankCls(index)" v-text="getRankText(index)"></span> </div> <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: [] }, rank: { type: Boolean, default: false } }, methods: { selectItem(item, index) { this.$emit('select', item, index) }, getDesc(song) { return `${song.singer}·${song.album}` }, getRankCls(index) { if (index <= 2) { return `icon icon${index}` } else { return 'text' } }, getRankText(index) { if (index > 2) { return index + 1 } } } } </script>
<template> <div class="music-list"> <div class="back" @click="back"> <i class="icon-back"></i> </div> <h1 class="title" v-html="title"></h1> <div class="bg-image" :style="bgStyle" ref="bgImage"> <div class="play-wrapper"> <div ref="playBtn" v-show="songs.length>0" class="play" @click="random"><!-- 當數據有了之後再顯示v-show --> <i class="icon-play"></i> <span class="text">隨機播放所有</span> </div> </div> <div class="filter" ref="filter"></div> </div> <div class="bg-layer" ref="layer"></div> <scroll :data="songs" @scroll="scroll" :listen-scroll="listenScroll" :probe-type="probeType" class="list" ref="list"> <div class="song-list-wrapper"> <song-list :songs="songs" :rank="rank" @select="selectItem"></song-list> </div> <div v-show="!songs.length" class="loading-container"> <loading></loading> </div> </scroll> </div> </template> <script > import Scroll from 'base/scroll/scroll' import Loading from 'base/loading/loading' import SongList from 'base/song-list/song-list' import {prefixStyle} from 'common/js/dom' import {playlistMixin} from 'common/js/mixin' import {mapActions} from 'vuex' const RESERVED_HEIGHT = 40 const transform = prefixStyle('transform') const backdrop = prefixStyle('backdrop-filter') export default { mixins: [playlistMixin], props: { bgImage: { type: String, default: '' }, songs: { type: Array, default: [] }, title: { type: String, default: '' }, rank: { type: Boolean, default: false } }, data() { return { scrollY: 0 } }, computed: { bgStyle() { return `background-image:url(${this.bgImage})` } }, created() { this.probeType = 3 this.listenScroll = true }, mounted() { this.imageHeight = this.$refs.bgImage.clientHeight this.minTransalteY = -this.imageHeight + RESERVED_HEIGHT this.$refs.list.$el.style.top = `${this.imageHeight}px` }, methods: { handlePlaylist(playlist) { const bottom = playlist.length > 0 ? '60px' : '' this.$refs.list.$el.style.bottom = bottom this.$refs.list.refresh() }, scroll(pos) { this.scrollY = pos.y }, back() { this.$router.back() }, selectItem(item, index) { this.selectPlay({ list: this.songs, index }) }, random() { this.randomPlay({ list: this.songs }) }, ...mapActions([ 'selectPlay', 'randomPlay' ]) }, watch: { scrollY(newVal) { let translateY = Math.max(this.minTransalteY, newVal) //最遠滾動位置 let scale = 1 let zIndex = 0 let blur = 0 const percent = Math.abs(newVal / this.imageHeight) if (newVal > 0) { scale = 1 + percent zIndex = 10 } 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 (newVal < this.minTransalteY) { 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 = '' } this.$refs.bgImage.style[transform] = `scale(${scale})` this.$refs.bgImage.style.zIndex = zIndex } }, components: { Scroll, Loading, SongList } } </script>
下面是父組件歌手詳情,封裝了一個createSong的類,可在源碼中查看提升了代碼的重用性、擴展性由於是面向對象的方式
<template> <transition name="slide"> <music-list :title="title" :bg-image="bgImage" :songs="songs"></music-list> </transition> </template> <script type="text/ecmascript-6"> import MusicList from 'components/music-list/music-list' import {getSingerDetail} from 'api/singer' import {ERR_OK} from 'api/config' import {createSong} from 'common/js/song' import {mapGetters} from 'vuex' export default { computed: { title() { return this.singer.name }, bgImage() { return this.singer.avatar }, ...mapGetters([ 'singer' ]) }, data() { return { songs: [] } }, created() { this._getDetail() }, 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) => { let {musicData} = item if (musicData.songid && musicData.albummid) { ret.push(createSong(musicData)) } }) return ret } }, components: { MusicList } } </script>
播放器內置組件 player.vue,經過actions的方法--selectPlay,在此組件拿到currentSong,這裏再重點說一下mutations和它的type要作到命名一致,nutations本質就是函數,第一個參數是state第二個參數是要修改的對象值
player組件定義到了app.vue,由於它不屬於某一個頁面是全局的,mapgetters是一個數組,屢次批量修改mutation就要用到actions
重點是動畫的過分效果,結合鉤子函數實現飛入飛出動畫,用到了開源動畫庫,create-key-animation
音樂播放事件togglePlaying,由於播放的暫停開始要調用audio的方法,可能會出現拿不到元素報錯,這是用到了nextTic延時函數,添加class能夠用到計算屬性,歌曲的前進後退經過currentIndex,有一個小問題,暫停後切換到下一首歌要自動播放,快速點擊的時候結合 ready err方法避免快速點擊頁面報錯
條形進度條,經過audio獲取能夠讀寫的當前播放時間,將其時間戳轉爲時分秒格式,經過_pad給秒位前補零,作到與設計圖一致,定義基礎組件progress-bar,事件拖動和點擊滾動條的交互實現,也就是說拖動無非就是三個事件,start move end,拖動開始前加一個開關表示初始化完成,若是拖動前是暫停狀態,拖動後再讓其播放
圓形進度條,用到了SVG再經過兩個circle實現,徹底能夠應用到實際工做中
播放模式,用到util裏面的shuttle函數把數組打亂,用到es6的findindex函數,因爲要實時改變currentSong,父組件監聽事件會被觸發因此作了一個判斷,若是id相同什麼都不錯,由於這個時候還沒觸發事件
<template> <div class="progress-bar" ref="progressBar" @click="progressClick"> <div class="bar-inner"> <div class="progress" ref="progress"></div> <div class="progress-btn-wrapper" ref="progressBtn" @touchstart.prevent="progressTouchStart" @touchmove.prevent="progressTouchMove" @touchend="progressTouchEnd" > <div class="progress-btn"></div> </div> </div> </div> </template> <script> import {prefixStyle} from 'common/js/dom' const progressBtnWidth = 16 const transform = prefixStyle('transform') export default { props: { percent: { type: Number, default: 0 } }, created() { this.touch = {} }, methods: { 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) }, progressTouchEnd() { this.touch.initiated = false this._triggerPercent() }, progressClick(e) { const rect = this.$refs.progressBar.getBoundingClientRect() const offsetWidth = e.pageX - rect.left this._offset(offsetWidth) // 這裏當咱們點擊 progressBtn 的時候,e.offsetX 獲取不對 // this._offset(e.offsetX) this._triggerPercent() }, _triggerPercent() { const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth const percent = this.$refs.progress.clientWidth / barWidth this.$emit('percentChange', percent) }, _offset(offsetWidth) { this.$refs.progress.style.width = `${offsetWidth}px` this.$refs.progressBtn.style[transform] = `translate3d(${offsetWidth}px,0,0)` } }, watch: { percent(newPercent) { if (newPercent >= 0 && !this.touch.initiated) { const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth const offsetWidth = newPercent * barWidth this._offset(offsetWidth) } } } } </script> <style scoped lang="stylus" rel="stylesheet/stylus"> @import "~common/stylus/variable" .progress-bar height: 30px .bar-inner position: relative top: 13px height: 4px background: rgba(0, 0, 0, 0.3) .progress position: absolute height: 100% background: $color-theme .progress-btn-wrapper position: absolute left: -8px top: -13px width: 30px height: 30px .progress-btn position: relative top: 7px left: 7px box-sizing: border-box width: 16px height: 16px border: 3px solid $color-text border-radius: 50% background: $color-theme </style>
<template> <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> </template> <script type="text/ecmascript-6"> export default { props: { radius: { type: Number, default: 100 }, percent: { type: Number, default: 0 } }, data() { return { dashArray: Math.PI * 100 } }, computed: { dashOffset() { return (1 - this.percent) * this.dashArray } } } </script> <style scoped lang="stylus" rel="stylesheet/stylus"> @import "~common/stylus/variable" .progress-circle position: relative circle stroke-width: 8px transform-origin: center &.progress-background transform: scale(0.9) stroke: $color-theme-d &.progress-bar transform: scale(0.9) rotate(-90deg) stroke: $color-theme </style>
<template> <div class="player" v-show="playlist.length>0"> <transition name="normal" @enter="enter" @after-enter="afterEnter" @leave="leave" @after-leave="afterLeave" > <div class="normal-player" v-show="fullScreen"> <div class="background"> <img width="100%" height="100%" :src="currentSong.image"> </div> <div class="top"> <div class="back" @click="back"> <i class="icon-back"></i> </div> <h1 class="title" v-html="currentSong.name"></h1> <h2 class="subtitle" v-html="currentSong.singer"></h2> </div> <div class="middle" @touchstart.prevent="middleTouchStart" @touchmove.prevent="middleTouchMove" @touchend="middleTouchEnd" > <div class="middle-l" ref="middleL"> <div class="cd-wrapper" ref="cdWrapper"> <div class="cd" :class="cdCls"> <img class="image" :src="currentSong.image"> </div> </div> <div class="playing-lyric-wrapper"> <div class="playing-lyric">{{playingLyric}}</div> </div> </div> <scroll class="middle-r" ref="lyricList" :data="currentLyric && currentLyric.lines"> <div class="lyric-wrapper"> <div v-if="currentLyric"> <p ref="lyricLine" class="text" :class="{'current': currentLineNum ===index}" v-for="(line,index) in currentLyric.lines">{{line.txt}}</p> </div> </div> </scroll> </div> <div class="bottom"> <div class="dot-wrapper"> <span class="dot" :class="{'active':currentShow==='cd'}"></span> <span class="dot" :class="{'active':currentShow==='lyric'}"></span> </div> <div class="progress-wrapper"> <span class="time time-l">{{format(currentTime)}}</span> <div class="progress-bar-wrapper"> <progress-bar :percent="percent" @percentChange="onProgressBarChange"></progress-bar> </div> <span class="time time-r">{{format(currentSong.duration)}}</span> </div> <div class="operators"> <div class="icon i-left" @click="changeMode"> <i :class="iconMode"></i> </div> <div class="icon i-left" :class="disableCls"> <i @click="prev" class="icon-prev"></i> </div> <div class="icon i-center" :class="disableCls"> <i @click="togglePlaying" :class="playIcon"></i> </div> <div class="icon i-right" :class="disableCls"> <i @click="next" class="icon-next"></i> </div> <div class="icon i-right"> <i @click="toggleFavorite(currentSong)" class="icon" :class="getFavoriteIcon(currentSong)"></i> </div> </div> </div> </div> </transition> <transition name="mini"> <div class="mini-player" v-show="!fullScreen" @click="open"> <div class="icon"> <img :class="cdCls" width="40" height="40" :src="currentSong.image"> </div> <div class="text"> <h2 class="name" v-html="currentSong.name"></h2> <p class="desc" v-html="currentSong.singer"></p> </div> <div class="control"> <progress-circle :radius="radius" :percent="percent"> <i @click.stop="togglePlaying" class="icon-mini" :class="miniIcon"></i> </progress-circle> </div> <div class="control" @click.stop="showPlaylist"> <i class="icon-playlist"></i> </div> </div> </transition> <playlist ref="playlist"></playlist> <audio ref="audio" :src="currentSong.url" @play="ready" @error="error" @timeupdate="updateTime" @ended="end"></audio> </div> </template> <script type=""> import {mapGetters, mapMutations, mapActions} from 'vuex' import animations from 'create-keyframe-animation' import {prefixStyle} from 'common/js/dom' import ProgressBar from 'base/progress-bar/progress-bar' import ProgressCircle from 'base/progress-circle/progress-circle' import {playMode} from 'common/js/config' import Lyric from 'lyric-parser' import Scroll from 'base/scroll/scroll' import {playerMixin} from 'common/js/mixin' import Playlist from 'components/playlist/playlist' const transform = prefixStyle('transform') const transitionDuration = prefixStyle('transitionDuration') export default { mixins: [playerMixin], data() { return { songReady: false, currentTime: 0, radius: 32, currentLyric: null, currentLineNum: 0, currentShow: 'cd', playingLyric: '' } }, computed: { cdCls() { return this.playing ? 'play' : 'play pause' }, playIcon() { return this.playing ? 'icon-pause' : 'icon-play' }, miniIcon() { return this.playing ? 'icon-pause-mini' : 'icon-play-mini' }, disableCls() { return this.songReady ? '' : 'disable' }, percent() { return this.currentTime / this.currentSong.duration }, ...mapGetters([ 'currentIndex', 'fullScreen', 'playing' ]) }, created() { this.touch = {} }, methods: { back() { this.setFullScreen(false) }, open() { this.setFullScreen(true) }, enter(el, done) { const {x, y, scale} = this._getPosAndScale() 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({ name: 'move', animation, presets: { duration: 400, easing: 'linear' } }) animations.runAnimation(this.$refs.cdWrapper, 'move', done) }, afterEnter() { animations.unregisterAnimation('move') this.$refs.cdWrapper.style.animation = '' }, leave(el, done) { this.$refs.cdWrapper.style.transition = 'all 0.4s' const {x, y, scale} = this._getPosAndScale() this.$refs.cdWrapper.style[transform] = `translate3d(${x}px,${y}px,0) scale(${scale})` this.$refs.cdWrapper.addEventListener('transitionend', done) }, afterLeave() { this.$refs.cdWrapper.style.transition = '' this.$refs.cdWrapper.style[transform] = '' }, togglePlaying() { if (!this.songReady) { return } this.setPlayingState(!this.playing) if (this.currentLyric) { this.currentLyric.togglePlay() } }, end() { if (this.mode === playMode.loop) { this.loop() } else { this.next() } }, loop() { this.$refs.audio.currentTime = 0 this.$refs.audio.play() this.setPlayingState(true) if (this.currentLyric) { this.currentLyric.seek(0) } }, next() { if (!this.songReady) { return } if (this.playlist.length === 1) { this.loop() return } else { let index = this.currentIndex + 1 if (index === this.playlist.length) { index = 0 } this.setCurrentIndex(index) if (!this.playing) { this.togglePlaying() } } this.songReady = false }, prev() { if (!this.songReady) { return } if (this.playlist.length === 1) { this.loop() return } else { let index = this.currentIndex - 1 if (index === -1) { index = this.playlist.length - 1 } this.setCurrentIndex(index) if (!this.playing) { this.togglePlaying() } } this.songReady = false }, ready() { this.songReady = true this.savePlayHistory(this.currentSong) }, error() { this.songReady = true }, updateTime(e) { this.currentTime = e.target.currentTime }, format(interval) { interval = interval | 0 const minute = interval / 60 | 0 const second = this._pad(interval % 60) return `${minute}:${second}` }, onProgressBarChange(percent) { const currentTime = this.currentSong.duration * percent this.$refs.audio.currentTime = currentTime if (!this.playing) { this.togglePlaying() } if (this.currentLyric) { this.currentLyric.seek(currentTime * 1000) } }, getLyric() { this.currentSong.getLyric().then((lyric) => { if (this.currentSong.lyric !== lyric) { return } this.currentLyric = new Lyric(lyric, this.handleLyric) if (this.playing) { this.currentLyric.play() } }).catch(() => { this.currentLyric = null this.playingLyric = '' this.currentLineNum = 0 }) }, handleLyric({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 }, showPlaylist() { this.$refs.playlist.show() }, middleTouchStart(e) { this.touch.initiated = true // 用來判斷是不是一次移動 this.touch.moved = false const touch = e.touches[0] this.touch.startX = touch.pageX this.touch.startY = touch.pageY }, middleTouchMove(e) { if (!this.touch.initiated) { return } const touch = e.touches[0] const deltaX = touch.pageX - this.touch.startX const deltaY = touch.pageY - this.touch.startY if (Math.abs(deltaY) > Math.abs(deltaX)) { return } if (!this.touch.moved) { this.touch.moved = true } const left = this.currentShow === 'cd' ? 0 : -window.innerWidth const offsetWidth = Math.min(0, Math.max(-window.innerWidth, left + deltaX)) this.touch.percent = Math.abs(offsetWidth / window.innerWidth) this.$refs.lyricList.$el.style[transform] = `translate3d(${offsetWidth}px,0,0)` this.$refs.lyricList.$el.style[transitionDuration] = 0 this.$refs.middleL.style.opacity = 1 - this.touch.percent this.$refs.middleL.style[transitionDuration] = 0 }, middleTouchEnd() { if (!this.touch.moved) { return } let offsetWidth let opacity if (this.currentShow === 'cd') { if (this.touch.percent > 0.1) { offsetWidth = -window.innerWidth opacity = 0 this.currentShow = 'lyric' } else { offsetWidth = 0 opacity = 1 } } else { if (this.touch.percent < 0.9) { offsetWidth = 0 this.currentShow = 'cd' opacity = 1 } else { offsetWidth = -window.innerWidth opacity = 0 } } const time = 300 this.$refs.lyricList.$el.style[transform] = `translate3d(${offsetWidth}px,0,0)` this.$refs.lyricList.$el.style[transitionDuration] = `${time}ms` this.$refs.middleL.style.opacity = opacity this.$refs.middleL.style[transitionDuration] = `${time}ms` this.touch.initiated = false }, _pad(num, n = 2) { let len = num.toString().length while (len < n) { num = '0' + num len++ } return num }, _getPosAndScale() { 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) const y = window.innerHeight - paddingTop - width / 2 - paddingBottom return { x, y, scale } }, ...mapMutations({ setFullScreen: 'SET_FULL_SCREEN' }), ...mapActions([ 'savePlayHistory' ]) }, watch: { currentSong(newSong, oldSong) { if (!newSong.id) { return } if (newSong.id === oldSong.id) { return } if (this.currentLyric) { this.currentLyric.stop() this.currentTime = 0 this.playingLyric = '' this.currentLineNum = 0 } clearTimeout(this.timer) this.timer = setTimeout(() => { this.$refs.audio.play() this.getLyric() }, 1000) }, playing(newPlaying) { const audio = this.$refs.audio this.$nextTick(() => { newPlaying ? audio.play() : audio.pause() }) }, fullScreen(newVal) { if (newVal) { setTimeout(() => { this.$refs.lyricList.refresh() }, 20) } } }, components: { ProgressBar, ProgressCircle, Scroll, Playlist } } </script> <style scoped lang="stylus" rel="stylesheet/stylus"> @import "~common/stylus/variable" @import "~common/stylus/mixin" .player .normal-player position: fixed left: 0 right: 0 top: 0 bottom: 0 z-index: 150 background: $color-background .background position: absolute left: 0 top: 0 width: 100% height: 100% z-index: -1 opacity: 0.6 filter: blur(20px) .top position: relative margin-bottom: 25px .back position absolute top: 0 left: 6px z-index: 50 .icon-back display: block padding: 9px font-size: $font-size-large-x color: $color-theme transform: rotate(-90deg) .title width: 70% margin: 0 auto line-height: 40px text-align: center no-wrap() font-size: $font-size-large color: $color-text .subtitle line-height: 20px text-align: center font-size: $font-size-medium color: $color-text .middle position: fixed width: 100% top: 80px bottom: 170px white-space: nowrap font-size: 0 .middle-l display: inline-block vertical-align: top position: relative width: 100% height: 0 padding-top: 80% .cd-wrapper position: absolute left: 10% top: 0 width: 80% height: 100% .cd width: 100% height: 100% box-sizing: border-box border: 10px solid rgba(255, 255, 255, 0.1) border-radius: 50% &.play animation: rotate 20s linear infinite &.pause animation-play-state: paused .image position: absolute left: 0 top: 0 width: 100% height: 100% border-radius: 50% .playing-lyric-wrapper width: 80% margin: 30px auto 0 auto overflow: hidden text-align: center .playing-lyric height: 20px line-height: 20px font-size: $font-size-medium color: $color-text-l .middle-r display: inline-block vertical-align: top width: 100% height: 100% overflow: hidden .lyric-wrapper width: 80% margin: 0 auto overflow: hidden text-align: center .text line-height: 32px color: $color-text-l font-size: $font-size-medium &.current color: $color-text .bottom position: absolute bottom: 50px width: 100% .dot-wrapper text-align: center font-size: 0 .dot display: inline-block vertical-align: middle margin: 0 4px width: 8px height: 8px border-radius: 50% background: $color-text-l &.active width: 20px border-radius: 5px background: $color-text-ll .progress-wrapper display: flex align-items: center width: 80% margin: 0px auto padding: 10px 0 .time color: $color-text font-size: $font-size-small flex: 0 0 30px line-height: 30px width: 30px &.time-l text-align: left &.time-r text-align: right .progress-bar-wrapper flex: 1 .operators display: flex align-items: center .icon flex: 1 color: $color-theme &.disable color: $color-theme-d i font-size: 30px .i-left text-align: right .i-center padding: 0 20px text-align: center i font-size: 40px .i-right text-align: left .icon-favorite color: $color-sub-theme &.normal-enter-active, &.normal-leave-active transition: all 0.4s .top, .bottom transition: all 0.4s cubic-bezier(0.86, 0.18, 0.82, 1.32) &.normal-enter, &.normal-leave-to opacity: 0 .top transform: translate3d(0, -100px, 0) .bottom transform: translate3d(0, 100px, 0) .mini-player display: flex align-items: center position: fixed left: 0 bottom: 0 z-index: 180 width: 100% height: 60px background: $color-highlight-background &.mini-enter-active, &.mini-leave-active transition: all 0.4s &.mini-enter, &.mini-leave-to opacity: 0 .icon flex: 0 0 40px width: 40px padding: 0 10px 0 20px img border-radius: 50% &.play animation: rotate 10s linear infinite &.pause animation-play-state: paused .text display: flex flex-direction: column justify-content: center flex: 1 line-height: 20px overflow: hidden .name margin-bottom: 2px no-wrap() font-size: $font-size-medium color: $color-text .desc no-wrap() font-size: $font-size-small color: $color-text-d .control flex: 0 0 30px width: 30px padding: 0 10px .icon-play-mini, .icon-pause-mini, .icon-playlist font-size: 30px color: $color-theme-d .icon-mini font-size: 32px position: absolute left: 0 top: 0 @keyframes rotate 0% transform: rotate(0) 100% transform: rotate(360deg) </style>