给我的小程序加了个丝滑的搜索功能,踩坑表情包长度问题

ops/2024/12/12 5:53:41/

前言

最近在用自己的卡盒小程序的时候,发现卡片越来越多,有时候要找到某一张来看看笔记要找半天,于是自己做了一个搜索功能,先看效果:

搜索.gif

怎么样,是不是还挺不错的,那么这篇文章就讲讲这样一个搜索展示的功能是如何实现的。

代码实现

首先我们分析这个搜索功能包含哪些需要实现的点:

  1. 输入关键字,匹配对应的卡片展示
  2. 匹配的卡片上面,要把符合搜索条件的所有词给高亮展示出来,不然文本多的时候容易看花了。
  3. 点击匹配到的卡片,要跳转到卡片的位置,并且闪烁两下,这样一看就知道要找卡片在哪了。

分析完成后,我们一点一点实现。

匹配关键字

我的小程序目前是纯前端搜索的,只是目前是这样,所以搜索逻辑也是在前端实现。搜索逻辑如果简单实现的话就是将搜索框的内容与列表中的每一项进行对比,看看内容中有没有包含这个字符串的,如果有就把这个项给返回回去。

先看代码:

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 }[]})[]

为什么要大费周章的记录这个索引呢,那是因为下一步需要用到,接下来说说关键词高亮的展示:

关键词高亮

搜索.gif

关键词高亮需要在字符串的某几个字符中更改它的样式,因此我们上一步才需要记录一下需要高亮的字符串开始和结束的位置,如此一来我们做这个高亮的组件就不用再执行一次匹配了。那么这个样式要如何实现呢,我们需要遍历这个字符串,在需要高亮的字增加额外的样式,最后再重新拼接成一个字符串。

// 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 了。效果如下:

滚动高亮.gif

总结

如果不是遇到了表情包长度问题,这个搜索功能的实现还是比较简单的,重点是交互和设计是否能够让用户快速定位到想找的内容。目前是纯前端实现,而且涉及了很多遍历,性能还有待提升,不过先实现再优化了。学习卡盒已经上线了,大家可以直接搜索到,这个搜索功能也发版了,欢迎体验。


http://www.ppmy.cn/ops/141173.html

相关文章

23种设计模式之观察者模式

目录 1. 简介2. 代码2.1 Subject2.2 ConcreteSubject2.3 Observer2.4 ConcreteObserver2.5 Test &#xff08;测试&#xff09;2.6 运行结果 3. 优缺点4. 总结 1. 简介 观察者模式&#xff08;Observer Pattern&#xff09; 是一种行为设计模式。它定义了一种一对多的依赖关系…

最近邻搜索 - 经典树型结构 M-Tree

前言 如果你对这篇文章感兴趣&#xff0c;可以点击「【访客必读 - 指引页】一文囊括主页内所有高质量博客」&#xff0c;查看完整博客分类与对应链接。 最近邻搜索的目标是从 N N N 个对象中&#xff0c;快速找到距离查询点最近的对象。根据需求的不同&#xff0c;该任务又分…

MAVEN--Maven的生命周期,pom.xml详解,Maven的高级特性(模块化、聚合、依赖管理)

目录 &#xff08;一&#xff09;Maven的生命周期 1.Maven的三套生命周期 2.Maven常用命令 &#xff08;二&#xff09;pom.xml详解 &#xff08;三&#xff09;Maven的高级特性&#xff08;模块化、聚合、依赖管理&#xff09; 1.Maven的依赖范围 2.版本维护 3.依赖传…

无人机飞手考证后从事吊运植保创业技术详解

无人机飞手考证后从事吊运植保创业的技术详解如下&#xff1a; 一、无人机飞手考证流程 1. 报名与资格审核&#xff1a; 选择正规培训机构&#xff0c;提交身份证明、学历证明等相关材料。 通过初步审核&#xff0c;确保学员满足年龄&#xff08;年满16周岁&#xff09;、身…

CGM:卡与应用内存管理

内存资源管理 1. 管理概述 1.1 可选功能说明 1.2 管理主体责任 2. 资源分配规则 2.1 加载时分配 2.1.1 最小内存要求 2.1.2 资源可用性检查 2.2安装时分配 2.2.1 内存配额设定 2.2.2 预留内存处理 3. 资源使用管理 3.1 应用运行时分配 3.1.1 数据存储分配 3.1.2 资源耗尽处理 3…

QT 中 QDateTime::currentDateTime() 输出格式备查

基础 QDateTime::currentDateTime() //当前的日期和时间。 QDateTime::toString() //以特定的格式输出时间&#xff0c;格式 yyyy: 年份&#xff08;4位数&#xff09; MM: 月份&#xff08;两位数&#xff0c;07表示七月&#xff09; dd: 日期&#xff08;两位数&#xff0c…

2024小迪安全基础入门第十一课

目录 一、算法识别加解密-MD5&AES&DES&RSA #算法加密-概念&分类&类型 二、 解密条件-有源码找逻辑&无源码JS逆向 #加密解密-识别特征&解密条件 #密码存储 一、算法识别加解密-MD5&AES&DES&RSA 安全测试中&#xff1a; 密文-有源…

ARM学习(36)静态扫描规则学习以及工具使用

笔者来学习了解一下静态扫描以及其规则&#xff0c;并且亲身是实践一下对arm 架构的代码进行扫描。 1、静态扫描认识 静态扫描&#xff1a;对代码源文件按照一定的规则进行扫描&#xff0c;来发现一些潜在的问题或者风险&#xff0c;因为不涉及代码运行&#xff0c;所以其一般…