MDN 的自动完成搜索是如何实现的

992 阅读6分钟

本文为翻译

原文标题:How MDN’s autocomplete search works

原文作者:Peter Bengtsson

原文地址:hacks.mozilla.org/2021/08/mdn…

上个月,我和 Gregor Weber 为 MDN Web Docs 添加了 自动完成搜索(autocomplete search)功能,有了这个功能,你可以通过输入文档的部分标题来快速查找并跳转到想查看的文档。这篇文章我会介绍这个功能是如何实现的。如果你坚持看到文章末尾,我还会分享一个 “彩蛋” 功能,一旦你学会使用它,你一定会成为派对上最靓的仔。不过,些许你只是想比普通人更快的浏览 MDN。

简单来说,输入框上有一个 onkeypress 事件监听器 用于过滤 (每个地区的)完整的文档标题列表。在我写这篇文章时,English US 有 11,690 个不同的文档标题和对应的 URL。你可以打开 developer.mozilla.org/en-US/searc… 来预览这些文档。没错,这个文件很大,但还没大到无法被全部放进内存。毕竟,执行搜索逻辑的代码只会在发现用户要输入某些内容时,该文件才会被加载。而提到文件大小,由于文该件通过 Brotli 算法 进行了压缩,所以在网络上该文件大小仅为 144KB。

实现细节

默认情况下,加载的 JavaScript 代码 只有一小段 shim 代码,用于设定监听 <input> 搜索框的 onmouseoveronfocus 。还有一个绑定在 document 上用于监听输入特定按键的事件监听器。在任何地方输入 / ,和你用鼠标把焦点放在 <input> 是一样的。一旦 focus 事件被触发,首先会 下载两个 JavaScript 包 来将 <input> 转变为更高级的东西。简单来说(通过伪代码),就是这样的:

<input 
 type="search" 
 name="q"
 onfocus="startAutocomplete()" 
 onmouseover="startAutocomplete()"
 placeholder="Site search..." 
 value="q">
let started = false;
function startAutocomplete() {
  if (started) {
    return false;
  }
  const script = document.createElement("script");
  script.src = "https://2r4s9p1yi1fa2jd7j43zph8r-wpengine.netdna-ssl.com/static/js/autocomplete.js";
  document.head.appendChild(script);
}

而这里加载的 /static/js/autocomplete.js 才是最神奇的。通过伪代码我来深入解释一下:

(async function() {
  const response = await fetch('/en-US/search-index.json');
  const documents = await response.json();
  
  const inputValue = document.querySelector(
    'input[type="search"]'
  ).value;
  const flex = FlexSearch.create();
  documents.forEach(({ title }, i) => {
    flex.add(i, title);
  });

  const indexResults = flex.search(inputValue);
  const foundDocuments = indexResults.map((index) => documents[index]);
  displayFoundDocuments(foundDocuments.slice(0, 10));
})();

正如你看到的,这是对实际工作原理的过度简化,但现在还不是深入这些细节的时候。下一步 就是展示匹配项。我们使用 (TypeScript) React 来实现,但是下面的伪代码应该更容易理解:

function displayFoundResults(documents) {
  const container = document.createElement("ul");
  documents.forEach(({url, title}) => {
    const row = document.createElement("li");
    const link = document.createElement("a");
    link.href = url;
    link.textContent = title;
    row.appendChild(link);
    container.appendChild(row);
  });
  document.querySelector('#search').appendChild(container);
}

然后通过一些 CSS,我们会把这些匹配项变成一个浮层,然后简单放在 <input> 下方。除此之外,我们还会根据 inputValue 突出展示每个文档标题,当通过上下按键浏览时,各个事件监听器会突出展示你正在浏览的行。

好的,我们再深入一下实现细节

我们只创建了一次 FlexSearch 索引,并在每一次新的键击出现时复用它。由于用户在等待网络响应时,可能会输入更多东西,所以当全部的 JavaScript 和 JSON XHR 都加载完毕,才会执行实质上的搜索。

在我们深入 FlexSearch 是什么之前,我想先说一下我们实际上是如何展示搜索结果的。我们使用了一个 React 库 downshift 来处理交互、展示 并确保搜索结果具有可访问性(Accessible,译者注:无障碍相关,国外页面对于 让残障人士更加便利的访问 比较重视)。 downshift 是一个很成熟的库,解决了我们在构建这个小组件时遇到的很多挑战,尤其是让搜索结果具有可访问性。

那么,FlexSearch 是一个怎样的库呢?它是我们引入的另一个第三方库,确保在标题上的搜索是以自然语言为基础的。它将自己描述为 “Web 上最快,内存最灵活 的 零依赖 全文搜索库”,它比简单的在字符串中搜索要准确高效很多。

决定优先展示哪些结果

有一说一,假设用户输入了 foreac ,从 10,000+ 的文档标题列表找到那些标题包含 foreac 的项 并不困难,在这之后我们需要决定优先展示哪些结果。我们根据 PV 数据来实现这一点。我们会记录每一个 MDN URL,如果一个页面获得很多的 PV ,那可能它是 “受欢迎” 的。大多数人选择访问的文档就是受欢迎的,也最有可能是用户想要搜索的。

我们在生成 search-index.json 文件的 构建阶段 可以知道每个 URL 的 PV 量。我们实际上并不关心绝对数字,我们真正关心的是其中的相对差异。例如,我们知道 Array.prototype.forEach() (文档标题之一)比 TypedArray.prototype.forEach() 更受欢迎,我们就会利用这一点,在 search-index.json 中对条目进行排序。现在,通过 FlexSearch 进行简化,我们利用数组的 “自然顺序” 来为用户提供他们可能在搜索的文档。这实际上和我们在全站搜索中使用的 Elasticsearch 是相同的技术。详见:How MDN’s site-search works

彩蛋:如何通过 URL 搜索

事实上,这个彩蛋可不是闹着玩的,而是一个功能,利用自动完成用来帮助我们的内容创作者。当你创作 MDN 中的内容 时,你会启动一个本地的 “预览服务器”,它是所有文档的完整拷贝,但运行在本地,作为一个静态站点 运行于 http://localhost:5000 。在那里,你不会希望依赖于服务器来进行搜索。内容创作者们需要快速的在文档间切换,这就是自动完成搜索完全实现在客户端的主要原因。

像是 VSCode 和 Atom IDE 这样的工具中 通常会实现 “模糊搜索”,这个功能可以帮助你通过简单的输入文件路径的一部分来查找和打开文件。例如,搜索 whmlemvo 就能找到 files/web/html/element/video 这个文件。你也可以在 MDN 的自动完成搜索中做同样的事情。你可以以 / 为第一个字符来开启这个功能。

如果你知道 URL 但不想完整写出来,你可以通过这种方式非常快速的直接跳转到对应的文档。

事实上,还有另一种导航方式,你可以在浏览 MDN 时先输入 / 激活自动完成搜索,然后在输入 /,接下来就轮到你了!

如何真正地深入实现细节

我上面提到的全部所有代码都在 Yari 的 repo 中,Yari 是构建和预览全部 MDN 内容 的一个项目。可以点击 client/src/search.tsx 来访问源代码,你会找到所有实现 懒加载,搜索,预加载 和 显示自动完成搜索结果的代码。