vuejs2.0 高级实战 全网稀缺 音乐WebAPP
日期: 2018-06-27 分类: 个人收藏 362次阅读
src简单的介绍
入口文件main.js
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,balbel-runtime为es6语法转义,fastclick解决移动端点击300毫秒的延迟
devDependencies 里面的插件(比如各种loader,babel全家桶及各种webpack的插件等)只用于开发环境,不用于生产环境,因此不需要打包;而 dependencies 是需要发布到生产环境的,是要打包的。
dependencies:应用能够正常运行所依赖的包。这种 dependencies 是最常见的,用户在使用 npm install 安装你的包时会自动安装这些依赖。
devDependencies:开发应用时所依赖的工具包。通常是一些开发、测试、打包工具,例如 webpack、ESLint、Mocha。应用正常运行并不依赖于这些包,用户在使用 npm install 安装你的包时也不会安装这些依赖。
{
"name": "vue-music",
"version": "1.0.0",
"description": "音乐播放器",
"author": "songhao",
"private": true,
"scripts": {
"dev": "node build/dev-server.js",
"start": "node build/dev-server.js",
"build": "node build/build.js",
"lint": "eslint --ext .js,.vue src"
},
"dependencies": {
"babel-runtime": "^6.0.0",
"vue": "^2.3.3",
"vue-router": "^2.5.3",
"vuex": "^2.3.1",
"fastclick": "^1.0.6",
"vue-lazyload": "1.0.3",
"axios": "^0.16.1",
"jsonp": "0.2.1",
"better-scroll": "^0.1.15",
"create-keyframe-animation": "^0.1.0",
"js-base64": "^2.1.9",
"lyric-parser": "^1.0.1",
"good-storage": "^1.0.1"
},
"devDependencies": {
"autoprefixer": "^6.7.2",
"babel-core": "^6.22.1",
"babel-eslint": "^7.1.1",
"babel-loader": "^6.2.10",
"babel-plugin-transform-runtime": "^6.22.0",
"babel-preset-env": "^1.3.2",
"babel-preset-stage-2": "^6.22.0",
"babel-register": "^6.22.0",
"babel-polyfill": "^6.2.0",
"chalk": "^1.1.3",
"connect-history-api-fallback": "^1.3.0",
"copy-webpack-plugin": "^4.0.1",
"css-loader": "^0.28.0",
"eslint": "^3.19.0",
"eslint-friendly-formatter": "^2.0.7",
"eslint-loader": "^1.7.1",
"eslint-plugin-html": "^2.0.0",
"eslint-config-standard": "^6.2.1",
"eslint-plugin-promise": "^3.4.0",
"eslint-plugin-standard": "^2.0.1",
"eventsource-polyfill": "^0.9.6",
"express": "^4.14.1",
"extract-text-webpack-plugin": "^2.0.0",
"file-loader": "^0.11.1",
"friendly-errors-webpack-plugin": "^1.1.3",
"html-webpack-plugin": "^2.28.0",
"http-proxy-middleware": "^0.17.3",
"webpack-bundle-analyzer": "^2.2.1",
"semver": "^5.3.0",
"shelljs": "^0.7.6",
"opn": "^4.0.2",
"optimize-css-assets-webpack-plugin": "^1.3.0",
"ora": "^1.2.0",
"rimraf": "^2.6.0",
"url-loader": "^0.5.8",
"vue-loader": "^11.3.4",
"vue-style-loader": "^2.0.5",
"vue-template-compiler": "^2.3.3",
"webpack": "^2.3.3",
"webpack-dev-middleware": "^1.10.0",
"webpack-hot-middleware": "^2.18.0",
"webpack-merge": "^4.1.0",
"stylus": "^0.54.5",
"stylus-loader": "^2.1.1",
"vconsole": "^2.5.2"
},
"engines": {
"node": ">= 4.0.0",
"npm": ">= 3.0.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8"
]
}
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<title>vue-music</title>
</head>
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
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>
jsonp原理
它发送的不是一个ajax请求,是创建了一个script标签不受同源策略的影响,通过src指向服务器的地址,在地址后面加一个callback=a,服务器解析这个URL发现有一个callback=a的参数,返回数据的时候调用这个a方法,前端定义的a方法就能直接拿到数据
最后返回的时候从1开始截取,是因为上面的方法已经添加了&符号,所有返回&符之后的内容
jsonp的封装 这个js放置于静态文件夹下
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)
//新型写法 es6写法
//url += `&${k}=${encodeURIComponent(value)}` es6语法
}
return url ? url.substring(1) : ''
}
推荐页面 recommend.js 使用jsonp 调取轮播图的数据
用到了es6对象的合并方法Object.assign,浅拷贝的方法
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)
}
config.js 因为数据是爬的,所以定义了通用的参数对象,私有的参数通过assign方法添加
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 在组件中调用接口
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 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"才能保证插槽的正确显示
dom.js ,比较重要
操作dom的文件,位于通用静态文件
//是否有指定class存在
export function hasClass(el, className) {
let reg = new RegExp('(^|\\s)' + className + '(\\s|$)')
return reg.test(el.className)
}
//如果存在什么都不做,否则就设置添加
export function addClass(el, className) {
if (hasClass(el, className)) {
return
}
let newClass = el.className.split(' ')
newClass.push(className)
el.className = newClass.join(' ')
}
//展现了方法的设置技巧,一个getter、一个setter
export function getData(el, name, val) {
const prefix = 'data-'
if (val) {
return el.setAttribute(prefix + name, val)
}
return el.getAttribute(prefix + name)
}
//下面的方法,在歌手详情页的自组件music-list用到,用于控制属性的兼容性
let elementStyle = document.createElement('div').style
let vendor = (() => {
let transformNames = {
webkit: 'webkitTransform',
Moz: 'MozTransform',
O: 'OTransform',
ms: 'msTransform',
standard: 'transform'
}
for (let key in transformNames) {
if (elementStyle[transformNames[key]] !== undefined) {
return key
}
}
return false
})()
export function prefixStyle(style) {
if (vendor === false) {
return false
}
if (vendor === 'standard') {
return style
}
//数据的拼接,首字母大写加上剩余的部分
return vendor + style.charAt(0).toUpperCase() + style.substr(1)
}
从下面开始开始编写项目
首先是推荐页面,由轮播图和热门歌单组成
轮播图的数据通过jsonp可以得到,但是热门歌单因为有referer、host的认证,所以需要在dev-server.js设置代理,(欺骗服务器)用到axios而不是jsonp
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()
}
}
recommend.js 使用axios 调取热门歌单的数据
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)
})
}
slider轮播图组件用到了dom.js中的addclass方法,引入了better-scroll,注意一下window.addEventListener,初始化的时候这个方法this._setSliderWidth(true)传入true,来控制2倍的dom复制,特别注意初始化dots的方法
<template>
<div class="slider" ref="slider">
<div class="slider-group" ref="sliderGroup">
<slot></slot>
</div>
<div class="dots">
<span
class="dot"
:class="{active: currentPageIndex === index }"
v-for="(item, index) in dots"
></span>
</div>
</div>
</template>
<script type="text/ecmascript-6">
import { addClass } from "common/js/dom";
import BScroll from "better-scroll";
export default {
name: "slider",
props: {
loop: {
type: Boolean,
default: true
},
autoPlay: {
type: Boolean,
default: true
},
interval: {
type: Number,
default: 4000
}
},
data() {
return {
dots: [],
currentPageIndex: 0
};
},
mounted() {
setTimeout(() => {
this._setSliderWidth();
this._initDots();
this._initSlider();
if (this.autoPlay) {
this._play();
}
}, 20);
window.addEventListener("resize", () => {
if (!this.slider) {
return;
}
this._setSliderWidth(true);
this.slider.refresh();
});
},
//keep-active下的声明周期,相当于小程序的onshow
activated() {
if (this.autoPlay) {
this._play();
}
},
//组件销毁后清除定时器,有利于内存释放
deactivated() {
clearTimeout(this.timer);
},
beforeDestroy() {
clearTimeout(this.timer);
},
methods: {
//计算轮播的宽度
_setSliderWidth(isResize) {
this.children = this.$refs.sliderGroup.children;
let width = 0;
let sliderWidth = this.$refs.slider.clientWidth;
for (let i = 0; i < this.children.length; i++) {
let child = this.children[i];
addClass(child, "slider-item");
child.style.width = sliderWidth + "px";
width += sliderWidth;
}
if (this.loop && !isResize) {
width += 2 * sliderWidth;
}
this.$refs.sliderGroup.style.width = width + "px";
},
//初始化BScroll
_initSlider() {
this.slider = new BScroll(this.$refs.slider, {
scrollX: true,
scrollY: false,
momentum: false,
snap: true,
snapLoop: this.loop,
snapThreshold: 0.3,
snapSpeed: 400
});
// 设置currentPageIndex
this.slider.on("scrollEnd", () => {
let pageIndex = this.slider.getCurrentPage().pageX;
if (this.loop) {
pageIndex -= 1;
}
this.currentPageIndex = pageIndex;
if (this.autoPlay) {
this._play();
}
});
//手动出发的时候清楚定时器
this.slider.on("beforeScrollStart", () => {
if (this.autoPlay) {
clearTimeout(this.timer);
}
});
},
//初始化dots
_initDots() {
this.dots = new Array(this.children.length);
},
//轮播关键实现
_play() {
let pageIndex = this.currentPageIndex + 1;
if (this.loop) {
pageIndex += 1;
}
this.timer = setTimeout(() => {
this.slider.goToPage(pageIndex, 0, 400);
}, this.interval);
}
}
};
</script>
<style scoped lang="stylus" rel="stylesheet/stylus">
@import '~common/stylus/variable';
.slider {
min-height: 1px;
.slider-group {
position: relative;
overflow: hidden;
white-space: nowrap;
.slider-item {
float: left;
box-sizing: border-box;
overflow: hidden;
text-align: center;
a {
display: block;
width: 100%;
overflow: hidden;
text-decoration: none;
}
img {
display: block;
width: 100%;
}
}
}
.dots {
position: absolute;
right: 0;
left: 0;
bottom: 12px;
text-align: center;
font-size: 0;
.dot {
display: inline-block;
margin: 0 4px;
width: 8px;
height: 8px;
border-radius: 50%;
background: $color-text-l;
&.active {
width: 20px;
border-radius: 5px;
background: $color-text-ll;
}
}
}
}
</style>
loading组件
<template>
<div class="loading">
<img width="24" height="24" src="./loading.gif">
<p class="desc">{{title}}</p>
</div>
</template>
<script type="text/ecmascript-6">
export default {
props: {
title: {
type: String,
default: '正在载入...'
}
}
}
</script>
<style scoped lang="stylus" rel="stylesheet/stylus">
@import "~common/stylus/variable"
.loading
width: 100%
text-align: center
.desc
line-height: 20px
font-size: $font-size-small
color: $color-text-l
</style>
接下来开发推荐页面滚动列表--仿原声应用的scorllview,所以抽出来一个公用组件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>
<style scoped lang="stylus" rel="stylesheet/stylus">
</style>
recommend.vue 推荐页面
可能会遇到一个问题,初始化后不能滚动,是因为高度的问题,所以给img加了一个方法,注意h2标签的应用,以及图片懒加载,这里提到了vuex的使用,那怎么给vuex提交数据细心的同学可能会发现↓↓↓↓↓ scroll的用法,只对下面的第一个div起作用
通过图片的高度撑起盒子,通过loadImage的方法限制让方法只执行一次!很实用的方法
<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">
</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>
<router-view></router-view>
</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 {playlistMixin} from 'common/js/mixin'
import {ERR_OK} from 'api/config'
import {mapMutations} from 'vuex'
export default {
mixins: [playlistMixin],
data() {
return {
recommends: [],
discList: []
}
},
created() {
this._getRecommend()
this._getDiscList()
},
methods: {
handlePlaylist(playlist) {
const bottom = playlist.length > 0 ? '60px' : ''
this.$refs.recommend.style.bottom = bottom
this.$refs.scroll.refresh()
},
//通过图片的高度撑起盒子,通过下面的方法限制让方法只执行一次!
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>
<style scoped lang="stylus" rel="stylesheet/stylus">
@import "~common/stylus/variable"
.recommend
position: fixed
width: 100%
top: 88px
bottom: 0
.recommend-content
height: 100%
overflow: hidden
.slider-wrapper
position: relative
width: 100%
overflow: hidden
.recommend-list
.list-title
height: 65px
line-height: 65px
text-align: center
font-size: $font-size-medium
color: $color-theme
.item
display: flex
box-sizing: border-box
align-items: center
padding: 0 20px 20px 20px
.icon
flex: 0 0 60px
width: 60px
padding-right: 20px
.text
display: flex
flex-direction: column
justify-content: center
flex: 1
line-height: 20px
overflow: hidden
font-size: $font-size-medium
.name
margin-bottom: 10px
color: $color-text
.desc
color: $color-text-d
.loading-container
position: absolute
width: 100%
top: 50%
transform: translateY(-50%)
</style>
为什么变量定义在created里面而不是data里面?
因为这些变量不需要被监听,定义在data、props中的变量vue都会添加一个getter、setter方法用来监听数据变化实现双向数据绑定
向上滚动Y值为负数
代码详细注释 listview组件
<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 type="text/ecmascript-6">
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 // 字母导航的高度 (字体高度加上padding值)
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, //scroll组件传入的y轴
currentIndex: 0, //当前联动的下标
diff: -1 //控制字母浮层是否被下一块内容顶上去,(是一个顶上去的效果)
}
}, //为什么要定义在created里面而不是data里面?
created() {
this.probeType = 3 //scroll组件需要的参数,表示不节流,慢滚动与快滚动都生效
this.listenScroll = true //是否触发scroll组件的监听事件
this.touch = {} //定义的一个touch事件
this.listHeight = [] //获取list的高度,是一个数组
},
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
//最后 | 0 表示向下取整
let delta = (this.touch.y2 - this.touch.y1) / ANCHOR_HEIGHT | 0
let anchorIndex = parseInt(this.touch.anchorIndex) + delta
this._scrollTo(anchorIndex)
},
refresh() {
this.$refs.listview.refresh()
},
//获取scroll组件传入的scroll值
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)
}
},
_scrollTo(index) {
//控制touchstart,点击边缘地带
if (!index && index !== 0) {
return
}
//控制touchstart,点击边缘地带
if (index < 0) {
index = 0
} else if (index > this.listHeight.length - 2) { // 控制touchmove,向上滚动的时候,可能大于最后一项的优化
index = this.listHeight.length - 2
}
// 控制touchstart,点击后通过手动设置scrollY实现,点击联动
this.scrollY = -this.listHeight[index]
//实现列表的滚动
this.$refs.listview.scrollToElement(this.$refs.listGroup[index], 0)
}
},
watch: {
//监听data,当数据变化,延迟20ms等都dom渲染完毕,再计算
data() {
setTimeout(() => {
this._calculateHeight()
}, 20)
},
//监听scrollY的值
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) {
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>
<style scoped lang="stylus" rel="stylesheet/stylus">
@import "~common/stylus/variable"
.listview
position: relative
width: 100%
height: 100%
overflow: hidden
background: $color-background
.list-group
padding-bottom: 30px
.list-group-title
height: 30px
line-height: 30px
padding-left: 20px
font-size: $font-size-small
color: $color-text-l
background: $color-highlight-background
.list-group-item
display: flex
align-items: center
padding: 20px 0 0 30px
.avatar
width: 50px
height: 50px
border-radius: 50%
.name
margin-left: 20px
color: $color-text-l
font-size: $font-size-medium
.list-shortcut
position: absolute
z-index: 30
right: 0
top: 50%
transform: translateY(-50%)
width: 20px
padding: 20px 0
border-radius: 10px
text-align: center
background: $color-background-d
font-family: Helvetica
.item
padding: 3px
line-height: 1
color: $color-text-l
font-size: $font-size-small
&.current
color: $color-theme
.list-fixed
position: absolute
top: 0
left: 0
width: 100%
.fixed-title
height: 30px
line-height: 30px
padding-left: 20px
font-size: $font-size-small
color: $color-text-l
background: $color-highlight-background
.loading-container
position: absolute
width: 100%
top: 50%
transform: translateY(-50%)
</style>
Singer class类
export default class Singer {
constructor({id, name}) {
this.id = id
this.name = name
this.avatar = `https://y.gtimg.cn/music/photo_new/T001R300x300M000${id}.jpg?max_age=2592000`
}
}
歌手页面 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}`
})
//调用被映射出来的方法,把数据传递到vuex里面,到这一步完成setter设置
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) => {
//默认前10条为热门数据
if (index < HOT_SINGER_LEN) {
map.hot.items.push(new Singer({
name: item.Fsinger_name,
id: item.Fsinger_mid
}))
}
//key 为ABCDEF。。。。。。
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)
},
//做一层映射,映射到mutations
...mapMutations({
setSinger: 'SET_SINGER'
})
},
components: {
ListView
}
}
</script>
nextTic延时函数 es6 FindIndex方法
vuex 状态管理
使用场景
- 多组件的状态共享
- 路由间复杂数据传递
mutation-type 用来定义常量,便于项目维护,mutation同步修改数据
export const SET_SINGER = 'SET_SINGER'
export const SET_PLAYING_STATE = 'SET_PLAYING_STATE'
export const SET_FULL_SCREEN = 'SET_FULL_SCREEN'
export const SET_PLAYLIST = 'SET_PLAYLIST'
export const SET_SEQUENCE_LIST = 'SET_SEQUENCE_LIST'
export const SET_PLAY_MODE = 'SET_PLAY_MODE'
export const SET_CURRENT_INDEX = 'SET_CURRENT_INDEX'
export const SET_DISC = 'SET_DISC'
export const SET_TOP_LIST = 'SET_TOP_LIST'
export const SET_SEARCH_HISTORY = 'SET_SEARCH_HISTORY'
export const SET_PLAY_HISTORY = 'SET_PLAY_HISTORY'
export const SET_FAVORITE_LIST = 'SET_FAVORITE_LIST'
mutations,接收两个参数,state就是state.js定义的变量,另一个就是入参(此参数要改变state里的值)
import * as types from './mutation-types'
const mutations = {
[types.SET_SINGER](state, singer) {
state.singer = singer
},
[types.SET_PLAYING_STATE](state, flag) {
state.playing = flag
},
[types.SET_FULL_SCREEN](state, flag) {
state.fullScreen = flag
},
[types.SET_PLAYLIST](state, list) {
state.playlist = list
},
[types.SET_SEQUENCE_LIST](state, list) {
state.sequenceList = list
},
[types.SET_PLAY_MODE](state, mode) {
state.mode = mode
},
[types.SET_CURRENT_INDEX](state, index) {
state.currentIndex = index
},
[types.SET_DISC](state, disc) {
state.disc = disc
},
[types.SET_TOP_LIST](state, topList) {
state.topList = topList
},
[types.SET_SEARCH_HISTORY](state, history) {
state.searchHistory = history
},
[types.SET_PLAY_HISTORY](state, history) {
state.playHistory = history
},
[types.SET_FAVORITE_LIST](state, list) {
state.favoriteList = list
}
}
export default mutations
getters,相当于vuex的计算属性
export const singer = state => state.singer
export const playing = state => state.playing
export const fullScreen = state => state.fullScreen
export const playlist = state => state.playlist
export const sequenceList = state => state.sequenceList
export const mode = state => state.mode
export const currentIndex = state => state.currentIndex
export const currentSong = (state) => {
return state.playlist[state.currentIndex] || {}
}
export const disc = state => state.disc
export const topList = state => state.topList
export const searchHistory = state => state.searchHistory
export const playHistory = state => state.playHistory
export const favoriteList = state => state.favoriteList
index.js初始化vuex,启用debug
import Vue from 'vue'
import Vuex from 'vuex'
import * as actions from './actions'
import * as getters from './getters'
import state from './state'
import mutations from './mutations'
import createLogger from 'vuex/dist/logger' //通过他能在控制台看到每次修改的日志
Vue.use(Vuex)
const debug = process.env.NODE_ENV !== 'production' //debug模式,是否是通过state来修改数据,只有在run dev 的模式下可行,应该他对性能也有消耗,不建议在正式环境使用
export default new Vuex.Store({
actions,
getters,
state,
mutations,
strict: debug,
plugins: debug ? [createLogger()] : []
})
state.js
import {playMode} from 'common/js/config'
import {loadSearch, loadPlay, loadFavorite} from 'common/js/cache'
const state = {
singer: {},
playing: false, //是否播放
fullScreen: false, //是否全屏播放
playlist: [], //播放列表
sequenceList: [], // 顺序列表
mode: playMode.sequence, //播放模式
currentIndex: -1, //当前播放的那首歌
disc: {},
topList: {},
searchHistory: loadSearch(),
playHistory: loadPlay(),
favoriteList: loadFavorite()
}
export default state
actions 处理异步操作、对mutations的封装(批量处理mutations)
import * as types from './mutation-types'
import {playMode} from 'common/js/config'
import {shuffle} from 'common/js/util'
import {saveSearch, clearSearch, deleteSearch, savePlay, saveFavorite, deleteFavorite} from 'common/js/cache'
//findindex 用来找出 歌曲在当前列表 下的index
function findIndex(list, song) {
return list.findIndex((item) => {
return item.id === song.id
})
}
//选择播放
export const selectPlay = function ({commit, state}, {list, index}) {
commit(types.SET_SEQUENCE_LIST, list) //设置值
if (state.mode === playMode.random) {
let randomList = shuffle(list)
commit(types.SET_PLAYLIST, randomList)
index = findIndex(randomList, list[index])
} else {
commit(types.SET_PLAYLIST, list)
}
commit(types.SET_CURRENT_INDEX, index) //播放索引
commit(types.SET_FULL_SCREEN, true) //播放器打开
commit(types.SET_PLAYING_STATE, true) //播放状态打开
}
//music-list里面的随机播放全部 事件
export const randomPlay = function ({commit}, {list}) {
commit(types.SET_PLAY_MODE, playMode.random)
commit(types.SET_SEQUENCE_LIST, list)
let randomList = shuffle(list)
commit(types.SET_PLAYLIST, randomList)
commit(types.SET_CURRENT_INDEX, 0)
commit(types.SET_FULL_SCREEN, true)
commit(types.SET_PLAYING_STATE, true)
}
export const insertSong = function ({commit, state}, song) {
let playlist = state.playlist.slice()
let sequenceList = state.sequenceList.slice()
let currentIndex = state.currentIndex
// 记录当前歌曲
let currentSong = playlist[currentIndex]
// 查找当前列表中是否有待插入的歌曲并返回其索引
let fpIndex = findIndex(playlist, song)
// 因为是插入歌曲,所以索引+1
currentIndex++
// 插入这首歌到当前索引位置
playlist.splice(currentIndex, 0, song)
// 如果已经包含了这首歌
if (fpIndex > -1) {
// 如果当前插入的序号大于列表中的序号
if (currentIndex > fpIndex) {
playlist.splice(fpIndex, 1)
currentIndex--
} else {
playlist.splice(fpIndex + 1, 1)
}
}
let currentSIndex = findIndex(sequenceList, currentSong) + 1
let fsIndex = findIndex(sequenceList, song)
sequenceList.splice(currentSIndex, 0, song)
if (fsIndex > -1) {
if (currentSIndex > fsIndex) {
sequenceList.splice(fsIndex, 1)
} else {
sequenceList.splice(fsIndex + 1, 1)
}
}
commit(types.SET_PLAYLIST, playlist)
commit(types.SET_SEQUENCE_LIST, sequenceList)
commit(types.SET_CURRENT_INDEX, currentIndex)
commit(types.SET_FULL_SCREEN, true)
commit(types.SET_PLAYING_STATE, true)
}
export const saveSearchHistory = function ({commit}, query) {
commit(types.SET_SEARCH_HISTORY, saveSearch(query))
}
export const deleteSearchHistory = function ({commit}, query) {
commit(types.SET_SEARCH_HISTORY, deleteSearch(query))
}
export const clearSearchHistory = function ({commit}) {
commit(types.SET_SEARCH_HISTORY, clearSearch())
}
export const deleteSong = function ({commit, state}, song) {
let playlist = state.playlist.slice()
let sequenceList = state.sequenceList.slice()
let currentIndex = state.currentIndex
let pIndex = findIndex(playlist, song)
playlist.splice(pIndex, 1)
let sIndex = findIndex(sequenceList, song)
sequenceList.splice(sIndex, 1)
if (currentIndex > pIndex || currentIndex === playlist.length) {
currentIndex--
}
commit(types.SET_PLAYLIST, playlist)
commit(types.SET_SEQUENCE_LIST, sequenceList)
commit(types.SET_CURRENT_INDEX, currentIndex)
if (!playlist.length) {
commit(types.SET_PLAYING_STATE, false)
} else {
commit(types.SET_PLAYING_STATE, true)
}
}
export const deleteSongList = function ({commit}) {
commit(types.SET_CURRENT_INDEX, -1)
commit(types.SET_PLAYLIST, [])
commit(types.SET_SEQUENCE_LIST, [])
commit(types.SET_PLAYING_STATE, false)
}
export const savePlayHistory = function ({commit}, song) {
commit(types.SET_PLAY_HISTORY, savePlay(song))
}
export const saveFavoriteList = function ({commit}, song) {
commit(types.SET_FAVORITE_LIST, saveFavorite(song))
}
export const deleteFavoriteList = function ({commit}, song) {
commit(types.SET_FAVORITE_LIST, deleteFavorite(song))
}
用vuex在路由间传递复杂数据
歌手页面完成数据的设置,在歌手详情页面开始获取数据
代码注释:获取单个数据字段,刷新的边界处理
歌手详情页,为了组件重用抽出来一个music-list.vue,在此基础又抽出来一个song-list.vue,有两层组件
song类
将乱而散的数据整理成我们想要的数据
import {getLyric} from 'api/song'
import {ERR_OK} from 'api/config'
import {Base64} from 'js-base64'
export default class Song {
constructor({id, mid, singer, name, album, duration, image, url}) {
this.id = id
this.mid = mid
this.singer = singer
this.name = name
this.album = album
this.duration = duration
this.image = image
this.url = url
}
getLyric() {
if (this.lyric) {
return Promise.resolve(this.lyric)
}
return new Promise((resolve, reject) => {
getLyric(this.mid).then((res) => {
if (res.retcode === ERR_OK) {
this.lyric = Base64.decode(res.lyric)
resolve(this.lyric)
} else {
reject('no lyric')
}
})
})
}
}
//工厂函数,调用song类
export function createSong(musicData) {
return new Song({
id: musicData.songid,
mid: musicData.songmid,
singer: filterSinger(musicData.singer),
name: musicData.songname,
album: musicData.albumname,
duration: musicData.interval,
image: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${musicData.albummid}.jpg?max_age=2592000`,
url: `http://ws.stream.qqmusic.qq.com/${musicData.songid}.m4a?fromtag=46`
})
}
//这个方法是将name数组按照/的方式展示,两个以上有效
function filterSinger(singer) {
let ret = []
if (!singer) {
return ''
}
singer.forEach((s) => {
ret.push(s.name)
})
return ret.join('/')
}
歌手详情页,transition动画、song类的应用
<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' //通过mapgetters语法糖
export default {
computed: {
//只需要数据结构的某一个字段
title() {
return this.singer.name
},
//只需要数据结构的某一个字段
bgImage() {
return this.singer.avatar
},
//位于计算属性,获取vuex里面的数据
...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>
//只需要数据结构的某一个字段
<style scoped lang="stylus" rel="stylesheet/stylus">
.slide-enter-active, .slide-leave-active
transition: all 0.3s
.slide-enter, .slide-leave-to
transform: translate3d(100%, 0, 0)
x轴 , y轴 ,z轴
</style>
歌手详情,子组件music-list
<template>
<div class="music-list">
<div class="back" @click="back">
<i class="icon-back"></i>
</div>
<h1 class="title" v-html="title"></h1>
//歌手头像背景,关注背景图的设置,以及样式,传说中的10:7写法???????
<div class="bg-image" :style="bgStyle" ref="bgImage">
<div class="play-wrapper">
<div ref="playBtn" v-show="songs.length>0" class="play" @click="random">
<i class="icon-play"></i>
<span class="text">随机播放全部</span>
</div>
</div>
//用来控制模糊层,但是兼容不好,pc、和部分安卓看不到效果,只有iOS可以看到
<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 type="text/ecmascript-6">
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' //语法糖用于获取actions
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
})
},
//一般会将这个方法放到methods对象的末尾
...mapActions([
'selectPlay',
'randomPlay'
])
},
watch: {
scrollY(newVal) {
let translateY = Math.max(this.minTransalteY, newVal) //最大不超过this.minTransalteY
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) //最小是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>
<style scoped lang="stylus" rel="stylesheet/stylus">
@import "~common/stylus/variable"
@import "~common/stylus/mixin"
.music-list
position: fixed
z-index: 100
top: 0
left: 0
bottom: 0
right: 0
background: $color-background
.back
position absolute
top: 0
left: 6px
z-index: 50
.icon-back
display: block
padding: 10px
font-size: $font-size-large-x
color: $color-theme
.title
position: absolute
top: 0
left: 10%
z-index: 40
width: 80%
no-wrap()
text-align: center
line-height: 40px
font-size: $font-size-large
color: $color-text
.bg-image
position: relative
width: 100%
height: 0
padding-top: 70%
transform-origin: top
background-size: cover
.play-wrapper
position: absolute
bottom: 20px
z-index: 50
width: 100%
.play
box-sizing: border-box
width: 135px
padding: 7px 0
margin: 0 auto
text-align: center
border: 1px solid $color-theme
color: $color-theme
border-radius: 100px
font-size: 0
.icon-play
display: inline-block
vertical-align: middle
margin-right: 6px
font-size: $font-size-medium-x
.text
display: inline-block
vertical-align: middle
font-size: $font-size-small
.filter
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background: rgba(7, 17, 27, 0.4)
.bg-layer
position: relative
height: 100%
background: $color-background
.list
position: fixed
top: 0
bottom: 0
width: 100%
background: $color-background
.song-list-wrapper
padding: 20px 30px
.loading-container
position: absolute
width: 100%
top: 50%
transform: translateY(-50%)
</style>
song-list子组件
<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 type="text/ecmascript-6">
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>
<style scoped lang="stylus" rel="stylesheet/stylus">
@import "~common/stylus/variable"
@import "~common/stylus/mixin"
.song-list
.item
display: flex
align-items: center
box-sizing: border-box
height: 64px
font-size: $font-size-medium
.rank
flex: 0 0 25px
width: 25px
margin-right: 30px
text-align: center
.icon
display: inline-block
width: 25px
height: 24px
background-size: 25px 24px
&.icon0
bg-image('first')
&.icon1
bg-image('second')
&.icon2
bg-image('third')
.text
color: $color-theme
font-size: $font-size-large
.content
flex: 1
line-height: 20px
overflow: hidden
.name
no-wrap()
color: $color-text
.desc
no-wrap()
margin-top: 4px
color: $color-text-d
</style>
随机播放 技术实现 随机数组
//随机数
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min)
}
//把数组打乱
export function shuffle(arr) {
let _arr = arr.slice()
for (let i = 0; i < _arr.length; i++) {
let j = getRandomInt(0, i)
let t = _arr[i]
_arr[i] = _arr[j]
_arr[j] = t
}
return _arr
}
export function debounce(func, delay) {
let timer
return function (...args) {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
func.apply(this, args)
}, delay)
}
}
时间戳转分秒 补零方法
//转换时间
format(interval) {
interval = interval | 0 //时间戳 向下取整
const minute = interval / 60 | 0 //向下取整
const second = this._pad(interval % 60)
return `${minute}:${second}`
},
//时间补零 n相当于补充的字符串的长度
_pad(num, n = 2) {
let len = num.toString().length //获取字符串的长度
while (len < n) {
num = '0' + num
len++
}
return num
},
播放器内置组件 player.vue,通过actions的方法--selectPlay,在此组件拿到currentSong,这里再重点说一下mutations和它的type要做到命名一致,nutations本质就是函数,第一个参数是state第二个参数是要修改的对象值
条形进度条应用到全屏播放
clientWidth 为content+padding的值
<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 type="text/ecmascript-6">
import {prefixStyle} from 'common/js/dom'
const progressBtnWidth = 16
const transform = prefixStyle('transform')
export default {
props: {
percent: {
type: Number,
default: 0
}
},
created() {
this.touch = {} //创建一个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 //计算差值
//max 的0 意思不能小于0 、、、、min,不能超过整个滚动条的宽度
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) {
//大于0 而且不是在拖动的状态下,拖动的时候不要改变
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>
圆形进度条 用到SVG
<template>
<div class="progress-circle">
<!-- viewBox 视口位置 width、height 是显示到页面的宽高 -->
<svg :width="radius" :height="radius" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
<!-- 内层圆圈 r半径 cx\yx为圆心的坐标,说明这个圆是100的宽度 -->
<circle class="progress-background" r="50" cx="50" cy="50" fill="transparent"/>
<!-- 外层圆圈 stroke-dasharray描边 stroke-dashoffset偏移量-->
<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">
<!-- 下面用到了动画的js钩子,用它来实现左下到右上的飞跃 -->
<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>
<!-- 圆形进度条 用到svg -->
<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="text/ecmascript-6">
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'
//下面的对象是 'common/js/config' 里面的内容
// export const playMode = {
// sequence: 0, //顺序播放
// loop: 1, //循环播放
// random: 2 //随机播放
// }
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)
},
// el 是元素、done执行会跳到afterEnter
enter(el, done) {
const {x, y, scale} = this._getPosAndScale()
//从零先跳到那个初始化的位置,60的时候放大,100的时候恢复正常
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 = ''
},
// el 是元素、done执行会跳到afterLeaave
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 //下一首歌,所以要加一
//如果到底类,就重置为0
if (index === this.playlist.length) {
index = 0
}
//设置vuex的index
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
},
//时间补零 n相当于补充的字符串的长度
_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 //大唱片的宽度,因为设置的是80%
const scale = targetWidth / width //初开始的缩放
const x = -(window.innerWidth / 2 - paddingLeft) //初始的x轴,从左下到右上所以是负值
const y = window.innerHeight - paddingTop - width / 2 - paddingBottom //初始化 y轴 2分支1的宽度
return {
x,
y,
scale
}
},
...mapMutations({
setFullScreen: 'SET_FULL_SCREEN'
}),
...mapActions([
'savePlayHistory'
])
},
watch: {
currentSong(newSong, oldSong) {
if (!newSong.id) {
return
}
//发现ID没变 就什么都不做,在切换模式的时候用到
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>
一级页面向二级页面传参数
首先在路由中定义一个二级,path就是id,再回到推荐页面加一个routerview以及点击事件,到此完成第一步。见下面的部分代码
{
path: '/recommend',
component: Recommend,
children: [
{
path: ':id',
component: Disc
}
]
},
<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>
<router-view></router-view>
</div>
</template>
selectItem(item) {
this.$router.push({
path: `/recommend/${item.dissid}`
})
this.setDisc(item)
},
正则整理数据结构
看下面的数据,其实请求接口的时候设置了json但是还是返回了,jsonp的格式怎么办?
if (typeof ret === 'string') {
//以字母开始 +表示一位或多位 \( \) 转义小括号 ()用它来分组 {} 表示下面的{}符号 \(\)表示不是小括号的任意字符 +表示一个或者多个
var reg = /^\w+\(({[^\(\)]+})\)$/
var matches = response.data.match(reg)
if (matches) {
ret = JSON.parse(matches[1])
}
}
打包后如何运行dist目录
1、npm run build 是本地打包 2、新建prod.server.js 最后运行 node prod.server.js 就能把项目跑起来
var express = require('express')
var config = require('./config/index')
var axios = require('axios')
var port = process.env.PORT || config.build.port
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)
}).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 = response.data.match(reg)
if (matches) {
ret = JSON.parse(matches[1])
}
}
res.json(ret)
}).catch((e) => {
console.log(e)
})
})
app.use('/api', apiRoutes)
app.use(express.static('./dist'))
module.exports = app.listen(port, function (err) {
if (err) {
console.log(err)
return
}
console.log('Listening at http://localhost:' + port + '\n')
})
是为了在build里面加port 让项目跑在这个端口
config/index.js
// see http://vuejs-templates.github.io/webpack for documentation.
var path = require('path')
module.exports = {
build: {
env: require('./prod.env'),
port: 9000,
index: path.resolve(__dirname, '../dist/index.html'),
assetsRoot: path.resolve(__dirname, '../dist'),
assetsSubDirectory: 'static',
assetsPublicPath: '',
productionSourceMap: true,
// Gzip off by default as many popular static hosts such as
// Surge or Netlify already gzip all static assets for you.
// Before setting to `true`, make sure to:
// npm install --save-dev compression-webpack-plugin
productionGzip: false,
productionGzipExtensions: ['js', 'css'],
// Run the build command with an extra argument to
// View the bundle analyzer report after build finishes:
// `npm run build --report`
// Set to `true` or `false` to always turn it on or off
bundleAnalyzerReport: process.env.npm_config_report
},
dev: {
env: require('./dev.env'),
port: 8080,
autoOpenBrowser: true,
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {},
// CSS Sourcemaps off by default because relative paths are "buggy"
// with this option, according to the CSS-Loader README
// (https://github.com/webpack/css-loader#sourcemaps)
// In our experience, they generally work as expected,
// just be aware of this issue when enabling this option.
cssSourceMap: false
}
}
如何优化首屏加载 提到了路由懒加载
让组件等于一个方法,这个方法再resolve这个组件
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
const Recommend = (resolve) => {
import('components/recommend/recommend').then((module) => {
resolve(module)
})
}
const Singer = (resolve) => {
import('components/singer/singer').then((module) => {
resolve(module)
})
}
const Rank = (resolve) => {
import('components/rank/rank').then((module) => {
resolve(module)
})
}
const Search = (resolve) => {
import('components/search/search').then((module) => {
resolve(module)
})
}
const SingerDetail = (resolve) => {
import('components/singer-detail/singer-detail').then((module) => {
resolve(module)
})
}
const Disc = (resolve) => {
import('components/disc/disc').then((module) => {
resolve(module)
})
}
const TopList = (resolve) => {
import('components/top-list/top-list').then((module) => {
resolve(module)
})
}
const UserCenter = (resolve) => {
import('components/user-center/user-center').then((module) => {
resolve(module)
})
}
vue升级的注意事项
在devDependencies里面的vue-template-compiler一定要和vue的版本保持一致否则编译的时候会报错。
除特别声明,本站所有文章均为原创,如需转载请以超级链接形式注明出处:SmartCat's Blog
标签:vuejs2.0
精华推荐