VUE移动端音乐APP学习【二十三】:搜索历史模块开发
搜索历史模块不仅在搜索模块出现,还在后续的添加歌曲模块中出现,多个组件多个模块共用了它,这个数据应该保存在全局的vuex中。
在state.js中添加searchHistory
// 搜索历史 searchHistory: [],
有了state就设置它的mutation-types、mutations以及getters
//mutation-types.js export const SET_SEARCH_HISTORY = 'SET_SEARCH_HISTORY'; //mutations.js [types.SET_SEARCH_HISTORY](state, history) { state.searchHistory = history; } //getters.js export const searchHistory = (state) => state.searchHistory;
在suggest组件里,当某个元素被点击时,让它派发事件。我们不能在selectItem函数里写保存搜索历史的数据,因为这应该是外组件做的事情,suggest组件做完自己的事情可以派发事件给外组件,外部关心这个事件的组件就会去保存搜索历史。
selectItem(item) { this.insertSong(item); this.$emit('select'); },
搜索历史除了要在组件中共享之外,还要进入到本地缓存,比如浏览器的localStorage中,这样在下次刷新页面的时候,还能拿到历史结果。要实现这样永久缓存的功能,得去封装一个action。
- 由于需要localstorage缓存,在common->js下新建cache.js,主要用来操作localstorage存取缓存相关的一些逻辑。
- 利用第三方库的方法保存搜索结果:npm install good-storage@1.0.1
import storage from 'good-storage'; const SEARCH_KEY = '__search__'; // 最大只能存15条数据 const SEARCH_MAX_LENGTH = 15; // 最新的搜索结果总是展现在最前面 function insertArray(arr, val, compare, maxLen) { // 查找数据是否存在在数组中 const index = arr.findIndex(compare); if (index === 0) { // 有该数据且为数组的第一条数据 return; } if (index > 0) { // 数组有这条数据但不是第一条 arr.splice(index, 1);// 删掉之前的数据 } // 插到数组的第一个位置 arr.unshift(val); // 限制数组长度 if (maxLen && arr.length > maxLen) { arr.pop(); } } // 利用第三方库的方法保存搜索结果 export function saveSearch(query) { let searches = storage.get(SEARCH_KEY, []); // 把query插入当前历史列表中 insertArray(searches, query, (item) => { return item === query; }, SEARCH_MAX_LENGTH); // 插入完之后保存 storage.set(SEARCH_KEY, searches); // 返回新数组 return searches; }
- 有了这样的方法,在action里就可以提交mutation,state就会被更新
import { saveSearch } from '../common/js/cache'; export const saveSearchHistory = function ({ commit }, query) { commit(types.SET_SEARCH_HISTORY, saveSearch(query)); };
在search组件中就可以监听该事件,调用action把当前的query存进去
<div class="search-result" v-show="query"> <suggest @select="saveSearch" @listScroll="blurInput" :query="query"></suggest> </div> import { mapActions } from 'vuex'; ...mapActions([ 'saveSearchHistory', ]), saveSearch() { // 把当前的query存进去 this.saveSearchHistory(this.query); },
因为state的searchHistory与本地缓存关联起来,所以在cache中添加一个方法,从本地缓存读取数据
export function loadSearch() { return storage.get(SEARCH_KEY, []); }
有了这个loadSearch就可以在state里做初始值替换
import { loadSearch } from '../common/js/cache'; // 搜索历史 searchHistory: loadSearch(),
可以看到控制台已经有searchHistory的数据缓存
接下来就是将存储在vuex的searchHistory渲染在dom上
首先在search里添加getter
computed: { ...mapGetters([ 'searchHistory', ]), },
在热门搜索下添加静态dom
<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>
开发基础组件search-list用于显示搜索历史数据,它的结构就是一个列表,然后在dom上遍历searches
<template> <div class="search-list" v-show="searches.length"> <ul> <li class="search-item" v-for="(item,index) in searches" :key="index"> <span class="text">{{item}}</span> <span class="icon"> <i class="icon-delete"></i> </span> </li> </ul> </div> </template> <script> export default { props: { searches: { type: Array, // eslint-disable-next-line vue/require-valid-default-prop default: [], }, }, }; </script> <style scoped lang="scss"> .search-list { .search-item { display: flex; align-items: center; height: 40px; overflow: hidden; &.list-enter-active, &.list-leave-active { transition: all 0.1s; } &.list-enter, &.list-leave-to { height: 0; } .text { flex: 1; color: $color-text-l; } .icon { @include extend-click(); .icon-delete { font-size: $font-size-small; color: $color-text-d; } } } } </style>
在search引入这个组件,将searchHistory数据传递给searches这个属性
<search-list :searches="searchHistory"></search-list> import SearchList from '../../base/search-list/search-list.vue'; components: { SearchBox, Suggest, SearchList, },
接下来是实现search-list的交互功能(点击列表的元素复原到搜索框;点击删除键删除当前记录,点击清空清空所有历史记录)
- 点击列表的时候派发事件,告诉外面的容器“我被选择了”,因为是基础组件是不会执行任何逻辑的。
<li @click="selectItem(item)" class="search-item" v-for="(item,index) in searches" :key="index"> methods: { selectItem(item) { this.$emit('select', item); }, },
- 同理点击删除键的时候也派发事件
<span class="icon" @click.stop="deleteOne(item)"> <i class="iconfont icon-delete"></i> </span> deleteOne(item) { this.$emit('delete', item); },
- 在search组件监听点击事件,之前点击hot列表的时候有一个addQuery方法,可以直接使用它
<search-list @select="addQuery" :searches="searchHistory"></search-list>
- 删除事件实际上是对vuex进行操作,最终修改的也是搜索结果列表,需要在cache.js扩展一个删除方法并在action中封装删除操作.
//cache.js // 删除方法 function deleteFromArray(arr, compare) { const index = arr.findIndex(compare); if (index > -1) { arr.splice(index, 1); } } export function deleteSearch(query) { // 获取缓存中的search列表 let searches = storage.get(SEARCH_KEY, []); // 删除 deleteFromArray(searches, (item) => { return item === query; }); // 保存 storage.set(SEARCH_KEY, searches); // 返回 return searches; }
- 在action调用deleteSearch
export const deleteSearchHistory = function ({ commit }, query) { commit(types.SET_SEARCH_HISTORY, deleteSearch(query)); };
- 在search里map这个action,再监听delete事件调用action。当点击删除时search-list就派发delete事件,search组件监听到这个事件后就提交action,这个action的作用就是从vuex里缓存的searchHistory删除要被删除的记录。
<search-list @select="addQuery" @delete="deleteSearchHistory" :searches="searchHistory"></search-list> ...mapActions([ 'saveSearchHistory', 'deleteSearchHistory', ]),
- 点击垃圾桶图标,把整个列表全都清空掉,和上面删掉单条记录的套路类似。先在cache.js定义一个方法:清空数组
export function clearSearch() { storage.remove(SEARCH_KEY); return []; }
- 在actions.js调用这个方法
export const clearSearchHistory = function ({ commit }) { commit(types.SET_SEARCH_HISTORY, clearSearch()); };
- 在search里map这个action,给垃圾桶元素绑定点击事件,一旦被点击就调用这个action提交清空数组操作。
<span class="clear" @click="clearSearchHistory">
<i class="iconfont icon-clear"></i>
</span>
...mapActions(
[
'saveSearchHistory',
'deleteSearchHistory',
'clearSearchHistory',
]),
- 设置弹窗组件:当我们点击垃圾桶清空记录时,应弹出弹框警告用户是否清空记录避免用户的误操作。弹窗组件基本代码如下:
<template> <transition name="confirm-fade"> <div class="confirm"> <div class="confirm-wrapper"> <div class="confirm-content"> <p class="text"></p> <div class="operate"> <div class="operate-btn left"></div> <div class="operate-btn"></div> </div> </div> </div> </div> </transition> </template> <script> export default { }; </script> <style scoped lang="scss"> .confirm { position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 998; background-color: $color-background-d; &.confirm-fade-enter-active { animation: confirm-fadein 0.3s; .confirm-content { animation: confirm-zoom 0.3s; } } .confirm-wrapper { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 999; .confirm-content { width: 270px; border-radius: 13px; background: $color-highlight-background; .text { padding: 19px 15px; line-height: 22px; text-align: center; font-size: $font-size-large; color: $color-text-l; } .operate { display: flex; align-items: center; text-align: center; font-size: $font-size-large; .operate-btn { flex: 1; line-height: 22px; padding: 10px 0; border-top: 1px solid $color-background-d; color: $color-text-d; &.left { border-right: 1px solid $color-background-d; } } } } } } @keyframes confirm-fadein { 0% { opacity: 0; } 100% { opacity: 1; } } @keyframes confirm-zoom { 0% { transform: scale(0); } 50% { transform: scale(1.1); } 100% { transform: scale(1); } } </style>
- 在search.vue放置弹窗组件
<div class="search-result" v-show="query"> <suggest @select="saveSearch" @listScroll="blurInput" :query="query"></suggest> </div> <confirm></confirm>
- confirm的显示状态是它自身来维护的,所以在confirm.vue中设置个data同时对外提供方法控制它的显示和隐藏
<div class="confirm" v-show="showFlag"> data() { return { showFlag: false, }; }, methods: { show() { this.showFlag = true; }, hide() { this.showFlag = false; }, },
- 回到search组件调用其方法来控制弹窗的显示和隐藏,同时点击垃圾桶不能直接调用action,改成新的方法
<span class="clear" @click="showConfirm"> <i class="iconfont icon-clear"></i> </span> <confirm ref="confirm"></confirm>
showConfirm() { this.$refs.confirm.show(); },
- 可以看到上图点击垃圾桶后弹窗弹出,但是没有内容。这个内容也是由外部去传入的,也就是这个基础组件需要提供props供外部来传入
<div class="confirm-content"> <p class="text">{{text}}</p> <div class="operate"> <div class="operate-btn left">{{cancelBtnText}}</div> <div class="operate-btn">{{confirmBtnText}}</div> </div> </div> props: { // 文案 text: { type: String, default: '', }, // 确定 confirmBtnText: { type: String, default: '确定', }, // 取消 cancelBtnText: { type: String, default: '取消', }, },
- 文案由search组件传给它
<confirm ref="confirm" text="是否清空所有搜索历史" confirmBtnText="清空"></confirm>
- 接下来就是完善这个弹窗组件的交互功能,在confrim.vue中添加几个点击事件,这里也是不做任何逻辑,只派发事件告诉外部组件用户点击了“取消”或”清空“
<div @click="cancel" class="operate-btn left">{{cancelBtnText}}</div> <div @click="confirm" class="operate-btn">{{confirmBtnText}}</div> cancel() { this.hide(); this.$emit('cancel'); }, confirm() { this.hide(); this.$emit('confirm'); },
- 在search组件中监听事件并完成它们的逻辑,因为取消事件不做任何处理可以选择不监听
<confirm ref="confirm" text="是否清空所有搜索历史" confirmBtnText="清空" @confirm="clearSearchHistory"></confirm>
搜索优化:
- 当搜索历史记录过多时,无法往下滚动。因为没有用到scroll组件,所以在search组件中引入scroll组件,将short-cut的div改为scroll,同时再添加一个div包裹2个子div,这样scroll就根据2块的模块作计算进行滚动。
仅仅这样写还是无法滚动,因为获取的hotKey和searchHistory都是异步获取的。给scroll传递data就可以根据data的变化重新计算高度,但是传递hotKey和searchHistory哪一个都不合适,可以利用计算属性定义一个新的值,当hotKey和searchHistory哪一个值发生变化它就会重新计算。
<scroll class="shortcut" :data="shortcut"> //computed shortcut() { return this.hotKey.concat(this.searchHistory); },
还有一种情况是点击搜索添加了歌曲之后需要滚动,想完善这个功能需要watch query的改变,因为我们在搜索时添加了歌曲,dom实际停留在搜索列表这块。
如果我们是搜索列表suggest切换到search的时候,query是一个从有到无的变化。
watch: { query(newQuery) { if (!newQuery) { setTimeout(() => { // 手动刷新scroll组件 this.$refs.shortcut.refresh(); }, 20); } }, },
- 迷你播放器和底部的搜索列表以及suggest的列表高度自适应的功能,和之前一样都是通过playmixin配上handleplaylist方法来做的。
先给suggest组件添加一个refresh方法给外组件代理刷新。
refresh() { this.$refs.suggest.refresh(); },
在search中引入playmixin并定义handleplaylist方法
import { playlistMixin } from '../../common/js/mixin'; 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(); },