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))
})
整体流程(分为开发环境和线上环境):
- 获取文档:通过
globby
在开发环境获取public目录下的所有文档 - 生成Ast语法树:通过
mdast-util-from-markdown
生成md文件的抽象语法书 - 处理抽象语法树:把抽象语法书处理成带有 link、type、headings和content的数组,其中link是文件对应的文档链接,type用来区分是文档内容,还是文档内的标题,headings用来展示内容在的位置,content即为文档内容,这里做了一定的过滤,把一些mdx特定的组件标签给过滤了,要想完美的话基于
mdast-util-from-mdx
来处理和过滤能获取纯的文本内容,当处理ast是个细活,目前不需要整这么复杂,先能用,闲的没事了可以再来优化。 - 把处理之后的数据对象加入到flexsearch的文档索引中
- 导出所有文档索引
- 写入索引到public目录下的search文件夹中
- 写入timestamp到public目录下的search文件夹中
下面是线上环境的处理:
- 当用户点击搜索框时懒加载对应语言的,之前写入到public/search目录的文档索引
- 把索引import到flexsearch的文档对象中
- 把索引和timestamp写入到indexeddb中,当下次载入时先检查本地是否存在缓存的索引,同时请求timestamp文件,如果本地不存在缓存或者本地缓存的timestamp和请求的不一致时,请求索引并写入indexeddb。
当然你如果嫌麻烦,可以只预生成处理mdx之后生成的数组,在用户访问时一个个add到flexsearch的文档对象中,直接导入export的索引能省去构建流程,节省一部分时间,当然,代价就是export出来的索引文件其实是包含了许多重复文字的,可以是lz-string进行压缩了之后再存储。