静态站点全文搜索实现原理之Rspress篇

大家好,我是老纪。

书接上回dumi篇,未阅读过的同学建议先看一遍。

前文提到,静态站点全文搜索有两种方案,一种是接入第三方搜索服务,本质上仍是后端方案,服务方以爬虫方式聚合了网站的所有文档信息,再以REST接口的形式返回给前端页面;另一种是前端方案,传统的标题搜索满足不了更细致的搜索需求,于是社区涌现出多种以dumi 2为代表的全文搜索的解决方案。

上篇我们分析了dumi实现全文搜索的原理,它巧妙地将Markdown文件当作Webpack资源的一种,定制了一个loader来动态介入到工作流里,实现了全文搜索,而为了优化性能,更额外采用了Web Worker的形式,保障了在搜索过程中不会出现页面卡顿。

只是在实际开发的过程中,受限于Webpack本身的性能,dumi在文件数量较多的情况下,开发体验与构建效率可能会差一些。

这时,熟悉前端工具链的同学们可能会想,这时是不是应该请Rust出山来拯救性能了呢?

正好,这两年字节团队基于RustRspack(你可以简单理解是使用Rust重写了Webpack,已有5-10倍的性能提升)如火如荼,他们基于Rspack,又开源了一个静态站点生成器,名为Rspress,对标的正是dumiVuepressVitePressDocusaurus等。

Rspress简介

Rspress 基于React框架进行渲染,内置了一套默认的文档主题,你可以通过 Rspress 来快速搭建一个文档站点,同时也可以自定义主题,来满足你的个性化静态站需求,比如博客站、产品主页等。当然,你也可以接入官方提供的相应插件来方便地搭建组件库文档。

Rspress 主要在两个性能敏感部分使用了Rust工具链:

  • 前端 Bundler。对于一个前端工程而言,Bundler是各个编译工具链的集成枢纽,是一个非常关键的工程能力,对项目构建性能影响巨大。Rspress使用 Rspack,本身就拥有了更高的起点和更强大的基座能力。
  • Markdown 编译器。对于SSG框架中另一大编译性能瓶颈,即Markdown 编译,Rspress 定制出 RspressMarkdown 编译器(即@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这样处理的原因。有别于dumiMarkdown当作资源引入到Webpack的工作流,Rspress这样做是将搜索模块的数据解耦出去了,优点是很明显的,因为搜索模块仅是个次要的功能,它不应该阻塞核心页面的展现。

我们可以猜测,这个元数据JSON文件的生成,Rspress应该是使用Node.jsRust生成的,无论这个过程是快是慢,都不影响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,
      );
    }),
  );

其中数据的来源pagesByLangpages

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.jsAPI读取文件,提取出元数据,并没有用什么魔法:

搜索处理

看完了元数据的生成,我们回到RspressGitHub仓库

找到使用以上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月,dumiissue里就有人提到希望使用FlexSearch改进其搜索质量,一直没有响应:

缺点分析

从上面的分析可以看出,Rspress优于dumi的一点是分离了搜索模块的元数据,可有效提升开发体验。但风险点在于没有Web Worker优化,猜测到了具体到了某个数量级或者差些的硬件设备可能会有卡顿发生。

当我使用同样500+的Markdown文件测试时,前文说过dumi的构建时间长达46秒,而Rspress只有5秒,非常丝滑。

当我把Markdown文件复制到8K+,构建的search_index.json体积高达12M,这时页面搜索仍没有卡顿,但未测试其它PC的情况。

但在实际的使用中,发现Rspress的全文搜索仍有其它缺陷,它仅分析了文档中的普通文字,没有将代码块中内容考虑进去,这就导致有相关需求的技术类文档网站暂时不能考虑这个方案。

总结

本文继续分析了静态站点全文搜索的Rspress的实现方案。

dumiMarkdown文件当作Webpack资源处理,并采用了Web Worker来优化性能,但在文件数量较多时可能存在性能问题。而Rspress则基于RustRspack和自定义的Markdown编译器实现了性能提升,同时在搜索模块中将元数据解耦,以提升开发和生产体验;它采用了社区最强大的全文搜索库FlexSearch进行搜索,由于其性能和检索能力的优秀表现,Rspress在实际使用中并未出现明显卡顿问题,但在某些硬件设备上可能会存在性能风险。

此外,由于Rspress的全文搜索没有处理代码块的内容,有相关需求的技术类文档网站在技术选型时需要注意。

相关推荐
燃先生._.15 分钟前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖1 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235241 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240252 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar2 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人3 小时前
前端知识补充—CSS
前端·css
GISer_Jing3 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试
m0_748245523 小时前
吉利前端、AI面试
前端·面试·职场和发展
理想不理想v3 小时前
webpack最基础的配置
前端·webpack·node.js