。目前来看同时需要面向开发者和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 - 2Copyul li0/li li 1 ul li1.1/li li 1.2 ul li1.2.1/li li1.2.2/li /ul /li li 1.3 br / with desc ul li1.3.1/li li1.3.2/li /ul /li /ul /li li2/li /ul可以看出示例中存在三级ul元素结构嵌套以及描述内容的li元素我们需要根据不同的情况来解析。理论上而言只有存在嵌套结构的li元素才需要解析为折叠结构其子元素内起始到ul之间的内容需要作为标题ul内元素则作为折叠展开的内容。通常来说实现类似手风琴的效果大概会主动管理状态用div等元素来绘制折叠面板然后主动处理点击事件来切换折叠展开的状态。不过HTML原生支持了details元素以及summary元素我们可以借助原生元素来实现折叠列表的效果其主要优点是:简单易用通常情况下不需要主动管理状态仅需要维护DOM结构。无需处理事件特别是在SSR的情况下不需要再hydrate注入事件。原生支持搜索使用浏览器搜索时可以自动展开包含搜索关键词的折叠列表。Copydetails summaryDetails/summary Something more. /details那么根据以上的HTML结构我们可以根据无序列表的结构转换为detailssummary元素的结构。观察其结构我们可以实现如下转换规则:ul元素作为折叠展开的内容这里可以自定义为block元素也可以保持ul元素。当li元素内存在嵌套的直属ul元素时该li元素需要转换为details元素。转换的details元素的子元素从起始到ul元素之间的内容需要包装summary元素。根据上述的转换规则我们可以将最开始的无序列表HTML内容转换为details summary元素的结构:1渲染示例2Copyul li渲染示例/li details summary1/summary ul li1.1/li details summary1.2/summary ul li1.2.1/li li1.2.2/li /ul /details details summary1.3 br / with desc/summary ul li1.3.1/li li1.3.2/li /ul /details /ul /details li2/li /ul元素重建#在设计好HTML结构的转换规则后我们需要在MarkdownIt的基础上实现转换逻辑。在MdIt中提供了诸多时机的Hook函数我们需要根据处理的时机来实现转换逻辑通常来说应该尽可能在后处理阶段来实现相关逻辑这里我们分别实现解析后处理和渲染时处理。渲染时处理#因此我们首先来看仅渲染阶段的rule处理逻辑在上述的转换规则中将ul元素转换为block元素以及将li元素渲染为details元素这两点是没什么问题的。然而为子节点包装summary元素则是比较麻烦的。在仅渲染阶段这件事并非不能实现但是却容易破坏MdIt的线性解析模式。如果这是个递归结构则仅需要将其节点包一层DOM元素即可而在线性结构中包装一层summary元素需要在li_open追加summary元素在ul_open前置/summary元素。CopymdIt.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指令来指定渲染模式。Copybullet-summary - ul - li - li那么此时问题在于如何判断现在现在嵌入的ul元素需要渲染为折叠列表。那么在渲染时机取得这个渲染指令并不是很容易因为其本身是扁平的那么每次调度rule渲染时都需要迭代向上查找该指令。而如果在渲染时处理p元素的话则在消费时实现写数据有点反逻辑。CopymdIt.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并不太准确因此还需要维护一个栈深度来记录当前的层级。Copyconst 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元素相互对应的节点。此外这里有个重要的点是要从后向前遍历以免前置内容的修改影响后续节点的处理特别是在插入元素的情况下。Copyconst 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元素结构了这部分就会更复杂一些。首先我们创建一个对应元素区域的迭代器来遍历open到close之间的所有节点。迭代器中重要的实现是要携带相关的meta信息辅助计算层级关系。Copylet 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数组会导致其长度发生变化从而影响到后续节点的遍历以及插入位置的计算。在这里我们统一处理插入行为这里需要关注的是按索引从大到小排序, 后索引的元素, 不影响前索引的元素。Copyactions .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写入标记在渲染时处理标签结构即可。此外结构处理写入的时候实际上应该将所有