我在前端做了一个搜索
由于运营侧有搜索的需求,而后端人手紧缺。商讨之下决定先在前端做一个简易的搜索,如果效果较好,再由后端实现。
本文大致整理了这次开发的思路,以及开发中遇到的一些小问题。
但由于能力有限,本次搜索并没有上升到语义层面,下面这种误差是无法避免的。
—— 请用“天真”造句。
—— 夏天真热。
背景 & 需求
通过输入的关键词,匹配到相应的皮肤。
标记皮肤的方式有两种,title和tag,均支持多语言,且结果优先级title > tag。
前期准备
- 运营侧准备能够搜索到的所有皮肤(提供皮肤id)
- 后端通过id,将对应的title和tag集结成一个map;由于支持多语言,每个语言使用一个map
主要工作
样式和交互
- 搜索入口,随机显示建议关键词,在一定的时机更新
- 搜索前,显示建议的tags
- 输入中,显示关联的关键词
- 搜索中,转菊花
- 搜索完成,显示【搜索结果 + 推荐皮肤】 or 【无结果提示 + 推荐皮肤】
相关组件
ListView
,原谅我们还停留在老旧的rn 0.31TextInput
,输入框Keyboard
一个比较完善的交互体验,起键盘和收键盘的时机是比较关键的。我们目前的交互是:
· 进入搜索页面,起键盘;
· 清空搜索内容,起键盘;
· 执行搜索时,收键盘;
· 滑动列表或有点击事件时,收键盘;
· 退出页面时,收键盘;
具体见我的这篇React Native 中的收键盘和起键盘
搜索算法
1 | let filteredKeys = Object.keys(searchKeys).filter(item => item.toLowerCase().includes(key.toLowerCase())); |
一开始没有想太多,只是简单的做了一个部分匹配,没想到第一次提测时就漏洞百出。不得不反思一下自己自身思维固话,画地为牢,限制了前端做搜索的可能性。
这篇博客使用 JavaScript 实现简单候选项推荐功能(模糊搜索)里提到了编辑距离,但实践下来发现并不适合,例如搜索Neon
时,Neon Blue
的编辑距离为5,而Lion
的编辑距离为2,但是显然Neon Blue
的相关性更高。还有一个js搜索库Fuse.js - JavaScript fuzzy-search library,但是结果也不符合预期,有兴趣的同学可以看看。
正如10 行 Python 代码写的模糊查询 中所说——
编辑距离非常适合用来做自动更正拼写错误的技术,但在从部分子串匹配长文件名时表现的不太好
这么一来二去,我们也理清了最终的需求:我们需要的不是匹配到的结果多,而是结果的准确性,并且按照一定的优先级排序。而最终结果的优先级应该如下,以英文的Live为例:
- 完全匹配,例如Live
- 部分完全匹配,并以匹配先后排序,例如Classic Live Famine
- 部分首位匹配,并以匹配先后排序,例如Lively
- 部分匹配,并以匹配先后排序,例如Sliver Purple Diamond
而在中文情况下,由于中文词汇大多没有空格,所以2基本是不会匹配到的。以爱
为例。
- =>爱
- /
- =>爱心
- =>可爱的熊猫
最终函数如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30function search(key, list){
if(!list || !key){
return [];
}
key = key.toLocaleLowerCase();
let level0 = [],//完全匹配
level1 = [],//部分单词完全匹配
reg1 = new RegExp('\\b' + key + '\\b','gi'),
level2 = [],//部分单词首字母匹配
reg2 = new RegExp('\\b'+ key + '\\B','gi'),
level3 = [];//部分匹配
let item, formatItem;
for(let i = 0, len = list.length; i < len; i++){
item = list[i];
formatItem = list[i].toLocaleLowerCase();
if(formatItem === key){
level0.push(item);
}else if(reg1.test(item)){
level1.push(item);
}else if(reg2.test(item)){
level2.push(item);
}else if(formatItem.includes(key)){
level3.push(item);
}
}
level1.sort((a,b)=>a.search(reg1) - b.search(reg1));
level2.sort((a,b)=>a.search(reg2) - b.search(reg2));
level3.sort((a,b)=>a.indexOf(key) - b.indexOf(key));
return [...level0,...level1,...level2,...level3];
}
小插曲
联想词汇的数量太多
输入的只有一个字母时,几乎能匹配到大半结果,导致页面卡顿。但从交互上来看,用户需要的联想词汇其实一屏足够,所以匹配到10个结果就不再继续。
搜索结果是一次性向后端请求的
测试的时候随意地输入了一个“色”字,结果直接请求失败。原来是”色”能匹配到所有的颜色tag,而每个皮肤上都标记着颜色tag,导致几乎所有的结果都被匹配到了,请求过长,直接失败。
目前的解决方法是截断,最多只取100个皮肤进行展示,因为太长的结果用户也无耐心查看。后续若有必要,可以在本地做分页,分别请求展示。
有输入就要小心脚本注入
需要过滤一些奇形怪状的字符(来源于网络,不过挺好用的):1
2
3function filterSpecialChar(str){
return str.replace(/[`~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/gi, '');
}