前言

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

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

搜索库的横向对比

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

DevNow: Search with Lunrjs-LMLPHP

DevNow: Search with Lunrjs-LMLPHP

选择 Lunr 的原因

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

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

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

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

DevNow 中接入 Lunr

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

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

构造索引文件

// 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'
    }
  });
}

构建搜索内容

// 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'
    }
  });
}

重构搜索组件

// 核心代码

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 组件中自动过滤功能关掉。否则搜索结果无法正常展示。

DevNow: Search with Lunrjs-LMLPHP

上调函数最大持续时间

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

DevNow: Search with Lunrjs-LMLPHP

前端搜索的优劣

适合使用 Lunr.js 的场景

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

适合使用 Algolia 的场景

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

总结

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

10-08 12:15