Wiki 开发日记:学做 Markdown 大纲视图

大家好,我是AY。

笔者折腾了Wiki 的 Markdown 大纲视图,整个过程基本就是从"一头雾水"到"强行破局",接下来记录下我充满艰辛的开发路程。

一、寻找 Markdown 的"真身"

刚开始做的时候,我一直在想 Markdown 大纲的核心本质到底是什么?

说白了就是提取、转化、渲染,这么三步走的一个数据流!

  1. 提取(Extraction) :从非结构化的 Markdown 或 Block 积木中识别标题。
  2. 转化(Transformation) :将积木转换为包含 idleveltext 的结构化 JSON 数组。
  3. 渲染(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,数组全展开了。我盯着看它的 typeprops 还有 level

发现 typeheading 的就是标题,而第三个非标题的 typeparagraph,也就是普通的文段,它确实没有 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 的魔法计算就不打算写成函数了,直接内联。

五、性能优化:useMemo 的"洗豆子"理论

做的时候我发现一个很严重的问题:

如果不做优化,我每输入一个字符,控制台就显示整个页面被重新渲染一遍。

我又去补充学习了一下 memouseMemo。``

简单来说,memo 是跳过组件渲染,而 useMemo 是缓存计算结果。

  • useMemo (计算缓存) :大纲提取就像从万颗豆子里挑出黄豆。useMemo 确保只有在 editor.document 这个依赖项真正改变时,才重新执行复杂的过滤和拼接逻辑。
  • memo (组件记忆) :将大纲 UI 封装在 React.memo 中,防止父组件其他状态更新引发大纲的无效刷新。

我的"洗豆子"理论: 如果你有 1 万个 Block,那 filtermap 就像是从 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都是学习机会,即使我只是做了一个这么小的功能。

限于个人经验,文中若有疏漏,还请不吝赐教。

参考文献:

介绍 - Ant Design X

总览 - Ant Design X

Using Pro - Marked Documentation

BlockNote - Manipulating Blocks

BlockNote - Introduction

BlockNote - Events

BlockNote - Real-time Collaboration

Array.prototype.map() - JavaScript | MDN

Array.prototype.join() - JavaScript | MDN

将 Props 传递给组件 -- React 中文文档

BlockNote - Overview

BlockNote - Cursor & Selections

useMemo -- React 中文文档

memo -- React 中文文档

Element:scrollIntoView() 方法 - Web API | MDN

相关推荐
mCell5 小时前
如何零成本搭建个人站点
前端·程序员·github
mCell6 小时前
为什么 Memo Code 先做 CLI:以及终端输入框到底有多难搞
前端·设计模式·agent
恋猫de小郭6 小时前
AI 在提高你工作效率的同时,也一直在增加你的疲惫和焦虑
前端·人工智能·ai编程
少云清6 小时前
【安全测试】2_客户端脚本安全测试 _XSS和CSRF
前端·xss·csrf
银烛木7 小时前
黑马程序员前端h5+css3
前端·css·css3
m0_607076607 小时前
CSS3 转换,快手前端面试经验,隔壁都馋哭了
前端·面试·css3
听海边涛声7 小时前
CSS3 图片模糊处理
前端·css·css3
IT、木易7 小时前
css3 backdrop-filter 在移动端 Safari 上导致渲染性能急剧下降的优化方案有哪些?
前端·css3·safari
0思必得07 小时前
[Web自动化] Selenium无头模式
前端·爬虫·selenium·自动化·web自动化
anOnion7 小时前
构建无障碍组件之Dialog Pattern
前端·html·交互设计