做 Markdown 编辑器这块业务的人,几乎都遇到过同一个场景:编辑区里明明写得规规矩矩,一切换到预览就变了样:图片全是裂的、Mermaid 图变成一块灰底文字、目录导航少了一级标题、复制按钮点下去粘贴出来是一堆<span class="hljs-keyword">。新手第一反应是去翻业务代码,结果查了半天发现业务侧根本没动过 HTML。问题的根子基本都埋在 md-editor-v3 的 Markdown 渲染预处理机制里:它在把字符串丢给 markdown-it 之前、在 markdown-it 输出 HTML 之后,各插了一层自己的处理逻辑,这两层才是真正决定"你看到什么"的地方。这篇东西不打算复述官方文档里那些 props 表格,我想从一个真正在项目里改过渲染管线的角度,把 md-editor-v3 的预处理链路拆开:哪些步骤发生在解析前,哪些发生在解析后,renderer.rules到底被覆盖了什么,noMermaid这类开关背后省掉的究竟是什么,以及沿着这条链路能做哪些不伤筋动骨的改造。如果你手上正有一个基于 Vue3 的 Markdown 编辑场景,或者单纯想搞清楚 markdown-it 的规则覆写套路,下面这些内容应该能直接用上。
1. 一段 Markdown 从字符串到 DOM:先认清链路上的四个关卡
1.1 markdown-it 实例到底是单例还是随组件新建
md-editor-v3 内部维护的是一个全局可配置、局部可复用 markdown-it 实例的模型。全局那一层由config()里的markdownItConfig和markdownItPlugins决定,这两个回调是整个库最关键的扩展入口:前者拿到的是实例本身,用来md.set({ html: false, breaks: true, linkify: true })这类调整;后者拿到的是已经注册好的插件数组,你可以往后面 push 自己的插件,返回值就是最终生效的插件列表。这两个回调只应该做"纯配置",不要在里面写带副作用的逻辑,因为它可能在实例初始化阶段被调用,此时组件还没挂载。
组件那一层,MdEditor、MdPreview、MdCatalog各自通过内部的工具函数拿渲染器。真实项目里最容易踩的坑是:在某个只在预览时才挂载的组件里临时改了md.set(),结果发现编辑器切回编辑态再切回来,配置丢了或者串了。原因是实例在不同挂载周期里可能被重新取用,而你那次修改并没有回到全局配置源头。我的建议很直接:所有md.set()级别的改动,一律收敛到markdownItConfig里,不要在任何组件的onMounted里改。
再补一个实践观察:如果你在同一个页面里同时放了编辑器和只读预览(比如左侧编辑、右侧详情页复用一个渲染器),它们共享全局配置但各自的渲染调用是独立的。这意味着你在预览侧加的插件会作用于编辑器侧,反过来也一样。这一点在没有任何提示的情况下很容易造成"我在详情页加的图床签名,怎么编辑器里也生效了"的困惑,想清楚这个共享模型,很多莫名其妙的现象就解释得通了。
1.2 renderer.rules 被覆盖的四个高频位置
markdown-it 的渲染规则是一张rules表,键是 token 类型,值是一个接收(tokens, idx, options, env, self)的函数。md-editor-v3 在这张表上动了四处,这四处基本覆盖了日常 90% 的自定义需求:
| 规则键 | 原始行为 | md-editor-v3 的改造意图 |
|---|---|---|
heading_open | 输出<h1>到<h6> | 注入锚点 id,供目录跳转使用 |
link_open | 输出<a href="..."> | 追加外链属性,配合图标化处理 |
image | 输出<img src="..." alt="..."> | 接管渲染以支持图片预览、地址处理 |
fence | 输出<pre><code class="language-x"> | 替换为带工具栏容器的结构,承载复制按钮 |
code_inline | 输出<code> | 行内代码样式与转义处理 |
fence这一处改动最重,因为它不只是加个按钮,而是把原本扁平的<pre><code>结构改成了"外层容器 + 语言标识 + 代码区 + 操作区"的组合件。这个改造带来了一个副作用:如果你在业务里写了针对pre > code的全局 CSS,某些情况下选择器可能匹配不到预期节点,得改成基于自定义类名去选。我见过不止一次"代码块没背景色"的反馈,最后追下去都是样式选择器挂在旧结构上。
image被接管之后,src的处理、alt的保留、额外属性的注入就都有了自己的位置。这是我认为最值得业务方上手改的一处,后面第 4 章会专门展开。
1.3 出 markdown-it 之后还有三道后处理
很多人以为md.render()出来的字符串就能直接塞进v-html了,在 md-editor-v3 里不是这么回事。字符串出来之后至少还要过三道:
第一道是安全过滤。库内部用了一个白名单式的 HTML 过滤方案,把不在名单上的标签和属性清掉。这一步是强制的,也是新手最容易被绊倒的地方:你写了个<div class="tip">提示</div>,预览里只剩"提示"两个字,容器和类名全没了,就是因为标签不在放行名单内。
第二道是高亮。代码高亮在规则层是用 highlight.js 完成的,但主题样式是另外加载的,两边不同步就会出现"高亮了但没颜色"的情况。这一道严格来说发生在渲染过程中,不算后处理,但因为它依赖外部 CSS,实际体感上更接近后处理阶段的问题。
第三道是异步挂载。Mermaid 图表、数学公式这类内容,第一次输出的只是占位节点,真正的渲染发生在 DOM 已经插入页面之后。这就解释了为什么你在onHtmlChanged回调里拿到的 HTML 里,Mermaid 部分还是个空的<div class="mermaid">...</div>——那一刻它确实还没画出来。
理解这三道顺序很重要。有一次我在项目里排查"自定义标签内的图片预览失效",最后定位到的原因是:先在规则层给图片注入了预览用的属性,然后在安全过滤那一道被当成了非白名单属性清除掉,于是在最终 DOM 上那个属性根本不存在,事件自然绑不上。顺序搞反,排查方向就会全错。
2. 预处理真正"预"的是什么:进入解析前的文本改写
2.1 相对路径与资源地址的补全时机
这是我认为整条链路里最值得说清楚的一点。Markdown 语法本身对路径没有任何约束,和在语法层面完全等价。如果你不主动处理,这个相对路径会原样进入src,然后浏览器以当前页面地址为基准去解析,结果就是大部分情况下裂图。
md-editor-v3 在图片规则的覆写里提供了介入点,但它不会替你做"补全前缀"这件事,因为库不可能知道你后端返回的图片根地址是什么。正确做法是在image规则的覆写函数里判断src是否以协议头或//开头,不是的话就拼接你的资源基地址,然后再交给原始规则去输出。这里有个容易忽略的细节:拼接时要注意基地址结尾有没有斜杠、src开头有没有斜杠,两边都缺或都多都会产生双斜杠或者路径粘连。我一般的写法是先做一次规整,把基地址尾部斜杠去掉、把相对路径头部斜杠去掉,中间补一个。
另外需要注意src里带 query 的情况,比如a.png?x-oss-process=style/thumb。如果你的拼接逻辑用的是字符串查找加简单截断,很可能把 query 一起当路径处理掉。稳妥的做法是只处理问号之前的部分,query 原样保留。这个细节不做,缩略图这类场景基本必挂。
2.2 代码块语言标识的自动探测与高亮兜底
```js和```的区别,在渲染链路上是两个完全不同的分支。前者走的是指定语言高亮,后者要么交给自动探测,要么干脆不做高亮直接输出纯文本。md-editor-v3 的高亮扩展支持自动识别,但自动识别不是没有代价的:它需要对每个代码块做一次语言特征匹配,代码块数量一多,首次渲染的耗时会有可感知的增加。
我在一个技术文档站里实测过这个差距:一篇约 80 个代码块的长文,开启自动识别后首次预览渲染大约多出几十毫秒到一百多毫秒不等,具体取决于每块代码的长度,用户能感觉到轻微的白屏延长。最终方案是保留自动识别,但把默认语言设成了一个高频类型作为兜底,同时引导内容生产方规范书写语言标识。这个取舍在我看来是划算的:自动识别带来的"偶尔识别错"比"完全没有高亮"体验要好。
还有一个细节值得提醒:语言标识的写法在不同语法高亮实现里差异很大,js、javascript、node可能都指向同一套规则,也可能只有一个被支持。如果发现某段代码加了语言标识却没有高亮,先别怀疑组件,去查一下该标识是不是被高亮规则库接受的标准别名。这个排查步骤能省掉大量瞎猜时间。
2.3 标题锚点 id 的生成规则与重名冲突
目录跳转依赖的是标题上的 id,这个 id 是渲染阶段生成的。生成逻辑通常是把标题文本做一次归一化:去掉标点、把空格换成连字符、处理大小写。这套规则本身没问题,问题出在重名。同一个页面里出现两个"注意事项"是很常见的,如果 id 生成不做去重,document.getElementById()永远只能拿到第一个,第二个标题在目录里点击就会跳到错误位置。
md-editor-v3 在这方面做了冲突处理,会在重名时追加序号。但你需要知道这件事的存在,因为一旦你自己在业务侧也生成了一套锚点(比如为了做站内分享链接),两套规则不一致就会出现"目录能跳但我分享的链接打不开"的情况。我现在的统一做法是:站内所有锚点一律以渲染器生成的为准,需要分享链接时从渲染后的 DOM 里读取真实 id,而不是自己根据标题文本再算一遍。
另外中文标题在锚点生成上有个常被忽略的点:如果归一化过程把中文全部剔除,最后 id 会变成空串或纯序号串,可读性极差并且极易冲突。实际项目里如果标题以中文为主,建议在插件层给一个基于哈希的兜底策略,保证即使文本被清理干净,也能得到一个稳定的标识。
2.4 HTML 标签的放行名单与转义优先级
这里要说清楚一个容易混淆的概念:html: true和"安全过滤放行"是两回事。前者决定 markdown-it 要不要把原始 HTML 当作 HTML 输出,后者决定输出的 HTML 里哪些标签能活下来。两个开关任意一个不满足,你的自定义标签都到不了最终 DOM。
我遇到过一个很典型的排查过程:业务需要在文档里插入自定义的折叠块标签,用来做"点击展开更多"的效果。第一步在markdownItConfig里开了html: true,预览里能看到包裹内容了,但外层容器的类名和属性没了;第二步去翻安全过滤的配置,发现自定义标签名根本不在白名单里。这里必须提醒一句,扩展白名单是有安全边界的,任何允许用户提交内容并渲染的场景,放开标签白名单之前必须想清楚 XSS 风险。如果内容来源不完全可信,正确做法不是放开标签,而是改用 markdown-it 插件生成安全的 HTML 结构——插件生成的内容同样会经过过滤,但你可以保证生成的标签都在白名单内。
转义优先级这块也踩过坑。行内代码和高亮块里的尖括号、花括号,是否被转义、在什么阶段被转义,直接影响最终显示。好几次有人反馈"代码里的泛型写不进去",最后发现是内容在进入渲染器之前先经过了某层模板处理,<T>被当成标签吃掉了。这类问题出在渲染链路更上游,跟 md-editor-v3 本身无关,但排查时很容易误判,所以要养成一个习惯:先确认进入渲染器的字符串本身是对的。
3. 异步渲染带来的时序问题:Mermaid、KaTeX 与目录提取
3.1 预览区"闪一下"的真正来源
先给结论:那一下闪烁不是渲染失败,是两次渲染叠加造成的视觉跳变。第一次是 markdown-it 同步输出的 HTML,里面 Mermaid 部分是占位节点,样式上表现为一块浅色区域或者一段原始文本;第二次是异步渲染完成后,占位节点被替换成真正的图形,尺寸和颜色都变了。两次之间的间隔就是异步渲染的耗时,图表越多、越复杂,间隔越长。
要缓解这个现象,思路有两条。一条是从样式上让占位状态和最终状态尽量接近,比如给占位节点预设一个固定的最小高度和背景,让布局在两次渲染之间不发生重排。另一条是控制渲染粒度,避免每次内容变动的其中一个字符都触发全量异步渲染。后者的实现方式是加防抖:监听内容变化,等到用户停止输入一段时间后再执行完整的解析加异步渲染流程。防抖窗口设多少合适,我的经验是 200 到 400 毫秒之间,太短起不到保护作用,太长会让人觉得输入和预览脱节。
另外要注意,异步渲染是串行还并行,直接影响长文档的表现。如果是全量串行执行,几十个图表会一个接一个画,总耗时线性增长;如果是对每个节点独立触发,虽然总耗时可控,但多个渲染任务同时争抢主线程反而可能造成更明显的卡顿。真实项目里我倾向于给异步任务排一个队列,限制同时进行的数量,这在图表密集的文档里体感提升很明显。
3.2 noMermaid、noKatex、noHighlight 三个开关省掉的到底是什么
这三个开关常被当成"关掉某个功能"来理解,但从渲染链路的视角看,它们省掉的东西并不一样。
关掉 Mermaid 省掉的是两块:一是异步渲染任务的调度和对应的运行时开销,二是一整套图表渲染依赖的加载。如果你的文档里根本没有图表,把这块关掉对首屏体积的收益是很实在的。关掉 KaTeX 同理,数学公式的字体文件和样式表体积不算小,纯业务文档场景完全没必要背这个包袱。
关掉高亮则更微妙一些。规则的覆写还在,但代码块内部不再走语法分析,直接输出转义后的纯文本。这时候视觉上代码块的背景色、结构都还在,只是没有彩色关键字了。如果你打算自己做一套代码主题,这个开关是可以保留按需切换的。
我把这三个开关理解成"渲染预算"的分配旋钮:一个面向内部使用的文档工具,完全可以按文档类型动态决定开哪些。比如同一个人写技术方案时开高亮和公式,写会议记录时全关。这种按场景切换的做法,在低端设备和长文档场景下体感差别非常明显。
3.3 目录与正文抢跑:为什么右侧导航偶尔少一个标题
这个现象我至少遇到过三次,每次排查路径都不太一样,但根源都指向同一件事:目录提取和正文渲染是两个独立流程,谁先完成不确定。目录一般是通过遍历解析出来的 token 树来获取标题列表的,如果这次遍历发生在异步渲染完成之前,标题本身是有的,但如果标题文本里包含行内代码或者公式,文本提取的时机就可能和渲染时序产生竞争,导致某一项为空或者缺失。
更常见的场景是内容更新。用户编辑时,正文重新渲染是防抖的,但目录更新可能是另一个触发路径。如果两者的防抖窗口不一致,就会出现"正文已经是新内容,目录还是旧的"这短暂的不一致。这不算 bug,但对用户来说就是"目录点不到新加的标题"。
处理这类问题,我的做法是:把目录提取也挂到和正文同一个渲染流程里,用同一份 token 树产出,而不是让它自己再解析一遍原文。多解析一遍不仅是性能浪费,更重要的是会产生两份可以不一致的真相。只要保证单一数据源,时序问题自然消失。如果你的业务里目录是独立组件,那就把底部那次解析的结果通过 props 或者状态管理传递过去,不要让两边各自解析。
4. 按需改造渲染管线:几个真实项目里落地的改法
4.1 优先用 markdownItPlugins 挂插件,而不是改配置项堆代码
改造渲染管线有三条路:改源码、堆markdownItConfig里的md.set()、用markdownItPlugins挂插件。第一条直接排除,升级会痛不欲生。第二条只适合开关式配置,比如breaks、linkify这类。真正需要改变渲染行为的,一律走第三条。
写一个最小插件大概是这样:
function myPlugin(md) { const defaultImage = md.renderer.rules.image || function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; md.renderer.rules.image = function (tokens, idx, options, env, self) { const token = tokens[idx]; const srcIndex = token.attrIndex('src'); if (srcIndex >= 0) { const src = token.attrs[srcIndex][1]; if (src && !/^(https?:)?\/\//.test(src) && !src.startsWith('data:')) { token.attrs[srcIndex][1] = joinUrl(BASE_URL, src); } } return defaultImage(tokens, idx, options, env, self); }; }这段代码里有几个点是踩过坑之后才写对的。第一,一定要保留原始规则的引用作为兜底,用||那条语句,因为有些规则在你挂插件的时候可能已经被别的插件改过了,直接覆盖会丢掉别人的改动。第二,attrIndex返回 -1 表示属性不存在,必须先判断再改,直接按下标取值会报错。第三,协议判断要覆盖//开头的协议相对地址和data:内联图,漏掉任何一个都会导致正常的地址被二次拼接。
注册方式就是在全局配置里:
import { config } from 'md-editor-v3'; config({ markdownItPlugins(plugins) { return [...plugins, myPlugin]; }, });注意markdownItPlugins的返回值才是最终生效的列表,直接plugins.push()虽然多数情况下也行,但返回值是更明确的契约,不会因为内部实现变化而失效。这个细节看起来无所谓,但我在两个项目里都因为图省事用 push,后来升级版本时发现插件顺序变了,导致图片规则被另一个插件覆盖,白排查了半天。
4.2 覆盖 image 规则做地址签名和懒加载
上一节给的是地址补全的最小版本,实际业务里图片规则往往还要承担更多职责。比较典型的两件事:给图片加上懒加载属性,以及给图片加上预览能力需要的标记。前者直接改属性就行,后者要注意属性和事件是两套东西:属性决定了 DOM 上的标记,事件绑定依赖组件在渲染完成后扫描这些标记再去绑定,所以属性必须是一个稳定的、可被扫描到的标识。
md.renderer.rules.image = function (tokens, idx, options, env, self) { const token = tokens[idx]; token.attrSet('loading', 'lazy'); token.attrSet('decoding', 'async'); if (!token.attrGet('data-preview')) { token.attrSet('data-preview', '1'); } return defaultImage(tokens, idx, options, env, self); };这里顺带说一个属性注入时机的经验。属性注入放在规则覆写里,比渲染完成后用querySelectorAll批量改 DOM 要好,因为后者会导致至少一次额外的样式重算和布局重排,长文档下这个代价是能被感知的。规则阶段改 token,最终输出的 HTML 一次性就是对的,浏览器只需要解析一次。这个思路在所有"给渲染结果统一加属性"的需求里都适用。
还有个边界要处理:>md.renderer.rules.link_open = function (tokens, idx, options, env, self) { const token = tokens[idx]; const href = token.attrGet('href') || ''; const isExternal = /^https?:\/\//.test(href); if (isExternal && typeof window !== 'undefined' && !href.startsWith(window.location.origin)) { token.attrSet('target', '_blank'); token.attrSet('rel', 'noopener noreferrer'); } return self.renderToken(tokens, idx, options); };
rel里那个noopener不是可选项,缺少它会让新页面能通过window.opener反向操作原页面,属于实实在在的安全隐患,顺手加上没有任何成本。
4.4 长文档下的渲染节流与局部更新
内容长度上去之后,全量重渲染的代价会变得不可接受。一篇两万字的技术文档,每次按键都触发完整解析加异步渲染,输入延迟会肉眼可见。这里的优化方向不是让单次渲染更快,而是减少渲染次数。
最直接的方案是内容监听加防抖,前面提过 200 到 400 毫秒。更精细一点,可以只在内容结构发生变化时才触发完整渲染,比如判断这次变化是否新增或删除了标题、代码块、图表这类结构性内容,如果只是普通段落内的字符增减,可以先跳过异步渲染部分,只更新纯文本。实现上不复杂,比较一下新内容里结构性标记的数量和上次是否一致即可。
另一个方向是缩小更新范围。如果文档是分节渲染的,可以把每一节独立成块,内容变动时只重新渲染对应块。这种做法收益很大,但代价是渲染管线的复杂度上升,需要自己维护块的划分逻辑,并且目录提取要跨块汇总。我的建议是:文档长度在两万字以内,防抖加结构判断足够;超过这个量级再考虑分块。
5. 那些只有真跑过才会撞上的坑
5.1 安全过滤把自定义标签连内容一起吃掉
表现是:预览里你的自定义容器完全消失了,连里面的文字都没了。这跟"标签被剥掉但内容保留"是两种不同结果,前者说明过滤策略是丢弃整个节点而不是解包。遇到这个现象,第一反应不要是去改过滤配置,而是先判断这个标签有没有必要存在。如果它的作用只是加个样式,最稳的替代是用 markdown-it 插件生成一个已经被广泛支持的容器结构,比如生成带特定类名的div,然后把div和class放进白名单——这两个是通用标签和通用属性,放行的风险远小于放行一个任意自定义标签名。
如果确实需要在内容里嵌入交互组件,那就不要走 HTML 标签这条路,改成约定标记语法,在渲染完成后由业务代码扫描并挂载。这种"标记 + 后挂载"的模式虽然多了一步,但它完全绕开了 HTML 过滤的边界,安全性可控,也更好维护。
还有一点值得提醒:写这类测试的时候,一定要用和线上完全一致的过滤配置去验证,别在开发环境把过滤全关了然后测通过验收,上线才发现全被吃掉。这个教训我吃得很实在。
5.2 复制按钮复制到的是高亮后的 HTML
代码块的复制功能,一个常见的实现误区是从 DOM 里读innerText或者textContent。高亮之后代码块内部是一堆<span>嵌套,textContent拿到的文本内容是对的,但如果你取的是innerHTML或者用了某些会把标签带出来的方式,粘贴出来就是一串 span 标签。这个 bug 的表现非常具有辨识度:用户复制代码,粘到编辑器里,满屏都是hljs-keyword。
正确的做法是在渲染阶段就把原始代码文本存下来。fence规则的 token 里本来就保留着未经高亮的原始内容,只是被包进了自定义属性或者独立节点里。可以把它写进data属性,复制时读属性值而不是读节点内容。这样无论高亮规则怎么变,复制出来的永远是原始文本。这个改法还有一个额外好处:原始文本可以保留缩进和换行,而innerText在某些浏览器里会做空白折叠。
顺带说一句,把原始代码塞进属性时要注意转义,代码里的引号会和属性值的引号冲突。稳妥的写法是存到子节点里并设置display:none,或者走序列化编码。
5.3 SSR 与水合阶段的 window 引用问题
如果这个编辑器被用在服务端渲染的场景里,渲染规则中任何直接访问window、document、location的代码都会在服务端执行时报错。前面 4.3 的例子里我加了typeof window !== 'undefined'的判断,这不是防御性编程的洁癖,而是必需。
除了规则层,异步渲染部分也必须在客户端才能执行。如果服务端渲染阶段就尝试调度 Mermaid 渲染任务,轻则首次渲染输出不一致,重则直接抛错导致整页白屏。合理的处理方式是:异步渲染只在明确处于浏览器环境且 DOM 已经挂载后才启动,服务端阶段只输出占位结构。
还有一个水合阶段的问题容易被忽略:服务端输出的 HTML 和客户端首次渲染的 HTML 必须一致,否则会出现水合不匹配的警告,视觉上表现为内容闪一下或者局部重绘。保证一致的关键就是,所有依赖浏览器环境才能确定的东西(比如基于location判断出来的外链属性),要么在服务端也用一个可预测的默认值,要么放在水合完成之后再补,不要在两边的初始渲染里给出不同结果。
6. 我对这套预处理的整体判断
翻完这条链路,我最大的感受是:md-editor-v3 把"可配置"和"可接管"这两件事分得很清楚。开关类的需求(关掉图表、关掉公式、关掉高亮)走配置,行为类的需求(改图片地址、改链接属性、加自定义语法)走插件和规则覆写。这个分层意味着大部分业务改造都不需要碰源码,代价只是你得先弄清楚每一层负责什么、执行顺序是什么。
我自己在做项目时的固定动作是:先拿一段包含图片、外链、代码块、图表的"全类型样本"跑一遍默认渲染,把输出的 HTML 结构完整看一遍。这一步花不了五分钟,但能让你对后面要改的那几个规则位置心里有数。比上来就翻文档找配置项要高效得多,因为文档告诉你有哪些选项,而实际输出告诉你这些选项该放在哪一层。