DevNow: Search with Lunrjs

前言

假期真快,转眼国庆假期已经到了最后一天。这次国庆没有出去玩,在北京看了看房子,原先的房子快要到期了,找了个更加通透一点的房子,采光也很好。

闲暇时间准备优化下 DevNow 的搜索组件,经过上一版 搜索组件优化 - Command ⌘K 的优化,现在的搜索内容只能支持标题,由于有时候标题不能百分百概括文章主题,所以希望支持 摘要文章内容 搜索。

搜索库的横向对比

这里需要对比了 fuse.jslunrflexsearchminisearchsearch-indexjs-searchelasticlunr对比详情。下边是各个库的下载趋势和star排名。

选择 Lunr 的原因

其实每个库都有一些相关的侧重点。

lunr.js是一个轻量级的JavaScript库,用于在客户端实现全文搜索功能。它基于倒排索引的原理,能够在不依赖服务器的情况下快速检索出匹配的文档。lunr.js的核心优势在于其简单易用的API接口,开发者只需几行代码即可为静态网页添加强大的搜索功能。

lunr.js的工作机制主要分为两个阶段:索引构建和查询处理。首先,在页面加载时,lunr.js会根据预定义的规则构建一个倒排索引,该索引包含了所有文档的关键字及其出现的位置信息。接着,在用户输入查询字符串后,lunr.js会根据索引快速找到包含这些关键字的文档,并按照相关度排序返回结果。

为了提高搜索效率和准确性,lunr.js还支持多种高级特性,比如同义词扩展、短语匹配以及布尔运算等。这些功能使得开发者能够根据具体应用场景定制搜索算法,从而提供更加个性化的用户体验。此外,lunr.js还允许用户自定义权重分配策略,以便更好地反映文档的重要程度。

DevNow 中接入 Lunr

这里使用 Astro 的 API端点 来构建。

在静态生成的站点中,你的自定义端点在构建时被调用以生成静态文件。如果你选择启用 SSR 模式,自定义端点会变成根据请求调用的实时服务器端点。静态和 SSR 端点的定义类似,但 SSR 端点支持附加额外的功能。

构造索引文件

js 复制代码
// search-index.json.js

import { latestPosts } from '@/utils/content';
import lunr from 'lunr';
import MarkdownIt from 'markdown-it';
const stemmerSupport = await import('lunr-languages/lunr.stemmer.support.js');
const zhPlugin = await import('lunr-languages/lunr.zh.js');
// 初始化 stemmer 支持
stemmerSupport.default(lunr);
// 初始化中文插件
zhPlugin.default(lunr);
const md = new MarkdownIt();

let documents = latestPosts.map((post) => {
  return {
    slug: post.slug,
    title: post.data.title,
    description: post.data.desc,
    content: md.render(post.body)
  };
});
export const LunrIdx = lunr(function () {
  this.use(lunr.zh);
  this.ref('slug');
  this.field('title');
  this.field('description');
  this.field('content');

  // This is required to provide the position of terms in
  // in the index. Currently position data is opt-in due
  // to the increase in index size required to store all
  // the positions. This is currently not well documented
  // and a better interface may be required to expose this
  // to consumers.
  // this.metadataWhitelist = ['position'];

  documents.forEach((doc) => {
    this.add(doc);
  }, this);
});

export async function GET() {
  return new Response(JSON.stringify(LunrIdx), {
    status: 200,
    headers: {
      'Content-Type': 'application/json'
    }
  });
}

构建搜索内容

js 复制代码
// search-docs.json.js

import { latestPosts } from '@/utils/content';
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt();
let documents = latestPosts.map((post) => {
  return {
    slug: post.slug,
    title: post.data.title,
    description: post.data.desc,
    content: md.render(post.body),
    category: post.data.category
  };
});

export async function GET() {
  return new Response(JSON.stringify(documents), {
    status: 200,
    headers: {
      'Content-Type': 'application/json'
    }
  });
}

重构搜索组件

js 复制代码
// 核心代码

import { debounce } from 'lodash-es';
import lunr from 'lunr';

interface SEARCH_TYPE {
  slug: string;
  title: string;
  description: string;
  content: string;
  category: string;
}

const [LunrIdx, setLunrIdx] = useState<null | lunr.Index>(null);
const [LunrDocs, setLunrDocs] = useState<SEARCH_TYPE[]>([]);
const [content, setContent] = useState<
	| {
			label: string;
			id: string;
			children: {
				label: string;
				id: string;
			}[];
		}[]
	| null
>(null);

useEffect(() => {
	const _init = async () => {
		if (!LunrIdx) {
			const response = await fetch('/search-index.json');
			const serializedIndex = await response.json();
			setLunrIdx(lunr.Index.load(serializedIndex));
		}
		if (!LunrDocs.length) {
			const response = await fetch('/search-docs.json');
			setLunrDocs(await response.json());
		}
	};
	_init();
}, [LunrIdx, LunrDocs.length]);

const onInputChange = useCallback(
	debounce(async (search: string) => {
		if (!LunrIdx || !LunrDocs.length) return;
		// 根据搜索内容从索引中结果
		const searchResult = LunrIdx.search(search);
		const map = new Map<
			string,
			{ label: string; id: string; children: { label: string; id: string }[] }
		>();

		if (searchResult.length > 0) {
			for (var i = 0; i < searchResult.length; i++) {
				const slug = searchResult[i]['ref'];
				// 根据索引结果 获取对应文章内容
				const doc = LunrDocs.filter((doc) => doc.slug == slug)[0];
				// 下边主要是数据结构优化
				const category = categories.find((item) => item.slug === doc.category);
				if (!category) {
					return;
				} else if (!map.has(category.slug)) {
					map.set(category.slug, {
						label: category.title || 'DevNow',
						id: category.slug || 'DevNow',
						children: []
					});
				}
				const target = map.get(category.slug);
				if (!target) return;
				target.children.push({
					label: doc.title,
					id: doc.slug
				});
				map.set(category.slug, target);
			}
		}
		setContent([...map.values()].sort((a, b) => a.label.localeCompare(b.label)));
	}, 200),

	[LunrIdx, LunrDocs.length]
);

过程中遇到的问题

基于 shadcn/ui Command 搜索展示

如果像我这样自定义搜索方式和内容的话,需要把 Command 组件中自动过滤功能关掉。否则搜索结果无法正常展示。

上调函数最大持续时间

当文档比较多的时候,构建的 索引文件内容文件 可能会比较大,导致请求 504。 需要上调 Vercel 的超时策略。可以在项目社会中适当上调,默认是10s。

前端搜索的优劣

特性 Lunr.js Algolia
搜索方式 纯前端(在浏览器中处理) 后端 API 服务
成本 完全免费 有免费计划,但有使用限制
性能 大量数据时性能较差 高效处理大规模数据
功能 基础搜索功能 高级搜索功能(拼写纠错、同义词等)
索引更新 手动更新索引(需要重新生成) 实时更新索引
数据量 适合小规模数据 适合大规模数据
隐私 索引暴露在客户端,难以保护私有数据 后端处理,数据可以安全存储
部署复杂度 简单(无需后端或 API) 需要配置后端或使用 API

适合使用 Lunr.js 的场景

  • 小型静态网站:如果你的网站内容较少(如几十篇文章或文档),Lunr.js 可以提供不错的搜索体验,不需要复杂的后端服务。
  • 不依赖外部服务:如果你不希望依赖第三方服务(如 Algolia),并且希望完全控制搜索的实现,Lunr.js 是一个不错的选择。
  • 预算有限:对于不想支付搜索服务费用的项目,Lunr.js 是完全免费的,且足够应对基础需求。
  • 无私密内容:如果你的站点没有敏感或私密的内容,Lunr.js 的客户端索引是可接受的。

适合使用 Algolia 的场景

  • 大规模数据网站:如果你的网站有大量内容(成千上万条数据),Algolia 的后端搜索服务可以提供更好的性能和更快的响应时间。
  • 需要高级搜索功能:如果你需要拼写纠错、自动补全、过滤器等功能,Algolia 提供的搜索能力远超 Lunr.js。
  • 动态内容更新:如果你的网站内容经常变动,Algolia 可以更方便地实时更新索引。
  • 数据隐私需求:如果你需要保护某些私密数据,使用 Algolia 的后端服务更为安全。

总结

基于 Lunr.js 的前端搜索方案适合小型、静态、预算有限且无私密数据 的网站,它提供了简单易用的纯前端搜索解决方案。但如果你的网站规模较大、搜索需求复杂或有隐私保护要求,Algolia 这样专业的搜索服务会提供更好的性能和功能。