DevNow: Search with Lunrjs

LaughingZhu發表於2024-10-08

前言

假期真快,轉眼國慶假期已經到了最後一天。這次國慶沒有出去玩,在北京看了看房子,原先的房子快要到期了,找了個更加通透一點的房子,採光也很好。

閒暇時間準備最佳化下 DevNow 的搜尋元件,經過上一版 搜尋元件最佳化 - Command ⌘K 的最佳化,現在的搜尋內容只能支援標題,由於有時候標題不能百分百概括文章主題,所以希望支援 摘要文章內容 搜尋。

搜尋庫的橫向對比

這裡需要對比了 fuse.jslunrflexsearchminisearchsearch-indexjs-searchelasticlunr對比詳情。下邊是各個庫的下載趨勢和star排名。

下載趨勢

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 端點支援附加額外的功能。

構造索引檔案

// 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 元件中自動過濾功能關掉。否則搜尋結果無法正常展示。

自動過濾

上調函式最大持續時間

當文件比較多的時候,構建的 索引檔案內容檔案 可能會比較大,導致請求 504。 需要上調 Vercel 的超時策略。可以在專案社會中適當上調,預設是10s。

Function Max Duration

前端搜尋的優劣

特性 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 這樣專業的搜尋服務會提供更好的效能和功能。

相關文章