我在前端做了一个搜索

由于运营侧有搜索的需求,而后端人手紧缺。商讨之下决定先在前端做一个简易的搜索,如果效果较好,再由后端实现。
本文大致整理了这次开发的思路,以及开发中遇到的一些小问题。
但由于能力有限,本次搜索并没有上升到语义层面,下面这种误差是无法避免的。

—— 请用“天真”造句。
—— 夏天真热。

背景 & 需求

通过输入的关键词,匹配到相应的皮肤。
标记皮肤的方式有两种,title和tag,均支持多语言,且结果优先级title > tag。

前期准备

  1. 运营侧准备能够搜索到的所有皮肤(提供皮肤id)
  2. 后端通过id,将对应的title和tag集结成一个map;由于支持多语言,每个语言使用一个map

主要工作

样式和交互

  • 搜索入口,随机显示建议关键词,在一定的时机更新
  • 搜索前,显示建议的tags
  • 输入中,显示关联的关键词
  • 搜索中,转菊花
  • 搜索完成,显示【搜索结果 + 推荐皮肤】 or 【无结果提示 + 推荐皮肤】

相关组件

  • ListView,原谅我们还停留在老旧的rn 0.31
  • TextInput,输入框
  • 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为例:

  1. 完全匹配,例如Live
  2. 部分完全匹配,并以匹配先后排序,例如Classic Live Famine
  3. 部分首位匹配,并以匹配先后排序,例如Lively
  4. 部分匹配,并以匹配先后排序,例如Sliver Purple Diamond

而在中文情况下,由于中文词汇大多没有空格,所以2基本是不会匹配到的。以为例。

  1. =>爱
  2. /
  3. =>爱心
  4. =>可爱的熊猫

最终函数如下:

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
30
function 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
3
function filterSpecialChar(str){
return str.replace(/[`~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/gi, '');
}