使用 fusejs 给 hugo 增加站内搜索
搜索方案概述
搜索方案大体上分为前端搜索和后端搜索。
后端搜索通常使用 elastic search,这是一个很成熟的解决方案。另外,使用 rust 编写的 meili search 也是一个很好的选择。最后,不同的语言通常会有一些小众化的解决方案,例如 php 中的 tnt search,以及迅搜。
后端搜索还可以使用第三方的服务,algolia 就是一个很不错的选择,有免费额度,初创公司或者个人用户可以考虑。但是离我们最近的数据中心在香港,延迟是一个大问题。再者就是使用腾讯云或者阿里云提供的 elastic search 服务,但是很贵,用不起🙂,溜了溜了。
前端搜索也可以叫做 browser search 或者 offline search,总的来说就是搜索的时候不需要发请求到服务器。关于前端搜索,我所了解的,有这么几个可供选择:
- lunrjs - js 写的一个搜索库
- elasticlunr - 基于 lunrjs 开发的另一个方案
- fusejs - js 写的一个搜索库,官方口号是 Fuse.js is a powerful, lightweight fuzzy-search library, with zero dependencies.
这个网站还有列举了另外几个不是很流行的库,可以瞄一眼。
总体上说,前端方案是轻量级的,而后端方案成熟、大而全。下面介绍 fusejs 方案。
fusejs
官方网站。
fusejs 是一个轻量级的模糊搜索库。本站的搜索方案就是用 fusejs 实现的(参考了官方的,并做了优化),可以点击右上角的搜索图标体验。
安装方式
npm or yarn
npm 或者 yarn 下载即可。
npm install --save fuse.js// oryarn 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 使用
总体的思想
- hugo 生成 json 数据。
- 搜索的时候,用 js 发请求,拿到数据。
- 实例化 fuse,搜索。
- 得到搜索结果后,把结果展示在页面上。
下面以官方给的代码为例,阐述一下实现步骤。
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 enginevar searchVisible = false; var firstRun = true; // allow us to delay loading json data unless search activatedvar list = document.getElementById('searchResults'); // targets the <ul>var first = list.firstChild; // first child of search listvar last = list.lastChild; // last child of search listvar maininput = document.getElementById('searchInput'); // input box for searchvar 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
的时候,还支持嵌套搜索,但是我没有深入研究,也没有做更多的测试。
接下来的选项和模糊搜索有关,分别是location
、threshold
、distance
、ignoreLocation
,前三个需要搭配使用。
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('云')) // 无结果
结论:
- 第一个实验中,threshold • distance = 2,意味着可以搜索
朝辞白
- 第二个实验中,threshold • distance = 4,意味着可以搜索
朝辞白帝彩
,比第一个实验多了2个字符
对照实验二
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 都是3,说明是以
帝
字为中心点进行计算的 - 第一个实验,threshold • distance = 1,意味着可以搜索
白帝彩
- 第二个实验,threshold • distance = 2,意味着可以搜索
辞白帝彩云
通过以上两组对照实验,可以看出 location 可比喻为“中心点”,而 threshold • distance 则是基于“中心点”向两侧延伸的“长度”。
ignoreLocation
当开启这个参数之后,location、distance 这两个参数则不再起作用。fusejs 将会搜索整个字符串。
值得注意的是 threshold 依然有效,threshold 的作用之一就是参与了可搜索的字符串范围的计算,之二则是用于过滤搜索结果集(和前面提到的 includeScore 有关),例如把 threshold 设置为 0.5,则 score 大于 0.5 的搜索结果会被丢弃。
除了上面讨论的这些参数,fusejs 还支持更高级的参数,useExtendedSearch
、getFn
、sortFn
、ignoreFieldNorm
,这几个参数先略过吧。等啥时候回来补充。