大家好,我是老纪。
之前写了两篇关于静态站点的文章(《静态站点全文搜索实现原理之dumi篇》和《静态站点全文搜索实现原理之Rspress篇》),花了不少力气写的,在各个平台上都没什么水花,可能关注这块的同学比较少。比如在掘金上阅读量是这样的:
而 pnpm的介绍显然更受欢迎些:
这篇修改第三方npm包的文章居然是爆款(对我而言),每天都有人点赞收藏,以至于我不得不把掘金APP的点赞收藏提示关了:
在知乎上这篇《错位之谜:网站简体中文何以变繁?》的热度更高些:
闲言少叙,言归正传。
本文简单记录下某文档网站使用dumi
后,呈现3D示例的一个骚操作。三言两语难以说尽,各位看官且听我慢慢道来。
技术选型前后
早在两年前,曾经有个团队问过我关于ES(Elastic Search)搜索的事情;又过了一段时间,又有同事让我调研Galacean
(蚂蚁的一个团队)怎么实现的代码与效果展示,我研究后发现是用Markdown实现的,于是写了一篇《Markdown自定义标签》,可能标题不够水,所以一点水花都没有。
去年年底,在我的团队已经被裁的剩下大猫小猫三两只时,接到一个任务,优化某文档网站的搜索功能。我这时才意识到上面两件事其实是同一件。
当前的文档网站,内容以Markdown
为主,不存在动态的内容,只是API和示例部分都是用iframe
嵌套。需要我们优化的搜索功能,主要是想将API、示例代码都可以搜索并定位跳转。
于是有了三种方案:
- 现有的功能+
ES
优化 - 现有的功能+
algolia
等第三方搜索服务 dumi
纯前端方案
正常来说,我们应该选择第一种,只需要考虑将API、示例的文本存储到ES数据库,但涉及到定位跳转的话,还得考虑存储的粒度、是否需要行数、段数等元数据。
而第二种方案,则是花钱买服务,algolia
等第三方搜索服务比较成熟了,只需要通过简单的配置,搜索服务会定时扫描整个网站,前端调用相关的API就可以了。
第三种方案,是我在网上看到dumi2
的发布信息后心动了。一直以为全文搜索是后端的活儿,当数据到了某个体量后,前端不得卡死?为了能够搜索全文,前端必然需要拿到所有文本,在网络请求里会不会太大?
但自己尝试后,发现对于我们的文档级别,还是可以接受的。dumi2
使用了Web Worker来优化搜索,避免卡顿;文本虽然大,但开启br
压缩后,4M可以压缩到600K,这点儿流量在今天看来,真是毛毛雨了。
再复习下dumi
的宣传优势,吸引我的主要是这几点:
- 全文搜索。省掉一个后端服务
- UI比我们的要好看些,自带响应式布局、暗黑模式、多语言等
- 可以自定义组件来扩展Markdown
看了下现有网站的功能,替换成本不高,大部分Markdown
文件移植过来就行了。需要处理的细节集中在以下几点:
- 首页需要定制,写个React组件
.dumi/pages/index.tsx
轻松搞定。 - API部分,原先是使用
jsdoc
导出的HTML,将之修改为Markdown
,再写个脚本替换下链接。 - 示例部分,是3D与代码的结合。正常来说也是一个React组件就很容易搞定,但并没有那么简单。这也正是撰写本文的由来。
示例部分的坑
如果不要求全文搜索搜索到示例中的代码块,那么,写个React组件,左侧是个iframe
,右侧是个代码块,是再简单不过了。
但是,如果考虑代码块的全文搜索,就有些令人头疼了。
因为,dumi
中的搜索,只包含了静态的Markdown部分,而React组件是动态的,这点儿是做不到的。
这也是使用框架的弊端,必须戴着脚链跳舞。
关于dumi
的搜索原理,简单来说,是将与路由相关的文档内容作为资源加入到Webpack
的工作流中,为此,dumi
提供了专门的Markdown loader
来解析处理定制的复杂规则。在项目启动后,内存中已经有了完整的文档元数据对象,使用JavaScript
进行搜索就顺理成章了。但这个搜索过程可能会很消耗CPU,造成页面卡顿,所以dumi
又使用了Web Worker
,将这一环节异步处理,保障了主线程的流畅运行。详见拙作《静态站点全文搜索实现原理之dumi篇》。
应该怎么办呢?有两种思路:
- 魔改
dumi
源码的Hook useSiteSearch,暴露一个可以注入搜索源的函数,在入口文件里找个时机调用。缺点是需要深入研究dumi
的搜索机制,怎么将示例的代码块分段、分行、分词,都是麻烦。 - 写一个
JavaScript
脚本,将示例的代码块批量生成多个Markdown
文件,上面部分是React
组件,用来呈现3D
效果,下面部分是JavaScript
源码。
从实现成本上讲,我更倾向于第二种。
但第二种,又有个缺点,它只能是上下布局,大致是这样的:
markdown
## Thingjs Example
```jsx
/**
* inline: true
*/
import { ThingjsExample } from 'thingjs-docs';
export default () => (
<ThingjsExample
scriptContent="const app = new THING.App();
app.camera.position = [7, 7, 7];"
/>
);
```
```ts
const app = new THING.App();
app.camera.position = [7, 7, 7];
```
呈现出来的效果是这样的:
在某些场景中,上下布局是合理的,但作为示例,显然是左右更佳些。
方案一:吸星大法
当我以为走进了死胡同时,突发奇想,是不是可以将示例组件回归到最初的设想,包含左右两部分布局,左侧是3D,右侧是代码块,但下方用来辅助搜索的源码代码块,可以考虑添加一个元属性,将之隐藏掉。
比如dumi
的文档里关于Markdown
的增强有一个高亮的示例,就扩展了代码块的能力:
我可以添加一个规则,比如:
markdown
```ts | hide
xxx
```
在渲染时,将相关DOM元素隐藏掉。
这思路是不是很可行?只有两点需要处理:
- 找到
dumi
源码(搜索关键字pure
),看能不能抄作业(其实是SourceCode
组件) - 右侧的代码块,也最好复用
dumi
现成的组件
方案二:乾坤大挪移
当我磨刀霍霍,准备开干时,另一个更骚操作的方案涌现在我脑海里:何不来一招乾坤大挪移?
简言之:
- 示例组件仍是左右布局,左侧3D不变,但右侧是空的
- 在组件渲染时,将下方的代码块移动到右侧的
div
里
感觉这个思路更对我的胃口啊!
难点是:怎么能知道下方的DOM元素呢?
呃,其实一点也不难。因为在我们这个场景里,这两个元素必然是相临的关系:
所以,代码也很容易写出来了:
tsx
const exampleRef = useRef<HTMLIFrameElement>(null);
const codeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const parent = exampleRef.current?.parentElement!;
const index = [...parent.childNodes].findIndex((node) =>
node === exampleRef.current
);
const next = parent.childNodes[index + 1];
codeRef.current!.appendChild(next);
}, []);
return (
<div className="examples" ref={exampleRef}>
<iframe ref={iframeRef} src={url} onLoad={onload}>
<p>Your browser does not support iframes.</p>
</iframe>
<div className="examples-code" ref={codeRef}></div>
</div>
);
效果如下:
哈!
呃,现实中并没有这么光鲜啦。事实上我是走了弯路滴。
首先,我给代码块添加了一个id
属性:
tsx
import React, { type FC } from 'react';
import Code from "dumi/theme-default/builtins/SourceCode";
import { type Language } from 'prism-react-renderer';
interface SourceCodeProps {
id?: string;
children: string;
lang: Language;
highlightLines?: number[];
}
const SourceCode: FC<SourceCodeProps> = (props) => {
const { id } = props;
if (id) {
return <div id={id}>
<Code {...props} />
</div>
}
return <Code {...props} />;
};
export default SourceCode;
其次,写脚本注入了以下模板:
markdown
```jsx
/**
* inline: false
*/
import { ThingjsExample } from 'thingjs-docs';
export default () => {
const content = `<%- replacedCode -%>`;
return <ThingjsExample scriptContent={content} scriptId="<%= scriptId %>" />;
};
```
```ts | #<%= scriptId %>
<%- code -%>
```
最终生成了这样的文件:
markdown
```jsx
/**
* inline: false
*/
import { ThingjsExample } from 'thingjs-docs';
export default () => {
const content = `
const app = new THING.App();
await app.load("./assets/scenes/campus/campus.gltf");
`;
return <ThingjsExample scriptContent={content} scriptId="THINGJS_SCRIPT_52" />;
};
```
```ts | #THINGJS_SCRIPT_52
const app = new THING.App();
await app.load("./assets/scenes/campus/campus.gltf");
```
这样,下方的代码块就有了id
了。
再在jsx
中将它移动到右侧代码块:
tsx
useEffect(() => {
if (!scriptId) {
return;
}
const script = document.getElementById(scriptId) as HTMLDivElement;
if (!script) {
console.warn(`script with id ${scriptId} not found`);
return;
}
codeRef.current!.appendChild(script);
}, []);
搞复杂了!这圈子绕的,有些远了啊!
总结
本文详细介绍了我在使用 dumi 展现3D示例过程中遇到的挑战和解决方案。
我们的文档网站以Markdown为主,没有动态内容,API和示例部分使用iframe
嵌套,需要优化搜索功能。这时,我们有三种方案处理:
- 现有功能 +
Elastic Search
(ES)优化。 - 现有功能 +
algolia
等第三方搜索服务。 - 使用
dumi
的纯前端方案。
最终选择了第三种方案,因为 dumi
有以下优点:
- 全文搜索,省去了后端服务。
- UI 设计更美观,支持响应式布局、暗黑模式、多语言等
- 可扩展 Markdown 的自定义组件。
但在实际调研过程中,遇到了示例部分的挑战。
- 示例部分需要实现左右布局,但还要兼顾代码块的全文搜索。
dumi
搜索仅包含静态 Markdown 部分,动态 React 组件无法被搜索。
这时衍生了以下解决方案:
- 魔改 dumi 源码 Hook,想办法将代码块动态注入搜索源。缺点是需要注入研究dumi搜索机制,较为复杂。
- 将代码块批量生成Markdown文件,上部分是React组件,呈现3D效果,下部分是代码块。缺点是只有上下布局。
- 在第二个方案基础上,上部分组件同时呈现3D和代码,添加元属性隐藏掉下方的代码块,仅供搜索使用。
- 将示例组件的代码块乾坤大挪移,上部分仍是左右布局,但右侧是空的div,在组件挂载后,将下方的代码块移动到右侧。这种方法简单而有效,它既复用了
dumi
的现有组件,同时保证了搜索功能的完整性。
虽然最终的方案看起来平平无奇,但不失为一种有趣的思路。希望能对大家有所启发!