随着文章数量的增加,我们的博客网站自然是不能只有归档或者标签页的,更别提有时候我们不记得标题、只记得文章内一个简短的词汇。

一个简单的本地搜索栏可以帮到我们。

  • 本文章使用到了 Hexo-Generator-Search 库。

    wzpan/hexo-generator-search - GitHub

  • 本文章使用的是 Pug 模板语言。

安装依赖

1
npm install --save hexo-generator-search

Hexo-Generator-Search 会生成搜索索引文件,其中包含文章的所有必要数据,可用于为博客编写本地搜索引擎。它支持 XML 和 JSON 格式输出,我们这里会使用 XML。

两者的输出区别可见官方仓库的示例。

在博客根目录中的 _config.yml 文件内添加以下配置项:

1
2
3
4
5
search:
path: search.xml
field: post
content: true
template: ./search.xml
  • path:文件路径。默认为 search.xml。如果文件扩展名为 .json,则输出格式为 JSON。否则将输出 XML 格式文件。
    • 值得注意的是,这里的路径指的是 public 路径。
  • field:您要搜索的搜索范围,您可以选择:
    • post(默认):仅涵盖博客的所有文章。
    • page:只涵盖博客的所有页面。
    • all:将涵盖博客的所有文章和页面。
      • 页面指的是 Hexo 中 archivetags 等页面。
  • content:是否包含每篇文章的全部内容。如果为 false,则生成的结果只包括标题和其他元信息,而不包括正文。默认为 true
  • template(可选):自定义 XML 模板的路径。

在博客根目录中添加 search.xml,用作生成搜索索引的模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?xml version="1.0" encoding="utf-8"?>
<search>
{% if posts %}
{% for post in posts.toArray() %}
{% if post.indexing == undefined or post.indexing %}
<entry>
<title>{{ post.title }}</title>
<link href="{{ (url + post.path) | uriencode }}"/>
<url>{{ (url + post.path) | uriencode }}</url>
{% if content %}
<content type="html"><![CDATA[{{ post.content | noControlChars | safe }}]]></content>
{% endif %}
{% if post.categories and post.categories.length>0 %}
<categories>
{% for cate in post.categories.toArray() %}
<category> {{ cate.name }} </category>
{% endfor %}
</categories>
{% endif %}
{% if post.tags and post.tags.length>0 %}
<tags>
{% for tag in post.tags.toArray() %}
<tag> {{ tag.name }} </tag>
{% endfor %}
</tags>
{% endif %}
</entry>
{% endif %}
{% endfor %}
{% endif %}
{% if pages %}
{% for page in pages.toArray() %}
{% if post.indexing == undefined or post.indexing %}
<entry>
<title>{{ page.title }}</title>
<link href="{{ (url + page.path) | uriencode }}"/>
<url>{{ (url + page.path) | uriencode }}</url>
{% if content %}
<content type="html"><![CDATA[{{ page.content | noControlChars | safe }}]]></content>
{% endif %}
</entry>
{% endif %}
{% endfor %}
{% endif %}
</search>

官方仓库中提到,当运行以下命令后就可以在 public 路径中看到生成的结果:

1
hexo g

显示结果

首先需要清楚一件事:Hexo-Generator-Search 生成搜索索引文件!你要如何使用这个文件就是你自己的事情,包括写不写搜索栏、怎么写搜索结果等。此处仅讲解我自己的方案,极大参考了 Hexo-Theme-Freemind 的写法。

wzpan/hexo-theme-freemind - GitHub

部分主题自带了本地搜索功能,建议先看一下你使用的主题是否有内置。

Hexo-Theme-Freemind 使用了 Ajax 和 jQuery。从网上找到最新的 jQuery CDN(要 minified 的),并将 script 标签写在 head.pug 中:

1
script(src='https://code.jquery.com/jquery-3.7.1.min.js', integrity='sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=', crossorigin='anonymous')

这样,我们就可以使用 jQuery 了。

jQuery 是一个轻量级的 JavaScript 库,使得开发者在网站上使用 JavaScript 更容易更方便。

例如,我们想要在页面加载时添加一个动画效果,就可以写:

1
2
3
4
script.
$(document).ready(function() {
$("body").fadeIn(2000);
});

$ 符号在 jQuery 中代表一个函数,也是 jQuery 对象的别名,可以靠它来选择和操作 HTML 元素。

Ajax 是一种在客户端创建异步 Web 应用程序的 Web 开发技术。它允许 Web 应用程序在不干扰现有页面显示和行为的情况下,异步地从服务器发送和检索数据。这意味着可以更新网页的部分内容,而无需重新加载整个页面。

编写搜索视图

搜索视图是用于显示搜索表单和搜索结果的地方。最直观的说法就是用户看到的搜索栏。

Hexo-Theme-Freemind 使用的是 EJS 模板语言,感兴趣的可以看一眼

由于我使用的是单列主题 Hexo-Theme-Hermes,整个页面就没有什么侧边栏等地方存放搜索栏。

claymcleod/hexo-theme-hermes - GitHub

而多列主题(例如 Hexo-Theme-Freemind)就可以在侧边栏内直接塞入搜索栏。

img.png

为了不破坏单列主题的核心概念,我决定在导航栏内添加一个搜索图标。当用户点击图标后,一个搜索窗口会弹出来,内含搜索栏以及显示搜索结果的子容器。用户再次点击搜索图标或者点击搜索窗口外的区域都会导致搜索窗口消失。

搜索图标我使用的是 FontAwesome 的 fas fa-search。从 FontAwesome 那边注册、拿到属于自己的 kit 后,在 head.pug 中添加:

1
script(src="https://kit.fontawesome.com/########.js", crossorigin="anonymous")

Hexo-Theme-Hermes 的导航栏写法如下:

1
2
3
4
5
6
7
8
ul.nav.nav-list
each value, key in theme.menu
li.nav-list-item
- var re = /^(http|https):\/\/*/gi;
- var tar = re.test(value) ? "_blank" : "_self"
- var act = !re.test(value) && "/"+page.current_url === value
a.nav-list-link(class={active: act} href=url_for(value) target=tar)
!= key.toUpperCase()

它是根据 theme 路径的配置项依次添加 li 标签,我们的搜索图标可以直接添加到 ul 标签的内部(和 each value, key in theme.menu 同级):

1
2
3
if config.search
li.nav-list-item#search-icon
i.fas.fa-search

CSS 样式:

1
2
3
#search-icon {
cursor: pointer;
}

cursor 属性设置鼠标指针在元素上方时显示的光标类型。pointer 表示光标将显示为一个指向链接的手指图标,通常在链接或可点击元素上使用,以向用户表明该元素可以被点击。这有助于提高用户界面的可用性。

img.png

接着我们来快速写一个显示搜索栏和搜索结果的容器。我要求不多,就是一个方方正正的小窗口在页面正中间。

1
2
3
4
div#search-popup.hidden
div#search-panel
input(type="text", id="local-search-input", name="q", results="0", placeholder=__('搜索...'))
div#local-search-results

CSS 样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.hidden {
display: none;
}

#search-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 75%;
height: 50%;
padding: 20px;
border: 3px solid #ccc;
background-color: #fff;
text-align: center;
z-index: 10;
}

#search-popup input[type="text"] {
display: block;
}

该窗口必须初始状态为隐藏,显现后则是一个固定在屏幕中心的弹出窗口,文本内容居中对齐。如果需要输入文本,输入框就会块级显示。

因为我希望该弹窗在 Z 轴上的位置优先于其他,我就写了个 z-index: 10(实际测试时发现 position: absolute 的类会优先于 #search-popup,才决定加上的)。

1
2
3
4
5
6
7
8
#search-panel {
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
height: 100%;
width: 100%;
}

#search-panel 的主要目的是将搜索栏和搜索结果以更简单的方式分开。Flexbox 布局好用、爱用、不用不会写。

1
2
3
4
5
6
7
8
9
#local-search-input {
width: 100%;
margin-bottom: 1rem;
}

#local-search-results {
overflow-y: auto;
flex-grow: 1;
}

一些之后会用到的 CSS 样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
em.search-keyword {
border-bottom: 1px dashed #4088b8;
font-weight: bold;
}

ul.search-result-list {
padding-left: 10px;
}

a.search-result-title {
font-weight: bold;
}

p.search-result {
color: #555;
}

最终结果:

img.png

写的很随便也不好看,未来再美化吧。能用就行。

添加弹出窗口的显示逻辑

只是单单写一个图标和窗口还不够,我们还没有添加窗口的显示逻辑。先前说了,我希望的是点击图标之后,窗口会显示;再次点击图标,窗口会隐藏。

在 Pug 文件中添加 script 标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
script.
document.getElementById('search-icon').addEventListener('click', function(event) {
const popup = document.getElementById('search-popup');
if (popup.classList.contains('hidden')) {
popup.classList.remove('hidden');
} else {
popup.classList.add('hidden');
}
event.stopPropagation();
});
document.getElementById('search-popup').addEventListener('click', function(event) {
event.stopPropagation();
});
document.addEventListener('click', function() {
const popup = document.getElementById('search-popup');
if (!popup.classList.contains('hidden')) {
popup.classList.add('hidden');
}
});
  1. 当用户点击 ID 为 search-icon 的元素时,会触发事件监听器。监听器会首先获取 ID 为 search-popup 的元素,然后检查该元素是否包含 hidden 类。如果是,就移除这个类;反之添加这个类。最后调用 event.stopPropagation() 来阻止事件冒泡、传播到父元素。
  2. 当用户点击 ID 为 search-popup 的元素时,也会触发一个事件监听器。这个监听器只会做一件事,那就是调用 event.stopPropagation() 来阻止事件冒泡。这样做的目的是防止当用户在弹出窗口上点击时,触发下面的文档点击事件监听器。
  3. 当用户点击文档的任何地方时,会触发一个事件监听器。它首先会获取 ID 为 search-popup 的元素,然后检查是否包含 hidden 类。如果不是,就添加这个类。这样的话,每当用户点击弹出窗口以外的任何地方时,弹出窗口就会隐藏。

编写搜索脚本

搜索脚本会告诉浏览器如何抓取搜索数据,并过滤出我们要搜索的内容。

这里我近乎是照搬了 Hexo-Theme-Freemind写法,只修改了一丢丢细节。

未来可能会尝试改进这段代码。先挖坑了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
const searchFunc = function (path, search_id, content_id) {
'use strict';
$.ajax({
url: path,
dataType: "xml",
success: function (xmlResponse) {
const datas = $("entry", xmlResponse).map(function () {
return {
title: $("title", this).text(),
content: $("content", this).text(),
url: $("url", this).text()
};
}).get();

const $input = document.getElementById(search_id);
if (!$input) return;
const $resultContent = document.getElementById(content_id);
if ($("#local-search-input").length > 0) {
$input.addEventListener('input', function () {
let str = '<ul class=\"search-result-list\">';
const keywords = this.value.trim().toLowerCase().split(/[\s\-]+/);
$resultContent.innerHTML = "";
if (this.value.trim().length <= 0) {
return;
}
datas.forEach(function (data) {
let isMatch = true;
const content_index = [];
if (!data.title || data.title.trim() === '') {
data.title = "Untitled";
}
const data_title = data.title.trim();
const data_content = data.content.trim().replace(/<[^>]+>/g, "");
const data_url = data.url;
let index_title = -1;
let index_content = -1;
let first_occur = -1;
if (data_content !== '') {
keywords.forEach(function (keyword, i) {
index_title = data_title.toLowerCase().indexOf(keyword.toLowerCase());
index_content = data_content.toLowerCase().indexOf(keyword.toLowerCase());
if (index_title < 0 && index_content < 0) {
isMatch = false;
} else {
if (index_content < 0) {
index_content = 0;
}
if (i === 0) {
first_occur = index_content;
}
}
});
} else {
isMatch = false;
}
if (isMatch) {
str += "<li><a href='" + data_url + "' class='search-result-title'>" + data_title + "</a>";
const content = data.content.trim().replace(/<[^>]+>/g, "");
if (first_occur >= 0) {
let start = first_occur - 20;
let end = first_occur + 80;

if (start < 0) {
start = 0;
}

if (start === 0) {
end = 100;
}

if (end > content.length) {
end = content.length;
}

let match_content = content.substring(start, end);

keywords.forEach(function (keyword) {
const regS = new RegExp(keyword, "gi");
match_content = match_content.replace(regS, function(match) {
return "<em class=\"search-keyword\">" + match + "</em>";
});
});

str += "<p class=\"search-result\">" + match_content + "...</p>"
}
str += "</li>";
}
});
str += "</ul>";
$resultContent.innerHTML = str;
});
}
}
});
};
  1. 函数定义和 Ajax 请求:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const searchFunc = function (path, search_id, content_id) {
    'use strict';
    $.ajax({
    url: path,
    dataType: "xml",
    success: function (xmlResponse) {
    // ... 后续代码
    }
    });
    };
    这段代码定义了一个名为 searchFunc 的函数,它接受三个参数:
    • path:XML 文件的路径
    • search_id:搜索栏的 ID
    • content_id:搜索结果显示区域的 ID
      函数使用 jQuery 的 Ajax 方法从指定的 path 获取 XML 数据。
  2. 数据处理:
    1
    2
    3
    4
    5
    6
    7
    const datas = $("entry", xmlResponse).map(function () {
    return {
    title: $("title", this).text(),
    content: $("content", this).text(),
    url: $("url", this).text()
    };
    }).get();
    处理从 XML 响应中获取的数据,将每个 entry 元素转换为包含 titlecontenturl 的对象数组。
    这里使用了 jQuery 的 map 方法来遍历 XML 元素,并使用 get() 来将结果转换为普通数组。
  3. 搜索输入监听:
    1
    2
    3
    4
    5
    6
    7
    8
    const $input = document.getElementById(search_id);
    if (!$input) return;
    const $resultContent = document.getElementById(content_id);
    if ($("#local-search-input").length > 0) {
    $input.addEventListener('input', function () {
    // ... 搜索逻辑
    });
    }
    设置对搜索输入框的监听。当用户在搜索栏中输入内容时就会触发搜索。
    这段代码混合使用了原生 JavaScript(document.getElementByIdaddEventListener)和 jQuery($("local-search-input").length)。
  4. 搜索逻辑:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    let str = '<ul class=\"search-result-list\">';
    const keywords = this.value.trim().toLowerCase().split(/[\s\-]+/);
    $resultContent.innerHTML = "";
    if (this.value.trim().length <= 0) {
    return;
    }
    datas.forEach(function (data) {
    // ... 匹配逻辑
    });
    str += "</ul>";
    $resultContent.innerHTML = str;
    这是搜索功能的核心逻辑。将输入的搜索词与数据进行匹配,并构建搜索结果 HTML。
  5. 匹配和高亮显示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    if (isMatch) {
    str += "<li><a href='" + data_url + "' class='search-result-title'>" + data_title + "</a>";
    const content = data.content.trim().replace(/<[^>]+>/g, "");
    if (first_occur >= 0) {
    // ... 截取匹配内容
    keywords.forEach(function (keyword) {
    const regS = new RegExp(keyword, "gi");
    match_content = match_content.replace(regS, function(match) {
    return "<em class=\"search-keyword\">" + match + "</em>";
    });
    });
    str += "<p class=\"search-result\">" + match_content + "...</p>"
    }
    str += "</li>";
    }
    处理匹配结果的显示和关键词高亮。此处使用了正则表达式去除 HTML 标签,使用 substring 截取匹配上下文,然后使用 replace 和正则表达式实现关键词高亮。

search.js 放在 theme/hermes/source/js 路径下,运行 hexo g 之后便会出现在 public/js 路径下。

连接视图和脚本

有了搜索视图和搜索脚本后,我们就可以把两者连接在一起。

在 Pug 文件中添加 script 标签:

1
2
3
4
5
6
7
8
9
if config.search
script(src="/js/search.js")
script.
let search_path = "#{config.search.path}";
if (search_path.length === 0) {
search_path = "search.xml";
}
const path = "#{config.root}" + search_path;
searchFunc(path, 'local-search-input', 'local-search-results');

首先是引入搜索脚本 search.js。前面说过,该脚本在 public 路径下时会是在 js 路径下。接着调用 search.js 中我们定义好的 searchFunc 函数。

最终效果:

img.png