# vue3Music
**Repository Path**: junpeng1024/vue3-music
## Basic Information
- **Project Name**: vue3Music
- **Description**: 基于vue3的音乐app
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 1
- **Forks**: 0
- **Created**: 2022-04-10
- **Last Updated**: 2023-05-25
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
## 一
全局引入变量和 mixin
```
module.exports = {
css: {
loaderOptions: {
sass: {
// 全局引入变量和 mixin
prependData: `
@import "@/assets/scss/variable.scss";
@import "@/assets/scss/mixin.scss";`
}
}
}
}
```
## 第2章 项目初始化和推荐页面开发
### 2-11 2-12 v-loading 自定义指令的开发
```js
import { createApp } from 'vue'
import Loading from './loading.vue'
const loadingDirective = {
mounted(el, binding) {
// vue 是可以多实例的
const app = createApp(Loading) // loading 组件
// DOM 对象
const instance = app.mount(document.createElement('div'))
// 保存起来 方便多次使用
el.instance = instance
if (binding.value) {
append(el)
}
},
updated(el, binding) {
// 当 前后值不一样时
if (binding.value !== binding.oldValue) {
// 执行添加 或 移除 操作
binding.value ? append(el) : remove(el)
}
}
}
function append(el) {
el.appendChild(el.instance.$el)
}
function remove(el) {
el.removeChild(el.instance.$el)
}
export default loadingDirective
```
此时还存在一些问题
> loading 组件要求 外层容器的定位是 'absolute', 'fixed', 'relative'
```js
import { createApp } from 'vue'
import { addClass, removeClass } from '../../../assets/js/dom'
import Loading from './loading.vue'
const relativeCls = 'g-relative'
const loadingDirective = {
mounted(el, binding) {
// vue 是可以多实例的
const app = createApp(Loading) // loading 组件
// DOM 对象
const instance = app.mount(document.createElement('div'))
// 保存起来 方便多次使用
el.instance = instance
if (binding.value) {
append(el)
}
},
updated(el, binding) {
// 当 前后值不一样时
if (binding.value !== binding.oldValue) {
// 执行添加 或 移除 操作
binding.value ? append(el) : remove(el)
}
}
}
function append(el) {
console.log(el.classList)
// el.instance.$el loading 组件的DOM对象
/** 还存在一些问题 ::
* loading 组件要求 外层容器的定位是 'absolute', 'fixed', 'relative'
*/
const style = getComputedStyle(el) // getComputedStyle 用于获取指定元素的css
if (['absolute', 'fixed', 'relative'].indexOf(style.position) === -1) {
addClass(el, relativeCls)
}
console.log(el.style)
el.appendChild(el.instance.$el)
}
function remove(el) {
removeClass(el, relativeCls)
el.removeChild(el.instance.$el)
}
export default loadingDirective
```
dom.js
```js
export function addClass(el, className) {
console.log(el.classList)
if (!el.classList.contains(className)) {
el.classList.add(className)
}
}
export function removeClass(el, className) {
// 删除一个不存在的 class 不会报错 所以不用判断
el.classList.remove(className)
}
```
设置 加载文本
recommend.vue 中
```html
Text: '测试测试...'
```
```js
mounted(el, binding) {
console.log(binding)
// vue 是可以多实例的
const app = createApp(Loading) // loading 组件
// DOM 对象
const instance = app.mount(document.createElement('div'))
// 保存起来 方便多次使用
el.instance = instance
const title = binding.arg
if (typeof title !== 'undefined') {
instance.setTitle(title)
}
if (binding.value) {
append(el)
}
},
updated(el, binding) {
const title = binding.arg
if (typeof title !== 'undefined') {
el.instance.setTitle(title)
}
// 当 前后值不一样时
if (binding.value !== binding.oldValue) {
// 执行添加 或 移除 操作
binding.value ? append(el) : remove(el)
}
}
```
## 第三章
### 3-1 歌手列表数据获取
### 3-2 IndexList 组件基础滚动功能实现
### 3-3 3-4 歌手列表固定标题实现(上)(中)
scroll 组件中修改配置
```js
emits: ['scroll'],
setup(props, { emit }) {
const rootRef = ref(null)
useScroll(rootRef, props, emit)
return { rootRef }
}
```
```js
// 根据传入的probeType来控制 滚动的监听强度 来节约资源
if (options.probeType > 0) {
scrollVal.on('scroll', (pos) => {
emit('scroll', pos)
})
}
```
index-list 传入type = 3 与监听事件
```vue
...
setup(props) {
// eslint-disable-next-line no-undef
const { groupRef, onScroll, fixedTitle } = useFixed(props)
return { groupRef, onScroll, fixedTitle }
}
```
use-fixed.js
```js
import { computed, nextTick, ref, watch } from 'vue'
export default function useFixed(props) {
const groupRef = ref(null)
const listHeights = ref([])
const scrollY = ref(0)
const currentIndex = ref(0) // 当前渲染组的索引
// const distance = ref(0)
const fixedTitle = computed(() => {
if (scrollY.value < 0) {
return ''
}
const currentGroup = props.data[currentIndex.value]
return currentGroup ? currentGroup.title : ''
})
watch(() => props.data, async () => {
/**
* 这样写还是存在问题
* 当数据发生变化之后,这个回调函数内部的DOM还是没有发生变化的
* DOM发生变化是在nextTick 之后
*/
// nextTick(() => {
// calculate()
// })
await nextTick()
calculate()
})
watch(scrollY, (newY) => {
const listHeightsVal = listHeights.value
for (let i = 0; i < listHeightsVal.length - 1; i++) {
const heightTop = listHeightsVal[i]
const heightBottom = listHeightsVal[i + 1]
// 此时落在区间上
if (newY >= heightTop && newY <= heightBottom) {
currentIndex.value = i
return
}
}
})
/** 计算
* 什么情况的时候去计算,当数据变化时,DOM就会发生变化,也就是DOM变化后要去计算
*/
function calculate() {
const list = groupRef.value.children
const listHeightsVal = listHeights.value
let height = 0
listHeightsVal.length = 0
listHeightsVal.push(height)
for (let i = 0; i < list.length; i++) {
// 每个元素对应的DOM
/**
* 这里为什么要累加? 因为滚动的一个值 就列表的高度 从0到最大滚动高度就一个不断递增的值
* 所以区间也要这样记录,方便对应它们的关系
*/
height += list[i].clientHeight
listHeightsVal.push(height)
}
console.log(listHeightsVal)
}
function onScroll(pos) {
// 这里scroll 往下滑动时反馈的值是一个负值
scrollY.value = -pos.y
}
return { groupRef, onScroll, fixedTitle }
}
```
### 3-6 歌手列表固定标题实现(下)
当标题快接近fixedTitle 的时候是一个顶上去的交互效果
```js
watch(scrollY, (newY) => {
const listHeightsVal = listHeights.value
for (let i = 0; i < listHeightsVal.length - 1; i++) {
const heightTop = listHeightsVal[i]
const heightBottom = listHeightsVal[i + 1]
// 此时落在区间上
// newY >= 0 && newT <= 760
if (newY >= heightTop && newY <= heightBottom) {
currentIndex.value = i
distance.value = heightBottom - newY // 距离
}
}
})
```
```js
const fixedStyle = computed(() => {
const distanceVal = distance.value
const diff = (distanceVal > 0 && distanceVal < TITLE_HEIGHT) ? distanceVal - TITLE_HEIGHT : 0
return {
transform: `translate3d(0,${diff}px,0)`
}
})
```
```vue
```
### 3-7 歌手列表快速导航入口实现(01)
```vue
```
### 3-8 歌手列表快速导航入口实现(02)
当点击字母的时候可以去切换对应的组,当用手指拖动的时候根据拖动情况去切换对应的组
点击交互: 在移动端通常会监听一些touch事件比如touchMove等,那么betterScroll 也是基于touch事件实现的滚动,那么就可以监听touchStatr事件, 那么可不可以为每个元素都监听一个touchStatr事件? 因为可以拿到元素所在的列表中的一个索引,然后这个索引可以对应的组的DOM,这样做是没问题的,问题是要去为每个元素都去做touchStart事件,是非常没有必要的,可以通过父元素去绑定一个事件,通过事件委托的方式,这样性能会更好,可以通过target拿到对应的索引
利用better-scroll 内置API `scrollToElement`
scrollToElement(el, time, offsetX, offsetY, easing)
- 参数
- {DOM | string} el 滚动到的目标元素, 如果是字符串,则内部会尝试调用 querySelector 转换成 DOM 对象。
- {number} time 滚动动画执行的时长(单位 ms)
- {number | boolean} offsetX 相对于目标元素的横轴偏移量,如果设置为 true,则滚到目标元素的中心位置
- {number | boolean} offsetY 相对于目标元素的纵轴偏移量,如果设置为 true,则滚到目标元素的中心位置
- {Object} easing 缓动函数,一般不建议修改,如果想修改,参考源码中的 `packages/shared-utils/src/ease.ts` 里的写法
- **返回值**:无
- **作用**:滚动到指定的目标元素
index-list.vue
```vue
```
scroll.vue
将 scroll return出去
```
setup(props, { emit }) {
const rootRef = ref(null)
const scroll = useScroll(rootRef, props, emit)
return { rootRef, scroll }
}
```
use-scroll.js
```js
export default function useScroll(wrapperRef, options, emit) {
const scroll = ref(null)
onMounted(() => {
/** BScroll 判断能不能滚动 new 的时候 此时会做计算
* debugger 的时候scroll 标签里面的内容是没有的,所以就不满足滚动条件,就不能滚动
* 解决: 利用 observe-dom 当 DOM 元素发生变化时去自动调用 refresh 方法
*/
const scrollVal = scroll.value = new BScroll(wrapperRef.value, {
observeDOM: true,
...options
})
// 根据传入的probeType来控制 滚动的监听强度 来节约资源
if (options.probeType > 0) {
scrollVal.on('scroll', (pos) => {
emit('scroll', pos)
})
}
})
onUnmounted(() => {
scroll.value.destroy()
})
return scroll
}
```
index-list.vue
利用自定义属性 :data-index="index" 便于获取DOM
```vue
export default {
setup(props) {
// eslint-disable-next-line no-undef
const { groupRef, onScroll, fixedTitle, fixedStyle, currentIndex } = useFixed(props)
const { shortcutList, onShortcutTouchStart, scrollRef } = useShortcut(props, groupRef)
return { groupRef, onScroll, fixedTitle, fixedStyle, shortcutList, currentIndex, onShortcutTouchStart, scrollRef }
}
}
```
use-shortcut.js
```js
function onShortcutTouchStart(e) {
const anchorIndex = parseInt(e.target.dataset.index) // 索引
const targetEl = groupRef.value.children[anchorIndex] // 通过索引拿到对应组的DOM
const scroll = scrollRef.value.scroll
scroll.scrollToElement(targetEl, 500) // 调用betterScroll 的 scrollToElement 方法
}
```
### 3-9 歌手列表快速导航入口实现(03)
手指交互的效果是怎么实现的?
前面的是根据touchstart索引求得的位置,那么怎么根据touchmove去拿到索引?在touchstart是有一个初始的值,并且可以拿到手指触碰的纵坐标,在touchmove也可以拿到纵坐标。用touchmove的纵坐标减去touchstart的纵坐标求得差,求出锚点高度这样可以知道偏移了几个身位,然后根据初始的索引去加上这个差,就可以求得touchmove的一个索引
```js
import { computed, ref } from 'vue'
export default function useShortcut(props, groupRef) {
const scrollRef = ref(null)
const ANCHOR_HEIGHT = 18
const touch = {} // 记录坐标
const shortcutList = computed(() => {
return props.data.map((group) => {
return group.title
})
})
function onShortcutTouchStart(e) {
const anchorIndex = parseInt(e.target.dataset.index) // 索引
touch.y1 = e.touches[0].pageY
touch.anchorIndex = anchorIndex
scrollTo(anchorIndex)
}
function onShortcutTouchMove(e) {
// 记录touchstart、touchmove的纵坐标
touch.y2 = e.touches[0].pageY
const delta = (touch.y2 - touch.y1) / ANCHOR_HEIGHT | 0 // 差值 | 0 意思是整数向下取整的简写法
const anchorIndex = touch.anchorIndex + delta
scrollTo(anchorIndex)
}
function scrollTo(index) {
if (isNaN(index)) {
return
}
index = Math.max(0, Math.min(shortcutList.value.length - 1, index))
const targetEl = groupRef.value.children[index] // 通过索引拿到对应组的DOM
const scroll = scrollRef.value.scroll
scroll.scrollToElement(targetEl, 500) // 调用betterScroll 的 scrollToElement 方法
}
return { shortcutList, onShortcutTouchStart, scrollRef, onShortcutTouchMove }
}
```
## 第四章
### 4-2 歌手详情页批量获取歌曲
### 4-3 歌手详情页 MusicList 组件基础代码编写
### 4-4 歌手详情页 MusicList 组件功能交互优化(01)
music-list.vue 里面的背景是没有高度的而是用top挤开的,因为scroll组件里面用了定位,一进入页面scroll占满了全屏 而且是无法滚动的,
```
position: absolute;
bottom: 0;
z-index: 30;
width: 100%;
```
此时需要利用背景图片的高度来动态计算 scroll 里面的top值, 实现动态计算高度,而且可以滚动了,为什么可以滚动?一旦设置top值,容器是固定的,内容的高度远远超过容器的高度就可以滚动
```js
computed: {
scrollStyle() {
return {
top: `${this.imageHeight}px`
}
}
},
mounted() {
this.imageHeight = this.$refs.bgImage.clientHeight
}
```
### 4-5 4-6歌手详情页 MusicList 组件功能交互优化
效果: 当滚动到顶端时,标题固定,而且往上拉时背景图片会有一个放大的效果,类似于app里面的效果
分析:用图片的高度减去标题的高度
```js
this.maxTranslateY = this.imageHeight - RESERVED_HEIGHT // 可以滚动最大距离的高度
```
当 scrollY > this.maxTranslateY 时 将 设置为 zIndex = 10 标题就可以不被覆盖
当往下来有图片放大的效果
```
if (scrollY < 0) {
scale = 1 + Math.abs(scrollY / this.imageHeight)
}
```
具体代码
```js
bgImageStyle() {
const scrollY = this.scrollY
let zIndex = 0
let paddingTop = '70%'
let height = 0
let translateZ = 0 // 处理ios兼容
let scale = 1
if (scrollY > this.maxTranslateY) {
zIndex = 10
paddingTop = 0
height = `${RESERVED_HEIGHT}px`
translateZ = 1
}
if (scrollY < 0) {
scale = 1 + Math.abs(scrollY / this.imageHeight)
}
return {
paddingTop,
height,
backgroundImage: `url(${this.pic})`,
zIndex,
transform: `scale(${scale})translateZ(${translateZ}px)`
}
},
```
4-4 歌手详情页 MusicList 组件功能交互优化(03)
当往上来遮挡到背景图片时会有一个模糊的效果
分析:可以借助一个css属性`background-filter` 滤镜,可以作用在一个半透明层
[backdrop-filter - CSS(层叠样式表) | MDN (mozilla.org)](https://developer.mozilla.org/zh-CN/docs/Web/CSS/backdrop-filter)
```vue
```
```js
filterStyle() {
let blur = 0
const scrollY = this.scrollY
const imageHeight = this.imageHeight
if (scrollY >= 0) {
blur = Math.min(this.maxTranslateY / imageHeight, scrollY / imageHeight) * 20
}
return {
backdropFilter: `blur(${blur}px)`
}
}
```
#### 关于性能优化
编码中,可能会发现,用了许多的临时变量 比如 `const imageHeight = this.imageHeight`, 当在一个计算属性中取一个响应式变量大于一次的时候,一定要用一个临时变量缓存,这也是vue的常用优化技巧,因为每次this.xxx的时候呢,它会执行vue里面的依赖收集的过程,当频繁执行的时候呢,也就是频繁使用this.xxx,这个依赖收集会发生多次,显然这个是非常没有必要的,用本地局部变量去缓存就行了,这时是不会触发依赖收集的
### 4-7 歌手详情页支持详情页刷新
解决页面刷新时报错问题
分析:刷新的报错原因是,数据是从一级路由传递到二级路由,通过props拿到数据,当刷新之后数据就丢失了。
解决: 本地缓存, 如果props无数据就取缓存里面的
[good-storage - npm (npmjs.com)](https://www.npmjs.com/package/good-storage)
```
npm install good-storage
```
```
import storage from 'good-storage'
// localStorage
storage.set(key,val)
storage.get(key, def)
// sessionStorage
storage.session.set(key, val)
storage.session.get(key, val)
```
singer.vue
当路由跳转之前将数据放进缓存里面
```js
methods: {
selectSinger(singer) {
this.selectedSinger = singer
this.cacheSinger(singer)
this.$router.push({
path: `/singer/${singer.mid}`
})
},
cacheSinger(singer) {
storage.session.set(SINGER_KEY, singer)
}
}
```
singer-detail.vue
```js
computedData() {
let ret = null
const singer = this.singer
if (singer) {
return singer
} else {
const cacheSinger = storage.session.get(SINGER_KEY)
if (cacheSinger && cacheSinger.mid === this.$route.params.id) {
ret = cacheSinger
}
}
return ret
}
```
```js
async created() {
if (!this.computedData) {
const path = this.$route.matched[0].path
this.$router.push({ path })
}
const result = await getSingerDetail(this.computedData)
const songs = await processSongs(result.songs)
this.songs = songs
this.loading = false
}
```
### 4-8 歌手详情页路由过渡效果实现
vue3 和 vue2 的动画略有改变
```vue
```
### 4-9 歌手详情页边界情况处理
当歌曲列表为空是时候,渲染no-Result组件,这个组件和Loading组件非常相似,可以将逻辑抽离开来
assets/js/create-loading-like-directive.js
```js
import { createApp } from 'vue'
import { addClass, removeClass } from '@/assets/js/dom'
const relativeCls = 'g-relative'
export default function createLoadingLikeDirective(Comp) {
return {
mounted(el, binding) {
// vue 是可以多实例的
const app = createApp(Comp) // loading 组件
// DOM 对象
const instance = app.mount(document.createElement('div'))
// 保存起来 方便多次使用
el.instance = instance
const title = binding.arg
if (typeof title !== 'undefined') {
instance.setTitle(title)
}
if (binding.value) {
append(el)
}
},
updated(el, binding) {
const title = binding.arg
if (typeof title !== 'undefined') {
el.instance.setTitle(title)
}
// 当 前后值不一样时
if (binding.value !== binding.oldValue) {
// 执行添加 或 移除 操作
binding.value ? append(el) : remove(el)
}
}
}
function append(el) {
// el.instance.$el loading 组件的DOM对象
/** 还存在一些问题 ::
* loading 组件要求 外层容器的定位是 'absolute', 'fixed', 'relative'
*/
const style = getComputedStyle(el) // getComputedStyle 用于获取指定元素的css
if (['absolute', 'fixed', 'relative'].indexOf(style.position) === -1) {
addClass(el, relativeCls)
}
el.appendChild(el.instance.$el)
}
function remove(el) {
removeClass(el, relativeCls)
el.removeChild(el.instance.$el)
}
}
```
loading/directive.js
```js
import Loading from './loading.vue'
import createLoadingLikeDirective from '@/assets/js/create-loading-like-directive'
const loadingDirective = createLoadingLikeDirective(Loading)
export default loadingDirective
```
no-result/directive.js
```js
import NoResult from './no-result'
import createLoadingLikeDirective from '@/assets/js/create-loading-like-directive'
const noResultDirective = createLoadingLikeDirective(NoResult)
export default noResultDirective
```
main.js中 use
做完以上步骤会发现报错了,报错的内容是 ncaught (in promise) DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
点进去发现是 el.removeChild(el.instance.$el) 这行代码报的错, 这里removeChild不是它的子节点
通过debug发现 removeChild执行时,本来是要remove Loading 的,但是刚才又创建了一次noResult, 实际上el.instance.$el 是 noResult 了,本来是要移除loading这个层。因为noResult还没有创建,还没有创建到容器内,然后把noResult给删除就会报错。
在作用指令的时候,两个指令都作用到同一个元素,都给它们绑定到 el.instance 不太合适,因为同一个元素都会相继触发这个mounted, 都把 el.instance = instance,后面的会覆盖前面的,导致后续获取el.instance 是后来哪个,前面的就丢掉了。解决方法很简单,多建一维,根据传进的Comp 不同来设置
```js
import { createApp } from 'vue'
import { addClass, removeClass } from '@/assets/js/dom'
const relativeCls = 'g-relative'
export default function createLoadingLikeDirective(Comp) {
return {
mounted(el, binding) {
// vue 是可以多实例的
const app = createApp(Comp) // loading 组件
// DOM 对象
const instance = app.mount(document.createElement('div'))
const name = Comp.name
if (!el[name]) {
el[name] = {}
}
// 保存起来 方便多次使用
el[name].instance = instance
const title = binding.arg
if (typeof title !== 'undefined') {
el[name].instance.setTitle(title)
}
if (binding.value) {
append(el)
}
},
updated(el, binding) {
const name = Comp.name
const title = binding.arg
if (typeof title !== 'undefined') {
el[name].instance.setTitle(title)
}
// 当 前后值不一样时
if (binding.value !== binding.oldValue) {
// 执行添加 或 移除 操作
binding.value ? append(el) : remove(el)
}
}
}
function append(el) {
// el.instance.$el loading 组件的DOM对象
/** 还存在一些问题 ::
* loading 组件要求 外层容器的定位是 'absolute', 'fixed', 'relative'
*/
const name = Comp.name
const style = getComputedStyle(el) // getComputedStyle 用于获取指定元素的css
if (['absolute', 'fixed', 'relative'].indexOf(style.position) === -1) {
addClass(el, relativeCls)
}
el.appendChild(el[name].instance.$el)
}
function remove(el) {
const name = Comp.name
removeClass(el, relativeCls)
el.removeChild(el[name].instance.$el)
}
}
```
### 4-10 歌手详情页歌曲列表点击以及 vuex 的应用
播放器在任何页面都可以显示所以是一个全局组件
index.js
```js
// 开发环境下使用 createLogger 插件 查看提交状态
import { createStore, createLogger } from 'vuex'
import mutations from './mutations'
import state from './state'
import * as getters from './getters'
import * as actions from './actions'
const debug = process.env.NODE_ENV !== 'production' // 开发环境
export default createStore({
state,
mutations,
getters,
actions,
/*
严格模式:当去检测state修改是不是在提交mutations的时候, 就会深度watch state,
当state有任何变化,就会检测是不是在提交mutations,如果不是的话就会报警告,
当人这个严格模式会有性能消耗,因为深度watch了,所以在开发环境开启
*/
strict: debug,
plugins: debug ? [createLogger()] : []
})
```
```js
import { PLAY_MODE } from '@/assets/js/constant'
const state = {
sequenceList: [],
playlist: [], // 播放列表
playing: false, // 播放状态
playMode: PLAY_MODE.sequence, // 播放模式
currentIndex: 0, // 歌曲索引
fullScreen: false // 播放器的状态
}
export default state
```
mutations.js
```js
const mutations = {
setPlayingState(state, playing) {
state.playing = playing
},
setSequenceList(state, list) {
state.sequenceList = list
},
setPlaylist(state, list) {
state.playlist = list
},
setPlayMode(state, mode) {
state.playMode = mode
},
setCurrentIndex(state, index) {
state.currentIndex = index
},
setFullScreen(state, fullScreen) {
state.fullScreen = fullScreen
}
}
export default mutations
```
actions.js
```js
import { PLAY_MODE } from '@/assets/js/constant'
export function selectPlay({ commit }, { list, index }) {
commit('setPlayMode', PLAY_MODE.sequence)
commit('setSequenceList', list)
commit('setPlayingState', true)
commit('setFullScreen', true)
commit('setPlaylist', list)
commit('setCurrentIndex', index)
}
```
getters.js
```js
export const currentSong = (state) => {
return state.playlist[state.currentIndex] || {}
}
```
music-list.vue
```js
selectItem({ song, index }) {
this.selectPlay({
list: this.songs,
index
})
}
```
### 4-11 歌手详情页歌曲列表实现随机播放
随机播放是对歌曲列表进行随机算法打乱
```js
/** 洗牌算法 */
export function shuffle(source) {
/**
* 在编写一些工具函数时,尽量不要用副作用,就是不要改变原始的值
*/
const arr = source.slice()
for (let i = 0; i < arr.length; i++) {
const j = getRandomInt(i)
swap(arr, i, j)
}
return arr
}
function getRandomInt(max) {
return Math.floor(Math.random() * (max + 1))
}
function swap(arr, i, j) {
const t = arr[i]
arr[i] = arr[j]
arr[j] = t
}
```
## 第五章
### 5-1 播放器基础样式及歌曲播放功能开发
### 5-2 播放器播放按钮的暂停与播放逻辑开发
### 5-3 播放器歌曲前进与后退逻辑开发
```js
export default {
setup() {
// data
const audioRef = ref(null)
// vuex
// computed 响应式 这里的东西一旦变化 数据立马改变
const store = useStore()
const currentSong = computed(() => store.getters.currentSong)
const playlist = computed(() => store.state.playlist)
const fullScreen = computed(() => store.state.fullScreen)
const playing = computed(() => store.state.playing)
const playIcon = computed(() => {
return playing.value ? 'icon-pause' : 'icon-play'
})
const currentIndex = computed(() => store.state.currentIndex)
// watch
watch(currentSong, (newSong) => {
if (!newSong.id || !newSong.url) {
return
}
const audioEl = audioRef.value
audioEl.src = newSong.url
audioEl.play()
})
watch(playing, (newPlaying) => {
const audioEl = audioRef.value
newPlaying ? audioEl.play() : audioEl.pause()
})
// methods
function goBack() {
store.commit('setFullScreen', false)
}
function togglePlay() {
store.commit('setPlayingState', !playing.value)
}
function prev() {
const list = playlist.value
if (!list.length) { // 没有任何歌曲
return
}
// 当列表只有一首歌的时候 循环播放
if (list.length === 1) {
loop()
} else {
let index = currentIndex.value - 1
if (index === -1) { // 如果是第一页跳转到最后一页
index = list.length - 1
}
store.commit('setCurrentIndex', index)
if (!playing.value) {
store.commit('setPlayingState', true)
}
}
}
function next() {
const list = playlist.value
if (!list.length) { // 没有任何歌曲
return
}
let index = currentIndex.value + 1
if (list.length === 1) {
loop()
} else {
if (index === list.length) { // 如果是第一页跳转到最后一页
index = 0
}
store.commit('setCurrentIndex', index)
if (!playing.value) {
store.commit('setPlayingState', true)
}
}
}
function loop() {
const audioEl = audioRef.value
audioEl.currentTime = 0
audioEl.play()
store.commit('setPlayingState', true)
}
/** 防止电脑待机状态等时候,playing 还是 true的状态 */
function pause() {
store.commit('setPlayingState', false)
}
return { currentSong, playlist, fullScreen, audioRef, goBack, playIcon, togglePlay, pause, prev, next }
}
}
```
### 5-4 播放器 DOM 异常错误处理_
报错信息: Uncaught (in promise) DOMException: The play() request was interrupted by a new load request. https://goo.gl/LdLk22
发现是在 ` audioEl.src = newSong.url` 赋值的时候报错, 当快速点击切换歌曲的时候也会报错
解决:audio 添加 canplay事件 当歌曲有一定的缓存数据,足够播放就会触发该事件,音频的流式加载的,缓存一段数据就可以播放这一段,然后继续缓存下一段
@canplay="ready" 用一个标准来控制切换,不能很快就切换,不能状态一更新就播放,要等歌曲是ready的时候才去播放。
定义变量 songReady = ref(false)
```
songReady = ref(false)
```
```js
function ready() {
// 会多次缓存数据所以会多次执行 ready, 如果已经是true 直接返回
if (songReady.value) {
return
}
songReady.value = true
}
```
当 songReady为 false的时候 retrue 出去 解决首次歌曲播放报错问题
```js
watch(playing, (newPlaying) => {
// 当 songReady 还是false 的时候什么都不做 解决播放报错问题
if (!songReady.value) {
return
}
const audioEl = audioRef.value
newPlaying ? audioEl.play() : audioEl.pause()
})
```
当歌曲变化时要处理
```js
watch(currentSong, (newSong) => {
if (!newSong.id || !newSong.url) {
return
}
/**
* 当切换歌曲的时候将 songReady 置为 false
* 然后再去执行 audioEl.play(),然后歌曲去进行缓存,触发canplay事件,然后执行ready函数 将songReady 置为 true
*/
songReady.value = false
const audioEl = audioRef.value
audioEl.src = newSong.url
audioEl.play()
})
```
当点击 next prev 时 判断 songReady状态,如果为false 不让它前进和后退
```js
if (!songReady.value || !list.length) { // 没有任何歌曲
return
}
```
当按钮点击是,如果为无效点击添加 :class="disableCls"
```js
const disableCls = computed(() => {
return songReady.value ? '' : 'disable'
})
```
当歌曲异常时执行error事件,不会执行canplay事件
```
```
```js
function error() {
// 这里的作用是,如果是error 的状态,歌曲是可以前进和后退的
songReady.value = true
}
```
### 5-5 播放器 歌曲播放模式相关逻辑开发
```js
```
前面开发播放器的歌曲切换,歌曲播放等最基础的功能。但播放模式可以理解为播放器的增强功能。 播放模式逻辑可以不在主逻辑里面写了,可以拆分成钩子函数,利用composition API 拆分到不同维护,这样的话逻辑拆分的就比较清晰了
新建 use-mode.js
```js
import { computed } from 'vue'
import { useStore } from 'vuex'
import { PLAY_MODE } from '@/assets/js/constant'
export default function useMode() {
const store = useStore()
const playMode = computed(() => store.state.playMode)
const modeIcon = computed(() => {
const playModeVal = playMode.value
return playModeVal === PLAY_MODE.sequence ? 'icon-sequence' : (playModeVal === PLAY_MODE.random ? 'icon-random' : 'icon-loop')
})
function changeMode() {
const mode = (playMode.value + 1) % 3
console.log(mode)
store.dispatch('changeMode', mode)
}
return { modeIcon, changeMode }
}
```
actions.js
这个时候发现,当点击播放模式的时候,当前正在播放的歌曲也跟着改变了,这是因为,当前播放的歌曲是根据 playList 和 currentIndex 进行索引取歌。 所以当重新洗牌时,将顺序打乱了,当是currentIndex 没有对应打乱的数据,造成歌曲被改变
```js
export function changeMode({ commit, state }, mode) {
// 顺序播放 => 随机播放
if (mode === PLAY_MODE.random) {
// 洗牌
commit('setPlaylist', shuffle(state.sequenceList))
} else {
commit('setPlaylist', state.sequenceList)
}
commit('setPlayMode', mode)
}
```
解决: 改变playList之前先去拿到当前歌曲的 id , 先去缓存, 提交playMode 之前先要去修改 currentIndex
```js
export function changeMode({ commit, state, getters }, mode) {
const currentId = getters.currentSong.id
// 顺序播放 => 随机播放
if (mode === PLAY_MODE.random) {
// 洗牌
commit('setPlaylist', shuffle(state.sequenceList))
} else {
commit('setPlaylist', state.sequenceList)
}
const index = state.playlist.findIndex(song => {
return song.id === currentId
})
commit('setCurrentIndex', index)
commit('setPlayMode', mode)
}
```
### 5-6 5-7 播放器 歌曲收藏功能相关逻辑开发(1)
收藏的歌曲是多个的,所以是一个列表,当刷新页面时还能够知道那些歌曲被收藏。还有收藏和非收藏之间的切换。
state.js 里面添加
```js
const state = {
favoriteList: []
}
```
对应的 mutations.js
```js
setFavoriteList(state, list) {
state.favoriteList = list
}
```
为了代码的可维护性,新建 use-favorite.js 钩子
收藏歌曲或未来会一些逻辑是类似的,所以抽离出来, assets/js/array-store.js
```js
import storage from 'good-storage'
export function save(item, key, compare, maxLen) {
const items = storage.get(key, []) // 默认为空
inertArray(items, item, compare, maxLen)
storage.set(key, items)
return items
}
export function remove(key, compare) {
const items = storage.get(key, [])
deleteFromArray(items, compare)
storage.set(key, items)
return items
}
export function load(key) {
return storage.get(key, [])
}
function inertArray(arr, val, compare, maxLen) {
const index = arr.findIndex(compare) // -1
if (index > 1) {
return
}
arr.unshift(val) // 插入array第一项
// 收藏最大值
if (maxLen && arr.length > maxLen) {
arr.pop() // 先进先出
}
}
function deleteFromArray(arr, compare) {
const index = arr.findIndex(compare)
if (index > -1) {
arr.splice(index, 1)
}
}
```
use-favorite
```js
import { computed } from 'vue'
import { useStore } from 'vuex'
import { save, remove } from '@/assets/js/array-store'
import { FAVORITE_KEY } from '@/assets/js/constant'
export default function useFavorite() {
// data
const maxLen = 100
// vuex
const store = useStore()
const favoriteList = computed(() => store.state.favoriteList)
// methods
/** 判断歌曲是否存在 favoriteList 中 */
function getFavoriteIcon(song) {
console.log(isFavorite(song))
return isFavorite(song) ? 'icon-favorite' : 'icon-not-favorite'
}
/** 收藏或删除歌曲 */
function toggleFavorite(song) {
let list
if (isFavorite(song)) {
list = remove(FAVORITE_KEY, compare)
} else {
list = save(song, FAVORITE_KEY, compare, maxLen)
}
store.commit('setFavoriteList', list)
/**
* 下面的findIndex 可以让开发人员自定义传参数compare,可以传id,也可以传mid
* 所以可以利用这点,可以传入一个compare函数,这个compare函数的具体实现是在外部实现的
* 对于这个库而言,只要支持用户可以传入一个compare函数,不用关心具体的实现细节,这个细节是外部来决定的
* 只管调用就行了,相当于将这部分的逻辑剥离出去耦合了
*/
function compare(item) {
return item.id === song.id
}
}
function isFavorite(song) {
return favoriteList.value.findIndex((item) => {
return item.id === song.id
}) > -1
}
return { getFavoriteIcon, toggleFavorite }
}
```
当页面加载时,将本地的值赋给 favoriteList
```
favoriteList: load(FAVORITE_KEY) // 已收藏的列表
```
### 5-8 播放器 进度条相关逻辑开发(上)
基础骨架
```js
```
```js
const progressBtnWidth = 16
export default {
props: {
progress: {
type: Number,
default: 0
}
},
data() {
return {
offset: 0
}
},
watch: {
progress(newProgress) {
this.setOffset(newProgress)
}
},
computed: {
progressStyle() {
return `width:${this.offset}px`
},
btnStyle() {
return `transform:translate3d(${this.offset}px,0,0)`
}
},
methods: {
onclick() {
},
setOffset(progress) {
const barWidth = this.$el.clientWidth - progressBtnWidth
this.offset = barWidth * progress
}
}
}
```
当音频开始播放时,会触发@timeupdate事件
```
@timeupdate="updateTime"
```
```js
function updateTime(e) {
currentTime.value = e.target.currentTime
}
```
此时的 currentTime的数据未格式化
util.js 里面定义工具函数
```js
export function formatTime(interval) {
interval = interval | 0 // 向下取整
const minute = ((interval / 60 | 0) + '').padStart(2, '0')
const second = (interval % 60 + '').padStart(2, '0')
return `${minute}:${second}`
}
```
按钮滚动
```js
btnStyle() {
return `transform:translate3d(${this.offset}px,0,0)`
}
```
```js
setOffset(progress) {
const barWidth = this.$el.clientWidth - progressBtnWidth // 204
this.offset = barWidth * progress
},
```
```js
setOffset(progress) {
const barWidth = this.$el.clientWidth - progressBtnWidth // 减去按钮的16 === 204
this.offset = barWidth * progress // 转化百分比
console.log(`${barWidth} * ${progress}`)
},
```
### 5-9播放器 进度条相关逻辑开发(下)
当拖动时或者点击时进度条时,歌曲发生进度变化
添加事件
```html
```
当手指刚刚触摸的X轴减去手指滑动时的距离,求出距离后,同时在手指刚刚触摸的时候拿到黄色条的初始宽度 beginWidth,
onTouchMove的过程中,拿到将 X 轴减 去 onTouchStatrt的 X 轴,拿到偏移量。偏移量再加上黄色进度条的宽度。然后拿到区间 `const progress = Math.min(1, Math.max(tempWidth / barWidth, 0))`
`this.offset = barWidth * progress` 滑动同时改变进度条进度
onTouchEnd 当滑动结束时,拿到 barWidth ,用黄色进度条 减去 barWidth,然后向外抛出事件
```js
onTouchStart(e) {
this.touch.x1 = e.touches[0].pageX
this.touch.beginWidth = this.$refs.progress.clientWidth // 黄色条初始化宽度
},
onTouchMove(e) {
const delta = e.touches[0].pageX - this.touch.x1 // 偏移
const tempWidth = this.touch.beginWidth + delta // 位移过后 + 黄色条的宽度
const barWidth = this.$el.clientWidth - progressBtnWidth // 整个进度条的宽度
const progress = Math.min(1, Math.max(tempWidth / barWidth, 0)) // 0 - 1 区间
this.offset = barWidth * progress
this.$emit('progress-changing', progress) // 手指未离开
},
onTouchEnd() {
const barWidth = this.$el.clientWidth - progressBtnWidth
const progress = this.$refs.progress.clientWidth / barWidth
this.$emit('progress-changed', progress)
}
```
player.vue
监听事件
```js
function onProgressChanging(progress) {
currentTime.value = currentSong.value.duration * progress
}
function onProgressChanged(progress) {
// 当收松开 再去修改audio的时间
audioRef.value.currentTime = currentTime.value = currentSong.value.duration * progress
// 如果当时歌曲是暂停的让它播放
if (!playing.value) {
store.commit('setPlayingState', true)
}
}
```
此时存在问题。在播放过程中拖动,进度条会拖动不了的异常。
分析:在 onProgressChanging() 修改了 currentTime,但是currentTime 一旦发生改变的话,progress是一个computed 会根据currentTime 做一个新的计算,一旦计算之后progress值传到 `progress-bar.vue`组件里面,会watch到progress的变化,它内部也会进行计算,根据 barWidth * newProress 重新计算
所以 在 onProgressChanging() 虽然修改的 currentTime 发生变化,会将进度条改变为对应的位置,由于歌曲正在播放过程中,实际上有一个updateTime 这里的 currentTime 仍然在修改。所以两边在同时修改,onProgressChanging这里修改了,但 updateTime 又将值修改回去了。所以进度条会来回跳
解决:在updateTime 做一层控制
```js
function updateTime(e) {
if (!progressChanging) {
currentTime.value = e.target.currentTime
}
}
function onProgressChanging(progress) {
progressChanging = true
currentTime.value = currentSong.value.duration * progress
}
function onProgressChanged(progress) {
progressChanging = false
// 当收松开 再去修改audio的时间
audioRef.value.currentTime = currentTime.value = currentSong.value.duration * progress
// 如果当时歌曲是暂停的让它播放
if (!playing.value) {
store.commit('setPlayingState', true)
}
}
```
点击功能
分析: 通过 getBoundingClientRect 获取得该元素在距离页面左侧的距离 减去 pageX 得到偏移量
```js
onClick(e) {
// getBoundingClientRect用于获得页面中某个元素的左,上,右和下分别相对浏览器视窗的位置。
const rect = this.$el.getBoundingClientRect()
const offsetWidth = e.pageX - rect.left
const barWidth = this.$el.clientWidth - progressBtnWidth
const progress = offsetWidth / barWidth
this.$emit('progress-changed', progress)
},
```
### 5-10 播放器 cd 唱片旋转相关逻辑开发
当 img 转动的时候, cd 是有一个角度的,所以当停止是时要 concat 两个角度
```js
function syncTransform(wrapper, inner) {
// 外层同步内层
const wrapperTransform = getComputedStyle(wrapper).transform
const innerTransform = getComputedStyle(inner).transform
wrapper.style.transform = wrapperTransform === 'none' ? innerTransform : innerTransform.concat(' ', wrapperTransform)
}
return { cdCls, cdRef, cdImageRef }
```
### 5-11 播放器 歌词相关逻辑开发(01)
后端要通过base64解码,所以要 npm
song.js
当前歌曲发生变化的时候调用该接口
```js
export function getLyric(song) {
const mid = song.mid
return get('api/getLyric', {
mid
}).then(result => {
const lyric = result ? result.lyric : '[00:00:00]该歌曲暂无法获取歌词'
return lyric
})
}
```
处理歌曲相关的钩子 use-lyric.js
use-lyric.js
```js
import { computed, watch } from 'vue'
import { useStore } from 'vuex'
import { getLyric } from '../../service/song'
export default function useLyric() {
const store = useStore()
const currentSong = computed(() => store.getters.currentSong)
watch(currentSong, async (newSong) => {
if (!newSong.url || !newSong.id) {
return
}
const lyric = await getLyric(newSong)
console.log(lyric)
})
}
```
当点击切换歌曲时,是可以做歌曲歌词的缓存的,当下一次切换时就不用去就行网络请求了
```js
const lyricMap = {}
export function getLyric(song) {
// 如果存在歌词就直接return
console.log(lyricMap)
if (song.lyric) {
return Promise.resolve(song.lyric)
}
const mid = song.mid
/** 进一步优化
* song 这个对象,不同对象它的mid可能是相等的,所以这里可以定义lyricMap
* lyricMap key是id 值是lyric
*/
const lyric = lyricMap[mid]
if (lyric) {
return Promise.resolve(lyric)
}
return get('api/getLyric', {
mid
}).then(result => {
const lyric = result ? result.lyric : '[00:00:00]该歌曲暂无法获取歌词'
lyricMap[mid] = lyric
console.log('发送了http请求歌词')
return lyric
})
}
```
使用 lyric-parser 解析歌词
```js
import { computed, ref, watch } from 'vue'
import { useStore } from 'vuex'
import { getLyric } from '../../service/song'
import Lyric from 'lyric-parser'
export default function useLyric() {
const currentLyric = ref(null)
const store = useStore()
const currentSong = computed(() => store.getters.currentSong)
watch(currentSong, async (newSong) => {
if (!newSong.url || !newSong.id) {
return
}
const lyric = await getLyric(newSong)
store.commit('addSongLyric', {
song: newSong,
lyric
})
/**
* 注意 getLyric 是个异步的过程是有网络延迟的,如果一首歌曲,比如说a切换到b这个时候 b在 getLyric过程中
* 又从b切到了c, 那么之前b getLyric 返回的逻辑都不用执行了,所以需要做一个判断
*/
if (currentSong.value.lyric !== lyric) {
return
}
currentLyric.value = new Lyric(lyric, handleLyric)
})
}
/** 当歌词切换过程中触发 */
function handleLyric() {
}
```
### 5-12 播放器 歌词相关逻辑开发(02)
当播放音乐的时候,歌词并没有反应,因为这里虽然实例化了,但是没有触发,什么时触发?可以在音乐播放后触发
调用插件 api seek 实现歌词高亮
```js
/** 这里虽然实例化了 但是没有播放 什么时候播放呢,可以在实例化后播放
* 但是这里判断,注意这里有两个异步过程 currentSong变化 和 歌曲播放的canplay 触发的 ready 也是一个异步过程
* 所以要判断 songReady 是否为 true ,为true时证明已经开始播放这时去触发实例播放才有意思
*/
currentLyric.value = new Lyric(lyric, handleLyric)
if (songReady.value) {
playLyric()
}
function playLyric() {
const currentLyricVal = currentLyric.value
if (currentLyricVal) {
currentLyricVal.seek(currentTime.value * 1000)
}
}
```
当歌词下一句的时候为什么会高亮?
当实例化后会触发 handleLyric函数 这个函数是歌词切换的过程中触发的
lineNum 是当前的行号
```js
/** 当歌词切换过程中触发 */
function handleLyric({ lineNum, txt }) {
// lineNum 当前行号
currentLineNum.value = lineNum
// 因为 lyricListRef 是组件实例所以 加上 Comp
const scrollComp = lyricScrollRef.value
// DOM 实例 加上 El
const listEl = lyricListRef.value
// 如果没有这个列表就 return
if (!listEl) {
return
}
if (lineNum > 5) {
// 保持歌词居中的位置
const lineEl = listEl.children[lineNum - 5]
scrollComp.scroll.scrollToElement(lineEl, 1000)
} else {
scrollComp.scroll.scrollTo(0, 0, 1000)
}
}
```
以上可以实现歌曲的高亮过程,但是歌词并没有随着歌曲的进行而滚动,所以要在scroll组件里面作文章
添加 ref="lyricScrollRef" ref="lyricListRef"
```js
const lyricScrollRef = ref(null)
const lyricListRef = ref(null)
```
当歌词切换过程中 让歌词滚动
如果 listEl 是 false 的话 可能是没有歌词,就return 出去
如果 lineNum > 5 让歌词保持在居中的位置,如果小于5 则滚动到顶部
```js
/** 当歌词切换过程中触发 */
function handleLyric({ lineNum, txt }) {
// lineNum 当前行号
currentLineNum.value = lineNum
// 因为 lyricListRef 是组件实例所以 加上 Comp
const scrollComp = lyricScrollRef.value
// DOM 实例 加上 El
const listEl = lyricListRef.value
// 如果没有这个列表就 return
if (!listEl) {
return
}
if (lineNum > 5) {
// 保持歌词居中的位置
const lineEl = listEl.children[lineNum - 5]
scrollComp.scroll.scrollToElement(lineEl, 1000)
} else {
scrollComp.scroll.scrollTo(0, 0, 1000)
}
}
```
这时当歌曲停止时,歌词仍然在滚动,这是因为歌词和歌曲之间的播放没有关联起来,要监听playing 的变化,如果说歌曲播放到暂停的状态,歌词也要做相应的暂停
```js
/** 停止歌曲滚动 */
function stopLyric() {
const currentLyricVal = currentLyric.value
if (currentLyricVal) {
currentLyricVal.stop()
}
}
```
当watch playing 变化的时候 就 停止 或者 滚动
```js
watch(playing, (newPlaying) => {
// 当 songReady 还是false 的时候什么都不做 解决播放报错问题
if (!songReady.value) {
return
}
const audioEl = audioRef.value
if (newPlaying) {
audioEl.play()
playLyric()
} else {
audioEl.pause()
// 因为当歌曲暂停时,歌词还没有暂停,这里要同时暂停歌词 做到同步
stopLyric()
}
})
```
当去拖动进度条的时候,歌曲并没有变化,这时拖动进度条的时候时间是随着进度拖动,歌词也随着滚动
```js
function onProgressChanging(progress) {
progressChanging = true
currentTime.value = currentSong.value.duration * progress
// 正在拖动的过程中 先play 同步到当前的位置 再stop 因为changing的时候是不需要变化的
playLyric()
stopLyric()
}
function onProgressChanged(progress) {
progressChanging = false
// 当收松开 再去修改audio的时间
audioRef.value.currentTime = currentTime.value = currentSong.value.duration * progress
// 如果当时歌曲是暂停的让它播放
if (!playing.value) {
store.commit('setPlayingState', true)
}
// 当 拖动结束时 播放歌词
playLyric()
}
```
当歌曲来回切换时,歌词会来回跳动
分析:在currentSong 切换的过程中 currentlyric是存在的,如果之前创建过currentLyric 它是存在的。所以要在切换的时候stop掉
```js
watch(currentSong, async (newSong) => {
if (!newSong.url || !newSong.id) {
return
}
stopLyric()
const lyric = await getLyric(newSong)
....
})
```
这时还是会出问题,虽然 stopLyric() 了 ,说明上一首歌已经stopLyric 了, 这时候监听 currentSong 了 切换到下一首歌,然后下一首歌会去加载歌曲,同时去加载歌词,假设歌词还没有加载完,还没有去创建一个新的 Lyric实例,所以虽然执行了 stopLyric() 但是还是上一首歌,还在加载歌词的过程中,然后这个时候触发 ready() , 下一首歌已经ready了 但是歌词还没有好,就执行playLyric() ,这时执行的playLyric()指向的还是前一个,就是播放前一个的 Lyric, 就会有来回跳的情况, 所以再做一层清理
```
currentLyric.value = null
currentLineNum.value = 0
```
### 5-13 播放器 歌词相关逻辑开发(03
处理纯音乐的情况
```html
```
当去实例化 Lyric 的时候,去判断 歌曲的长度,如果有长度的话,就有歌词
```js
currentLyric.value = new Lyric(lyric, handleLyric)
const hasLyric = currentLyric.value.lines.length
if (hasLyric) {
if (songReady.value) {
playLyric()
}
} else {
pureMusicLyric.value = lyric.replace(/\[(\d{2}):(\d{2}):(\d{2})\]/g, '') // 这里的作用是 截取掉[00:00:00]
}
```
当前播放的文案
```html
```
当歌曲播放的过程中赋值
```
function handleLyric({ lineNum, txt }) {
playingLyric.value = txt
}
```
```
处理边界问题
pureMusicLyric.value = ''
```
### 5-14 播放器 中间视图层手指交互相关逻辑开发(上
歌词和 CD视图层相互滑动,滑动过程中,透明度会发生改变,底部有会白点对应的视图层,当滑动到百分之20这样就可以实现滑动视图,并不需要滑动的特别多,也符合用户的需求
```html
```
中间层的逻辑也单独提出
计算位移,根据当前的屏幕不同就有不同的初始值,比如当前的view是一个cd, 一开始的初始值就是0,如果当前的view是lyric,那它的位移就是整个屏幕的宽度,所以要根据当前的视图来做判断
定义变量
```
// data
const currentShow = ref('cd')
const middleLStyle = ref(null)
const middleRStyle = ref(null)
```
手指数据
```
const touch = {}
let currentView = 'cd'
```
```js
// methods
function onMiddleTouchStart(e) {
touch.startX = e.touches[0].pageX
}
function onMiddleTouchMove(e) {
const deltaX = e.touches[0].pageX - touch.startX
// 如果 currentView === 'lyric' 则它的初始值为负的 window.innerWidth
const left = currentView === 'cd' ? 0 : -window.innerWidth
const offsetWidth = Math.min(0, Math.max(-window.innerHeight, left + deltaX))
console.log(offsetWidth)
touch.percent = Math.abs(offsetWidth / window.innerWidth) // abs 返回绝对值
if (currentView === 'cd') {
if (touch.percent > 0.2) {
currentShow.value = 'lyric'
} else {
currentShow.value = 'cd'
}
} else {
if (touch.percent < 0.8) {
currentShow.value = 'cd'
} else {
currentShow.value = 'lyric'
}
}
middleLStyle.value = {
opacity: 1 - touch.percent,
transitionDuration: '0ms'
}
middleRStyle.value = {
transform: `translate3d(${offsetWidth}px, 0, 0)`,
transitionDuration: '0ms'
}
}
function onMiddleTouchEnd() {
let offsetWidth
let opacity
if (currentShow.value === 'cd') {
currentView = 'cd'
offsetWidth = 0
opacity = 1
} else {
currentView = 'lyric'
offsetWidth = -window.innerWidth
opacity = 0
}
const duration = 300
middleLStyle.value = {
opacity,
transitionDuration: `${duration}ms`
}
middleRStyle.value = {
transform: `translate3d(${offsetWidth}px, 0, 0)`,
transitionDuration: `${duration}ms`
}
}
```
### 5-15 播放器 中间视图层手指交互相关逻辑开发(下)
解决斜着也可以滑动的问题
分析:本身这个歌词列表是支持纵向滑动的,better-scroll有一个方向锁,可以锁定一个滑动方向,这里的需求是锁定横向滑动
```js
function onMiddleTouchStart(e) {
touch.startX = e.touches[0].pageX
touch.startY = e.touches[0].pageY
touch.directionLocked = ''
}
```
```js
function onMiddleTouchMove(e) {
const deltaX = e.touches[0].pageX - touch.startX
const deltaY = e.touches[0].pageY - touch.startY
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
if (!touch.directionLocked) {
touch.directionLocked = absDeltaX >= absDeltaY ? 'h' : 'v'
}
if (touch.directionLocked === 'v') {
return
}
}
```
### 5-16 播放器 mini 播放器开发(01)
基础样式
```js
{{ currentSong.name }}
{{ currentSong.singer }}
```
cd 旋转可以复用 useCd 钩子的逻辑
```
const { cdCls, cdRef, cdImageRef } = useCd()
```
min 播放器的出入场动画
```css
&.mini-enter-active,
&.mini-leave-active {
transition: all 0.6s cubic-bezier(0.45, 0, 0.55, 1);
}
&.mini-enter-from,
&.mini-leave-to {
opacity: 0;
transform: translate3d(0, 100%, 0);
}
```
### 5-17 播放器 mini 播放器开发(02)
点击按钮可以暂停或者播放歌曲,按钮外层还有一个圈,表示播放进度,这里是用svg实现的,具体实现看视频
progress-circle.vue
```vue
```
### 5-18 播放器 mini 播放器开发(03)
当我们点击暂停mini播放器,再进入播放器页面发现进度条失效了
分析:progressStyle 是依赖 offset 计算的,但是这个offset 会 watch, progress 动态更新的,这个 watch里面会有问题
```js
// watch
progress(newProgress) {
this.setOffset(newProgress)
}
// methods
setOffset(progress) {
const barWidth = this.$el.clientWidth - progressBtnWidth // 减去按钮的16 === 204
this.offset = barWidth * progress // 转化百分比
},
```
这里的 barWidth 依赖于 DOM api this.$el.clientwidth, 这个 progress 是一直更新的,但是 fullScreen 为 false 的时候也就是 mini播放器显示的时候,那么 normal-player 为 display:none, 那么这个情况下去调用 DOM api 去计算 clienWidth 肯定是不对的,所以offset 值也是不对的
所以当 normal-player 为 true 的时候再去计算一次 注意 要等待DOM渲染完毕才能获取 使用 nextTick
```js
watch(fullScreen, async (newFullScreen) => {
if (newFullScreen) {
await nextTick()
barRef.value.setOffset(progress.value)
}
})
```
### 5-19 播放器 mini 播放器开发(04)
mini 支持手指滑动切换歌曲功能
分析:渲染歌曲列表,通过better-scroll 渲染歌曲,通过index对应当前那首歌
修改样式
```html
{{ song.name }}
{{ song.singer }}
```
新的钩子 use-mini-slider
```js
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useStore } from 'vuex'
import BScroll from '@better-scroll/core'
import Slide from '@better-scroll/slide'
BScroll.use(Slide)
export default function useMiniSlider() {
const sliderWrapperRef = ref(null)
const slider = ref(null)
const store = useStore()
const fullScreen = computed(() => store.state.fullScreen)
const playlist = computed(() => store.state.playlist)
const currentIndex = computed(() => store.state.currentIndex)
const sliderShow = computed(() => {
return !fullScreen.value && !!playlist.value // !! 强制转换为布尔类型
})
onMounted(() => {
let sliderVal
watch(sliderShow, async (newSliderShow) => {
if (newSliderShow) {
await nextTick() // 等待DOM渲染完 如果没有DOM数据 那么 new BScroll 没有意义
if (!sliderVal) {
// 只执行一次
sliderVal = slider.value = new BScroll(sliderWrapperRef.value, {
click: true, // 配置意思参照官网
scrollX: true,
scrollY: false,
momentum: false,
bounce: false,
probeType: 2,
slide: {
autoplay: false, // 禁止自动播放
loop: true
}
})
console.log(sliderVal)
// 触发时机:当 slide 切换 page 之后触发
sliderVal.on('slidePageChanged', ({ pageX }) => {
store.commit('setCurrentIndex', pageX)
store.commit('setPlayingState', true)
})
} else {
sliderVal.refresh()
}
// 滚动到对应的歌曲
sliderVal.goToPage(currentIndex.value, 0, 0)
}
})
watch(currentIndex, async (newIndex) => {
if (sliderVal && sliderShow.value) {
await nextTick()
sliderVal.goToPage(newIndex, 0, 0)
}
})
})
onUnmounted(() => {
if (slider.value) {
slider.value.destroy()
}
})
return { slider, sliderWrapperRef }
}
```
### 5-20 播放器 全屏切换过渡效果实现(上)
当切换时,从 min CD 到 player 的 CD 有一个从小到大的变化和位移,当在player 点击 back 图标的时候有从上到下的位移,大CD 变为小 CD,播放器按钮有从下到上或者从上到下的位移
```
...
```
```scss
&.normal-enter-active,
&.normal-leave-active {
transition: all 0.6s;
.top,
.bottom {
transition: all 0.6s cubic-bezier(0.45, 0, 0.55, 1);
}
}
&.normal-enter-from,
&.normal-leave-to {
opacity: 0;
.top {
transform: translate3d(0, -100px, 0);
}
.bottom {
transform: translate3d(0, 100px, 0);
}
}
}
```
使用 JavaScript Hooks 钩子 不用css,用js来实现动画效果
使用 `create-keyframe-animation` 库
* 首先获取CD位置,如果从大CD中间的位置,变到左下角的小CD位置,有X,Y轴的偏移量,X轴相当于二分之一屏幕宽度减去小的 CD 圆心到左边的距离。Y轴:整个屏幕的高度减去圆心到顶部的位置,再减去圆心到底部的偏移
* 所以要获取 小的CD 左边和底边的偏移等
```css
```
```js
import { ref } from 'vue'
import animations from 'create-keyframe-animation'
export default function useAnimation() {
const cdWrapperRef = ref(null)
function enter(el, done) {
// el 对应的DOM
// done: 通过js去做动画,vue内部是不知道什么时候动画结束的,所以需要你来告诉它
const { x, y, scale } = getPosAndScale()
const animation = {
0: {
// -147.5 407 0.1333333...
transform: `translate3d(${x}px, ${y}px, 0) scale(${scale})`
},
100: {
transform: 'translate3d(0, 0, 0) scale(1)'
}
}
// 注册动画
animations.registerAnimation({
name: 'move',
animation,
presets: {
duration: 600, // 时长
easing: 'cubic-bezier(0.45, 0, 0.55, 1)' // 缓动效果
}
})
/**
* 参数: DOM 、动画名称
* 调用 done 说明结束了 进入 afterEnter
*/
animations.runAnimation(cdWrapperRef.value, 'move', done)
}
function afterEnter() {
// 清理操作
animations.unregisterAnimation('move')
cdWrapperRef.value.animation = ''
}
function leave(el, done) {
const { x, y, scale } = getPosAndScale()
const cdWrapperEl = cdWrapperRef.value
cdWrapperEl.style.transition = 'all .6s cubic-bezier(0.45, 0, 0.55, 1)'
cdWrapperEl.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`
cdWrapperEl.addEventListener('transitionend', next) // 触发 transition 结束事件
function next() {
// 解绑 其实vue 内部也会有这样的逻辑,也就是为什么vue 会知道动画什么时候结束,也是调用了这些API
// 所以要经常考虑如果绑定了一个事件,那么什么时候解绑掉 如果不解绑有可能导致内存泄漏
cdWrapperEl.removeEventListener('transitionend', next)
done() // 告诉vue已经结束动画
}
}
function afterLeave() {
const cdWrapperEl = cdWrapperRef.value
cdWrapperEl.style.transition = ''
cdWrapperEl.style.transform = ''
}
function getPosAndScale() {
const targetWidth = 40
const paddingLeft = 40 // min CD 的 r 20 + 距离左边的距离 20 = 40
const paddingBottom = 30
const paddingTop = 80 // 大 CD 距离顶部距离
const width = window.innerWidth * 0.8 // 大 CD 的宽度 屏幕宽度的百分之80
const x = -(window.innerWidth / 2 - paddingLeft) // x 偏移量 往左偏移是负值
// 667 - 80 - 300 / 2 - 30
const y = window.innerHeight - paddingTop - width / 2 - paddingBottom
const scale = targetWidth / width
console.log(x, y, scale)
return { x, y, scale }
}
return { enter, afterEnter, leave, afterLeave, cdWrapperRef }
}
```
### 5-21 播放器 全屏切换过渡效果实现(下)
当把动画改为两秒时测试,enter动画还没有结束的时候去触发 Leave动画,导致动画失效
分析:是因为enter 是一个异步过程,然后执行动画 registerAnimation 它是有一个时长的为两秒,在这个两秒内,也就是这个动画还没有完成的时候,又去触发leave,这时又会对css进行一些修改,这样会导致 runAnimation 事件不会执行,所以 done 也不会执行,导致 afterEnter() 不执行,这样就乱掉了
相应的是在 leave 的时候,它的过渡动画也需要执行时间,如果在 leave 的过程中又去触发 enter,那么afterLeave 也不会执行,也会导致乱掉,这就是出生bug的原因
定义变量
```
// 这里不用响应式,因为不需要关心它们的变化,只是标志位
let entering = false
let leaving = false
```
当执行到enter时将 entering = true, 当执行到 afterEnter 时 将 entering = false,然后执行判断,如果当前的 enter 还没有执行完毕,就马上去执行 leave 时,进行判断, 如果 entering 为 false的话,说明 afterEnter 还没有执行,需要手动调用,相反即可,保证严谨性
```js
function enter(el, done) {
if (leaving) {
afterLeave()
}
entering = true
.....
}
function afterEnter() {
entering = false
// 清理操作
animations.unregisterAnimation('move')
cdWrapperRef.value.animation = ''
}
function leave(el, done) {
// 如果 entering 还是为true 就手动触发 afterEnter()
if (entering) {
afterEnter()
}
leaving = true
}
function afterLeave() {
leaving = false
}
```
### 5-22 播放器 播放列表组件实现(01)
这里复用了 useMode 和 useFavorite 的钩子
注意:又出现了 scroll 不能滚动的问题, 因为 初始化 scroll组件的时候,这个时候去没有去渲染歌曲列表的,也就是 scroll 实例化的时候,这个页面并没有渲染,所以计算不对,这也是我们封装 scroll 组件经常遇到的问题
解决: 执行 scroll 的 refresh 方法, 注意要在 DOM 渲染完毕时调用,否则无效,也就是 nextTick(),
这里依赖DOM 所以要等待 DOM 渲染完毕 才能计算
```vue
```
### 5-23 播放器 播放列表组件实现(02)
当每次展开的时候,滚动到当前播放的歌曲
```vue
```
### 5-24 播放器 播放列表组件实现(03)
删除功能
playlist.vue
```js
function removeSong(song) {
store.dispatch('removeSong', song)
}
```
actions.js
```js
export function removeSong({ commit, state }, song) {
// 加上 slice 没有副作用
const sequenceList = state.sequenceList.slice()
const playlist = state.playlist.slice()
// 找出 index 删除歌曲
const sequenceIndex = findIndex(sequenceList, song)
const playIndex = findIndex(playlist, song)
sequenceList.splice(sequenceIndex, 1)
playlist.splice(playIndex, 1)
commit('setSequenceList', sequenceList)
commit('setPlaylist', playlist)
}
function findIndex(list, song) {
return list.findIndex(item => {
return item.id === song.id
})
}
```
以上是完成了删除的功能。但是我们希望不是删除当前的歌曲,是不会影响当前的播放的,但是当删除当前播放歌曲的前面一首歌的时候会影响播放,歌曲变化了
原因:虽然删除了歌曲,当是 vuex 中的 currentIndex 还是原来的数据,所以就播放了下一首歌,也就是说,假如当前的歌曲是第二首歌,我删除了第三首歌,currendIndex 还是1,歌曲没有变化,当我删除第一首歌,那么第二首歌的下标就改为第一首歌的了,所以歌曲播放内容也变化了
此时还有一个问题:如果删除最后一首歌的,会报错,因为,假如只有10首歌,下标对应 0 - 9,删除最后一首歌,它的下标还是9,会报错
```js
export function removeSong({ commit, state }, song) {
// 加上 slice 没有副作用
const sequenceList = state.sequenceList.slice()
const playlist = state.playlist.slice()
// 找出 index 删除歌曲
const sequenceIndex = findIndex(sequenceList, song)
const playIndex = findIndex(playlist, song)
sequenceList.splice(sequenceIndex, 1)
playlist.splice(playIndex, 1)
let currentIndex = state.currentIndex
if (playIndex < currentIndex || currentIndex === playlist.length) {
currentIndex--
}
commit('setCurrentIndex', currentIndex)
commit('setSequenceList', sequenceList)
commit('setPlaylist', playlist)
}
```
### 5-25 播放器 播放列表组件实现(04)
这时会发现, 当删除歌曲是当前播放歌曲的前面的时候,会将播放状态做修改。
分析:这个时候会去触发 slidePageChang 事件
```js
sliderVal.on('slidePageChanged', ({ pageX }) => {
store.commit('setCurrentIndex', pageX)
store.commit('setPlayingState', true)
})
```
这样就修改的播放状态为 true,所以就造成bug, 那么这里为什么要这样写? 是当在min播放器里面左右切换时去触发播放,当在playlist在做删除操作时会触发这个事件
解决
将 store.commit('setPlayingState', true) 部分删除
```js
sliderVal.on('slidePageChanged', ({ pageX }) => {
console.log('slidePageChanged')
store.commit('setCurrentIndex', pageX)
// store.commit('setPlayingState', true)
})
```
player.vue
prev(), next() 里面的 store.commit('setPlayingState', true) 删除
在 监听 currentSong 的时候,监听 store.commit('setPlayingState', true),因为当 currentIndex 变化的时候会去触发 currentSong, 这时去改变 playingState 的值
```js
watch(currentSong, (newSong) => {
if (!newSong.id || !newSong.url) {
return
}
/**
* 当切换歌曲的时候将 songReady 置为 false
* 然后再去执行 audioEl.play(),然后歌曲去进行缓存,触发canplay事件,然后执行ready函数 将songReady 置为 true
*/
currentTime.value = 0 // 当歌曲变化时 置为0
songReady.value = false
const audioEl = audioRef.value
audioEl.src = newSong.url
audioEl.play()
store.commit('setPlayingState', true)
})
```
这时又发现切换的时候,图片不能显示除了,因为歌曲已经删除了,但是DOM还没有更新, 之前的 sliderVal.refresh() 是解决 fullScreen 来回切换时去 refresh(),
解决:在playlist 展开或者隐藏的时候呢,那个 refresh() 是没有发生变化的,所以这里要多加一个逻辑去watch playlist 的变化, 因为歌曲发生变化了本质上是playlist的变化
```js
watch(playlist, async () => {
// 当 playlist 发生变化的时候,可能 sliderVal 不存在
if (sliderVal && sliderShow.value) {
await nextTick()
sliderVal.refresh()
}
})
```
这时还是存在问题,当去快速删除的时候,会报错 offsetWidth of undefined,这是因为DOM没有找到 为 -1, 那为什么为 - 1 ? 当点击按钮的时候,是有过渡动画的,当动画没有结束时也就是按钮还是存在的,疯狂点击之后可能触发了好几次,所以对同一首歌执行了好几次,第一次已经将歌曲删除掉了,但是又去触发一次所以就得到 - 1, currentSong 也就是空对象
```js
function scrollToCurrent() {
const index = sequenceList.value.findIndex((song) => {
return currentSong.value.id === song.id
})
// const target = listRef.value.children[index]
const target = listRef.value.$el.children[index]
scrollRef.value.scroll.scrollToElement(target, 300)
}
```
解决
```js
watch(currentSong, async (newSong) => {
/** currentSong 变化有可能 playlist 是没有显示的 */
if (!visible.value || !newSong.id) {
return
}
// currentSong 发生变化时候,为了保证 scroll 没有问题
await nextTick()
scrollToCurrent()
})
```
```js
function scrollToCurrent() {
const index = sequenceList.value.findIndex((song) => {
return currentSong.value.id === song.id
})
if (index === -1) {
return
}
// const target = listRef.value.children[index]
const target = listRef.value.$el.children[index]
scrollRef.value.scroll.scrollToElement(target, 300)
}
```
这里我们还是不希望能够快速去点击删除歌曲,可能上一首歌没有ready时又去切换下一首歌可能会触发DOM报错,所以要做一些限制
解决:当去删除过程中,也就是动画过程中不希望再去删除歌曲了,包括当前的歌曲
```
const removing = ref(false)
```
```js
function removeSong(song) {
if (removing.value) {
return
}
removing.value = true
store.dispatch('removeSong', song)
setTimeout(() => {
removing.value = false
}, 300)
}
```
```HTML
```
要考虑多维度保护
```js
export function removeSong({ commit, state }, song) {
// 加上 slice 没有副作用
const sequenceList = state.sequenceList.slice()
const playlist = state.playlist.slice()
// 找出 index 删除歌曲
const sequenceIndex = findIndex(sequenceList, song)
const playIndex = findIndex(playlist, song)
if (sequenceIndex < 0 || playIndex < 0) {
return
}
sequenceList.splice(sequenceIndex, 1)
playlist.splice(playIndex, 1)
let currentIndex = state.currentIndex
if (playIndex < currentIndex || currentIndex === playlist.length) {
currentIndex--
}
commit('setCurrentIndex', currentIndex)
commit('setSequenceList', sequenceList)
commit('setPlaylist', playlist)
}
```
### 5-26 播放器 播放列表组件实现(05)
当点击垃圾桶图标会弹出对话框,点击清空,和取消执行对应的逻辑
confirm.vue
```vue
{{text}}
{{confirmBtnText}}
{{cancelBtnText}}
```
playlist.vue
```js
function confirmClear() {
store.dispatch('clearSongList')
hide()
}
```
actions.js
```js
export function clearSongList({ commit }) {
commit('setSequenceList', [])
commit('setPlaylist', [])
commit('setCurrentIndex', 0)
commit('setPlayingState', false)
}
```
### 5-27 播放器 滚动列表高度自适应_
当min播放器存在的时候,会遮挡scroll 列表,所以动态添加 bottom 高度,
但是在推荐列表,歌手列表等地方都需要动态添加,所以在 router-view 添加
```vue
```
此时还会有问题,scroll高度变化了,但是没有去 refresh 重新计算,但是滚动之后到底部又拉一下又行了,其实内部会执行一次 refresh 计算,所以计算又正确了
### 5-28 播放器 高阶 Scroll 组件的实现
解决前面提到的问题
解决: 监测playlist 的变化然后重新计算 refresh
那么可以将解决逻辑放在 scroll 里面? 这样是不行的
* 因为 scroll 是基础组件,playlist 是个业务数据,整个逻辑都是偏业务的逻辑,如果放在基础组件里面显然是不合理的
* 并不是所有scroll 组件都需要整个逻辑,比如说歌词也是用了scroll组件但是不需要这些逻辑
可以封装业务组件,封装高阶scroll组件
```js
import { h, mergeProps, withCtx, renderSlot, ref, computed, watch, nextTick } from 'vue'
import Scroll from '@/components/base/scroll/scroll'
import { useStore } from 'vuex'
export default {
name: 'wrap-scroll',
props: Scroll.props,
emits: Scroll.emits,
render(ctx) {
return h(Scroll, mergeProps({
ref: 'scrollRef'
}, ctx.$props, {
onScroll: (e) => {
ctx.$emit('scroll', e)
}
}), {
default: withCtx(() => {
return [renderSlot(ctx.$slots, 'default')]
})
})
},
setup() {
const scrollRef = ref(null)
const scroll = computed(() => {
return scrollRef.value.scroll
})
const store = useStore()
const playlist = computed(() => store.state.playlist)
watch(playlist, async () => {
await nextTick()
scroll.value.refresh()
})
return {
scrollRef,
scroll
}
}
}
```
### 6-1 歌单详情页开发(上)
歌单详情页面有很多代码是可以复用之前的逻辑的,所以当有重复的逻辑代码的时候,尽量不要去复制粘贴然后去修改代码,这是非常low的操作,我们要将其封装到一个文件里面,方便后续功能的复用
新建 created-detail-components.js
封装可以复用代码
```js
import MusicList from '@/components/music-list/music-list'
import storage from 'good-storage'
import { processSongs } from '@/service/song'
export default function createDetailComponent(name, key, fetch) {
return {
name,
components: { MusicList },
props: {
data: Object
},
data() {
return {
songs: [],
loading: true
}
},
computed: {
computedData() {
let ret = null
const data = this.data
if (data) {
ret = data
} else {
const cacheSinger = storage.session.get(key)
if (cacheSinger && cacheSinger.mid === this.$route.params.id) {
ret = cacheSinger
}
}
return ret
},
pic() {
const data = this.computedData
return data && data.pic
},
title() {
const data = this.computedData
return data && (data.name || data.title)
}
},
async created() {
const data = this.computedData
if (!data) {
const path = this.$route.matched[0].path
this.$router.push({
path
})
return
}
const result = await fetch(data)
this.songs = await processSongs(result.songs)
this.loading = false
}
}
}
```
singer-detail 使用
```vue
```
recommed.vue
```vue
热门歌单推荐
-
{{ item.username }}
{{ item.title }}
```
album.vue
```vue
```
### 6-2 歌单详情页开发(下)
### 6-3 排行榜页面开发_
```vue
-
-
{{ index + 1 }}
{{ song.songName }}-{{ song.singerName }}
```
### 6-4 排行榜详情页开发(上)
点击排行榜进入榜单详情页,发现它和歌手详情页和歌单详情页都是差不多的,还是用 create-detail-components 去开发
top-detail.vue
```vue
```
### 6-5 排行榜详情页开发(下)
### 7-1 搜索页面搜索框开发_
当输入搜索key时,会生成一个搜索列表,这个列表是可以上来加载的等
默认情况下,组件上的 `v-model` 使用 `modelValue` 作为 prop 和 `update:modelValue` 作为事件。我们可以通过向 `v-model` 传递参数来修改这些名称:
search.vue
```vue
```
search-input.vue
```vue
```
### 7-2 搜索页面热门搜索开发_
```vue
```
### 7-3 搜索页面 Suggest 组件开发(01)
```vue
-
-
{{ song.singer }}-{{ song.name }}
```
### 7-4 搜索页面 Suggest 组件开发(02)
解决边界情况,当没有搜索到内容时,提示没有搜索到内容
```vue
-
-
{{ song.singer }}-{{ song.name }}
```
### 7-5 搜索页面 Suggest 组件开发(03)
上拉加载,具体功能看better-scroll 文档
当下拉结束时要告诉better-scoll `scrollVal.finishPullUp()`
suggest.vue
```vue
-
-
{{ song.singer }}-{{ song.name }}
```
use-pull-up-load.js
```js
import BScroll from '@better-scroll/core'
import PullUp from '@better-scroll/pull-up'
import { onMounted, onUnmounted, ref } from 'vue'
import ObserveDOM from '@better-scroll/slide'
BScroll.use(PullUp)
BScroll.use(ObserveDOM)
export default function usePullUpLoad(requestData, preventPullUpLoad) {
const rootRef = ref(null)
const scroll = ref(null)
const isPullUpLoad = ref(false) // 判断拉去是否结束
onMounted(() => {
const scrollVal = scroll.value = new BScroll(rootRef.value, {
pullUpLoad: true,
click: true,
ObserveDOM: true
})
scrollVal.on('pullingUp', pullingUpHandler)
async function pullingUpHandler() {
isPullUpLoad.value = true
await requestData()
scrollVal.finishPullUp() // 告诉better-scroll 上拉结束
scrollVal.refresh() // 更新DOM
isPullUpLoad.value = false
}
})
onUnmounted(() => {
scroll.value.destroy()
})
return {
scroll,
rootRef,
isPullUpLoad
}
}
```
### 7-6 搜索页面 Suggest 组件开发(04)
有一些数据返回不足一屏,是因为后端数据处理过程中过来掉了部分付费歌曲等,所以首次请求的数据比较少,不足铺满一屏幕,在老版本的better-scroll是不能滚动的,因为没有满足滚动条件,新版本的better-scroll是做了些处理的可以滚动
但是在当前的场景下,better-scroll做的处理是不够的,站在用户角度去看这个事情,如果当数据不足一屏幕,就会认为已经加载完了,没有更多的数据,就不会去做上拉加载。有时候加载的数据比较少,得上拉多次才能铺满一屏
```vue
-
-
{{ song.singer }}-{{ song.name }}
```
### 7-7 搜索页面 Suggest 组件开发(05)
当进行搜索时候,假如当前数据比较少,就去不断的请求。那么填充过程中,没有填充完成我就退出了,会发现请求还是不断的发送,所以要判断
```js
async function searchFirst() {
if (!props.query) {
return
}
}
async function searchMore() {
if (!hasMore.value || !props.query) {
return
}
}
```
当搜索时会出现两个loadding图标,这是因为首次进入的时候一次loadding效果,第二次是 better-scroll 内部做的处理,因为better-scroll 此时也是满足滚动的所以就有loadding 效果。
当首次进入歌曲正在填满过程中也不希望用户可以上拉加载,这样可能会触发其他问题。要做一些限制
分析: 首次请求 loadding 为 true的时候和执行makeItScrollable()的时候不去触发上拉加载
```js
const preventPullUpLoad = computed(() => {
return loading.value || manualLoading.value
})
async function makeItScrollable() {
// 如果大于等于-1 不可滚动
if (scroll.value.maxScrollY >= -1) {
manualLoading.value = true
await searchMore()
manualLoading.value = false
}
}
```
```js
onMounted(() => {
const scrollVal = scroll.value = new BScroll(rootRef.value, {
pullUpLoad: true,
observeDOM: true,
click: true
})
scrollVal.on('pullingUp', pullingUpHandler)
async function pullingUpHandler() {
if (preventPullUpLoad.value) {
scrollVal.finishPullUp()
return
}
isPullUpLoad.value = true
await requestData()
scrollVal.finishPullUp() // 告诉 better-scroll 上拉结束
scrollVal.refresh()
isPullUpLoad.value = false
}
})
```
### 7-8 搜索页面 Suggest 组件开发(06)
点击功能:当点击歌手和歌曲是不一样的功能
当点击歌曲时添加一首歌曲
```js
function selectSong(song) {
store.dispatch('addSong', song)
}
```
```Js
export function addSong({ commit, state }, song) {
const playlist = state.playlist.slice()
const sequenceList = state.sequenceList.slice()
let currentIndex = state.currentIndex
const playIndex = findIndex(playlist, song) // 判断这个列表中是否包含这首歌
if (playIndex > -1) {
currentIndex = playIndex // 如果存在将 currentIndex = playIndex
} else {
playlist.push(song)
currentIndex = playlist.length - 1 // 同时改变 currentIndex 对应歌曲
}
const sequenceIndex = findIndex(sequenceList, song)
if (sequenceIndex === -1) {
sequenceList.push(song)
}
commit('setSequenceList', sequenceList)
commit('setPlaylist', playlist)
commit('setCurrentIndex', currentIndex)
commit('setPlayingState', true)
commit('setFullScreen', true)
}
```
### 7-9 搜索页面 Suggest 组件开发(07)
实现歌手点击相关逻辑
```js
@click="selectSinger(singer)"
function selectSinger(singer) {
emit('select-singer', singer)
}
```
```vue
```
```js
function selectSinger(singer) {
selectedSinger.value = singer
cacheSinger(singer)
router.push({
path: `/search/${singer.mid}`
})
}
function cacheSinger(singer) {
storage.session.set(SINGER_KEY, singer)
}
```
### 7-10 搜索页面搜索历史功能开发(01)
开发搜索历史功能,保存到本地存储中
use-search-history.js
```js
import { save } from '@/assets/js/array-store'
import { SEARCH_KEY } from '@/assets/js/constant'
import { useStore } from 'vuex'
export default function useSearchHistory() {
const maxLen = 200
const store = useStore()
function saveSearch(query) {
const searches = save(query, SEARCH_KEY, (item) => {
return item === query
}, maxLen)
store.commit('setSearchHistory', searches)
}
return { saveSearch }
}
```
```js
function selectSong(song) {
saveSearch(query.value)
store.dispatch('addSong', song)
}
function selectSinger(singer) {
saveSearch(query.value)
selectedSinger.value = singer
cacheSinger(singer)
router.push({
path: `/search/${singer.mid}`
})
}
```
### 7-11 搜索页面搜索历史功能开发(02)
点击搜索历史关键字的时候,填充到搜索框中,删除搜索历史功能
```vue
```
```js
function deleteSearch(query) {
const searches = remove(SEARCH_KEY, (item) => {
return item === query
})
store.commit('setSearchHistory', searches)
}
```
### 7-12 搜索页面搜索历史功能开发(03)
解决 :当搜索列表足够一屏的时候不能滚动问题
```js
watch(query, async (newQuery) => {
if (!newQuery) {
await nextTick()
refreshScroll()
}
})
function refreshScroll() {
scrollRef.value.scroll.refresh()
}
```
### 8-1 添加歌曲到列表功能开发(01)
### 8-2 添加歌曲到列表功能开发(02)
### 8-3 添加歌曲到列表功能开发(03)
add-song.vue
```vue