Typecho 的搜索在服务端处理;迁到纯静态站后,检索被拆成构建期整理数据、浏览器读取索引与筛选结果两个阶段。Waxy 现在已经采用独立的 search.json,并按站点配置分页展示结果,不再把全部卡片和检索正文预先铺进搜索页。

构建时:准备两组数据

索引生成器读取已发布文章,按日期倒序组织条目。标题、地址、日期、封面、摘要、字数、分类、标签和推荐状态用于绘制卡片;另一个 text 字段把标题、分类、标签与正文片段合在一起,压缩空白并转成小写,供浏览器匹配。

这份 JSON 是公开的静态文件。草稿不会作为已发布文章写入索引,但已进入索引的正文片段对访客可见,不能靠“搜索框里不展示”保护资料。需要控制访问权限的内容,可以先看前端加密的边界,再决定它是否应该发布。

在当前站点中,主题设置放在 _config.hexo-theme-waxy.yml:

search_path: /search/
search:
  content_limit: 3000

content_limit 限制每篇正文进入索引的字符数,标题、分类和标签另外加入。提高上限能覆盖更靠后的内容,也会增加下载量。入口路径还需要有对应的搜索页面,不能只改配置里的名字。修改文章或设置后,要重新构建并发布索引与页面。

打开页面:先加载,再读当前输入

搜索页会请求站点根路径下的 search.json。下面这个独立函数只演示读取索引,真正的页面还会处理提示、卡片和分页;部署在子目录时,应传入包含该根路径的地址。

async function readIndex(url) {
  const response = await fetch(url, { credentials: 'same-origin' });
  if (!response.ok) throw new Error(`索引请求失败:${response.status}`);
  return response.json();
}

不能只写 catch 就认为 HTTP 错误都被处理了:fetch 收到 404 等响应时仍可能正常得到 Response,所以代码先检查 ok。当前页面加载失败后会显示重试提示;索引未就绪时提交搜索用于重试,不会把失败状态伪装成“没有结果”。

这里还有一个容易漏掉的先后顺序:请求发出后,访客可能已经改过关键词。因此索引返回时读取的是输入框的当前值,而不是开始请求那一刻保存的旧值。等待期间改词或清空,也会把请求页码重置到第一页。

输入时:过滤全部条目,只渲染当前页

当前匹配是去掉首尾空白、忽略大小写后的子串查找。它没有自动分词、模糊纠错或相关性评分;输入带空格的短语,也不会被自动拆成几个独立条件。先理解这个口径,再判断一次未命中是否属于问题。

分页条数来自站点 _config.yml,没有第二个搜索专用条数参数:

per_page: 10

页面模板把值写入 data-per-page,浏览器先算完整命中集合与总数,再取当前页。per_page: 0 表示全部展示,不生成分页控件。下面是切片的核心逻辑,参数 perPage 应为页面提供的非负整数:

function resultPage(matches, requested, perPage) {
  const total = perPage === 0 ? 1 : Math.max(1, Math.ceil(matches.length / perPage));
  const raw = String(requested ?? '1');
  const value = Number(raw);
  const valid = /^\d+$/.test(raw) && Number.isSafeInteger(value) && value > 0 ? value : 1;
  const current = Math.min(valid, total);
  const start = (current - 1) * perPage;
  return {
    current, total,
    items: perPage === 0 ? matches : matches.slice(start, start + perPage)
  };
}

总命中数不会随着页切片变成“本页几条”。卡片在浏览器中按主题现有结构生成,只有当前页需要创建 DOM。分页因此降低的是卡片渲染规模,整份 JSON 仍需要下载;它没有把客户端检索变成服务端检索。

URL 也属于搜索状态

页码放在 page,关键词放在 q,例如 /search/?q=Hexo&page=2。分页使用普通链接,刷新后能重新得到同一页,浏览器返回也有正常的页面记录。输入新词或清空则回到第一页,并通过 replaceState 替换当前地址,不为每次按键新建一条历史。

中文、空格和 C++ 这样的词应交给 URLSearchParams 编码。手工拼接 q=C++ 可能把加号解释成空格。非法页码归一为第一页,超过实际页数会落到末页;若关键词只有一页结果,打开它的第二页地址被归一是预期行为。

关闭 JavaScript 时,页面仍提供全部文章的普通链接列表。它供浏览使用,不执行关键词检索;这份兜底列表也会随文章数量增长,但不携带所有卡片和检索正文。

怎样判断要不要换方案

先看网络面板中索引的传输体积和请求时间,再看输入时是否明显停顿、结果是否符合预期。本站内容会继续增加,不能用“十几篇够用”或固定篇数阈值替代检查。缓存能否复用也取决于部署时的 HTTP 响应头,不能只因为文件名没变就假定不会重新请求。

验证时至少覆盖:第二页刷新与返回、改词后的第一页、空结果、索引请求失败后重试,以及未加载完成就改输入。需要更完整覆盖时再调整正文上限;需要排序、纠错或更低的首包成本时,再评估专门索引、分片等方案。先定位下载、匹配还是渲染慢,才知道应该动哪一层。

参考资料