独立开发日记30: 150行代码基于Flexsearch实现本地全文搜索

152 阅读3分钟

TinySnap-2024-12-25-23.30.05.png

2024年12月25日 星期三。

今天研究了一天如何实现本地搜索,即用户访问官网文档进行搜索时,在不需要第三方(比如Algolia,它对开源项目免费,对商业项目收费)支持的情况下完美支持全文搜索。

核心就是 Flexsearch 这个目前最强的搜索库,直接上代码:

import { Document } from 'flexsearch'
import { readFileSync, writeFileSync } from 'fs'
import { globbySync } from 'globby'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { nanoid } from 'nanoid'

import type { RootContent } from 'mdast'
import type { IndexOptionsForDocumentSearch } from 'flexsearch'

const docs_dir = `${process.cwd()}/public/docs`
const search_dir = `${process.cwd()}/public/search`
const mds_en = globbySync(['**/en.mdx'], { cwd: docs_dir })
const mds_zh_cn = globbySync(['**/zh-cn.mdx'], { cwd: docs_dir })

const mds = [
	{
		locale: 'en',
		docs: mds_en
	},
	{
		locale: 'zh-cn',
		docs: mds_zh_cn
	}
]

const tags = ['Tabs', 'Video', 'ImageWrap', 'ImageLayout', 'Row', 'Col']
const tags_str = tags.join('|')
const reg_replace = new RegExp(`<(${tags_str})\s+[^>]*>|</(${tags_str})\s*>`, 'g')

interface IndexItem {
	id: string
	link: string
	type: 'heading' | 'content'
	headings: string
	content: string
}

const getText = (item: RootContent) => {
	if ('children' in item) {
		return item.children.reduce((total, item) => {
			if ('children' in item) {
				total += getText(item)
			} else {
				if ('value' in item) {
					total += item.value
				}
			}

			return total
		}, '')
	} else {
		if ('value' in item) {
			return item.value
		}
	}

	return ''
}

const common_options: IndexOptionsForDocumentSearch<IndexItem, true> = {
	cache: 100,
	tokenize: 'full',
	document: {
		id: 'id',
		index: 'content',
		store: true
	},
	context: {
		resolution: 9,
		depth: 3,
		bidirectional: true
	}
}

mds.forEach(async mds_item => {
	const indexes = new Document(common_options)

	mds_item.docs.forEach(async item => {
		const link = item.replace(`/${mds_item.locale}.mdx`, '')
		const doc = readFileSync(`${docs_dir}/${item}`)
		const ast = fromMarkdown(doc)
		const items = ast.children

		let headings = [] as Array<{ title: string; level: number }>

		items.forEach(item => {
			const prev_heading = headings.at(-1)

			let target_item = null as unknown as IndexItem
			const id = nanoid(6)

			if (item.type === 'heading') {
				if (prev_heading) {
					if (prev_heading.level < item.depth) {
						headings.push({ title: getText(item), level: item.depth })
					} else if (prev_heading.level === item.depth) {
						headings.pop()

						headings.push({ title: getText(item), level: item.depth })
					} else if (prev_heading.level > item.depth) {
						while (headings.at(-1)!.level > item.depth) {
							headings.pop()
						}

						if (headings.at(-1)!.level === item.depth) {
							headings.pop()

							headings.push({ title: getText(item), level: item.depth })
						} else {
							headings.push({ title: getText(item), level: item.depth })
						}
					}
				} else {
					headings.push({ title: getText(item), level: item.depth })
				}

				target_item = {
					id,
					link,
					type: 'heading',
					headings: headings.map(item => item.title).join('>'),
					content: getText(item)
				}
			} else {
				let content: string = getText(item)

				content = content.replaceAll(reg_replace, '').replaceAll('\n', '')

				if (content) {
					target_item = {
						id,
						link,
						type: 'content',
						headings: headings.map(item => item.title).join('>'),
						content
					}
				}
			}

			if (target_item) {
				indexes.add(target_item)
			}
		})
	})

	const export_index = {} as Record<string, any>

	await indexes.export((id, value) => {
		if (value) {
			export_index[id] = value
		}
	})

	writeFileSync(`${search_dir}/timestamp`, new Date().valueOf().toString())
	writeFileSync(`${search_dir}/${mds_item.locale}.json`, JSON.stringify(export_index))
})

整体流程(分为开发环境和线上环境):

  1. 获取文档:通过globby在开发环境获取public目录下的所有文档
  2. 生成Ast语法树:通过mdast-util-from-markdown生成md文件的抽象语法书
  3. 处理抽象语法树:把抽象语法书处理成带有 link、type、headings和content的数组,其中link是文件对应的文档链接,type用来区分是文档内容,还是文档内的标题,headings用来展示内容在的位置,content即为文档内容,这里做了一定的过滤,把一些mdx特定的组件标签给过滤了,要想完美的话基于mdast-util-from-mdx来处理和过滤能获取纯的文本内容,当处理ast是个细活,目前不需要整这么复杂,先能用,闲的没事了可以再来优化。
  4. 把处理之后的数据对象加入到flexsearch的文档索引中
  5. 导出所有文档索引
  6. 写入索引到public目录下的search文件夹中
  7. 写入timestamp到public目录下的search文件夹中

下面是线上环境的处理:

  1. 当用户点击搜索框时懒加载对应语言的,之前写入到public/search目录的文档索引
  2. 把索引import到flexsearch的文档对象中
  3. 把索引和timestamp写入到indexeddb中,当下次载入时先检查本地是否存在缓存的索引,同时请求timestamp文件,如果本地不存在缓存或者本地缓存的timestamp和请求的不一致时,请求索引并写入indexeddb。

当然你如果嫌麻烦,可以只预生成处理mdx之后生成的数组,在用户访问时一个个add到flexsearch的文档对象中,直接导入export的索引能省去构建流程,节省一部分时间,当然,代价就是export出来的索引文件其实是包含了许多重复文字的,可以是lz-string进行压缩了之后再存储。