基于 Markdown-It 的无序列表折叠插件

📅 2026/7/6 14:18:52 👁️ 阅读次数 📝 编程学习
基于 Markdown-It 的无序列表折叠插件

。目前来看,同时需要面向开发者和AI的状态应该还需要存在较长的时间,因此实现一套Md渲染器还是有必要的。

解析规则#

首先我们需要分析无序列表结构及其解析后的HTML,基本的无序列表结构如下所示:

Copy

- 0 - 1 - 1.1 - 1.2 - 1.2.1 - 1.2.2 - 1.3 with desc - 1.3.1 - 1.3.2 - 2

Copy

<ul> <li>0</li> <li> 1 <ul> <li>1.1</li> <li> 1.2 <ul> <li>1.2.1</li> <li>1.2.2</li> </ul> </li> <li> 1.3 <br /> with desc <ul> <li>1.3.1</li> <li>1.3.2</li> </ul> </li> </ul> </li> <li>2</li> </ul>

可以看出示例中存在三级ul元素结构嵌套,以及描述内容的li元素,我们需要根据不同的情况来解析。理论上而言,只有存在嵌套结构的li元素才需要解析为折叠结构,其子元素内起始到ul之间的内容需要作为标题,ul内元素则作为折叠展开的内容。

通常来说,实现类似手风琴的效果,大概会主动管理状态,用div等元素来绘制折叠面板,然后主动处理点击事件,来切换折叠展开的状态。不过,HTML原生支持了details元素以及summary元素,我们可以借助原生元素来实现折叠列表的效果,其主要优点是:

  • 简单易用,通常情况下不需要主动管理状态,仅需要维护DOM结构。
  • 无需处理事件,特别是在SSR的情况下,不需要再hydrate注入事件。
  • 原生支持搜索,使用浏览器搜索时,可以自动展开包含搜索关键词的折叠列表。

Copy

<details> <summary>Details</summary> Something more. </details>

那么根据以上的HTML结构,我们可以根据无序列表的结构,转换为details+summary元素的结构。观察其结构,我们可以实现如下转换规则:

  • ul元素作为折叠展开的内容,这里可以自定义为block元素,也可以保持ul元素。
  • li元素内存在嵌套的直属ul元素时,该li元素需要转换为details元素。
  • 转换的details元素的子元素,从起始到ul元素之间的内容,需要包装summary元素。

根据上述的转换规则,我们可以将最开始的无序列表HTML内容转换为details + summary元素的结构:

1

        • 渲染示例
        • 2

        Copy

        <ul> <li>渲染示例</li> <details> <summary>1</summary> <ul> <li>1.1</li> <details> <summary>1.2</summary> <ul> <li>1.2.1</li> <li>1.2.2</li> </ul> </details> <details> <summary>1.3 <br /> with desc</summary> <ul> <li>1.3.1</li> <li>1.3.2</li> </ul> </details> </ul> </details> <li>2</li> </ul>

        元素重建#

        在设计好HTML结构的转换规则后,我们需要在MarkdownIt的基础上实现转换逻辑。在MdIt中提供了诸多时机的Hook函数,我们需要根据处理的时机来实现转换逻辑,通常来说应该尽可能在后处理阶段来实现相关逻辑,这里我们分别实现解析后处理和渲染时处理。

        渲染时处理#

        因此,我们首先来看仅渲染阶段的rule处理逻辑,在上述的转换规则中,将ul元素转换为block元素,以及将li元素渲染为details元素,这两点是没什么问题的。然而,为子节点包装summary元素,则是比较麻烦的。

        在仅渲染阶段,这件事并非不能实现,但是却容易破坏MdIt的线性解析模式。如果这是个递归结构,则仅需要将其节点包一层DOM元素即可,而在线性结构中,包装一层summary元素需要在li_open追加<summary>元素,在ul_open前置</summary>元素。

        Copy

        mdIt.renderer.rules.bullet_list_open = (tokens: Token[], idx: number) => { const current = tokens[idx]; for (let i = idx - 1; i >= 0; i--) { const token = tokens[i]; if (token.level < current.level - 1) break; if (token.type === "list_item_open" && token.level === current.level - 1) { return "</summary>" + "<ul class=\"bullet-summary-group\">"; } } return "<ul class=\"bullet-summary-group\">"; }; mdIt.renderer.rules.list_item_open = (tokens: Token[], idx: number) => { const current = tokens[idx]; for (let i = idx + 1; i < tokens.length; i++) { const token = tokens[i]; if (token.level <= current.level) break; if (token.type === "bullet_list_open" && token.level === current.level + 1) { return "<details>" + "<summary>"; } } return "<li>"; }; mdIt.renderer.rules.list_item_close = (tokens: Token[], idx: number) => { const prevToken = tokens[idx - 1]; if (prevToken && prevToken.tag === "ul") return "</details>"; return "</li>"; };

        虽然这种模式实现起来简单,理论上也并没有什么问题。然而这里存在的问题是,如果我们需要判断大多情况下保持无序列表,仅表达API参数时才将其渲染为折叠列表,那么此时我们在ul元素上方添加@bullet-summary指令来指定渲染模式。

        Copy

        @bullet-summary - ul - li - li

        那么此时问题在于,如何判断现在现在嵌入的ul元素需要渲染为折叠列表。那么在渲染时机,取得这个渲染指令并不是很容易,因为其本身是扁平的,那么每次调度rule渲染时,都需要迭代向上查找该指令。而如果在渲染时处理p元素的话,则在消费时实现写数据,有点反逻辑。

        Copy

        mdIt.renderer.rules.list_item_open = (tokens: Token[], idx: number) => { const current = tokens[idx]; for (let i = idx - 1; i >= 0; i--) { // 找到该组顶级 ul 元素, 检查其前置 @bullet-summary 指令 } }; mdIt.renderer.rules.bullet_list_open = (tokens: Token[], idx: number) => { // 检查其前置元素是否为 @bullet-summary 指令, 此时在 env 设置变量 }; mdIt.renderer.rules.bullet_list_close = (tokens: Token[], idx: number) => { // 检查其匹配的 ul env 设置的环境变量, 此时在 env 清理环境变量 };

        解析时处理#

        MdIt的解析过程中,除了渲染时的rule处理逻辑,还可以在解析阶段后处理Token,此时可以找到相关指令再实现相关的转换逻辑。由于我们并不没有额外实现新的语法,指令更多是起到了标记的作用,因此不需要时机解析内容,而是重新组织Tokens

        那么此时,我们先来判断一下指令标记,如果匹配到了该标记,则需要进入到重建Tokens的阶段。不过在此之前,我们需要将该指令节点隐藏,不过如果渲染指令是注释类型的话,倒是可以直接隐藏而无需特殊处理。

        Copy

        // paragraph_open // inline: @bullet-summary // paragraph_close // bullet_list_open if ( token.content === identifier && token.type === "inline" && nextToken && nextStep2Token && nextToken.type === "paragraph_close" && nextStep2Token.type === "bullet_list_open" ) { prevToken && (prevToken.hidden = true); (token.hidden = true) && (token.children = []); nextToken && (nextToken.hidden = true); rebuildUlTokens(state, i + 2); }

        紧接着,我们需要找到该节点的对应close节点,以此来圈定具体需要处理的范围。说起来,由于MdIt的解析是线性的,虽然规避了递归的问题,但是最差情况下时间复杂度还是O(n)。此外,由于token.level并不太准确,因此还需要维护一个栈深度来记录当前的层级。

        Copy

        const baseType = openToken.type.slice(0, -5); const closeType = baseType + "_close"; // open 1 // start iterator // open 2 // close 1 // close 0 // end iterator let level = 1; for (let i = openIdx + 1; i < tokens.length; i++) { const token = tokens[i]; if (token.type === openToken.type) { level++; } else if (token.type === closeType) { level--; if (level <= 0) return i; } } return -1;

        接下来,需要对ul元素做一些修改,主要是为ul加入class属性,用以指定样式。然后维护一个栈,来记录li元素相互对应的节点。此外,这里有个重要的点是要从后向前遍历,以免前置内容的修改影响后续节点的处理,特别是在插入元素的情况下。

        Copy

        const stack: Token[] = []; // 从后向前遍历, 避免修改后, 影响后续 i 遍历 for (let i = closeIdx; i >= startIdx; i--) { const token = tokens[i]; if (token.type === "bullet_list_open") { token.attrJoin("class", "bullet-summary-group"); } if (token.type === "list_item_close") { stack.push(token); } if (token.type === "list_item_open") { const peer = stack.pop(); rebuildLiTokens(state, i, peer, actions); } }

        在匹配到list_item_open节点时,就需要重建li元素结构了,这部分就会更复杂一些。首先我们创建一个对应元素区域的迭代器,来遍历openclose之间的所有节点。迭代器中重要的实现是要携带相关的meta信息,辅助计算层级关系。

        Copy

        let depth = 0; for (let i = openIdx; i < tokens.length; i++) { const token = tokens[i]; if (token.nesting >= 0) { depth++; } yield { token, depth: depth - 1, idx: i, serial: i - openIdx }; if (token.nesting <= 0) { depth--; if (depth <= 0) break; } }

        li节点区域遍历过程中,我们需要根据depth来判断其直属子元素。如果直属子元素为ul,则代表该li元素嵌套了无序列表,这样就需要将其转换为details元素。注意,这里修改其type不应该影响外层的栈,需要注意保持关系正确。

        Copy

        // 查找 li 下的子项, 主要目的是检查其直属子元素 for (const node of walker) { const k = node.idx; const tokenK = node.token; if (node.depth !== 1) continue; // 直属的 ul 子项, 若是存在则需要转换为 details 组 if (tokenK.type === "bullet_list_open") { // i 的 li 元素需要变为 details 元素 liToken.type = "li_details_open"; liToken.tag = "details"; } }

        接下来,我们需要为i - k之间的元素创建summary元素,用以指定折叠标题。这里是最难以处理的点,因为不仅是修改内容,还需要插入新的token。并且需要对其peer节点进行处理,将其token.type转换为li_details_close元素。

        Copy

        // 为 i - k 之间的元素创建 summary const sOpen = new state.Token("li_summary_open", "summary", 1); const sClose = new state.Token("li_summary_close", "summary", -1); // 现在 peer 是 i 之后的元素, 不会影响原始遍历 li 的栈平衡 if (peer) { peer.type = "li_details_close"; peer.tag = "details"; } // 处理 summary 元素的插入位置 actions.push({ idx: openIdx + 1, token: sOpen }); actions.push({ idx: k, token: sClose });

        上述的actions是需要关注的点,我们并不会直接修改tokens数组,因为此时修改tokens数组会导致其长度发生变化,从而影响到后续节点的遍历,以及插入位置的计算。在这里我们统一处理插入行为,这里需要关注的是按索引从大到小排序, 后索引的元素, 不影响前索引的元素。

        Copy

        actions .sort((a, b) => b.idx - a.idx) .forEach(action => { tokens.splice(action.idx, 0, action.token); });

        最后,由于我们插入了新的层级,我们需要将内部的level也更新一下。因此从这里也可以看出来level并不是那么准确,如果注册的插件并没有处理好level的话,则会影响到后续依赖该字段的插件。

        Copy

        // 处理 summary 及其内部元素的 level sOpen.level = liToken.level + 1; sClose.level = liToken.level + 1; for (let i = openIdx + 1; i < k; i++) { const token = tokens[i]; token.level = (token.level || 0) + 1; }

        CSS 样式#

        实际上,由于不同浏览器的details + summary元素的默认样式不同,因此需要对其样式进行统一化处理。不过,这部分主要是由组件库来实现的,我们只需要关注其基本功能即可。此外提一下,summary还是需要一个border样式的,特别是存在多行内容的情况下。

        Copy

        /* * Add the correct display in Edge, IE 10+, and Firefox. */ details { display: block; } /* * Add the correct display in all browsers. */ summary { display: list-item; }

        总结#

        在这里我们基于MdIt,解析了基础的无序列表结构,并且观察了其层级关系,设计出了一套DOM结构转换规则。基于此分别使用纯渲染模式以及解析后处理模式,实现了无序列表折叠插件,这种结构表达在思维导图和API参数表达中非常有用。

        实际上,我们实现的插件还有很多可以优化的地方。首先我们可以将结构化表达和渲染时表达结合起来,在解析后处理时仅需要将需要相关token写入标记,在渲染时处理标签结构即可。此外,结构处理写入的时候实际上应该将所有