词条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>