给博客加上全站搜索:方案设计、REST API 实现与两次部署事故实录

起因

这个博客用 WordPress 搭的,主题是一款经典主题(Arke)。文章还不算多,但想找一篇旧文越来越费劲:原生 ?s= 搜索只做标题和内容的模糊匹配,不支持按分类缩小范围,没有关键词高亮,主题本身也没给搜索一个像样的入口。于是给自己提了个需求:

给博客加一个文章检索功能:能按标题、内容、分类搜,界面干净美观,别给站点增加负担和依赖。

方案选型

老规矩,先摆选项再动手:

方案 思路 评估
A · 换主题 / 装搜索插件 用自带现代搜索的主题,或上 SearchWP 之类插件 见效最快,但要动整站外观;好用的搜索插件按年收费,为一个功能不值
B · 前端轻量定制 + REST API 主题不动,注入一段全站前端脚本,数据走 WordPress 自带的 REST API 改动小、可随时摘除、体验完全自主可控,最终选用
C · 独立全文搜索引擎 Meilisearch / Algolia 建索引 对个人博客的量级是杀鸡用牛刀,运维成本不划算

选 B,内部代号 YSS Search。核心思路一句话:WordPress 只当数据源(REST API 本来就在),所有检索体验放在前端完成。

功能设计

两个入口,一套体验

  • 导航搜索胶囊:往导航栏注入一枚白底圆角胶囊——放大镜 + 「搜索…」占位文字 + Ctrl K 键位帽,窄屏下自动收缩成纯图标;
  • 首页内联过滤框:首页文章列表上方再放一个过滤框,输入即筛,适合「就在首页扫一眼」的场景。

检索链路

  • 输入 250ms 防抖后请求 /wp-json/wp/v2/posts?search=…&categories=…,REST 的 search 参数本身就覆盖标题 + 内容;
  • 客户端按权重重排:标题命中 ×3、摘要 ×2、分类 ×2、正文 ×1,比 REST 默认的相关性排序更贴合直觉;分类名也并入匹配池,所以搜「网络技术」能直接命中该分类的文章;
  • 面板顶部分类 chips 从 /wp-json/wp/v2/categories 动态拉取,点一下就带着分类参数重新检索;
  • 命中词 <mark> 高亮;支持相关性 / 最新两种排序,↑↓ 选择、Enter 打开、Esc 关闭;
  • 降级兜底:REST 不可用时自动跳转原生 ?s= 搜索——功能可以变笨,但不能坏。
另外带了全局快捷键:Ctrl+K 或 / 随时呼出面板,移动端自动切全屏。整个功能是一个自包含的 HTML 片段(CSS + JS 约 500 行),不依赖任何外部库。

部署的现实:两个「没想到」

没想到一:搜索插件没有 REST API

代码注入用的是 WPCode 这款插件,本想全程用 REST API 自动化部署,结果发现 WPCode Lite 的 REST 端点全是 404——它自定义的内容类型压根没对 REST 开放。能用的只有它自带的「页头/页脚编辑器」表单。

于是把部署流程固化成一个幂等脚本,本质是模拟浏览器走表单:

  1. 登录 wp-admin(浏览器自动化完成,会话导出供脚本复用);
  2. GET 编辑器页面,取表单 nonce 和三个输入框的现值;
  3. 本地把新代码与旧内容做幂等合并(只替换自己的标记块,不碰别人注入的内容);
  4. POST 保存 → 回读校验 → 再抓线上页面验证。

没想到二:SVG 图标渲染成小黑点

上线第一版后,导航角落的搜索图标缩成了一个小黑点。原因很初学:手绘的放大镜 SVG 只有 circle 和 path,没写 fill="none" 和 stroke 样式——浏览器默认填充黑色,15px 尺寸下线稿直接糊成一坨。教训:手写 SVG 图标,填充和描边必须显式声明。

事故主角:$& 替换事故

这次最精彩的坑,值得单独一节。

部署脚本更新代码的方式最初是「删旧块、插新块」,用了正则替换:

const merged = oldContent.replace(
  /<!-- MARK-START -->[\s\S]*?<!-- MARK-END -->/,
  newCode   // ← 问题在这里
);

而新代码里恰好有一段正则转义的工具函数:

function escReg(s) {
  return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

'\\$&' 是 JS 正则转义的惯用写法(把特殊字符替换成「反斜杠 + 自身」)。但当整段代码作为 replace 的替换字符串时,$& 是特殊序列,含义是「插入整个匹配」——于是旧版代码被原样插回了新代码内部,形成 46KB 的「块中块」,页面里躺着两份搜索脚本。

第一次修复,留了个尾巴

改成「成对标记删除」:找到开始标记,删到其后最近的结束标记。重新部署后体积仍有 40KB——比预期多了 15.7KB,直觉不对。

挖下去才发现:旧块当初嵌在新代码内部,成对删除删到的是旧块的结束标记,新代码的后半截被留下了——一段孤立的 </script> 加 15.7KB 裸 JS 文本。这些文本落在 <head> 里,浏览器会提前结束 head、隐式打开 body,把 JS 代码当正文渲染出来。

最终方案

// a) 循环删除所有成对标记块
while ((i = s.indexOf(START)) !== -1) {
  const j = s.indexOf(END, i);
  s = j > -1 ? s.slice(0, i) + s.slice(j + END.length) : s.slice(0, i);
}
// b) 兜底删除「无头残尾」:只有结束标记、没有开始标记的残留
while ((j = s.indexOf(END)) !== -1) {
  s = s.slice(j + END.length);
}
// c) 三处硬校验:合并结果、表单回读、线上 HTML,
//    开始/结束标记都要「各恰好 1 份」——只查开始标记检不出残尾

三条教训

  1. 把代码文本传给 String.replace 第二个参数之前,先想想里面有没有 $&、$1、$` 这些特殊序列。要么用函数形式 () => code,要么用 indexOf 手动拼接;
  2. 幂等脚本的完整性校验,开合标记都要断言数量,最好再配体积、行数这类硬指标;
  3. 修完 bug 用「总量守恒」复核:删了 A、加了 B,最终应该恰好是 B,而不是「A 的一部分 + B」。

最终效果

现在导航栏是一枚搜索胶囊,点开或 Ctrl+K 呼出全局面板:搜索框、分类 chips、排序切换、高亮列表、键盘导航一应俱全;首页文章列表上方有内联过滤框,输入即筛。整个功能零外部依赖、零后端改动,随时可以整块摘除。

回头再可以打磨的方向:把搜索词带进文章页做落地高亮、热搜词统计、拼音首字母匹配。不过对目前的文章量来说,先把「找得到」这件事做扎实就够了。

小结

这次改造最大的收获不是搜索功能本身,而是三条工程习惯:方案上,能不动后端就不动后端,把 WordPress 当数据源用;实现上,外部依赖越少越好,一个自包含片段胜过一串插件;部署上,自动化脚本必须幂等,而幂等的可信度来自严格的机器校验,不是「应该没问题」。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

顶部