【音乐App】—— Vue-music 项目学习笔记:搜索页面开发
前言:以下内容均为学习慕课网高级实战课程的实践爬坑笔记。
项目github地址:https://github.com/66Web/ljq_vue_music,欢迎Star。
![]() |
![]() |
搜索歌手歌曲 | 搜索历史保存 |
一、搜索页面布局 |
search-Box组件开发
<div class="search-box">
<i class="icon-search"></i>
<input class="box" v-model="query" :placeholder="placeholder"/>
<i class="icon-dismiss" v-show="query" @click="clear"></i>
</div>
其中:placeholder和其他页面公用,所以,是props传入的
课程中老师卖了个关子:为什么在created的时候去watch收缩框value的变化呢?
export default {
props: {
placeholder: {
type: String,
default: '搜索歌曲、歌手'
}
},
data() {
return {
query: ''
}
},
methods: {
clear() {
this.query = ''
},
setQuery(query) {
this.query = query
}
},
created() {
this.$watch('query', (newQuery) => {
this.$emit('query', newQuery)
})
}
}
search数据获取及应用
- 数据获取就是普通的jsonp请求抓取,同前面的页面一样
- data中维护一个数据
hotKey: []
- 抓取到数据后取前10个给hotKey赋值
this.hotKey = res.data.hotkey.slice(0, 10)
热搜功能开发
- 拿到数据后开始渲染热搜页面结构
<div class="shortcut-wrapper"> <div class="shortcut"> <div class="hot-key"> <h1 class="title">热门搜索</h1> <ul> <li @click="addQuery(item.k)" class="item" v-for="(item, index) in hotKey" :key="index"> <span>{{item.k}}</span> </li> </ul> </div> </div> </div>
- 给搜索项添加点击事件,将内容传给子组件select-box的query中
- 使用search-box的refs来调用他内部的方法
addQuery(query) { this.$refs.searchBox.setQuery(query) }
- 同时,子组件search-box.vue中需要暴露一个setQuery()
setQuery(query) { this.query = query }
二、搜索页面suggest组件开发 |
搜索数据的获取及一次检索功能实现
- QQ音乐搜索数据的获取也禁止跨域,需要通过后端配置Webpack强制修改请求头,返回json数据
- search组件中监听search-box的query的变化
<search-box ref="searchBox" @query="onQueryChange"></search-box>
- data中维护一个数据
query: ''
- 如果query发生改变,就赋值给query
onQueryChange(query){ this.query = query }
- 这时就把query传给suggest组件
<suggest :query="query"></suggest>
- suggest组件依赖获取到的query,发送请求检索对应内容
- 定义数据接口时,有四个值是变化的,所以需要传入四个参数:
const perpage = 20 //抓取数据一页有多少数据 search() { search(this.query, this.page, this.showSinger, perpage).then((res) => { //console.log(res) if(res.code === ERR_OK) { this.result = this._genResult(res.data) //console.log(this.result) } }) }
直接拿到的数据是不理想的,需要进行处理
- 处理后的数据是一个数组,其中包含两个对象:一个歌手、一个歌曲列表
_genResult(data) { let ret = [] if(data.zhida && data.zhida.singerid) { //使用es6对象扩展运算符...把两个对象添加到一个对象上 ret.push({...data.zhida, ...{type: TYPE_SINGER}}) } if(data.song){ ret = ret.concat(data.song.list)//合并时出现难题 } return ret }
-
监听接收到的query的变化,如果变化,调用search()检索:
watch: { query() { this.search() } }
- 拿到数据后在DOM中使用
<div class="suggest"> <ul class="suggest-list"> <li class="suggest-item" v-for="(item, index) in result" :key="index"> <div class="icon"> <i :class="getIconCls(item)"></i> </div> <div class="name"> <p class="text" v-html="getDisplayName(item)"></p> </div> </li> </ul> </div>
getIconCls(item) { if (item.type === TYPE_SINGER) { return 'icon-mine' }else{ return 'icon-music' } }, getDisplayName(item) { if (item.type === TYPE_SINGER) { return item.singername }else{ return `${item.songname}-${filterSinger(item.singer)}` } }
- 难题: 把singer合并进songs里时,输出结果并没有合并到数据,只有数组中只有歌手和type两项
- 执行时,结果是空,长度为0
- 控制台检查这个值的内容可知是真实的
- 原因分析:异步内使用异步 导致数组操作不能执行
- 这个值的内容虽然是真实的,但是在不恰当的位置去调用其内容却是不行的,因为程序运行到调用所处位置时,顺序执行并没有发现值的存在。
- 歌曲数组数据是异步获取的,其中处理数据时拼接播放源url的vkey也是异步获取的,异步内部再异步导致
- 解决思路:处理异步问题一般都是使用监听器来完成,如计算属性computed、监听器watch;这里使用监听器操作
- 把歌手对象存为data
- 把歌曲数据获取存为data
- 新建歌曲数据获取标识flag
- 根据歌曲数据获取完成就执行合并
this.zhida = res.data.zhida this.searchSongs = this._nomalizeSongs(res.data.song.list) _nomalizeSongs(list){ let ret = [] let pushIndex =0 //判断是否是最最后一次push list.forEach((musicData)=>{ if(musicData.songid && musicData.albummid){ //获取歌曲源url数据 let songUrl = '' getSongs(musicData.songmid).then((res)=>{ if(res.code === ERR_OK){ songUrl = res.req_0.data.midurlinfo[0].purl ret.push(createSong(musicData,songUrl)) /**********把歌曲源数据push后判断是否异步完成************/ pushIndex++ this.pushOver = list.length===pushIndex } }) } }) watch:{ //监听异步问题,对数据无法操作,把值赋值出来 searchSongs(newValue){ console.log(this.pushOver) //判断异步完成后去合并已存在的数组和singer if(this.pushOver){ this._genResult(this.zhida,newValue) } } }, //有zhida就合并对象到数组中 _genResult(data,newValue){ let ret = [] //push歌手进空数组 if(data.singerid){ ret.push({...this.zhida,...{type:TYPE_SINGER}}) //es6语法,对象拓展符。等同于object.assign()新建对象 } //合并歌曲进数组 if (newValue) { ret = ret.concat(value) } this.result = ret },
- 最后可以取到一个result是21项的数组(20歌曲、1歌手)
- 参考链接
搜索结果上拉加载
- 引入scroll组件,替换掉根元素div
<scroll class="suggest" :data="result">
- 扩展scroll.vue,实现上拉刷新
- 添加props参数,在父组件调用时传入控制是否执行上拉刷新
pullup: { type: Boolean, default: true }
- 如果有上拉刷新的选项,在初始化scroll时执行以下操作
if(this.pullup) { this.scroll.on('scrollEnd', () => { // 当滚动距离小于等于最大的滚动条的距离 + 50 的时候,向外传递一个scrollToEnd的事件 if(this.scroll.y <= (this.scroll.maxScrollY + 50)) { this.$emit('scrollToEnd') } }) }
- suggest.vue组件中给scroll传入pullup,监听scrollToEnd事件
<scroll class="suggest" :data="result" :pullup="pullup" @scrollToEnd="searchMore" ref="suggest">
- data中维护数据
pullup:true, hasMore: true, firstList: {}
searchMore() { if(!this.hasMore) { return } this.page++ getSearch(this.query, this.page, this.showSinger, perpage).then((res) => { if(res.code === ERR_OK) { //把下一页数据,拼接上原页面数据 this.searchSongs = this._normalizeSongs(this.firstList.concat(res.data.song.list)) this._checkMore(res.data.song) } }) }
- checkMore判断标志位的状态:第一次search()和加载更多时searchMore()都需要判断
_checkMore(data) { if(!data.list.length || (data.curnum + data.curpage * perpage) >= data.totalnum){ this.hasMore = false } }
- 坑: 如果再改变query的时候,scroll的位置就不对了
- 解决: watch到query改变后,在第一次search()中将page重置为1和scroll位置重置到顶部
search() { this.page = 1 this.$refs.suggest.scrollTo(0, 0) //scroll位置重置到顶部 this.hasMore = true getSearch(this.query, this.page, this.showSinger, perpage).then((res) => { // console.log(res.data) if(res.code === ERR_OK) { this.zhida = res.data.zhida this.firstList = res.data.song.list //记录第一次加载后获得的歌曲 this.searchSongs = this._normalizeSongs(res.data.song.list) // this.result = this._genResult(this.zhida, this.searchSongs) this._checkMore(res.data.song) } }) }
- 引用loading组件,通过标志为hasMore控制显示
<loading v-show="hasMore" title=""></loading>
使用二级路由实现点击搜索结果项跳转
- search.vue中添加路由容器
<router-view></router-view>
- router->index.js中为Search路由添加二级路由
{ path: '/search', component: Search, children: [ { path: ':id', component: SingerDetail } ] }
- suggest.vue中给列表项添加点击事件
@click="selectItem(item)"
selectItem(item) { if(item.type === TYPE_SINGER) { const singer = new Singer({ id: item.singermid, name: item.singername }) this.$router.push({ path: `/search/${singer.id}` }) this.setSinger(singer) }else{ this.insertSong(item) } } ...mapMutations({ setSinger: 'SET_SINGER' }), ...mapActions([ 'insertSong' ])
- 点击歌手: 调用Singer方法实例化当前歌手的singer对象,通过mapMutations提交singer,同时,跳转到二级路由
- 点击歌曲: 与从歌手详情页和歌单详情页列表选歌跳转不同,这些都是在跳转的同时将当前歌曲列表全部添加到了播放列表;而搜索结果中点击歌曲,要执行的是添加当前歌曲到播放列表中,同时判断如果播放列表存在所选歌曲需将其删掉
- actions.js中封装insertSong():
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] //查找当前列表中是否有待插入的歌曲并返回其索引 letfpIndex = 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)
- 坑: 报错[vuex] Do not mutate vuex store state outside mutation handlers.
- 原因: Vuex 规定state中的值必须在回调函中更改
- 解决: 将state中要修改的数据复制一个副本.slice()进行修改,再提交
suggest组件边界情况的处理
- 没有检索到结果时的情况
- src->base目录下:创建no-result.vue
<div class="no-result"> <div class="no-result-icon"></div> <div class="no-result-text">{{title}}</div> </div>
其中,title由外部组件传入,定义props数据
- suggest.vue中应用:当hasMore为false且result无内容时显示<no-result>
<div class="no-result-wrapper" v-show="!hasMore && !result.length"> <no-result title="抱歉,暂无搜索结果"></no-result> </div>
注意:当传入的值是固定值而非动态值时,可以不用加v-bind:或:
- 对input数据进行截流,避免输入过程中数据的每次改变都发送请求
- common->js->util.js中:定义截流函数
//截流 //对一个函数做截流,就会返回新的函数,新函数是在延迟执行原函数 //如果很快的多次调用新函数,timer会被清空,不能多次调用原函数,实现截流 export function debounce(func, delay){ let timer return function (...args) { if (timer) { clearTimeout(timer) } timer = setTimeout(() => { func.apply(this, args) }, delay) } }
- search-box.vue中:引用并对$watch query的回调函数做截流
created() { this.$watch('query', debounce((newQuery) => { this.$emit('query', newQuery) }, 200)) }
- 移动端滚动列表时收起键盘
- 思路: 首先拿到scroll事件,经过两层传递,scroll->suggest->search,然后监听事件让input失去焦点
- 扩展scroll.vue
//是否派发beforeScroll事件 beforeScroll: { type: Boolean, default: false }
- 在_initScroll()中:执行一下操作
if(this.beforeScroll) { this.scroll.on('beforeScrollStart', () => { this.$emit('beforeScroll') }) }
- suggest.vue中:data中维护一个数据
beforeScroll: true
<scroll>中传入并监听事件:
:beforeScroll="beforeScroll" @beforeScroll="listScroll"
listScroll()中同样也派发一个listScroll事件:
this.$emit('listScroll')
-
search-box.vue中:给<input>添加引用
ref="query"
定义blur()供外部调用:
this.$refs.query.blur()
-
search.vue中:
<suggest :query="query" @listScroll="blurInput"></suggest>
blurInput() { this.$refs.searchBox.blur() }
三、搜索历史记录保存功能实现 |
- 关键:新建vuex的state数组变量,点击时存入和调用时读取数据即可
- 利用本地缓存插件,取、操作后再存
- Vuex配置
- state.js中:添加数据
searchHistory: []
- mutation-types.js中:定义事件类型常量
export const SET_SEARCH_HISTORY = 'SET_SEARCH_HISTORY'
- mutiaions.js中:创建方法
[types.SET_SEARCH_HISTORY](state, history){ state.searchHistory = history }
- getters.js中:添加数据映射
export const searchHistory = state => state.searchHistory
- suggest.vue中:在点击搜索结果项的同时,向外派发一个select事件 -- 用于外部监听进行存取搜索结果
- search.vue中:给<suggest>监听select事件,保存搜索结果
@select="saveSearch"
- 需求: 搜索结果不仅要显示在“搜索历史”组件中,还需要保存在本地浏览器的localStorage缓存
- 实现: 多个数据操作,封装action
- 第三方开源Storage库:https://github.com/ustbhuangyi/storage
- 安装:
npm install good-storage --save
- common->js目录下:创建catch.js -- 定义storage操作相关的方法
- catch.js中
- 引入本地存储插件
import storage from 'good-storage'
-
设置数组添加项到第一个,并且限定个数,把旧的删除,重复的删除
const SEARCH_KEY = '_search_' //双下划线标识内部key, 避免与外部key冲突 const SEARCH_MAX_LENGTH = 15 //搜索历史最多存入数组15个 //操作搜索历史数组的方法 //参数:搜索记录数组,添加的项,筛选方法,最大数量 function insertArray(arr, val, compare, maxLen){ const index = arr.findIndex(compare) //判断是否以前有搜索过,compare在外部编写 if (index === 0) { //上一条搜索历史就是这个,就不需要添加历史 return } if (index > 0) { //历史记录中有这条,把历史记录删了,重新添加 arr.splice(index, 1) } arr.unshift(val) //没有历史记录,添加项目到第一项 if (maxLen && arr.length > maxLen) { //大于最大数量的时候,删除最后一项 arr.pop() } }
- 使用插件的方法get和set,获取到本地缓存的数据,操作新内容后,存入本地缓存
//插入最新搜索历史到本地缓存,同时返回新的搜索历史数组 export function saveSearch(query) { let searches = storage.get(SEARCH_KEY, []) //如果已有历史就get缓存中的数组,没有就空数组 insertArray(searches, query, (item) => { //对传入的项与已有数组进行操作 return item === query }, SEARCH_MAX_LENGTH) storage.set(SEARCH_KEY, searches) //把操作过后的数组set进缓存,直接替换掉原历史 return searches }
- actions.js中:commit调用js方法,存入本地缓存
import {saveSearch} from '@/common/js/catch' export const saveSearchHistory = function({commit}, query){ commit(types.SET_SEARCH_HISTORY, saveSearch(query)) }
- search.vue中:通过mapActions实现数据映射
...mapActions([ 'saveSearchHistory' ]) saveSearch() { this.saveSearchHistory(this.query) }
- 坑:重启服务器之后本地缓存还是没了,是因为在state的初始值中空数组
- 解决:把空数组改成获取本地缓存,在catch.js中设置一个新的方法,来获取
- catch.js中:
//states获取本地缓存中的数据 export function loadSearch() { return storage.get(SEARCH_KEY, []) }
- state.js中:
import {loadSearch} from '@/common/js/catch' searchHistory: loadSearch()
注:可以在控制台输入localStorage,查看浏览器本地缓存
四、搜索页面search-list组件功能实现 |
- search.vue中:
- 通过mapGetters获取state中的searchHistory
computed: { ...mapGetters([ searchHistory' ]) }
- 引用scroll替换shortcut的div:实现滚动
<scroll class="shortcut" ref="shortcut" :data="shortcut">
shortcut() { return this.hotKey.concat(this.searchHistory) }
注意:<scroll>里面包含多个同级元素,要在外层再嵌套一个<div>, 已经在这里掉坑很多次了!
- watch中监听query(),当query值发生变化后,searchHistory的值一定会变化,此时需要强制scroll重新计算
watch: { query(newQuery) { if (!newQuery) { setTimeout(() => { this.$refs.shortcut.refresh() }, 20) } } }
- 添加search-history的DOM结构:设置只有当searchHistory有内容时显示
<div class="search-history" v-show="searchHistory.length"> <h1 class="title"> <span class="text">搜索历史</span> <span class="clear"> <i class="icon-clear"></i> </span> </h1> </div>
注:可以在控制台调用localStorage.clear(),测试查看清空内容的情况
- base->search-list目录下:创建search-list.vue 引入search.vue
<search-list :searches="searchHistory"></search-list>
- 应用mixin实现播放器底部自适应
- 首先添加引用:
ref="shortcutWrapper"
ref="searchResult"
ref="suggest"import {playListMixin} from '@/common/js/mixin' mixins: [playListMixin],
- suggest.vue中:暴露一个refresh(),代理scroll组件的refresh()
this.$refs.suggest.refresh()
handlePlaylist(playlist){ const bottom = playlist.length > 0 ? '60px' : '' this.$refs.shortcutWrapper.style.bottom = bottom this.$refs.shortcut.refresh() this.$refs.searchResult.style.bottom = bottom this.$refs.suggest.refresh() }
- search-list.vue中:
- 给列表项添加点击事件,向外派发事件
@click="selectItem(item)"
selectItem(item) { this.$emit('select', item) }
- 给删除按钮添加点击事件,向外派发事件
@click.stop.prevent="deleteOne(item)"
deleteOne(item) { this.$emit('delete', item) }
- search.vue中:
- <search-list>监听select事件,添加query同时进行检索
@select="addQuery"
- <search-list>监听delete事件,从vuex数据和本地缓存中删掉query
@delete="deleteOne"
catch.js中:创建函数将query从localStorage中删除,同时返回操作后的新数组
function deleteFromArray(arr, compare){ const index = arr.findIndex(compare) if(index > -1) { arr.splice(index, 1) } } export function deleteSearch() { let searches = storage.get(SEARCH_KEY, []) deleteFromArray(searches, (item) => { return item === query }) storage.set(SEARCH_KEY, searches) return searches }
actions.js中:封装action,将已从localStorage删除指定query后的新数组存入searchHistory
export const deleteSearchHistory = function({commit}, query){ commit(types.SET_SEARCH_HISTORY, deleteSearch(query)) }
通过mapActions调用action:
'deleteSearchHistory'
deleteOne(item) { this.deleteSearchHistory(item) }
- 清空按钮添加点击事件,清除数据的方法同上:
catch.js中:
export function clearSearch() { storage.remove(SEARCH_KEY) return [] }
actions.js中:
export const clearSearchHistory = function ({commit}){ commit(types.SET_SEARCH_HISTORY, clearSearch()) }
search.vue中:
@click="deleteAll"
'clearSearchHistory' deleteAll(){ this.clearSearchHistory() }
注意:deleteOne()和deleteAll()纯粹是action方法的代理,可以直接将action方法用在DOM上,省略methods的定义
- 优化:在点击清空按钮后,先弹出一个确认弹窗,如果确认清空,执行后面的操作;否则,不请空
base->confirm目录下: 创建confim.vue 在需要的组件处使用
在需要的组件处使用的好处:
①每个confirm是独立的,各自传递需要的事件和参数
②confirm和外部调用的组件时紧密关联的,外部组件更容易调用confirm中的方法;confirm更容易通过外部组件的数据控制显示隐藏
confirm.vue作为基础组件:只负责向外提供确认和取消事件,接收从父组件传来的props参数
<confirm ref="confirm" text="是否清空所有搜索历史" confirmBtnText="清空" @confirm="clearSearchHistory"></confirm>
注:项目来自慕课网