在构建前端文档网站时,搜索功能是用户体验的关键组成部分之一。从技术的维度上来看,网站的搜索方案可分为后端搜索和前端搜索两种方式。
后端搜索是处理动态内容的理想选择。特别是对于那些文档内容实时更新的动态站点,在后端部署搜索功能显得尤为必要。后端搜索方案通常结合使用数据库或专用搜索引擎来处理搜索请求。在这种情形下,Elastic Search
是一个广泛采用的选择,因为它不仅优化了全文搜索过程,还能有效地处理高亮显示等高级搜索功能,非常适合处理和索引大量的实时数据。
相反,静态站点(比如个人博客、官方文档等),由于页面内容在部署时已经确定,社区有两种成熟的方案:
-
后端方案:接入第三方的搜索服务。优点是能保证搜索质量,缺点是有一定门槛。常见的搜索服务有Algolia(Vue、Vite官网在使用,VuePress也有相应的集成文档)、Meilisearch、Orama(Node.js官网在使用)等。
-
前端方案:
受限于文章篇幅,本文带你了解下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/pages
的tsx
文件以及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件事:
- 遍历路由相关文件夹,生成一个元数据的入口文件
- 元数据的入口文件包含
tsx
和md
两种文件类型,dumi
为Markdown
文件定制了一个Webpack
的loader
来分析转换,最终必然生成一个JavaScript文件 - 为避免处理大量文本内容时搜索卡顿,
dumi
使用Web Worker来优化性能,在这个单独的线程里进行搜索处理
我们分析下可能的性能瓶颈在哪里:
步骤1:受限于我们的业务范围,整个网站的文档内容体量是有限的。这个步骤是由Node.js完成的,不涉及到复杂的CPU计算,正是Node.js的拿手好戏,所以不存在瓶颈。
步骤2:由于dumi
的底层使用了umi
框架,umi
又使用了Webpack
,但事实上Webpack
的性能已经被诟病良久,所以社区才又出现Vite、Rspack、Farm等替代方案。虽然官方号称『通过结合使用 Umi 4 MFSU、esbuild
、SWC
、持久缓存等方案,带来了比 dumi 1.x
更快的编译速度』,但在实际开发体验中,开启esbuild
与SWC
都失败,只能使用默认的配置,不得不说官方文档还很不完善。仅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
要小不少。