现象

博客编辑器有个功能:粘贴 Markdown 文本时弹窗询问「保持原样」还是「转换」成格式化内容。

复现步骤很短:

  1. 打开后台新建文章页;
  2. 把一份包含 **code**(粗体里包行内代码) 的 Markdown 文件粘贴进编辑器;
  3. 弹窗出现,点「转换」。

结果:弹窗消失了,编辑器里什么都没有,界面上毫无报错。整个操作看起来就是"什么都没发生"。

换一份不含这种写法的 Markdown(比如只有标题和列表),转换一切正常。

环境

  • 前端 Nuxt 4(nuxt ^4.5.2)+ Tiptap 3.30.2(@tiptap/vue-3 + @tiptap/markdown + StarterKit)
  • 正文以 Markdown 存库,编辑器初始化与外部 setContent 均声明 contentType: 'markdown'
  • 粘贴流程:handlePaste 检测到 Markdown 语法 → 弹窗 → 「转换」走 insertContent(text, { contentType: 'markdown' })

排查

1. 先排除粘贴功能本身

handlePaste 的检测、弹窗、按钮回调都是显式逻辑,逐行看过没问题;弹窗也确实按预期出现。问题出在点「转换」之后的插入环节。

2. 把异常逼出来

从 Vue 组件树里拿到 tiptap 的 editor 实例,手动重放插入:

  • 小片段 # Hi\n\n- a\n- b → 正常插入;
  • 完整文件 → 抛异常:
纯文本
Invalid collection of marks for node text: bold,code

错误信息直接把嫌疑人供出来了:某个 text 节点同时带 boldcode 两个 mark,而 schema 认为这个组合非法。

3. 定位触发源

文件里有多处这样的写法——粗体范围里包着行内代码:

纯文本
**而 `category.yaml` 的唯一作用是:…被迁移一次。**

@tiptap/markdown(底层是 marked)解析它时,外层是 bold 样式,内部的 category.yaml 是带 code mark 的文本——两个 mark 落在同一个文本节点上。这在 Markdown 语义里完全合法(GitHub、marked 都渲染粗体代码),但撞上了 tiptap 的 schema 校验。

4. 翻源码确认 excludes: '_' 的含义

StarterKit 自带的 Code mark 声明:

js
var Code = Mark.create({
  name: "code",
  excludes: "_",
  code: true,
  exitable: true,
  // ...
})

_ 是什么?翻 ProseMirror model 的源码:

js
// Schema 初始化时给每个 mark 算 excluded 集合
type.excluded = excl == null ? [type] : excl == "" ? [] : gatherMarks(this, excl.split(" "));

// gatherMarks
if (name == "_" || (mark.spec.group && mark.spec.group.split(" ").indexOf(name) > -1))
  found.push(ok = mark);

_ 是通配符:匹配 schema 里所有 mark。也就是说 excludes: '_' 的语义是——code 与任何其他 mark 互斥,一个文本节点不能既是 code 又是 bold/italic/strike……

5. 补齐失败链条

为什么是"静默"的?两个细节叠加:

  1. 插入路径有 schema 校验insertContentinsertContentAt → 对每个待插入节点调 node.check(),text 节点的 check 逐对检查 mark 互斥,撞上就抛。
  2. 异常没人接confirmPaste 的顺序是先关弹窗再插入,插入调用外面没有 try/catch,异常抛进 Vue 事件处理器后只进控制台。

弹窗已关 + 无提示 = 用户视角"操作被吞了"。

顺带确认两个容易误判的点:

  • 编辑器初始化加载走 markdown 扩展的 onBeforeCreate 钩子(把内容 parse 成 JSON),再由 nodeFromJSON 构建 doc,check()
  • setContentcontentType: 'markdown' 时,同样由 markdown 扩展先 parse 成 JSON,core 走 createDocument 分支,默认 enableContentCheck: false,也校验 mark 集——实测 setContent**code** 不抛,bold+code 节点能正常写入并回写。

所以历史文章里就算有 **code**,打开编辑页面也不会炸——只有插入类命令(insertContent / insertContentAt)会抛。这也解释了为什么这个 bug 只在"粘贴转换"时暴露,而不是全站性故障。

根因

一句话:tiptap 默认 code mark 的"排斥一切 mark"设计,与 Markdown 允许嵌套行内样式(粗体/斜体/删除线包行内代码)的语义冲突;插入时的 node.check() 校验把这个冲突变成异常,而异常无提示,就成了静默失败。

触发面比看起来大:*code*~~code~~++code++——任何"行内代码 + 其他行内样式"的组合都会中,因为 code 排斥的是所有 mark。

修复

两个候选方案:

选后者。改动很小(加一个依赖 + 两行):

diff
 import StarterKit from '@tiptap/starter-kit'
+import Code from '@tiptap/extension-code'
 import { Markdown } from '@tiptap/markdown'

  extensions: [
-    StarterKit.configure({ codeBlock: false }),
+    // 默认 code mark 的 excludes:'_' 排斥一切 mark,与 **`code`** 冲突
+    StarterKit.configure({ codeBlock: false, code: false }),
+    Code.extend({ excludes: '' }),

excludes: '' 在 ProseMirror 里对应 excluded = [](不排斥任何人)。code 的样式、code: true、进出行为原样保留,只是不再和其他行内 mark 互斥。

验证

用触发 bug 的那份完整文件端到端验证:

  • 转换:全文成功插入——19 个标题、20 个代码块、68 个行内代码(该文件当日实测计数),strong > code 正常渲染(修复前这一步是整笔静默失败);
  • 回写getMarkdown()**而 category.yaml 的唯一作用是:…被迁移一次。** 逐字保留,无数据丢失;
  • 保持原样:不受影响,仍是纯文本插入;
  • 最小复现# 标题\n\n含 **bold code** 粗体代码。 单独验证通过。

经验教训

  • "弹窗关了、没报错、也没生效"三连,优先怀疑调用链中途抛了没人接的异常。 本次异常就埋在控制台里,UI 上零线索。对外部库的命令链(.run() 这类)要么接住异常给用户提示,要么确认它不抛。
  • 默认配置里藏着语义开关。 excludes: '_' 这种写法不翻 ProseMirror 源码根本不知道是通配符。集成富文本库时,"解析器能产出什么"和"schema 允许什么"要两头对齐,中间差一口就是这种静默失败。
  • 修 schema 还是修数据,看哪边是错的。 这里 Markdown 语义没错,是 tiptap 默认的 mark 配置和博客场景不匹配,所以放宽 schema 而不是清洗数据——清洗等于替用户做决定(丢粗体),还只修了一条路径。
  • 同一个校验只拦部分路径时,要把所有路径盘一遍,而且要对源码逐条确认,不能只凭报错现场猜。 node.check() 只在插入路径(insertContent / insertContentAt 家族)触发,初始加载与 setContent 都不走 check()——"哪条路径会炸"的结论错了,修的方案可能也跟着偏。