现象
博客编辑器有个功能:粘贴 Markdown 文本时弹窗询问「保持原样」还是「转换」成格式化内容。
复现步骤很短:
- 打开后台新建文章页;
- 把一份包含
**code**(粗体里包行内代码) 的 Markdown 文件粘贴进编辑器; - 弹窗出现,点「转换」。
结果:弹窗消失了,编辑器里什么都没有,界面上毫无报错。整个操作看起来就是"什么都没发生"。
换一份不含这种写法的 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 节点同时带 bold 和 code 两个 mark,而 schema 认为这个组合非法。
3. 定位触发源
文件里有多处这样的写法——粗体范围里包着行内代码:
**而 `category.yaml` 的唯一作用是:…被迁移一次。**
@tiptap/markdown(底层是 marked)解析它时,外层是 bold 样式,内部的 category.yaml 是带 code mark 的文本——两个 mark 落在同一个文本节点上。这在 Markdown 语义里完全合法(GitHub、marked 都渲染粗体代码),但撞上了 tiptap 的 schema 校验。
4. 翻源码确认 excludes: '_' 的含义
StarterKit 自带的 Code mark 声明:
var Code = Mark.create({
name: "code",
excludes: "_",
code: true,
exitable: true,
// ...
})
_ 是什么?翻 ProseMirror model 的源码:
// 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. 补齐失败链条
为什么是"静默"的?两个细节叠加:
- 插入路径有 schema 校验:
insertContent→insertContentAt→ 对每个待插入节点调node.check(),text 节点的 check 逐对检查 mark 互斥,撞上就抛。 - 异常没人接:
confirmPaste的顺序是先关弹窗再插入,插入调用外面没有 try/catch,异常抛进 Vue 事件处理器后只进控制台。
弹窗已关 + 无提示 = 用户视角"操作被吞了"。
顺带确认两个容易误判的点:
- 编辑器初始化加载走 markdown 扩展的
onBeforeCreate钩子(把内容 parse 成 JSON),再由nodeFromJSON构建 doc,不调check(); setContent带contentType: '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。
修复
两个候选方案:
选后者。改动很小(加一个依赖 + 两行):
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()——"哪条路径会炸"的结论错了,修的方案可能也跟着偏。
评论