序
经过几周的开发,i18n.site(纯静态的markdown多语言翻译&建站工具)现在支持纯前端全文搜索。
本文将分享i18n.site纯前端全文搜索的技术实现,访问i18n.site可体验搜索效果。
无服务全文搜索解决方案综述
对应文档/个人博客等中小型纯静态网站而言,自建全文搜索后台太重,无服务全文搜索是更常见的选择。
无服务全文搜索解决方案分两大类:
其一,类似 algolia.com 第三方搜索服务商,提供了全文搜索的前端组件。
此类服务需按搜索量付费,且常因网站合规性等问题,导致中国大陆用户无法使用。
无法离线使用,无法在内网使用,局限性很大。 本文不多做讨论。
其二,就是纯前端的全文搜索。
当下常见纯前端的全文搜索的有 lunrjs 和 ElasticLunr.js(基于lunrjs二次开发)。
lunrjs 有两种索引构建方式,又都有各自的问题。
- 
预构建索引文件
因为索引包含了所有文档的词,体积大。 每当文档有增改,都要加载新的索引文件。 会增加用户的等待时间,并消耗大量带宽。
 - 
加载文档并实时构建索引
构建索引是计算密集型任务,每次访问都重新构建索引会有明显的卡顿,用户体验差。
 
除了 lunrjs 之外,还有一些其他的全文搜索方案,比如:
fusejs,计算字符串之间的相似度来搜索。
此方案性能极差,无法用于全文搜索(参见 Fuse.js 长查询耗时超过10秒,如何优化?)。
TinySearch,使用布隆过滤器来搜索,无法用于前缀搜索(比如输入goo,搜索good、google),无法实现类似自动补全效果。
因为现有方案各自的弊端, i18n.site 自研了全新纯前端全文搜索方案,具有以下特色:
- 支持多语言搜索,体积小,搜索内核打包
gzip后体积为6.9KB(作为对比,lunrjs体积为25KB) - 基于 
indexedb构建倒排索引,内存占用少,速度快 - 当文档有新增/改动的时候,只对增改的文档重新索引,减少了计算量
 - 支持前缀搜索,可以在用户输入的同时实时展示搜索结果
 - 离线可用
 
下面,将详细介绍 i18n.site 技术实现细节。
多语言分词
分词采用浏览器原生的分词 Intl.Segmenter,主流浏览器都支持此接口。
分词coffeescript代码如下
            
            
              coffee
              
              
            
          
          SEG = new Intl.Segmenter 0, granularity: "word"
seg = (txt) =>
  r = []
  for {segment} from SEG.segment(txt)
    for i from segment.split('.')
      i = i.trim()
      if i and !'|`'.includes(i) and !/\p{P}/u.test(i)
        r.push i
  r
export default seg
export segqy = (q) =>
  seg q.toLocaleLowerCase()
        其中:
/\p{P}/是匹配标点符号的正则表达式,具体匹配的符号包括:! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \ ] ^ _{ | } ~ 。`。split('.')是因为Firefox浏览器分词不切分.。
索引构建
IndexedDB 中创建了5 个对象存储表 :
word: id - 词doc: id - 文档url - 文档版本号docWord: 文档id - 词id的数组prefix: 前缀 - 词id的数组rindex: 词id - 文档id : 行号的数组
传入文档的url和版本号ver的数组,在doc表中查找文档是否存在,如果不存在,则创建倒排索引。同时,移除那些未传入文档的倒排索引。
如此就可以实现增量索引,降低了计算量。
前端交互上,可以显示索引的加载进度条避免首次加载时的卡顿感,参见《带动画的进度条,基于单个 progress + 纯css实现》英文 / 中文。
IndexedDB 高并发写入
项目基于 IndexedDB 的异步封装 idb 开发。
IndexedDB 读取和写入都是异步的。而创建索引的时候会并发地加载文档创建索引。
为了避免竞争性写入导致部分数据丢失,可以参考下面的coffeescript代码,在读写之间加一个ing缓存拦截竞争性写入。
            
            
              coffee
              
              
            
          
          pusher = =>
  ing = new Map()
  (table, id, val)=>
    id_set = ing.get(id)
    if id_set
      id_set.add val
      return
    id_set = new Set([val])
    ing.set id, id_set
    pre = await table.get(id)
    li = pre?.li or []
    loop
      to_add = [...id_set]
      li.push(...to_add)
      await table.put({id,li})
      for i from to_add
        id_set.delete i
      if not id_set.size
        ing.delete id
        break
    return
rindexPush = pusher()
prefixPush = pusher()
        准确率和查全率
搜索会先将用户输入的关键词进行分词。
假设有分词之后有N个词,返回结果的时候,会先返回包含所有关键词的结果,然后再返回包含 N-1、N-2、... 、1 个关键词的结果。
优先展示的搜索结果保证了查询的准确率,后续加载的结果(点击加载更多按钮)保证了查全率。
按需加载
为了提高响应速度,搜索借助yield生成器实现了按需加载的方式,每查询到limit个结果就返回一次。
注意,每次 yield 之后再次搜索时,需要重新打开一个 IndexedDB 的查询事务。
前缀实时搜索
为了实现用户输入的同时展示搜索结果,比如输入 wor 的时候,展示 words 和 work 等以 wor 为前缀的单词。
搜索内核会对分词后的最后一个词借助prefix表,找到所有以它为前缀的词,依次搜索。
前端交互中还采用了防抖函数 debounce(实现如下),降低用户输入触发搜索的频率减少计算量。
            
            
              js
              
              
            
          
          export default (wait, func) => {
  var timeout;
  return function(...args) {
    clearTimeout(timeout);
    timeout = setTimeout(func.bind(this, ...args), wait);
  };
}
        离线可用
索引表没有存储原文,只存储词,减少了存储量。
搜索结果高亮需要重新加载原文,配合service worker即可避免重复的网络请求。
同时,因为service worker会缓存所有文章,所以用户一旦进行过搜索,整个网站包含搜索在内都离线可用。
对 MarkDown 文档的显示优化
i18n.site 的纯前端搜索解决方案针对 MarkDown 文档做了优化。
展示显示搜索结果时,会显示章节名称,并在点击时定位到该章节。
总结
纯前端实现的倒排全文搜索,无需服务器。非常适合文档、个人博客等中小型网站。
i18n.site 开源的自研纯前端搜索,体积小,响应快,解决了当前纯前端全文搜索的种种弊端,提供了更好的用户体验。