☰
拆解 md-editor-v3 Markdown 渲染预处理机制
2026/11/2 16:57:48 网站建设 项目流程

md-editor-v3 这个 Vue 3 的 Markdown 编辑器,我前后在四个项目里用过:内部知识库、API 文档站、低代码平台的富文本备注框,还有一个给运营同学用的活动页配置后台。用得越久越发现一件事:真正决定它好不好用的,并非编辑器本体,而是从你敲下字符到屏幕上出现 HTML 之间那段“Markdown 渲染预处理机制”。同样一段 Markdown,有人渲染出来目录错乱、代码高亮丢失、公式闪烁,有人却丝滑得像原生文档站,差别基本都藏在这条链路上。

这篇就干一件事:把这套预处理机制从源码结构到实战落地拆开讲。我会说清楚 md-editor-v3 在哪些环节允许你插手、每个环节的规则链顺序为什么不能乱、markdownItConfig和markdownItPlugins的执行时机差在哪、sanitize为什么必须卡在最后一道关口。适合两类人看:一是已经在用这个库、但只会改v-model和主题的开发者;二是想搞清楚 Markdown 渲染管线设计思路、准备自己造轮子的人。下面所有代码和参数都是我在真实项目里跑过的,能直接抄。

1. 先搞清楚 md-editor-v3 到底在哪些环节动你的 Markdown

1.1 一条完整的渲染链路:从敲下第一个字符到屏幕上出现 HTML

很多人以为<MdEditor v-model="text" />就是把字符串丢给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_open+text+link_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 字符串做解析再序列化的,如果你的后处理依赖原始字符串的格式(比如用正则找<pre><code class="language-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: Record<string, 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,直接跳过了。

反过来,如果你的插件排在后面,高亮先跑完,内部的<pre><code>已经生成,容器插件只需要做一层外壳包裹,效果就正常。

// 顺序很重要 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这种形式,地址栏里看着很别扭,而且容易和别的锚点撞。

我在文档站项目里统一换了一套slugify:

md.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 id="doc-main" v-model="text" /> <MdCatalog editor-id="doc-main" :scroll-element="scrollEl" />

还有一个细节:MdCatalog的滚动定位依赖scrollElement。如果不传,它会用document.documentElement或者最近的滚动父级。在某个后台项目里,预览区被包在一个overflow: auto的div里,没传scrollElement,结果点击目录标题,页面纹丝不动。这个问题的排查花了我一整个下午,因为控制台不报错,就是不动。后来发现scrollElement默认值是document.documentElement,而真正的滚动容器是那个div。

4. 实战:搭一条能上生产的预处理管线

4.1 需求梳理与方案取舍

前面是理论,这里给一个完整的实战案例。场景是内部文档平台,需求有四条:

  1. 支持::: tip / warning / danger三种自定义告示块,渲染成带配色的卡片;
  2. 把@用户名渲染成带背景色的 mention 标签,点击弹出用户卡片;
  3. 所有外链自动加target="_blank"和rel;
  4. 全站统一清洗零宽字符和全角符号。

方案对比我先列个表:

需求可选方案我的选择理由
告示块文本层正则替换 /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 class="doc-container doc-container-${type}"><span class="doc-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 id="doc-main" v-model="text" :markdown-it-plugins="markdownItPlugins" :markdown-it-config="markdownItConfig" :sanitize="sanitize" code-theme="atom" preview-theme="github" /> </template>

sanitize这块特别提醒一句: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 相关逻辑放到onMounted

5.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的能力几乎原样透出来,没有过度封装,好处是灵活,坏处是所有坑都得自己填。真正省时间的做法,是先把“我要改的是字符、结构还是结果”这个问题想清楚,选对层级,剩下的就是照着一份可靠的测试文本反复调。层级选错了,写再多代码都是在和工具较劲;层级选对了,很多规则十几行就够用,半年后回来看还能一眼看懂。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询