🔍
https://yunshuai.me

使用 fusejs 给 hugo 增加站内搜索

搜索方案概述

搜索方案大体上分为前端搜索和后端搜索。

后端搜索通常使用 elastic search,这是一个很成熟的解决方案。另外,使用 rust 编写的 meili search 也是一个很好的选择。最后,不同的语言通常会有一些小众化的解决方案,例如 php 中的 tnt search,以及迅搜

后端搜索还可以使用第三方的服务,algolia 就是一个很不错的选择,有免费额度,初创公司或者个人用户可以考虑。但是离我们最近的数据中心在香港,延迟是一个大问题。再者就是使用腾讯云或者阿里云提供的 elastic search 服务,但是很贵,用不起🙂,溜了溜了。

前端搜索也可以叫做 browser search 或者 offline search,总的来说就是搜索的时候不需要发请求到服务器。关于前端搜索,我所了解的,有这么几个可供选择:

这个网站还有列举了另外几个不是很流行的库,可以瞄一眼。

总体上说,前端方案是轻量级的,而后端方案成熟、大而全。下面介绍 fusejs 方案。

fusejs

官方网站

fusejs 是一个轻量级的模糊搜索库。本站的搜索方案就是用 fusejs 实现的(参考了官方的,并做了优化),可以点击右上角的搜索图标体验。

安装方式

npm or yarn

npm 或者 yarn 下载即可。

npm install --save fuse.js
// or
yarn add fuse.js

手动

如果不想用 npm 之类的工具,可以去仓库里手动复制到本地来用。

cdn引入

传统 cdn 引入方式。

<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.4.6"></script>

module导入

可以使用浏览器原生支持的模块功能从外部导入。

<script type="module">
import Fuse from 'https://cdn.jsdelivr.net/npm/fuse.js@6.4.6/dist/fuse.esm.js'
</script>

deno

值得一提的是,如果你使用 deno,也可以用 deno 的方式引入。

import Fuse from 'https://deno.land/x/fuse@v6.4.6/dist/fuse.esm.min.js'

基本用法

<script>
// 数据
const data = [
{
"title": "hello world",
},
{
"title": "你好,世界",
}
];
 
// 弄一个实例
const fuse = new Fuse(data, {
// 自定义需要搜索的字段
keys: ["title"]
});
 
// 执行搜索
const result = fuse.search('world');
</script>

结合 hugo 使用

官方罗列了好几个解决方案,其中这个使用的是 fusejs。

总体的思想

  1. hugo 生成 json 数据。
  2. 搜索的时候,用 js 发请求,拿到数据。
  3. 实例化 fuse,搜索。
  4. 得到搜索结果后,把结果展示在页面上。

下面以官方给的代码为例,阐述一下实现步骤。

hugo 生成 json 数据

hugo 支持自定义输出类型,参考

配置

Config.toml 中增加 json 输出:

[outputs]
home = ["HTML", "RSS", "JSON"]

定义 json 格式

layout/_default/index.json

{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
{{- $.Scratch.Add "index" (dict "title" .Title "permalink" .Permalink "content" .Plain) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

做好上面两步之后,可以通过 http(s)://youdomain.com/index.json 拿到数据了。执行 hugo 构建站点,在 public 文件夹中也可以看到 index.json 文件。

引入 fuse.js

通常来说,主题文件里有一个 themes/xxx/layouts/_default/baseof.html 的文件,把 <script src="https://cdn.jsdelivr.net/npm/fuse.js@6.4.6"></script> 放到这个 html 的 header 中。

搜索框

同样找到 themes/xxx/layouts/_default/baseof.html 文件,在 body 中放入:

<div id="fastSearch">
<input id="searchInput" tabindex="0">
<ul id="searchResults"></ul>
</div>

搜索框样式

新建文件 static/css/search.css ,填入以下内容:

记得在 themes/xxx/layouts/_default/baseof.html 中引入:<link rel="stylesheet" href='{{ "css/search.css" | relURL }}'>

#fastSearch {
visibility: hidden;
position: absolute;
right: 0px;
top: 0px;
display: inline-block;
width: 300px;
}
 
#fastSearch input {
padding: 4px 10px;
width: 100%;
height: 31px;
font-size: 1.6em;
color: #aaa;
font-weight: bold;
background-color: #000;
border-radius: 3px 3px 0px 0px;
border: none;
outline: none;
text-align: left;
display: inline-block;
}
 
#searchResults li {
list-style: none;
margin-left: 0em;
background-color: #333;
border-bottom: 1px dotted #000;
}
 
#searchResults li .title { font-size: 1.1em; margin-bottom: 10px; display: inline-block;}
 
#searchResults { visibility: inherit; display: inline-block; width: 320px; }
#searchResults a { text-decoration: none !important; padding: 10px; display: inline-block; }
#searchResults a:hover, a:focus { outline: 0; background-color: #666; color: #fff; }

search.js

新建文件 static/js/search.js ,填入以下内容:

记得在 themes/xxx/layouts/_default/baseof.html 中引入:<script src='{{ "js/search.js" | relURL }}'></script>

var fuse; // holds our search engine
var searchVisible = false;
var firstRun = true; // allow us to delay loading json data unless search activated
var list = document.getElementById('searchResults'); // targets the <ul>
var first = list.firstChild; // first child of search list
var last = list.lastChild; // last child of search list
var maininput = document.getElementById('searchInput'); // input box for search
var resultsAvailable = false; // Did we get any search results?
 
// ==========================================
// The main keyboard event listener running the show
//
document.addEventListener('keydown', function(event) {
 
// CMD-/ to show / hide Search
if (event.metaKey && event.which === 191) {
// Load json search index if first time invoking search
// Means we don't load json unless searches are going to happen; keep user payload small unless needed
if(firstRun) {
loadSearch(); // loads our json data and builds fuse.js search index
firstRun = false; // let's never do this again
}
 
// Toggle visibility of search box
if (!searchVisible) {
document.getElementById("fastSearch").style.visibility = "visible"; // show search box
document.getElementById("searchInput").focus(); // put focus in input box so you can just start typing
searchVisible = true; // search visible
}
else {
document.getElementById("fastSearch").style.visibility = "hidden"; // hide search box
document.activeElement.blur(); // remove focus from search box
searchVisible = false; // search not visible
}
}
 
// Allow ESC (27) to close search box
if (event.keyCode == 27) {
if (searchVisible) {
document.getElementById("fastSearch").style.visibility = "hidden";
document.activeElement.blur();
searchVisible = false;
}
}
 
// DOWN (40) arrow
if (event.keyCode == 40) {
if (searchVisible && resultsAvailable) {
console.log("down");
event.preventDefault(); // stop window from scrolling
if ( document.activeElement == maininput) { first.focus(); } // if the currently focused element is the main input --> focus the first <li>
else if ( document.activeElement == last ) { last.focus(); } // if we're at the bottom, stay there
else { document.activeElement.parentElement.nextSibling.firstElementChild.focus(); } // otherwise select the next search result
}
}
 
// UP (38) arrow
if (event.keyCode == 38) {
if (searchVisible && resultsAvailable) {
event.preventDefault(); // stop window from scrolling
if ( document.activeElement == maininput) { maininput.focus(); } // If we're in the input box, do nothing
else if ( document.activeElement == first) { maininput.focus(); } // If we're at the first item, go to input box
else { document.activeElement.parentElement.previousSibling.firstElementChild.focus(); } // Otherwise, select the search result above the current active one
}
}
});
 
 
// ==========================================
// execute search as each character is typed
//
document.getElementById("searchInput").onkeyup = function(e) {
executeSearch(this.value);
}
 
 
// ==========================================
// fetch some json without jquery
//
function fetchJSONFile(path, callback) {
var httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = function() {
if (httpRequest.readyState === 4) {
if (httpRequest.status === 200) {
var data = JSON.parse(httpRequest.responseText);
if (callback) callback(data);
}
}
};
httpRequest.open('GET', path);
httpRequest.send();
}
 
 
// ==========================================
// load our search index, only executed once
// on first call of search box (CMD-/)
//
function loadSearch() {
fetchJSONFile('/index.json', function(data){
 
var options = { // fuse.js options; check fuse.js website for details
shouldSort: true,
location: 0,
distance: 100,
threshold: 0.4,
minMatchCharLength: 2,
keys: [
'title',
'permalink',
'summary'
]
};
fuse = new Fuse(data, options); // build the index from the json file
});
}
 
 
// ==========================================
// using the index we loaded on CMD-/, run
// a search query (for "term") every time a letter is typed
// in the search box
//
function executeSearch(term) {
let results = fuse.search(term); // the actual query being run using fuse.js
let searchitems = ''; // our results bucket
 
if (results.length === 0) { // no results based on what was typed into the input box
resultsAvailable = false;
searchitems = '';
} else { // build our html
for (let item in results.slice(0,5)) { // only show first 5 results
searchitems = searchitems + '<li><a href="' + results[item].item.permalink + '" tabindex="0">' + '<span class="title">' + results[item].item.title + '</span><br /> <span class="sc">'+ results[item].item.section +'</span> — ' + results[item].item.date + ' — <em>' + results[item].item.desc + '</em></a></li>';
}
resultsAvailable = true;
}
 
document.getElementById("searchResults").innerHTML = searchitems;
if (results.length > 0) {
first = list.firstChild.firstElementChild; // first result container — used for checking against keyboard up/down location
last = list.lastChild.firstElementChild; // last result container — used for checking against keyboard up/down location
}
}

官方的这个例子可以使用快捷键打开搜索框,在 MacOS 上按 Command + / 或者在 Windows 上按 win + / 即可开启搜索。

官方给的这个例子呢,只使用了 fusejs 最基本的功能。本站使用了一些 fusejs 的进阶的功能,下面记录一些我的研究成果,希望对你有帮助。

fusejs 的 option

官方文档

在初始化 fusejs 对象的时候,可以传入一个 option,调整 fusejs 的行为。我所使用到的,有这么几个:

isCaseSensitive

是否希望大小写敏感,默认是不敏感,大多数情况,保持默认即可,除非你希望你输入 Rust rust 得到不一样的结果。

includeScore

是否希望搜索结果中包含分数,默认是不包含的。如果开启,搜索结果中就会包含一个分数。

需要注意的是,分数越高,意味着结果越不匹配;分数越低,意味着结果越匹配。并且,分数不会超过 threshold 配置设定的值。

这个分数有啥用呢,我们可以通过此 item,过滤结果集。例如可以把分数小于2的,标注为精确匹配项,分数大于等于2的标注为非精确匹配项。

includeMatches

是否希望搜索结果中包含 Matches 项,开启之后,fusejs 会把原始内容返回给我们,这就给了我们操作空间了,例如我们可以利用这个内容做高亮显示。

minMatchCharLength

默认值是1,意味着只输入一个字符,fusejs 就开始工作。如果设定为2,当输入一个字符时,fusejs 会忽略。同理,如果设定为3,当输入一个字符或者两个字符时,fusejs 会忽略。

shouldSort

默认是开启的,fusejs 会把结果集按照分数排列,分数最低(最匹配)的排在最前面。

findAllMatches

这一项类似于正则匹配中的贪婪与非贪婪,默认是关闭的,开启之后,fusejs 会在搜索到结果之后,继续往字符串后面进行搜索。暂时不知道有啥用。

keys

定义需要被搜索的内容,支持数组和对象,可以在官方提供的 playground 做实验。

举个例子,被搜索的内容是一个字符串数组,就可以使用数组的方式,此时不需要设置 keys 的值。代码如下:

const names = [
'first book name',
'second book name',
];
 
const fuse = new Fuse(names, options)
 
const result = fuse.search('con')

如果被搜索的内容是文章,需要搜索标题和正文,就可以使用对象的方式,此时需要设置 keys 的值。代码如下:

const blogs = [
{
'title': 'title 1',
'content': 'content 1'
},
{
'title': 'title 2',
'content': 'content 2'
},
];
 
const options = {
keys: ['title', 'content']
}
 
const fuse = new Fuse(blogs, options)
 
const result = fuse.search('tit')

如果要设定权重,keys 可以这样设置:

const options = {
keys: [
{
name: "title",
weight: 12 // title 权重设为12
},
{
name: "content",
weight: 1
},
]
}

在设定 keys 的时候,还支持嵌套搜索,但是我没有深入研究,也没有做更多的测试。

接下来的选项和模糊搜索有关,分别是locationthresholddistanceignoreLocation,前三个需要搭配使用。

location、threshold、distance

官方文档

这三个参数共同决定了可搜索的字符串范围,下面通过几个实验进一步了解。

这几个实验的 ignoreLocation 都要设置为 false(保持默认即可)。

对照实验一

const list = [
"朝辞白帝彩云间"
]
 
const options = {
location: 0,
threshold: 0.1,
distance: 20,
}
 
const fuse = new Fuse(list, options)
 
console.log(fuse.search('白')) // 有结果
 
console.log(fuse.search('帝')) // 无结果
const list = [
"朝辞白帝彩云间"
]
 
const options = {
location: 0,
threshold: 0.2,
distance: 20,
}
 
const fuse = new Fuse(list, options)
 
console.log(fuse.search('白')) // 有结果
 
console.log(fuse.search('帝')) // 有结果
 
console.log(fuse.search('彩')) // 有结果
 
console.log(fuse.search('云')) // 无结果

结论:

对照实验二

const list = [
"朝辞白帝彩云间"
]
 
const options = {
location: 3,
threshold: 0.1,
distance: 10,
}
 
const fuse = new Fuse(list, options)
 
console.log(fuse.search('彩')) // 有结果
 
console.log(fuse.search('白')) // 有结果
 
console.log(fuse.search('云')) // 无结果
 
console.log(fuse.search('辞')) // 无结果
const list = [
"朝辞白帝彩云间"
]
 
const options = {
location: 3,
threshold: 0.1,
distance: 20,
}
 
const fuse = new Fuse(list, options)
 
console.log(fuse.search('彩')) // 有结果
 
console.log(fuse.search('白')) // 有结果
 
console.log(fuse.search('云')) // 有结果
 
console.log(fuse.search('辞')) // 有结果
 
console.log(fuse.search('间')) // 无结果
 
console.log(fuse.search('朝')) // 无结果

结论:

通过以上两组对照实验,可以看出 location 可比喻为“中心点”,而 threshold • distance 则是基于“中心点”向两侧延伸的“长度”。

ignoreLocation

当开启这个参数之后,location、distance 这两个参数则不再起作用。fusejs 将会搜索整个字符串。

值得注意的是 threshold 依然有效,threshold 的作用之一就是参与了可搜索的字符串范围的计算,之二则是用于过滤搜索结果集(和前面提到的 includeScore 有关),例如把 threshold 设置为 0.5,则 score 大于 0.5 的搜索结果会被丢弃。

除了上面讨论的这些参数,fusejs 还支持更高级的参数,useExtendedSearchgetFnsortFnignoreFieldNorm,这几个参数先略过吧。等啥时候回来补充。