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

在构建前端文档网站时,搜索功能是用户体验的关键组成部分之一。从技术的维度上来看,网站的搜索方案可分为后端搜索和前端搜索两种方式。

后端搜索是处理动态内容的理想选择。特别是对于那些文档内容实时更新的动态站点,在后端部署搜索功能显得尤为必要。后端搜索方案通常结合使用数据库或专用搜索引擎来处理搜索请求。在这种情形下,Elastic Search 是一个广泛采用的选择,因为它不仅优化了全文搜索过程,还能有效地处理高亮显示等高级搜索功能,非常适合处理和索引大量的实时数据。

相反,静态站点(比如个人博客、官方文档等),由于页面内容在部署时已经确定,社区有两种成熟的方案:

  1. 后端方案:接入第三方的搜索服务。优点是能保证搜索质量,缺点是有一定门槛。常见的搜索服务有Algolia(Vue、Vite官网在使用,VuePress也有相应的集成文档)、MeilisearchOrama(Node.js官网在使用)等。

  2. 前端方案:

    1. 传统的标题搜索。因为生成大纲原本就需要采集标题,用做索引顺理成章,这也是 SSG 框架的常见做法,缺点是能搜到的东西太少,满足不了更细致的搜索需求。
    2. 全文搜索。这种方案的优点显而易见,用户的搜索请求直接在本地浏览器中处理,而不涉及服务器端的计算。这样不仅减轻了服务器的工作负担,还能为用户提供即时的搜索体验。缺点也很明显,当文档内容达到某个数量级时,构建产物的尺寸会是惊人的。目前开箱即用的框架代表有dumi 2Rspress等。

受限于文章篇幅,本文带你了解下dumi的全文搜索实现思路,下一篇再讲Rspress

dumi简介

dumi,中文发音嘟米 ,是一款为组件开发场景而生的静态站点框架。它是基于umi的React组件文档工具,显然是阿里旗下的作品。

以下是官方首页的宣传语:

dumi的官方文档本身就是用dumi搭建的,所以搜索框自然也支持全文搜索:

我们在网络里找到这个最大的JS文件,大小有1.3 M,使用br压缩后为383 K:

这个文件是在渲染的HTML的script标签里直接引用的:

文档的分词都在这里:

所以,dumi的搜索方案会导致首页资源膨胀,不过这是SPA的通病,也不会阻塞页面的渲染,性能看起来还不错:

我们有个文档网站也采用了dumi,我们的文档内容虽多,但最终使用br压缩后只有600K+,还在可以接受的范围:

搜索原理探析

要在前端做到全文搜索,必然有个文件包含了所有的文档信息。在上面的网络请求中,我们已经找到了这个打包后的文件。那么在开发阶段,它在哪儿呢?又是什么样的形式呢?我们来找个项目研究下。

元数据文件

dumi在项目启动后,生成的动态文件都在.dumi/tmp.dumi/tmp-production文件夹下。

我们找到.dumi/tmp/dumi/meta/index.ts

它的内容大致如下:

javascript 复制代码
// @ts-nocheck
// This file is generated by Umi automatically
// DO NOT CHANGE IT MANUALLY!
import { demos as dm0, frontmatter as fm0, toc as toc0, texts as txt0 } from '/Users/jw/wk/gitlab/docs/dumi/.dumi/pages/index.en-US.tsx?type=meta';
import { demos as dm1, frontmatter as fm1, toc as toc1, texts as txt1 } from '/Users/jw/wk/gitlab/docs/dumi/.dumi/pages/index.tsx?type=meta';

import { demos as dm2, frontmatter as fm2, toc as toc2, texts as txt2 } from '/Users/jw/wk/gitlab/docs/dumi/docs/examples/earth-geo-ground-line-and-polygon.en-US.md?type=meta';
import { demos as dm3, frontmatter as fm3, toc as toc3, texts as txt3 } from '/Users/jw/wk/gitlab/docs/dumi/docs/examples/extend-volume-rendering-texture.en-US.md?type=meta';
import { demos as dm4, frontmatter as fm4, toc as toc4, texts as txt4 } from '/Users/jw/wk/gitlab/docs/dumi/docs/examples/earth-featureLayerGeoBoundary.en-US.md?type=meta';
...
import { demos as dm519, frontmatter as fm519, toc as toc519, texts as txt519 } from '/Users/jw/wk/gitlab/docs/dumi/src/Example/index.md?type=meta';


export { components } from './atoms';
export { tabs } from './tabs';

export const filesMeta = {
  'index.en-US': {
    frontmatter: fm0,
    toc: toc0,
    texts: txt0,
    demos: dm0,
  },
  'index': {
    frontmatter: fm1,
    toc: toc1,
    texts: txt1,
    demos: dm1,
  },
  'docs/examples/earth-geo-ground-line-and-polygon.en-US': {
    frontmatter: fm2,
    toc: toc2,
    texts: txt2,
    demos: dm2,
  },
  ...
]

在解释这个文件前,我们先简单说下dumi的约定式路由,即根据路由文件路径自动生成路由,是 dumi 默认且推荐使用的路由模式,共有 3 种读取方式,分别是:

类型 默认读取路径 适用场景及特点
文档路由 docs 适用于普通文档生成路由,路径下的文档会根据嵌套结构自动识别并归类到不同的导航类目下
资产路由 src 适用于资产(比如组件或 hooks)文档的生成,路径下第一层级的文档会被识别并归类到指定的类别下,dumi 默认会将 src 下的文档都归类到 /components
React 路由 .dumi/pages 适用于为当前站点添加额外的、无法用 Markdown 编写的复杂页面,这些页面必须使用 React 编写,识别规则与文档路由一致

上面的.dumi/tmp/dumi/meta/index.ts,将.dumi/pagestsx文件以及docs文件夹和src文件夹下所有Markdown文件都作为资源引入,这覆盖了以上3种路由形式,包含了所有的静态文档内容。

markdown loader

为了扩展Markdown语法,可嵌入jsx代码并显示,也就是dumi的初衷『为组件研发而生』,dumi实现了一个loader来专门处理Markdown:

它处理了各式各样的规则的解析与转换,有个子目录fixtures中代码是这个功能的测试用例:

这样一来,dumi(其实是Webpack)就识别了这些Markdown文件,与我们平时开发的CSS、SASS、图片等其它资源没有区别了,最终都会转换为JavaScript对象。

搜索处理

dumi考虑到大数据量时的搜索性能,为避免在搜索时页面卡顿,使用了Web Worker,单开一个线程来进行搜索行为,源码在src/client/theme-api/useSiteSearch/searchWorker.ts

并写了个构建脚本生成searchWorker.min.js

搜索的核心代码在useSiteSearch/index.ts,使用了这个worker进行交互:

typescript 复制代码
import workerCode from '-!../../../../compiled/_internal/searchWorker.min?dumi-raw';

let worker: Worker;

// for ssr
if (typeof window !== 'undefined') {
  // use blob to avoid generate entry(chunk) for worker
  worker = new Worker(
    URL.createObjectURL(
      new Blob([workerCode], { type: 'application/javascript' }),
    ),
  );
}

export const useSiteSearch = () => {
  const debounceTimer = useRef<number>();
  const routes = useLocaleDocRoutes();
  const { demos } = useSiteData();
  const [loading, setLoading] = useState(false);
  const [keywords, setKeywords] = useState('');
  const navData = useNavData();
  const [result, setResult] = useState<ISearchResult>([]);
  const setter = useCallback((val: string) => {
    setLoading(true);
    setKeywords(val);
  }, []);

  useEffect(() => {
    worker.onmessage = (e) => {
      setResult(e.data);
      setLoading(false);
    };
  }, []);

  useEffect(() => {
    // omit demo component for postmessage
    const demoData = Object.entries(demos).reduce<
      Record<string, Partial<typeof demos[0]>>
      >(
      (acc, [key, { asset, routeId }]) => ({
        ...acc,
        [key]: { asset, routeId },
      }),
      {},
    );

    worker.postMessage({
      action: 'generate-metadata',
      args: {
        routes: JSON.parse(JSON.stringify(routes)),
        nav: navData,
        demos: demoData,
      },
    });
  }, [routes, demos, navData]);

  useEffect(() => {
    const str = keywords.trim();

    if (str) {
      clearTimeout(debounceTimer.current);
      debounceTimer.current = window.setTimeout(() => {
        worker.postMessage({
          action: 'get-search-result',
          args: {
            keywords: str,
          },
        });
      }, 200);
    } else {
      setResult([]);
    }
  }, [keywords]);

  return { keywords, setKeywords: setter, result, loading };
};

以上代码使用worker.postMessage发送事件,对应到searchWorker.ts中接收事件的部分代码是这样的:

typescript 复制代码
let metadata: ISearchMetadata;

self.onmessage = ({ data }) => {
  switch (data.action) {
    case 'generate-metadata':
      metadata = generateSearchMetadata(
        data.args.routes,
        data.args.demos,
        data.args.nav,
      );
      break;

    case 'get-search-result':
      self.postMessage(generateSearchResult(metadata, data.args.keywords));
      break;
    default:
  }
};

而执行搜索的代码是这段:

typescript 复制代码
import { findAll } from 'highlight-words-core';

function generateHighlightTexts(
  str = '',
  keywords: string[],
  priority = 1,
): [IHighlightText[], Record<string, number>] {
  const chunks = findAll({
    textToHighlight: str,
    searchWords: keywords,
    autoEscape: true,
  });
  ...
}

我们看到,这里的搜索使用了highlight-words-core。我们到npm上看了下,是个5年前的包,稍微老了点,不过有可能是因为很健壮?没有用到第三方包,代码不长,不到200行,有兴趣的同学可以看下,纯粹的数组、字符串处理与正则匹配。

缺点分析

我们复盘下上,dumi的全文搜索其实就做了3件事:

  1. 遍历路由相关文件夹,生成一个元数据的入口文件
  2. 元数据的入口文件包含tsxmd两种文件类型,dumiMarkdown文件定制了一个Webpackloader来分析转换,最终必然生成一个JavaScript文件
  3. 为避免处理大量文本内容时搜索卡顿,dumi使用Web Worker来优化性能,在这个单独的线程里进行搜索处理

我们分析下可能的性能瓶颈在哪里:

步骤1:受限于我们的业务范围,整个网站的文档内容体量是有限的。这个步骤是由Node.js完成的,不涉及到复杂的CPU计算,正是Node.js的拿手好戏,所以不存在瓶颈。

步骤2:由于dumi的底层使用了umi框架,umi又使用了Webpack,但事实上Webpack的性能已经被诟病良久,所以社区才又出现Vite、Rspack、Farm等替代方案。虽然官方号称『通过结合使用 Umi 4 MFSUesbuildSWC、持久缓存等方案,带来了比 dumi 1.x更快的编译速度』,但在实际开发体验中,开启esbuildSWC都失败,只能使用默认的配置,不得不说官方文档还很不完善。仅500+的Markdown文件,在我的机器上要耗费46秒(1983 modules)才能显示出完整网页。如果开发途中切换目录结构,原先的缓存失效,又要重复这一步骤。

步骤3:dumi使用highlight-words-core进行搜索,性能并非最佳的,但采用了Web Worker机制,避免主线程的阻塞,在实际体验中还是非常流畅的。

总结

我们通过查看dumi的工程与源码,找到了它处理全文搜索的核心逻辑。简单来说,是将与路由相关的文档内容作为资源加入到Webpack的工作流中,为此,dumi提供了专门的Markdown loader来解析处理定制的复杂规则。在项目启动后,内存中已经有了完整的文档元数据对象,使用JavaScript进行搜索就顺理成章了。但这个搜索过程可能会很消耗CPU,造成页面卡顿,所以dumi又使用了Web Worker,将这一环节异步处理,保障了主线程的流畅运行。

由于Webpack的先天缺陷,dumi在文件数量多的情况下,开发体验与构建效率可能会差一些。此外,部署时请开启br压缩,实测比gzip要小不少。

相关推荐
Fan_web14 分钟前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常16 分钟前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇1 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr1 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
Tiffany_Ho2 小时前
【TypeScript】知识点梳理(三)
前端·typescript
安冬的码畜日常3 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
小白学习日记4 小时前
【复习】HTML常用标签<table>
前端·html
丁总学Java4 小时前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
yanlele4 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范