资讯详情 拆解 md-editor-v3 Markdown 渲染预处理机制
📅 2026/11/2 16:57:50
md-editor-v3 这个 Vue 3 的 Markdown 编辑器我前后在四个项目里用过内部知识库、API 文档站、低代码平台的富文本备注框还有一个给运营同学用的活动页配置后台。用得越久越发现一件事真正决定它好不好用的并非编辑器本体而是从你敲下字符到屏幕上出现 HTML 之间那段“Markdown 渲染预处理机制”。同样一段 Markdown有人渲染出来目录错乱、代码高亮丢失、公式闪烁有人却丝滑得像原生文档站差别基本都藏在这条链路上。这篇就干一件事把这套预处理机制从源码结构到实战落地拆开讲。我会说清楚 md-editor-v3 在哪些环节允许你插手、每个环节的规则链顺序为什么不能乱、markdownItConfig和markdownItPlugins的执行时机差在哪、sanitize为什么必须卡在最后一道关口。适合两类人看一是已经在用这个库、但只会改v-model和主题的开发者二是想搞清楚 Markdown 渲染管线设计思路、准备自己造轮子的人。下面所有代码和参数都是我在真实项目里跑过的能直接抄。1. 先搞清楚 md-editor-v3 到底在哪些环节动你的 Markdown1.1 一条完整的渲染链路从敲下第一个字符到屏幕上出现 HTML很多人以为MdEditor v-modeltext /就是把字符串丢给v-html其实中间隔了至少六道工序。我按数据流顺序捋一遍这个顺序是理解后面所有配置项的前提。第一步编辑器侧。CodeMirror 6 负责输入modelValue通过update:modelValue往上抛。这一步只处理文本不碰渲染。第二步调度。组件的watch监听到值变化后触发内部的解析函数。这里有个细节值得注意md-editor-v3 对输入做了节流和“值相同则跳过”的判断所以你在onChange里连续快速输入时并不是每一次按键都会触发完整渲染这也是它在大文档场景下还能保持手感的原因之一。第三步Markdown 解析。核心是markdown-it实例的render()方法。这一步把纯文本切成 token 流再序列化成 HTML 字符串。所有插件、所有自定义规则都挂在这一步的某条规则链上。第四步后处理。render()吐出来的 HTML 字符串还要经过一层加工比如代码块的折叠包裹、外链的target属性、图片的懒加载标记。第五步消毒。如果开启了sanitize字符串会再过一遍白名单过滤把script、on*事件属性之类的危险内容削掉。第六步挂载。最终 HTML 通过v-html塞进预览容器此时浏览器才开始真正解析 DOM、加载图片、执行mermaid的异步渲染、处理katex的字体。这六步里能让你插手的地方集中在第三、四、五步。而“预处理”这个词在社区里被用得很泛有人指第二步之前的文本清洗有人指 markdown-it 的规则注入还有人指 HTML 出来之后的 DOM 修补。这三种其实对应三个完全不同的技术位置混着说就会踩坑。我见过一个典型翻车案例某同学想给所有二级标题加序号写了个正则在外层把## xxx替换成## 1. xxx结果代码块里恰好有一行以##开头的注释也被一起改了。这就是把“文本预处理”当成了“结构预处理”位置选错了。1.2 三个预处理切入点不是只有一个 preprocess我把 md-editor-v3 暴露的干预能力归成三层每一层的输入输出类型完全不同用错层就会出现“改了没效果”或者“效果溢出”的问题。层级切入方式输入输出适合做的事文本层外层包装v-model值 /watch后再赋值原始字符串原始字符串全角转半角、去除零宽字符、统一换行符语法层markdownItPlugins、markdownItConfig、core.rulerToken 流Token 流自定义容器、任务列表、标题锚点、链接协议改写输出层sanitize、onHtmlChanged、后置 DOM 处理HTML 字符串HTML 字符串白名单过滤、属性注入、外链处理这三层的执行顺序是固定的文本层最先语法层次之输出层最后。顺序反过来想都别想比如你想在输出层给标题加锚点那目录组件早就把标题提取完了锚点加了也白加。注意文本层最容易出问题。因为它是纯字符串操作没有任何语法上下文。任何“看起来像 Markdown 语法”的正则替换都有可能误伤代码块、行内代码、甚至链接地址里的内容。我个人的原则是——文本层只做“字符级”的清洗凡是涉及语义的一律下沉到语法层用 Token 处理。1.3 为什么这个顺序不能乱举个我自己踩过的例子。项目里需要把内部文档中的[[页面名]]双链语法转成带跳转的a。最开始我图省事在文本层用一个正则全局替换text text.replace(/\[\[(.?)\]\]/g, a href/wiki/$1$1/a);看着挺好跑起来就炸了。原因有两个一是如果[[...]]出现在代码块的字符串里会被替换成一堆 HTML代码高亮直接崩二是这个替换发生在markdown-it解析之前而默认配置里html: true插入的a会被当成内联 HTML 保留下来但一旦某个版本把html关掉整段就变成转义文本了。后来改成在语法层注册inline规则识别到[[开头的文本片段就生成link_opentextlink_close三个 token代码块里的内容因为根本不会进入inline规则链天然就安全了。这个改动让我彻底明白预处理的位置选择本质上是在选择“你能看到多少上下文”。文本层看到的是字符语法层看到的是结构输出层看到的是结果。2. 源码视角核心渲染模块是怎么串起来的2.1 markdown-it 实例的单例复用策略翻开源码你会发现md-editor-v3 并没有在每个组件实例里new MarkdownIt()而是在模块级别维护了一个解析器实例配合一套默认插件完成初始化。这个设计有好有坏值得展开说。好处很直接性能。markdown-it的构造过程要初始化四条规则链、注册几十条默认规则、加载插件单个实例的开销其实不小。如果页面里有三个编辑器、五个纯预览组件每个都新建实例首屏时间会明显被拖长。单例复用让这部分成本只付一次。另一个好处是配置一致性。你在 A 组件里通过markdownItConfig注册的规则B 组件天然就能用上不需要重复写。对于文档站这种“同一种语法在全站生效”的场景这是刚需。坏处也很明显状态污染。markdown-it的use()和ruler操作都是直接修改实例状态没有沙箱隔离。如果你在某个组件的onMounted里给md.core.ruler推了一条自定义规则而这条规则里用了组件闭包变量那么当这个组件卸载、另一个组件复用同一个实例时规则还在闭包变量却已经失效轻则报错重则渲染结果诡异。我的处理办法是所有规则注册都写成无状态的纯函数规则内部不引用任何组件作用域的东西需要参数就从env里取或者提前在规则外层把配置固化成常量。另外如果项目里确实需要“A 页面的语法和 B 页面不一样”我建议不要硬啃单例直接用MdPreview拆成独立构建产物或者把差异语法统一到一套规则里用前缀区分。// 反例规则里引用了组件状态 onMounted(() { mdInstance.core.ruler.push(bad_rule, (state) { // 这里的 this.prefix 组件卸载后就是 undefined state.tokens.forEach(t t.content this.prefix t.content); }); }); // 正例配置固化规则无状态 const PREFIX [内部]; function prefixRule(state: any) { state.tokens.forEach((t: any) { if (t.type inline) t.content ${PREFIX}${t.content}; }); } mdInstance.core.ruler.push(prefix_rule, prefixRule);2.2 core ruler 里的隐藏关卡markdown-it的core规则链是很多人忽略的地方但它恰恰是“文本级预处理”最优雅的落点。默认的 core 链顺序大致是normalize→block→inline→linkify→replacements→smartquotes→text_join。normalize是第一个执行的它干的事很朴素把\r\n和\r统一成\n把\0替换成替换字符。这意味着如果你想做“全局字符清洗”最合适的插入点是normalize之前md.core.ruler.before(normalize, clean_zero_width, (state) { state.src state.src.replace(/[\u200B-\u200D\uFEFF]/g, ); });这条规则会把零宽空格、零宽连接符、BOM 全部干掉。为什么需要这个因为从 Word、飞书、微信里复制出来的文本经常夹带这些看不见的字符它们会导致**加粗**这种语法识别失败——两个星号之间夹了个零宽字符正则就匹配不上了。这个坑我在三个项目里遇到过每次都是同一副症状用户说“我明明加粗了怎么没生效”。再往后一个关键节点是block和inline之间。如果你要改写的是“块级结构”比如把::: note变成一个带样式的 div应该在block之前插入规则如果要改写的是“行内内容”比如把张三变成 mention 标签就在inline之后、text_join之前动手。text_join是 core 链的最后一步它负责把相邻的文本 token 合并。这条规则执行完之后token 流就固化了再改就只能动content字段结构改不了。所以任何涉及“拆分或合并 token”的操作都必须排在text_join之前。我用错过一次把规则 push 到了text_join后面结果拆出来的两个 token 在渲染时又被合并回去了白忙活两小时。2.3 sanitize 与 postprocess 的先后关系这是整条链路里最容易被搞反的一环我专门画个文字版的顺序说明。正确的顺序是markdown-it render→代码块/图片等后处理→sanitize→v-html。sanitize必须放在最后理由很简单你所有的后处理都可能引入新的 HTML 片段如果先消毒再后处理等于给后处理开了一个不受监管的口子。举个具体场景你写了个后处理给所有img加上onerror做兜底图替换——这段逻辑如果跑在sanitize之后那这个onerror就永远进不了白名单校验等于自己给自己埋了个 XSS 入口。反过来sanitize也不能放太早。它是对 HTML 字符串做解析再序列化的如果你的后处理依赖原始字符串的格式比如用正则找precode classlanguage-xxx消毒过程可能把属性顺序、引号风格改掉正则就匹配不上了。我在项目里的做法是sanitize只做安全兜底业务性的属性注入全部收拢到markdownItConfig的 renderer 规则里。这样职责清晰也不依赖字符串格式。// 在 renderer 层注入属性而不是等 HTML 出来再改 md.renderer.rules.link_open (tokens, idx, options, env, self) { const href tokens[idx].attrGet(href) || ; if (/^https?:\/\//.test(href) !href.includes(location.host)) { tokens[idx].attrSet(target, _blank); tokens[idx].attrSet(rel, noopener noreferrer); } return self.renderToken(tokens, idx, options); };这段代码跑在render()内部输出的 HTML 天然带上了属性sanitize只要在配置里放行target和rel就行不需要事后补救。3. 预处理机制的关键细节与坑位3.1 文本归一化那些看不见的字符前面提到零宽字符这里再补几个真实场景。换行符混用。Windows 下编辑的文档带\r\n从某些系统导出的带\r纯文本编辑器保存的带\n。markdown-it的normalize会处理这个但如果你在它之前做了正则替换用^和$匹配行首行尾\r会让匹配失效。我的习惯是在最外层先做一次text.replace(/\r\n?/g, \n)把这个不确定性提前消掉。全角符号。中文输入法下很容易打出全角的、、、。这些问题在纯文本渲染时只是显示难看但在 Markdown 里会直接导致语法失效。## 标题打成 标题那就只是个普通段落。我在运营后台做过统计用户提交的 Markdown 里有大概 3% 存在全角符号污染。解决方式有两条路一是文本层做字符映射把常见的全角符号转成半角二是在语法层容错自定义规则识别全角标记。我倾向前者因为成本低且不影响正常语法。const FULL_WIDTH_MAP: Recordstring, string { : *, : #, : -, : , : |, : [, : ], : , : ~, }; function normalizeText(text: string) { return text .replace(/\r\n?/g, \n) .replace(/[\u200B-\u200D\uFEFF]/g, ) .replace(/[]/g, ch FULL_WIDTH_MAP[ch] || ch); }这段映射表是逐步补出来的每次线上有人反馈“我的表格没渲染成表格”我就往里加一条。现在稳定在九条基本覆盖了常见输入法的问题。提示映射表千万别加全角括号。中文正文里全角括号太常见了你把它转成半角会破坏正常文本的观感。链接语法用的是[]和()中括号可以转圆括号要慎重——我的做法是只在中括号已被识别为链接文本形态时才处理圆括号这就回到语法层了。3.2 插件加载顺序对输出的影响markdownItPlugins是一个函数数组按数组顺序依次执行。听起来很简单但顺序真的会改变结果。最典型的场景是“自定义容器”和“代码高亮”的组合。假设你要实现::: warning这样的告示块插件会在block规则里把整段内容包成一个container_warning的 token。如果你的插件排在代码高亮插件之前那告示块内部的代码就无法被高亮规则识别因为高亮规则看到的是一个未知的块级 token直接跳过了。反过来如果你的插件排在后面高亮先跑完内部的precode已经生成容器插件只需要做一层外壳包裹效果就正常。// 顺序很重要 const plugins [ codeHighlightPlugin, // 先跑把代码块处理完 containerPlugin, // 后跑只负责外壳 ];另一个坑是markdown-it-anchor和自定义标题规则的冲突。anchor插件会在heading_open上生成id属性如果你的插件也想给标题加属性两个插件都改同一个 token后执行的会覆盖先执行的。我遇到过标题id变成一个空字符串的情况最后通过attrSet前先判断attrGet(id)是否存在来解决。插件之间的耦合度越低越好这是我从这里学到的教训。每个插件只干一件事输出格式约定好组合起来才不会打成一团。3.3 标题 ID 生成与目录联动目录组件MdCatalog依赖标题的id来定位。而id是谁生成的是markdown-it-anchor。它的默认slugify策略对中文不太友好早期版本会把中文标题编码成%E4%B8%AD%E6%96%87这种形式地址栏里看着很别扭而且容易和别的锚点撞。我在文档站项目里统一换了一套slugifymd.use(anchor, { slugify: (s) encodeURIComponent( s.trim().toLowerCase().replace(/\s/g, -) ).replace(/%/g, ), permalink: false, });这个策略的效果是中文保留其转义后的形态但去掉百分号英文转小写加连字符。实测下来唯一的问题是不同标题可能生成相同的id所以还要加一层去重计数。去重逻辑本身也有坑。anchor插件的去重是基于“本次渲染内已出现的id集合”如果你的页面里同时挂了MdPreview和MdCatalog两者可能各渲染一次各自的去重集合互不相通导致目录里两个标题的id一样点一个跳到另一个。我的处理方式是给MdEditor显式指定id并保证整个页面里唯一。目录组件通过editorId关联这样两者的id空间就对齐了。MdEditor iddoc-main v-modeltext / MdCatalog editor-iddoc-main :scroll-elementscrollEl /还有一个细节MdCatalog的滚动定位依赖scrollElement。如果不传它会用document.documentElement或者最近的滚动父级。在某个后台项目里预览区被包在一个overflow: auto的div里没传scrollElement结果点击目录标题页面纹丝不动。这个问题的排查花了我一整个下午因为控制台不报错就是不动。后来发现scrollElement默认值是document.documentElement而真正的滚动容器是那个div。4. 实战搭一条能上生产的预处理管线4.1 需求梳理与方案取舍前面是理论这里给一个完整的实战案例。场景是内部文档平台需求有四条支持::: tip / warning / danger三种自定义告示块渲染成带配色的卡片把用户名渲染成带背景色的 mention 标签点击弹出用户卡片所有外链自动加target_blank和rel全站统一清洗零宽字符和全角符号。方案对比我先列个表需求可选方案我的选择理由告示块文本层正则替换 /block规则 / 输出层 DOM 处理block规则只有这一层能正确处理嵌套内容mention文本层正则 /inline规则 / 输出层替换inline规则需要避开代码块和链接内容外链属性renderer.rules/sanitize后处理 / 全局委托renderer.rules属性注入在源头不依赖字符串格式字符清洗外层包装 /core.ruler.before(normalize)外层包装 core 规则双保险外层处理粘贴core 处理程序赋值这里重点说第一条。告示块为什么必须用block规则因为它需要跨行包裹内容可能包含列表、代码块、嵌套告示块。文本层正则只能匹配到:::这一行管不了里面的内容输出层处理需要先解析 HTML 找边界成本高且脆弱。只有block规则能在 token 层面精确地把一段内容包成一个自定义块。4.2 代码实现从规则注册到组件接入先写告示块插件。核心思路是在block规则里识别:::开头、:::结尾的区间把中间内容递归解析然后用自定义的renderer输出。import type MarkdownIt from markdown-it; const CONTAINER_TYPES [tip, warning, danger] as const; type ContainerType typeof CONTAINER_TYPES[number]; export function containerPlugin(md: MarkdownIt) { function containerRule(state: any, startLine: number, endLine: number, silent: boolean) { const start state.bMarks[startLine] state.tShift[startLine]; const max state.eMarks[startLine]; const lineText state.src.slice(start, max).trim(); if (!lineText.startsWith(:::)) return false; const type lineText.slice(3).trim() as ContainerType; if (!CONTAINER_TYPES.includes(type)) return false; // 只在非 silent 模式下推进状态避免影响其他规则的探测 if (silent) return true; let nextLine startLine 1; let ended false; for (; nextLine endLine; nextLine) { const s state.bMarks[nextLine] state.tShift[nextLine]; const e state.eMarks[nextLine]; if (state.src.slice(s, e).trim() :::) { ended true; break; } } if (!ended) return false; const oldParent state.parentType; const oldLineMax state.lineMax; state.parentType container as any; state.lineMax nextLine; const tokenOpen state.push(container_open, div, 1); tokenOpen.block true; tokenOpen.info type; tokenOpen.map [startLine, nextLine]; state.md.block.tokenize(state, startLine 1, nextLine); const tokenClose state.push(container_close, div, -1); tokenClose.block true; state.parentType oldParent; state.lineMax oldLineMax; state.line nextLine 1; return true; } md.block.ruler.before(fence, container, containerRule, { alt: [paragraph, reference, blockquote], }); md.renderer.rules.container_open (tokens, idx) { const type tokens[idx].info; return div classdoc-container doc-container-${type}span classdoc-container-title${type.toUpperCase()}/span; }; md.renderer.rules.container_close () /div; }几个关键点说明。md.block.ruler.before(fence, ...)把规则插在fence围栏代码块之前这样代码块会先被识别:::不会误吞代码块内容。state.lineMax的临时改写是为了让内部tokenize只在我们划定的区间里工作处理完必须还原否则后续规则会读到错误边界。silent分支直接返回true不推进状态是因为markdown-it的块级规则会在“探测阶段”被反复调用推进状态会导致解析错位。然后是 mention 插件挂在inline上const MENTION_RE /([\u4e00-\u9fa5\w.-]{1,32})/g; export function mentionPlugin(md: MarkdownIt) { md.core.ruler.after(inline, mention, (state) { for (const blockToken of state.tokens) { if (blockToken.type ! inline) continue; const children blockToken.children || []; const next: any[] []; for (const child of children) { // 只处理纯文本节点代码、链接、HTML 内容一律跳过 if (child.type ! text) { next.push(child); continue; } const text child.content; let last 0; MENTION_RE.lastIndex 0; let m: RegExpExecArray | null; while ((m MENTION_RE.exec(text))) { if (m.index last) { const t new state.Token(text, , 0); t.content text.slice(last, m.index); next.push(t); } const open new state.Token(mention_open, span, 1); open.attrs [[class, doc-mention], [data-user, m[1]]]; next.push(open); const inner new state.Token(text, , 0); inner.content ${m[1]}; next.push(inner); next.push(new state.Token(mention_close, span, -1)); last m.index m[0].length; } if (last text.length) { const t new state.Token(text, , 0); t.content text.slice(last); next.push(t); } } blockToken.children next; } }); }这里用core.ruler.after(inline, ...)而不是inline.ruler是因为我要遍历整个 token 流、替换children数组属于“跨 token 的结构改写”在 core 层做更稳妥。注意只处理type text的子节点code_inline、link_open、html_inline全部原样保留这样就天然避开了代码块和链接内的误伤。外链处理用renderer.rules前面已经给过。最后是接入组件const markdownItPlugins [ containerPlugin, mentionPlugin, ]; const markdownItConfig (md: MarkdownIt) { md.renderer.rules.link_open (tokens, idx, options, env, self) { const href tokens[idx].attrGet(href) || ; if (/^https?:\/\//.test(href)) { tokens[idx].attrSet(target, _blank); tokens[idx].attrSet(rel, noopener noreferrer); } return self.renderToken(tokens, idx, options); }; }; const sanitize (html: string) DOMPurify.sanitize(html, { ADD_ATTR: [target, data-user], ADD_TAGS: [span], });template MdEditor iddoc-main v-modeltext :markdown-it-pluginsmarkdownItPlugins :markdown-it-configmarkdownItConfig :sanitizesanitize code-themeatom preview-themegithub / /templatesanitize这块特别提醒一句ADD_ATTR里必须把你自定义插件生成的所有属性都列上否则它们会在消毒环节被削掉。我第一次跑的时候mention 标签渲染出来是纯文本查了半天发现是>// 只在粘贴时清洗 const handlePaste (e: ClipboardEvent) { const raw e.clipboardData?.getData(text/plain) || ; const cleaned normalizeText(raw); if (cleaned ! raw) { e.preventDefault(); document.execCommand(insertText, false, cleaned); } };document.execCommand虽然被标了废弃但在这个场景下依然是跨浏览器最稳的插入方式。CodeMirror 6 的 API 也能做不过要拿到view实例得通过editorExtensions或者onMount拿引用绕一圈反而麻烦。5. 常见问题排查实录5.1 问题速查表这张表是我从项目 issue 记录里整理出来的按出现频率排序。现象高概率原因排查方向修复方式自定义 HTML 被转义成文本html选项被关掉检查markdownItConfig里是否改过md.set({ html: false })显式设为true同时用sanitize兜底插件加了但没生效插件数组没传给组件或传了但顺序被默认插件覆盖在插件函数里打console.log确认是否执行用markdownItPlugins传入注意执行顺序内容渲染出来但样式全丢忘引style.css检查入口文件import md-editor-v3/lib/style.css代码块没有高亮noHighlight或主题未设置检查 props去掉noHighlight设置codeTheme目录点击不跳转scrollElement指向错误检查预览区滚动容器显式传入实际的滚动父元素目录项重复或缺失标题id冲突检查是否有多个渲染实例给每个实例唯一id公式渲染两次或残留原文katex与自定义文本规则冲突检查是否有正则改了$符号避免在文本层碰$长文档输入卡顿全量重渲染定位耗时环节减少MdCatalog、避免每次输入跑全局正则生产环境白屏开发正常SSR 阶段访问了window查看构建日志把 DOM 相关逻辑放到onMounted5.2 三个让我熬过夜的坑第一个坑markdown-it实例的配置串味。项目里有两个页面A 页面需要把~~删除线~~关掉因为内部有别的语义B 页面需要保留。我在 A 页面的markdownItConfig里写了md.disable(strikethrough)切换过去测试正常。结果从 A 跳到 B 再跳回来B 页面的删除线也没了。原因就是前面说的单例。disable改的是实例状态切页面时实例没重建状态就带过去了。最后我的解法是不在运行时disable而是在规则层面做判断——给strikethrough规则加个env条件渲染时根据env.enableStrike决定是否生效。要传env得用md.render(text, env)而组件内部没暴露env参数所以还是绕不开。最终方案更干脆把 A 页面改成用一个独立的MdPreview构建语法配置在构建时固化和主实例彻底隔离。代价是包体积大了一点但维护成本低了不止一个量级。第二个坑sanitize把翻译功能干掉了。文档站接了个翻译需求用>const MdEditor defineAsyncComponent(() import(md-editor-v3).then(m m.MdEditor) );配合nuxt.config.ts里的ssr: false或者用ClientOnly包裹。这个改动的副作用是首屏需要等 JS 加载所以我加了个骨架屏过渡体验上勉强能接受。如果你也打算上 SSR我的建议是先把编辑器和预览拆开评估。如果只是展示文档MdPreview在服务端渲染的可行性比编辑器高得多如果确实要在服务端出 HTML那不如在构建时用markdown-it直接预渲染成静态 HTML前端只负责交互增强这样性能最好也不用和这些兼容性问题较劲。5.3 一条我一直在用的自检流程每次新增一个预处理规则我会按这个顺序过一遍基本能拦住 90% 的问题。先拿一段“最脏”的测试文本里面要有代码块、行内代码、链接、图片、表格、嵌套列表、中文、英文、全角符号、零宽字符。这是我的标准测试用例十几行覆盖了所有容易出问题的语法边界。然后确认规则的作用域。在规则函数里临时打一行console.log(state.tokens.map(t t.type))看看你拿到的 token 流到底长什么样。很多时候问题出在“你以为执行到了其实根本没进这条规则”。接着检查顺序。把新规则插在最前面、最后面、中间各跑一次对比输出差异。如果三种顺序下结果一样说明这条规则无副作用可以放心如果不一样就得想清楚它依赖什么、被什么依赖。最后是性能。拿那份 12 万字的文档跑一次看耗时增量。我的容忍线是单次渲染增加不超过 30ms超过就得优化。优化的第一顺位永远是“能不能不做”第二顺位是“能不能少做”第三才是“能不能做快”。我个人的体会是md-editor-v3 这套预处理机制的设计其实挺克制——它把markdown-it的能力几乎原样透出来没有过度封装好处是灵活坏处是所有坑都得自己填。真正省时间的做法是先把“我要改的是字符、结构还是结果”这个问题想清楚选对层级剩下的就是照着一份可靠的测试文本反复调。层级选错了写再多代码都是在和工具较劲层级选对了很多规则十几行就够用半年后回来看还能一眼看懂。