大家好,我是AY。
笔者折腾了Wiki 的 Markdown 大纲视图,整个过程基本就是从"一头雾水"到"强行破局",接下来记录下我充满艰辛的开发路程。
一、寻找 Markdown 的"真身"
刚开始做的时候,我一直在想 Markdown 大纲的核心本质到底是什么?
说白了就是提取、转化、渲染,这么三步走的一个数据流!
- 提取(Extraction) :从非结构化的 Markdown 或 Block 积木中识别标题。
- 转化(Transformation) :将积木转换为包含
id、level、text的结构化 JSON 数组。 - 渲染(Rendering) :将数组映射为 UI 组件,通过
level决定缩进。
但我首先得看懂现在的项目是怎么把 Markdown 长出来的------我看 Wiki 页面用了 Ant Design X 的 AI 组件,里面嵌套了 Markdown 解析器,我想从解析器入手看它是怎么渲染大纲的,结果发现我还没看明白,也许逻辑藏得太深...那么,我就简单粗暴地去找 F12 控制台,直接去 Source 里面溯源。
结果发现根源在 node_modules 一个灰色块里------这说明它是个三方组件库。
我顺着 DevTools 往上摸,盯着组件树一层层找,看 render 里的配置。
我心想,配置里一定有一个状态(State)储存着我们的 Wiki 文本内容,不然这个编辑区它到底是怎么长出来的?
二、深入 BlockNote:静态看不懂,就看动态
摸到最后发现,这玩意儿不是简单的纯文本渲染,而是用了 BlockNote ------ 跟 Notion 类似,是块级编辑器。
我想搞清楚数据到底在哪,就去翻代码里的 page detail(从服务器数据库拿出来的整篇数据),然后去读 BlockNote 的 Editor API。
我发现我其实看不太明白,尤其是 editor.document 这个属性返回的数据结构,去看那个 document entity 的字段定义时,感觉自己有点迷失。
我看静态类型的定义搞不清楚,那我就要去学会看动态运行的数据。
我直接在配置里加了一段调试代码:
scss
useEffect(() => {
if (editor) {
// 强制把 editor 挂在 window 对象上,管理员直接在控制台随时调遣
(window as any).myEditor = editor;
console.log("✅ 管理员已就位,请前往控制台输入 myEditor 检查");
}
}, [editor]);
这一按回车,在控制台输入 myEditor.document,数组全展开了。我盯着看它的 type、props 还有 level。
发现 type 是 heading 的就是标题,而第三个非标题的 type 是 paragraph,也就是普通的文段,它确实没有 level 这个属性。
最关键的发现是 content:标题的文字内容不是一个简单的字符串,而是一个数组------所以我后面不能直接拿来用。
根据 BlockNote 官方文档定义,文档的核心是 Block 对象 。我在控制台展开 myEditor.document 后,验证了其标准的 Block Schema:
-
Block 类型 :每个块都有一个
type字段。大纲只关注type: "heading"。 -
Props 属性 :标题的级别(H1/H2/H3)存储在块的
props对象中,定义为props: { level: number }。 -
InlineContent 数组 :这是最核心的发现。官方文档指出,BlockNote 的内容并非
string,而是InlineContent[]。定义:
InlineContent可以是StyledText(带样式的文本)或Link(链接)。这意味着提取标题时,必须遍历这个数组并拼接所有text节点。
三、架构方案:为什么必须实时?
关于这个大纲的数据源,我一开始没有弄清楚,以为要从后端获取。
但仔细一想逻辑不对:
大纲必须得是用户输入、内存状态更新,然后立刻触发 UI 渲染,大纲和编辑器的文字要同步变。
这种同步逻辑必须从当前编辑器的实时状态中提取,因为它是最快的,也是最真实的。如果非要走后端,那你在 Wiki 里打一个字,都要等数据传到服务器、存进数据库、再推回给大纲,中间那几百毫秒甚至几秒的延迟,会让大纲的操作感非常卡顿。
****即,大纲必须实现强实时性 (Real-time Synchronization) 。 若将逻辑放在后端,会产生明显的网络延迟 (Latency) 。因此,我选择监听编辑器的 Editor Snapshot(编辑器快照) 。这是最真实的内存数据源,能保证用户每输入一个字符,大纲都能毫秒级地响应。
四、逻辑拆解:监听、过滤、结构化
我想清楚了大纲实现的三个核心动作:
-
监听 :怎么感知用户敲了键盘?我去翻 BlockNote 里面有没有内容改变的 Hook(钩子)------那就是调用
editor.onChange钩子。 -
过滤 :编辑器里可能有图片、段落,大纲却只要文字。对策是:我写了个
filter,专门根据type === 'heading'进行过滤,把正文部分全部扔掉。 -
结构化:
- 处理层级 :H1、H2、H3 怎么体现?我发现几级标题就藏在
props.level里面,这就是我区分标题级别、显示缩进的依据。 - 文字合并 :因为
content是数组,我得用 MDN 里的Array.prototype.map映射出文字,再用join('')把它们连成一串。 - UI 渲染 :缩进我打算直接在
div里写个公式,把level数值乘上一定的像素(比如 12px 或 20px),CSS 的魔法计算就不打算写成函数了,直接内联。
- 处理层级 :H1、H2、H3 怎么体现?我发现几级标题就藏在
五、性能优化:useMemo 的"洗豆子"理论
做的时候我发现一个很严重的问题:
如果不做优化,我每输入一个字符,控制台就显示整个页面被重新渲染一遍。
我又去补充学习了一下 memo 和 useMemo。``
简单来说,memo 是跳过组件渲染,而 useMemo 是缓存计算结果。
- useMemo (计算缓存) :大纲提取就像从万颗豆子里挑出黄豆。
useMemo确保只有在editor.document这个依赖项真正改变时,才重新执行复杂的过滤和拼接逻辑。 - memo (组件记忆) :将大纲 UI 封装在
React.memo中,防止父组件其他状态更新引发大纲的无效刷新。
我的"洗豆子"理论: 如果你有 1 万个 Block,那 filter 和 map 就像是从 1 万个豆子里挑出黄豆再洗干净。如果没有 useMemo,你每次打一个空格,React 就要重新挑一次、重新洗一次。但 useMemo 能让这一万次循环只在 editor.document 真正改变时才发生,减少了巨大的性能开销。
六、交互与布局的"临门一脚"
最后是大纲作为侧边栏的弹出与布局。
我参考了 项目已有的 Chat 页面的三栏布局,但它是完美的 Splitter 组件库。我们 Wiki 的边栏需要自己布局,我看了下父子组件都是 Flex。
我决定在父组件里加一个 useState 的 Hook 来控制大纲"这扇门"的开关,并把整个提取逻辑写进去。
可是,还有一个交互大坑:BlockNote 默认只会移光标,不会滚动页面。
文档长了,光标跑到底部去了,浏览器视口却不动。点击大纲跳转的逻辑我决定写在主页面(大管家)里,因为它同时拿着 Editor 实例和大纲组件。 我最后用了 DOM 修正。
为了防止滚动到顶时挡住标题,我还在内联 CSS 里加了高度限制(偏移量),并配合一个 setTimeout 定时器。
由于 Wiki 的 Header 是 Sticky (粘性布局) 的,直接跳转会导致标题被遮挡。
CSS 的 Scroll Margin 属性硬核修正:
css
[data-id] { scroll-margin-top: 80px; } /* 预留出 Header 的高度 */
虽然不知道是不是最好的方案,但它能规避原生滚动"弹一下"的抖动感,让视觉效果更丝滑。
七、学习防抖 (Debounce) 与 异步更新
原本我没有看懂配合的一个防抖,但确实稳了很多。统统通过这个小项目补上,坚强的菜鸟学习中!
防抖 (Debounce):高频状态更新的"节流阀"
在 BlockNote 这种高度响应式的编辑器中,用户的每一次击键都会触发 onChange 事件。如果文档包含数千个节点,不加干预的实时提取会导致 CPU 在用户输入时陷入持续的"遍历-计算-重绘"死循环。
防抖机制本质上是一个基于计时器的事件合并方案。
它通过 useMemo 维持一个单例的计时器,当连续的 onChange 触发时,旧的计时器被不断销毁,只有当用户停止输入并超过 300ms 的阈值后,才会真正触发那一版"最真实"的大纲提取逻辑。这不仅大幅降低了计算开销,还规避了 UI 频繁闪烁的问题。
ini
// 伪代码实现逻辑
const debouncedUpdate = useMemo(() => {
return debounce(() => {
// 只有在用户"停手"后的间隙,才去执行昂贵的过滤与拼接逻辑
const snapshot = editor.document;
const headings = filterHeadings(snapshot);
setOutline(headings);
}, 300);
}, [editor]);
异步调度 (Async Scheduling):跳转逻辑的"缓冲带"
在处理大纲跳转时,存在一个关键的交互冲突 :编辑器的 setTextCursorPosition 会触发内部的焦点切换与视图定位,而我们自定义的 scrollIntoView 也在尝试改变滚动位置。如果这两者同步发生,浏览器会因为竞争控制权而产生一种"弹一下"的抖动感,甚至定位失败。
这个利用了任务队列(Task Queue) 的异步特性。
通过微任务或延迟调度,先让编辑器完成光标定位和状态更新,待 DOM 稳定后,再执行平滑滚动逻辑。这种"先定光标,后滚视图"的先后顺序,配合 CSS 的 scroll-margin-top 偏移量,完美消除了原生滚动冲突导致的视觉抖动。
javascript
// 伪代码实现逻辑
onItemClick = (id) => {
// 1. 同步执行:先告诉编辑器光标在哪,它会处理自己的内部逻辑
editor.focus();
editor.setTextCursorPosition(id, 'start');
// 2. 异步调度:将滚动任务推入下一轮任务循环,确保 DOM 已响应光标状态
setTimeout(() => {
const el = document.querySelector(`[data-id="${id}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 0);
};
啥意思呢?其实就是说:防抖负责"省 CPU ",异步调度负责"稳 UI"。
⭐小结
虽然只是这么一个小功能,但我在逐步尝试脱离AI喂代码,自己去思考代码这样写是否稳定,数据从哪里来,又应该到哪里去。
我不敢说这样一个小功能有多少难点,但我可以非常确定这样的学习方式对我有很大的成长:我学着去看一个完全陌生的BlockNote的API,也接触到当前AI开发场景下的组件库antd X;对于一个好的前端开发者来说,AI不是来杀死前端的,而是在提供更加新的开发场景。(不过当然,过去的我太依赖ai开发,没有自己的反思,绝对是无法在ai强大的编程能力下存活的,哈哈哈哈...)
前端开发所需要的技术素养实在是太多,单独拎出来一个浏览器视口为什么不随光标移动,也就可以深入去学浏览器的默认行为,底层原理;不单单是demo能用,还要模拟如果上线真实投入使用用户会如何操作?高频更新的顾虑,考虑怎么防抖,等等。保持我现有的耐心和热情,一次又一次的bug和debug都是学习机会,即使我只是做了一个这么小的功能。
限于个人经验,文中若有疏漏,还请不吝赐教。
参考文献:
Using Pro - Marked Documentation
BlockNote - Manipulating Blocks
BlockNote - Real-time Collaboration
Array.prototype.map() - JavaScript | MDN
Array.prototype.join() - JavaScript | MDN