大家好,我是老纪。
书接上回dumi篇,未阅读过的同学建议先看一遍。
前文提到,静态站点全文搜索有两种方案,一种是接入第三方搜索服务,本质上仍是后端方案,服务方以爬虫方式聚合了网站的所有文档信息,再以REST接口的形式返回给前端页面;另一种是前端方案,传统的标题搜索满足不了更细致的搜索需求,于是社区涌现出多种以dumi 2
为代表的全文搜索的解决方案。
上篇我们分析了dumi
实现全文搜索的原理,它巧妙地将Markdown
文件当作Webpack
资源的一种,定制了一个loader
来动态介入到工作流里,实现了全文搜索,而为了优化性能,更额外采用了Web Worker的形式,保障了在搜索过程中不会出现页面卡顿。
只是在实际开发的过程中,受限于Webpack
本身的性能,dumi
在文件数量较多的情况下,开发体验与构建效率可能会差一些。
这时,熟悉前端工具链的同学们可能会想,这时是不是应该请Rust
出山来拯救性能了呢?
正好,这两年字节团队基于Rust
的Rspack
(你可以简单理解是使用Rust
重写了Webpack
,已有5-10倍的性能提升)如火如荼,他们基于Rspack
,又开源了一个静态站点生成器,名为Rspress,对标的正是dumi
、Vuepress
、VitePress
、Docusaurus
等。
Rspress简介
Rspress
基于React
框架进行渲染,内置了一套默认的文档主题,你可以通过 Rspress
来快速搭建一个文档站点,同时也可以自定义主题,来满足你的个性化静态站需求,比如博客站、产品主页等。当然,你也可以接入官方提供的相应插件来方便地搭建组件库文档。
Rspress
主要在两个性能敏感部分使用了Rust
工具链:
- 前端
Bundler
。对于一个前端工程而言,Bundler
是各个编译工具链的集成枢纽,是一个非常关键的工程能力,对项目构建性能影响巨大。Rspress
使用Rspack
,本身就拥有了更高的起点和更强大的基座能力。 Markdown
编译器。对于SSG
框架中另一大编译性能瓶颈,即Markdown
编译,Rspress
定制出Rspress
的Markdown
编译器(即@rspress/mdx-rs
),相比社区的JavaScript
版本的编译器,有近 20 倍的性能提升。从这点上讲,dumi
定制的Markdown loader可能也是其性能枷锁的一部分。
Rspress
在文档站基础能力的打磨上也做了相当多的工作,支持了如下的功能特性:
- 自动生成布局,包括导航栏、左侧侧边栏等等;
- 静态站点生成,项目构建后直出 HTML;
- 国际化,支持多语言文档;
- 全文搜索,提供开箱即用的搜索功能;
- 多版本文档管理;
- 自定义文档主题;
- 自动生成组件 Demo 预览及 Playground;
从官方文档上看,这些能力与dumi
大致类似,有些甚至要更灵活方便些。当然,具体孰优孰劣不在本文的讨论范畴,我们今天的重点仍是中间毫不起眼的那条『全文搜索,提供开箱即用的搜索功能 』。我原以为是dumi
的专利,谁想Rspress
横空出世,也内置了这个功能。
下来我们再来探案,看它是如何实现的。
搜索原理探析
元数据文件
与dumi
一样,Rspress
的官方网站也实现了自举。我们先在网络里看下,按照我们上一篇的思路,全文搜索必然有个文件包含了所有的文档信息,这个通常是占比最大的JavaScript
文件:
但事实上,Rspress
走的并不是dumi
的路子。
我们点击搜索框后,弹窗里有短暂的loading
处理:
这时到网络里看,果然有一个资源请求,不过不是我们想象的JavaScript,而是一个JSON文件:
这个文件正是我们要找的元数据文件,包含了搜索必需的信息:
我们点开这个文件的调用堆栈信息:
展开后可以看到是个fetch
请求:
我们使用pnpm create rspress@latest
新建一个工程,当启动服务后,会发现额外生成了一个doc_build
文件夹,文件夹内目前仅有一个search_index
文件,正是我们的元数据文件。
bash
|-- doc_build
| `-- static
| `-- search_index.1427c47e.json
|-- docs
| |-- _meta.json
| |-- guide
| | |-- _meta.json
| | `-- index.md
| |-- hello.md
| |-- index.md
| `-- public
| |-- rspress-dark-logo.png
| |-- rspress-icon.png
| `-- rspress-light-logo.png
|-- package.json
|-- pnpm-lock.yaml
|-- rspress.config.ts
`-- tsconfig.json
我们来看下文件的内容(以下是省略版本),仔细看的话,会发现它包含了标题、内容、路由、大纲、语言等信息,可谓是非常丰富了:
json
[
{
"id": 0,
"title": "Markdown & MDX",
"content": "#\n\nRspress supports not only Markdown but also MDX ...",
"routePath": "/guide/",
"lang": "",
"toc": [
{
"text": "Markdown",
"id": "markdown",
"depth": 2,
"charIndex": 88
},
{
"text": "Use Component",
"id": "use-component",
"depth": 2,
"charIndex": 198
},
...
],
"domain": "",
"frontmatter": {},
"version": ""
},
{
"id": 1,
"title": "Hello World!",
"content": "#\n\n\nStart#\n\nWrite something to build your own docs! 🎁",
"routePath": "/hello",
"lang": "",
"toc": [
{
"text": "Start",
"id": "start",
"depth": 2,
"charIndex": 3
}
],
"domain": "",
"frontmatter": {},
"version": ""
}
]
这里,我们简要分析下Rspress
这样处理的原因。有别于dumi
将Markdown
当作资源引入到Webpack
的工作流,Rspress
这样做是将搜索模块的数据解耦出去了,优点是很明显的,因为搜索模块仅是个次要的功能,它不应该阻塞核心页面的展现。
我们可以猜测,这个元数据JSON
文件的生成,Rspress
应该是使用Node.js
或Rust
生成的,无论这个过程是快是慢,都不影响Rspress
的服务启动与站点展示(这个环节虽然也会对Markdown
进行AST
分析或转换等处理,但在Rust
的加持下,性能极高,且与搜索模块关注的点不同),这样无疑会提升开发者的用户体验。
我们在GitHub
源码中找到生成部分,是用Node.js
开发的:
javascript
await Promise.all(
Object.keys(pagesByLang).map(async lang => {
// Avoid writing filepath in compile-time
const stringfiedIndex = JSON.stringify(
pagesByLang[lang].map(deletePriviteKey),
);
const indexHash = createHash(stringfiedIndex);
indexHashByLang[lang] = indexHash;
await fs.ensureDir(TEMP_DIR);
await fs.writeFile(
path.join(
TEMP_DIR,
`${SEARCH_INDEX_NAME}${lang ? `.${lang}` : ''}.${indexHash}.json`,
),
stringfiedIndex,
);
}),
);
其中数据的来源pagesByLang
是pages
:
javascript
const pages = (
await extractPageData(
replaceRules,
alias,
domain,
userDocRoot,
routeService,
)
).filter(Boolean);
// modify page index by plugins
await pluginDriver.modifySearchIndexData(pages);
// Categorize pages, sorted by language, and write search index to file
const pagesByLang = pages.reduce((acc, page) => {
if (!acc[page.lang]) {
acc[page.lang] = [];
}
if (page.frontmatter?.pageType === 'home') {
return acc;
}
acc[page.lang].push(page);
return acc;
}, {} as Record<string, PageIndexInfo[]>);
而extractPageData
的代码在siteData/extractPageData.ts,完全是使用Node.js
的API
读取文件,提取出元数据,并没有用什么魔法:
搜索处理
看完了元数据的生成,我们回到Rspress
的GitHub仓库:
找到使用以上JSON
的地方在src/components/Search/logic/providers/LocalProvider.ts:
javascript
async #getPages(lang: string): Promise<PageIndexInfo[]> {
const result = await fetch(
`${process.env.__ASSET_PREFIX__}/static/${SEARCH_INDEX_NAME}${
lang ? `.${lang}` : ''
}.${searchIndexHash[lang]}.json`,
);
return result.json();
}
再仔细看这篇文件,也就一百来行,核心代码是使用flexsearch
这个库进行搜索:
typescript
import type { CreateOptions, Index as SearchIndex } from 'flexsearch';
import FlexSearch from 'flexsearch';
export class LocalProvider implements Provider {
async #getPages(lang: string): Promise<PageIndexInfo[]> {
...
}
async init(options: SearchOptions) {
const { currentLang } = options;
const pagesForSearch: PageIndexForFlexSearch[] = (
await this.#getPages(currentLang)
)
.filter(page => page.lang === currentLang)
.map(page => ({
...page,
normalizedContent: normalizeTextCase(page.content),
headers: page.toc
.map(header => normalizeTextCase(header.text))
.join(' '),
normalizedTitle: normalizeTextCase(page.title),
}));
const createOptions: CreateOptions = {
tokenize: 'full',
async: true,
doc: {
id: 'routePath',
field: ['normalizedTitle', 'headers', 'normalizedContent'],
},
cache: 100,
split: /\W+/,
};
// Init Search Indexes
// English Index
this.#index = FlexSearch.create(createOptions);
// CJK: Chinese, Japanese, Korean
this.#cjkIndex = FlexSearch.create({
...createOptions,
tokenize: (str: string) => tokenize(str, cjkRegex),
});
// Cyrilic Index
this.#cyrilicIndex = FlexSearch.create({
...createOptions,
tokenize: (str: string) => tokenize(str, cyrillicRegex),
});
this.#index.add(pagesForSearch);
this.#cjkIndex.add(pagesForSearch);
this.#cyrilicIndex.add(pagesForSearch);
}
async search(query: SearchQuery) {
const { keyword, limit } = query;
const searchParams = {
query: keyword,
limit,
field: ['normalizedTitle', 'headers', 'normalizedContent'],
};
const searchResult = await Promise.all([
this.#index?.search(searchParams),
this.#cjkIndex?.search(searchParams),
this.#cyrilicIndex.search(searchParams),
]);
const flattenSearchResult = searchResult.flat(2).filter(Boolean);
return [
{
index: LOCAL_INDEX,
hits: flattenSearchResult,
},
];
}
}
在这里,我们看出Rspress
没有采用dumi
的Web Worker方案来优化搜索,而在实际使用中(500+ Markdown文件),也并未发现有明显卡顿,显然这个FlexSearch是有两把刷子的。
其实,FlexSearch
是目前Web最快且最具内存灵活性的全文搜索库,零依赖。
官方性能对比爆表:
由于FlexSearch
强大的性能与出色的检索能力,暂时不进行优化也是可以接受的,不过当搜索内容达到某个数量级,或者考虑某些性能欠佳的硬件设备时,我认为仍是有必要的。有兴趣的同学可以研究下使用方法,Web
端与Node.js
端都支持。
有趣的是,早在2023年1月,dumi
的issue里就有人提到希望使用FlexSearch
改进其搜索质量,一直没有响应:
缺点分析
从上面的分析可以看出,Rspress
优于dumi
的一点是分离了搜索模块的元数据,可有效提升开发体验。但风险点在于没有Web Worker
优化,猜测到了具体到了某个数量级或者差些的硬件设备可能会有卡顿发生。
当我使用同样500+的
Markdown
文件测试时,前文说过dumi
的构建时间长达46秒,而Rspress
只有5秒,非常丝滑。当我把
Markdown
文件复制到8K+,构建的search_index.json
体积高达12M,这时页面搜索仍没有卡顿,但未测试其它PC的情况。
但在实际的使用中,发现Rspress
的全文搜索仍有其它缺陷,它仅分析了文档中的普通文字,没有将代码块中内容考虑进去,这就导致有相关需求的技术类文档网站暂时不能考虑这个方案。
总结
本文继续分析了静态站点全文搜索的Rspress
的实现方案。
dumi
将Markdown
文件当作Webpack
资源处理,并采用了Web Worker
来优化性能,但在文件数量较多时可能存在性能问题。而Rspress
则基于Rust
的Rspack
和自定义的Markdown
编译器实现了性能提升,同时在搜索模块中将元数据解耦,以提升开发和生产体验;它采用了社区最强大的全文搜索库FlexSearch
进行搜索,由于其性能和检索能力的优秀表现,Rspress
在实际使用中并未出现明显卡顿问题,但在某些硬件设备上可能会存在性能风险。
此外,由于Rspress
的全文搜索没有处理代码块的内容,有相关需求的技术类文档网站在技术选型时需要注意。