词条
目前市面上还没有一个Vue 2.0 的高级教学,都是一些基础的入门课程,你很难找到一个基于Vue.js的复杂应用的教学, 但是,我们为你准备了这门独一无二的Vue 2.0 高级实战课程
src简单的介绍
入口文件
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页面
<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.vue
<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的封装
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 调取轮播图的数据
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.js
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 在组件中调用接口
<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>
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。