使用 fusejs 给 hugo 博客增加搜索功能

搜索方案概述

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

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

后端搜索还可以使用第三方的服务,algolia 就是一个不错的选择,有免费额度,个人用户可以考虑。再者就是使用各大云厂商提供的搜索服务。

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

这个网站 还有列举了另外几个不是很流行的库。

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

基本用法

<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 支持自定义输出类型,参考: https://gohugo.io/templates/output-formats#output-format-definitions

需要在 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/[email protected]"></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; }

执行搜索的 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

参考: https://fusejs.io/api/options.html

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

threshold

如果设定为 0.0,则会精确的匹配关键字中的每个字符是否相同以及位置是否相同。
如果设定为 1.0,则会把任何内容都搜索出来。

我的实际经验:

isCaseSensitive

是否希望大小写敏感,默认是不敏感,大多数情况,保持默认即可。

includeScore

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

2024011616222909.png

需要注意的是,分数越高,意味着结果越不匹配;分数越低,意味着结果越匹配。分数低于 threshold 的结果将被丢弃。

可以通过分数过滤结果集。例如可以把分数小于 2 的,标注为精确匹配项,分数大于等于 2 的标注为非精确匹配项。

includeMatches

是否希望搜索结果中包含 Matches 项,开启之后,fusejs 会把原始内容返回,可以利用原始内容做高亮显示。

2024011616251081.png

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

参考: https://www.fusejs.io/concepts/scoring-theory.html

只有在 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,这几个参数先略过吧。等啥时候回来补充。 todo 补充内容