对于像Hexo这样的静态博客而言,有一种痛苦叫不知道怎么处理搜索内容。自然有一种方法是依傍大型的搜索引擎,例如本主题的代码主要参考来源——landscape(同时也是Hexo的默认主题),使用的就是依赖于Google的搜索方式。诚然,也有swiftype这样的第三方公司为站点提供数据收录与搜索引擎集成,但暂且不说完全依赖第三方的服务是否具有合理的稳定性,光是其高昂的售价就足以让人望而却步了。因此,如何构建一个独立的搜索库,以便于用户更方便地搜寻所需要的资源,对于我们Hexo站长而言,自然也就成为了一大急需思考的难题。
实现了本地搜索功能的主题也不在少数,例如NexT、Suka等等,考虑到NexT需要另外安装不利于自定义的搜索生成插件,而Suka则实现了从生成到搜索的完整过程,因而本次我就是以Suka为参照,构建一个至少能比较正常地工作的本地搜索功能吧。
现在已经可以将搜索相关的配置项全部放入主题配置里去啦!不再需要修改站点设置了的说呢,具体可以参见这篇文章哦~
准备工作
要想实现搜索,无非就是需要两大模块:搜索页面
和内容数据库
。如何在Hexo默认不带有的路由情况下,新建一个搜索专属的页面呢?Hexo为我们提供了许多API可以使用。官方给出的样例非常简洁,因此我们可以根据第三方的教程参照,发现更多实现相关的细节。
搜索页面
例如,以下这一段代码,可以调用主题的layout/_pages/search-page这个页面,而路由的切入点,则是*sitedir/search/*。
1 2 3 4 5 6 7 8
| hexo.extend.generator.register('searchPage', function(locals){ return { path: 'search/index.html', data: locals.posts, layout: '_pages/search-page' }; });
|
将这一段代码写入以.js
结尾的文件中,保存在主题的scripts文件夹内,那么当hexo运行的时候,就会被页面生成进程调用,从而生成出搜索页面对应的文件与文件夹。
同时,我们需要给出_pages/search-page这个页面的具体配置。请注意,使用layout调用时,会按照主题的layout.ejs给出对应页面的样式,因此请记得保持相关页面的内容一致哦
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
| <section class="col-md-8"> <style> .input-group{display:flex;width:80%;margin:30px auto;} #search-input{flex:auto;margin:0 5px;border-radius:5px;padding:0 8px;} #kr-search-notice.alert{transition:.3s} #result-posts .kr-search-result{margin:auto;height:auto;width:90%;} #result-posts m{color:#333;background-color:yellow;} </style> <div class="kratos-hentry kratos-post-inner clearfix"> <div class="kratos-post-content"> <h2 class="title-h2"><%- __('search') %></h2> <form class="input-group" onsubmit="return inpSearch();"> <input class="form-input input-lg" id="search-input" maxlength="80" name="s" placeholder="<%- __('search_notice') %>" required type="search"> <button class="btn btn-primary" type="submit"><%- __('search_submit') %></button> </form> </div> </div>
<div class="alert" id="kr-search-notice"></div>
<div id="result-posts"></div>
</section>
<script>var searchDataFile = "<%- config.root + (config.kratos_rebirth.search.path || 'search.json') %>";</script> <script defer src="/js/local-search.min.js"></script>
|
注意,由于无法通过js直接调用Hexo的设置,因此此处单独将搜索文件的路径进行了提取。
对于表单默认的提交跳转事件会导致页面的强制刷新,我们需要使用return false进行拦截;为了运行搜索函数,我们让搜索函数返回值也变成false,然后将该拦截时间返回至表单,以防止出现跳转即可。
内容数据
如果是使用第三方接口的话,兴许到此就已经是基本完成了;但既然要构建本地搜索,我们还需要一个用来搜索的“数据库”。
有一个插件叫做hexo-generator-search,可以生成便于搜索使用的相关数据文件;但我们主题展示的页面里,存在的还不仅仅只有所列出来的这一些条目。如果需要用户为此专门去安装这个插件并进行相关代码的修改,那显然会带来更多难以维护的困难情况。因此,可以参照相关的生成方式,构建属于我们主题自己的搜索数据库:
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
| const pathFn = require('path'); const { stripHTML } = require('hexo-util');
let config = hexo.config.kratos_rebirth.search;
if (!config.path) config.path = 'search.json';
if (pathFn.extname(config.path) === '.json') { hexo.extend.generator.register('searchdb', function(locals){ const url_for = hexo.extend.helper.get('url_for').bind(this);
const parse = (item) => { let _item = {}; if (item.title) _item.title = item.title; if (item.date) _item.date = item.date; if (item.path) _item.url = url_for(item.path); if (item.tags && item.tags.length > 0) { _item.tags = []; item.tags.forEach((tag) => { _item.tags.push([tag.name, url_for(tag.path)]); }); } if (item.categories && item.categories.length > 0) { _item.categories = []; item.categories.forEach((cate) => { _item.categories.push([cate.name, url_for(cate.path)]); }); } if (hexo.config.kratos_rebirth.search.content && item.content) { _item.content = stripHTML(item.content.trim().replace(/<pre(.*?)\<\/pre\>/gs, '')) .replace(/\n/g, ' ').replace(/\s+/g, ' ') .replace(new RegExp('(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]', 'g'), ''); } return _item; };
const searchfield = config.field;
let posts, pages;
if (searchfield) { if (searchfield === 'post') { posts = locals.posts.sort('-date'); } else if (searchfield === 'page') { pages = locals.pages; } else { posts = locals.posts.sort('-date'); pages = locals.pages; } } else { posts = locals.posts.sort('-date'); }
let res = [];
if (posts) { posts.each((post) => { res.push(parse(post)); }); } if (pages) { pages.each((page) => { res.push(parse(page)); }); }
return { path: config.path, data: JSON.stringify(res) }; }); }
|
为了能给这个主题的使用者提供更多的客制化选项,对于主题这个搜索功能,我选择设置了一个用于控制的开关。
但Hexo的API似乎没有提供直接读取主题配置参数的设置,因此需要将相关的设置代码写入站点的配置文件中。(可参见这篇文章进行修改调整)
1 2 3 4 5 6
| kratos_rebirth: search: enable: true path: search.json field: post content: true
|
准备工作到此就已经结束,接下来就是在页面上调用搜索数据库,对于内容进行搜索了。
搜索之前
获取关键词
由于我们提供了两种方式,一种是直接带请求链接的搜索,另一种是表单的手动提交事件进行搜索,因此我们需要两种对应处理的方式,即一种是通过获取窗口的URL并进行解码来获取参数,另一种是通过获取表单的输入内容来搜索。
这里参考Suka的窗口参数获取事件给出了一个参考的函数写法:
1 2 3 4 5 6 7
| function getParam(reqParam) { reqParam = reqParam.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]"); const paraReg = new RegExp('[\\?&]' + reqParam + '=([^&#]*)'); const results = paraReg.exec(window.location); return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); }
|
获取以参数reqParam开头的所有参数(我传入的是’s’),获取URL的请求路径。
这个事件在页面载入时即可触发,因此我们可以设置如下的函数来调用:
1 2 3 4 5 6 7 8 9
| (()=>{ const skeys = getParam('s'); if (skeys !== '') { document.getElementById('search-input').value = skeys; keySearch(skeys); } })();
|
为了提升用户交互的友好性,我还选择将关键词放入输入框内,以便于用户的交互、修改和后续的处理等。
而对于使用输入表单的提交搜索,直接调用相关的搜索函数即可;此处为了链接的双向同步,使用了浏览器的pushState事件来触发一次不刷新页面的浏览器地址变更(使用了正则表达式来处理空格问题):
1 2 3 4 5 6 7 8 9
| function inpSearch() { const skeys = document.getElementById('search-input').value; window.history.pushState({},0,window.location.href.split('?')[0]+'?s=' + skeys.replace(/\s/g, '+')); keySearch(skeys); return false; }
|
请注意对于一些特殊HTML字符的转义传参,否则可能在结果生成的时候带来不必要的困难:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function searchEscape(keyword) { const htmlEntityMap = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''', '/': '/' };
return keyword.replace(/[&<>"'/]/g, function (i) { return htmlEntityMap[i]; }); }
|
同时也有一些会影响正则表达式的字符,可以用类似的方式进行处理;但这样做相当于直接禁用了正则表达式的搜寻方式:(如有必要,可以设置一个切换开关)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| function regEscape(keyword) { const regEntityMap = { '{': '\\\{', '}': '\\\}', '[': '\\\[', ']': '\\\]', '(': '\\\(', ')': '\\\)', '?': '\\\?', '*': '\\\*', '.': '\\\.', '+': '\\\+', '^': '\\\^', '$': '\\\$' };
return keyword.replace(/[\{\}\[\]\(\)\?\*\.\+\^\$]/g, function (i) { return regEntityMap[i]; }); }
|
keySearch为传入参数后的搜索函数,可以这样来处理:
1 2 3 4 5 6 7 8 9 10 11 12
| function keySearch(skeys) { setNotice('info', '正在加载搜索文件...');
if (typeof NProgress !== 'undefined') { NProgress.start(); }
loadDataSearch(searchDataFile, searchEscape(skeys)); }
|
获取数据
jQuery有封装XHR操作,可以使用ajax来获取数据;而ES6也有引入一个新的XHR方式:Fetch API,可以更加优雅地实现获取数据的操作。相关格式的的简单样例如下:
1 2 3 4 5 6 7 8 9
| fetch('path/to/file') .then((res)=>{ ... }) .catch((error)=>{ ... });
|
具体的使用方法可参见MDN的文档:使用 Fetch
格式化数据
我们fetch到的是一个text类型传输的json序列,无法直接被使用,因此需要进行.json()操作将其转化成一个json对象。但不知为何,直接使用此操作似乎无法有效将其转化为可以forEach的对象。因此我们可以将其作为一个函数的参数进行传出。具体的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| fetch('path/to/file') .then((res)=>{ ... return res.json(); }) .then((datas)=>{ ... }) .catch((error)=>{ ... });
|
在第二个.then里得到的datas,就可以进行forEach操作啦。
开始搜索
当数据准备完成,提示信息也已经给出之后,是时候开始正式的搜索过程啦。
搜索的过程主要分为三步,先是分割关键词、再是针对每一篇文章进行关键词检索、最后是汇总结果并标出高亮内容。
分割关键词
为了避开大小写对于搜索结果的影响,将搜索关键词去除两端空格后转换成小写,再以空格等分隔符进行分割即可。例如,可以使用这样的方式:
1
| let keywords = skeys.trim().toLowerCase().split(/\s/);
|
其中的skeys是带有空格的搜索字符串,如"关键词1 关键词2 关键词3 ..."
等;经过转换后的keywords则成了一个数组,如["关键词1", "关键词2", "关键词3", ...]
这样的格式,便于后续的搜索操作。
文章检索
对于文章,一般主要关心的是文章的标题和内容,因此搜索模块也从这两方面进行着手考虑。
为了避免大小写导致的结果减少,我们同样将文章的标题和内容都转换成小写的字符串:
1 2
| const dataTitle = data.title.trim().toLowerCase(); const dataContent = data.content ? data.content.trim().replace(/<[^>]+>/g, '').toLowerCase() : '';
|
为了表达搜索数据的权重关系,我们引入一个权重标记,并规定一个简单的权重算法:当标题中出现关键词时,该文章的权重+2;当内容中出现关键词时,该文章的权重+1。
但是这个权重算法很简陋,而且在我目前的代码中没有很好的被实现,因此只是提供一个参考的思路吧。
完成设计之后,我们就可以开始当前文章的检索工作了。先假设当前文章里没有任何关键词,再在搜寻的过程中,如果有发现匹配成功,则将该文章标记为有关键词的文章即可。
参考的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| let matched = false; keywords.forEach((keyword)=>{ indexs.title = dataTitle.indexOf(keyword); indexs.content = dataContent.indexOf(keyword); if (indexs.title !== -1 || indexs.content !== -1) { matched = true; ... dataWeight += indexs.title !== -1 ? 2 : 0; dataWeight += indexs.content !== -1 ? 1 : 0; resultCount++; } });
|
标记高亮
在一篇文章搜索完成后,就已经可以将其中的高亮内容进行标记了,以便于后续的处理。对于之前设置的matched参数,此使便可以用于控制是否需要进行标记。一个样例的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| if (matched) { let tPage = {}; tPage.title = data.title; keywords.forEach((keyword)=>{ const regS = new RegExp(regEscape(keyword) + '(?!>)', 'gi'); tPage.title = tPage.title.replace(regS, '<m>$&</m>'); }); if (indexs.firstOccur >= 0) { const halfLenth = 100; ... tPage.content = dataContent.substr(start, end-start); keywords.forEach((keyword)=>{ const regS = new RegExp(regEscape(keyword) + '(?!>)', 'gi'); tPage.content = tPage.content.replace(regS, '<m>$&</m>'); }); } resultArray.push([tPage, dataWeight]); }
|
请注意我使用到的(?!>)
,这一部分是用于将可能存在的搜索结果里的HTML高亮标签(以>
来辨识)进行排除,以免因搜索关键词中出现标签名,而导致重复嵌套的显示错误出现。
汇总结果
由之前的搜索权重,对每一个结果进行排序,以便于优先显示关联度更高的内容。如果没有搜索到任何结果,则直接返回没有输出。
1 2 3 4 5 6 7 8 9 10 11
| if (resultCount !== 0) { const finishTime = performance.now(); setNotice('success', '找到 ' + resultCount + ' 条搜索结果,用时 ' + Math.round((finishTime - startTime)*100)/100 + ' 毫秒~'); resultArray.sort((a, b)=>{ return b[1] - a[1]; }); createPosts(resultArray); } else { setNotice('danger', '什么都没有找到欸...'); clearPosts(); }
|
输出内容
没什么特别需要注意的地方,只是不要忘记将之前的结果清空即可。由于我使用了整体的替换选项,因此直接就可以进行覆盖。
我使用了ES6的字符串模板,因此能更有效地提升相关内容处理的效率,和后期的便于维护性。
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
| function createPosts(resArr) { const resultSectionElement = document.getElementById('result-posts'); let resultString = '';
resArr.forEach((resInfo)=>{ const pageInfo = resInfo[0]; let pageTags = ''; pageInfo.tags.forEach((tag, i)=>{ pageTags += i ? ', ' : ''; const postTagTemplate = `<a class="tag-link" href="${tag[1]}" rel="tag">${tag[0]}</a>`; pageTags += postTagTemplate; }); const postTemplate = ` <article class="kratos-hentry clearfix"> <div class="kratos-entry-border-new clearfix"> <div class="kratos-post-inner-new kr-search-result"> <header class="kratos-entry-header-new"> <a class="label-link" href="${pageInfo.category[1]}">${pageInfo.category[0]}</a> <h2 class="kratos-entry-title-new"><a href="${pageInfo.link}">${pageInfo.title}</a></h2> </header> <div class="kratos-entry-content-new"> <p>...${pageInfo.content}...</p> </div> </div> <div class="kratos-post-meta-new"> <span class="pull-left"> <a><i class="fa fa-calendar"></i></a><a>${pageInfo.date}</a> <a><i class="fa fa-tags"></i></a> ${pageTags} </span> <span class="pull-right"> <a class="read-more" href="${pageInfo.link}" title="阅读全文">阅读全文 <i class="fa fa-chevron-circle-right"></i></a> </span> </div> </div> </article> `;
resultString += postTemplate; }); resultSectionElement.innerHTML = resultString; }
|
到此,基本功能就已经实现了。由于为了功能的细节划分,部分代码进行了一定的修改;同时后续也将进行持续的升级,以便于提供更好的性能。具体的代码可以参见GitHub上的源文件:
参考资料