前言
最近在用自己的卡盒小程序的时候,发现卡片越来越多,有时候要找到某一张来看看笔记要找半天,于是自己做了一个搜索功能,先看效果:
怎么样,是不是还挺不错的,那么这篇文章就讲讲这样一个搜索展示的功能是如何实现的。
代码实现
首先我们分析这个搜索功能包含哪些需要实现的点:
- 输入关键字,匹配对应的卡片展示
- 匹配的卡片上面,要把符合搜索条件的所有词给高亮展示出来,不然文本多的时候容易看花了。
- 点击匹配到的卡片,要跳转到卡片的位置,并且闪烁两下,这样一看就知道要找卡片在哪了。
分析完成后,我们一点一点实现。
匹配关键字
我的小程序目前是纯前端搜索的,只是目前是这样,所以搜索逻辑也是在前端实现。搜索逻辑如果简单实现的话就是将搜索框的内容与列表中的每一项进行对比,看看内容中有没有包含这个字符串的,如果有就把这个项给返回回去。
先看代码:
const onSearch = () => {// 检查搜索文本框是否有值if (searchText.value) {// 创建一个正则表达式对象,用于在卡片内容中搜索文本// 'giu' 标志表示全局搜索、不区分大小写和支持 Unicodeconst searchTextRegex = new RegExp(searchText.value, 'giu')// 遍历所有的卡片盒子const matchCardBox = cardDataStore.cardBoxList.map((cardBox) => {// 对每个卡片盒子,创建一个新对象,包含原始属性和修改后的卡片项目return {...cardBox,// 映射并过滤卡片项目,只保留匹配搜索文本的项目cardItems: cardBox.cardItems.map((cardItem) => {// 初始化前面和背面内容的匹配数组const frontMatches = []const backMatches = []let match// 在卡片前面内容中搜索匹配项while ((match = searchTextRegex.exec(cardItem.frontContent)) !== null) {// 记录每个匹配项的起始和结束索引frontMatches.push({startIndex: match.index,endIndex: match.index + match[0].length,})}// 重置正则表达式的 lastIndex 属性,以便重新搜索searchTextRegex.lastIndex = 0// 在卡片背面内容中搜索匹配项while ((match = searchTextRegex.exec(cardItem.backContent)) !== null) {// 记录每个匹配项的起始和结束索引backMatches.push({startIndex: match.index,endIndex: match.index + match[0].length,})}// 检查是否有匹配项(前面或背面)const isMatched = frontMatches.length > 0 || backMatches.length > 0// 返回一个新的卡片项目对象,包含是否匹配和匹配项的位置return {...cardItem,isMatched,frontMatches,backMatches,}})// 过滤掉不匹配的项目.filter((item) => item.isMatched),}})// 过滤掉没有匹配项目的卡片盒子filteredCards.value = matchCardBox.filter((cardBox) => cardBox.cardItems.length > 0)} else {// 如果没有搜索文本,则清空过滤后的卡片列表filteredCards.value = []}
}
1. 创建正则表达式
const searchTextRegex = new RegExp(searchText.value, 'giu')
searchText.value
:这是用户输入的搜索文本。-
new RegExp(...)
:通过传入的搜索文本和标志(‘giu’)创建一个新的正则表达式对象。
-
g
:全局搜索标志,表示搜索整个字符串中的所有匹配项,而不是在找到第一个匹配项后停止。
-
i
:不区分大小写标志,表示搜索时忽略大小写差异。 -u
:Unicode 标志,表示启用 Unicode 完整匹配模式,这对于处理非 ASCII 字符很重要。
2. 搜索匹配项
let match
let match
:声明一个变量match
,它将用于存储RegExp.exec()
方法找到的匹配项。
while ((match = searchTextRegex.exec(cardItem.frontContent)) !== null) { // ... }
searchTextRegex.exec(cardItem.frontContent)
:在cardItem.frontContent
(卡片的正面内容)中执行正则表达式搜索。-
- 如果找到匹配项,
exec()
方法返回一个数组,其中第一个元素(match[0]
)是找到的匹配文本,index
属性是匹配项在字符串中的起始位置。
- 如果找到匹配项,
- 如果没有找到匹配项,
exec()
方法返回null
。 -while
循环:继续执行,直到exec()
方法返回null
,表示没有更多的匹配项。
3. 记录匹配项的索引
frontMatches.push({ startIndex: match.index, endIndex: match.index + match[0].length, })
- 在每次循环迭代中,都会找到一个匹配项。
startIndex
:匹配项在cardItem.frontContent
中的起始位置。endIndex
:匹配项在cardItem.frontContent
中的结束位置(即起始位置加上匹配文本的长度)。frontMatches.push(...)
:将包含起始和结束索引的对象添加到frontMatches
数组中。
经过这么一番操作,我们就可以获得一个筛选后的数组,其中包含了所有匹配的项,每个项还有一个二维数组用来记录匹配位置开头结尾的索引:
cardItems: (CardItem & {id: stringfrontMatches?: { startIndex: number; endIndex: number }[]backMatches?: { startIndex: number; endIndex: number }[]})[]
为什么要大费周章的记录这个索引呢,那是因为下一步需要用到,接下来说说关键词高亮的展示:
关键词高亮
关键词高亮需要在字符串的某几个字符中更改它的样式,因此我们上一步才需要记录一下需要高亮的字符串开始和结束的位置,如此一来我们做这个高亮的组件就不用再执行一次匹配了。那么这个样式要如何实现呢,我们需要遍历这个字符串,在需要高亮的字增加额外的样式,最后再重新拼接成一个字符串。
// Highlight.vue
<template><view class="flex flex-wrap"><viewv-for="(charWithStyle, index) in styledText"class="text-sm":key="index":class="charWithStyle.isMatched ? 'text-indigo-500 font-semibold' : ''">{{ charWithStyle.char }}</view></view>
</template><script lang="ts" setup>
import { defineProps, computed } from 'vue'interface Props {text: stringmatches: { startIndex: number; endIndex: number }[]
}const props = defineProps<Props>()const styledText = computed(() => {const textArray = _.toArray(props.text)const returnArr = []let index = 0let arrIndex = 0while (index < props.text.length) {let char = ''if (textArray[arrIndex].length > 1) {char = textArray[arrIndex]} else {char = props.text[index]}const isMatched = props.matches.some((match) => {const endIndex = match.endIndexconst startIndex = match.startIndexreturn startIndex <= index && index < endIndex})returnArr.push({ char, isMatched })index += textArray[arrIndex].lengtharrIndex += 1}return returnArr
})
</script>
这里我没有使用 for of
直接遍历字符串,这也是我的一个踩坑点,像 emoji 表情这种字符它的长度其实不是 1,如果你直接使用 for of
去遍历会把它的结构给拆开,最终展示出来的是乱码,如果你想正常展示就要用 Array.from(props.text)
的方式将字符串转换成数组,再进行遍历,这样每个字符就都是完整的。
假设我们打印:
console.log('😟'[0], '😟'.length)console.log(Array.from('😟')[0], Array.from('😟').length)
这里我没有使用 Array.from
而是使用了 lodash 中的 toArray
,是因为看到这篇文章 https://fehey.com/emoji-length 中提到:
Array.from(props.text)
创建的数组textArray
中的每个元素实际上是一个 UTF-16 代码单元的字符串表示,而不是完整的 Unicode 字符 Emoji 表情有可能是多个 Emoji + 一些额外的字符 来拼接出来的,像 ‘👩👩👧👧’ 就是由 [‘👩’, ‘’, ‘👩’, ‘’, ‘👧’, ‘’, ‘👧’] 拼接而成的,单个 Emoji 长度为 2,中间的连接字符长度为 1,故返回了 11。如何获取 ‘👩👩👧👧’ 的长度为视觉的 1 呢,可以使用 lodash 的 toArray 方法,_.toArray(‘👩👩👧👧’).length = 1,其内部实现 了对 unicode 字符转换数组的兼容。
正是因为我们第一步中使用正则去匹配字符串的时候,是根据表情包字符实际的长度返回的索引值,所以我们这里有一个逻辑:
let index = 0let arrIndex = 0while (index < props.text.length) {let char = ''if (textArray[arrIndex].length > 1) {char = textArray[arrIndex]} else {char = props.text[index]}
//,,,index += textArray[arrIndex].lengtharrIndex += 1}
如果字符的长度大于一我们就从字符串数组中取值,这样表情包就能正常展示了,然后维护两个索引,一个索引给字符长度大于1的字符用,一个给字符长度为1的用,根据不同的情况取不同的值,这样就能处理好表情包的这种情况了。下面这种很多个表情包的文本,也能在正确的位置高亮
请添加图片描述
滚动到指定位置并高亮
这一步就比较简单了,直接上代码:
import { getCurrentInstance } from 'vue'
const instance = getCurrentInstance()
const { safeAreaInsets } = uni.getWindowInfo()// 滚动到卡盒位置
const scrollToCardBox = (position: 'top' | 'bottom' = 'top') => {const query = uni.createSelectorQuery().in(instance.proxy)query.select(`#card-box-${props.cardBoxIndex}`).boundingClientRect((data) => {return data}).selectViewport().scrollOffset((data) => {return data}).exec((data) => {uni.pageScrollTo({scrollTop:data[1].scrollTop +data[0].top -safeAreaInsets.top +(position === 'top' ? 0 : data[0].height),duration: 200,})})
}
关于解析,在 🥲踩坑无数,如何用 uniapp 实现一个丝滑的英语学习卡盒小程序 这篇文章中有详细提到,这里不赘述了。
uni.pageScrollTo
有一个回调,可以用于滚动到指定位置后,执行某个函数,那么我们可以在这里设置触发高亮的动画,动画的实现如下:
<view//...:class="cardItemStore.scrollToCardItemId === props.cardItemData.id ? 'animation-after-search' : ''".animation-after-search {animation: vague 1s;animation-iteration-count: 2;
}@keyframes vague {0%,100% {box-shadow: inset 0 0 0 0 transparent; /* 初始和结束时没有阴影 */}50% {box-shadow: inset 0 0 0 2px #6366f1; /* 中间时刻显示阴影 */}
}
这里没有使用边框,而是使用了内嵌的阴影,避免边框会把容器撑大的问题,滚动完成后动态给指定元素一个执行动画的 class
,动画触发完成后再移除 class
就 OK 了。效果如下:
总结
如果不是遇到了表情包长度问题,这个搜索功能的实现还是比较简单的,重点是交互和设计是否能够让用户快速定位到想找的内容。目前是纯前端实现,而且涉及了很多遍历,性能还有待提升,不过先实现再优化了。学习卡盒已经上线了,大家可以直接搜索到,这个搜索功能也发版了,欢迎体验。