TipTap 实战与自定义扩展(自定义 Node / Mark / Command)
返回 🗂 富文本编辑专题 · 相关私有笔记
这篇是动手向:从 new Editor({ extensions, content }) 起步,逐步写出自定义 Node / Mark / Extension,挂上命令、输入规则与快捷键,再用框架 NodeView 做交互节点,最后收一遍踩坑。TipTap 的每个 API 几乎都是 ProseMirror 内核某个概念的声明式封装,但第一次照着跑不必先学完整内核;每个用法都保留了可按需回看的底层链接。
产品路线起点:这是第一次接触富文本时可以直接开始的一篇。先完成“第一轮”,你就有一个可输入、可加粗、可导出内容的编辑器;不要为了理解每个回调而卡在第一段代码前。
最小先修:会 JavaScript / TypeScript 和浏览器 DOM 即可。
StarterKit会提供最小合法 Schema;若“浏览器 DOM 为什么不是文档真相”这件事仍陌生,先花几分钟读 ProseMirror 入门。按需继续:
第一轮:先运行一个编辑器
起步:Editor 构造与 StarterKit
最小可运行的编辑器只需要 Editor 构造函数加一个 extensions 数组:
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
const editor = new Editor({
element: document.querySelector('.editor'), // 可省略,稍后再挂载
extensions: [StarterKit],
content: '<p>Hello World!</p>',
})几个要点:
extensions是必填的,即使最小化也至少要Document/Paragraph/Text(否则 schema 没有可承载文字的节点),或者直接用StarterKit。这对应 PM 里「没有 Schema 就无法构造合法文档」——见 PM Schema。element是可选的。TipTap 是 headless 设计:可以先创建编辑器和文档模型、暂时不挂 DOM;因此它也能存在于 SSR 或测试环境,之后再挂载EditorView——见 View 与 NodeView。content可以是 JSON、HTML 字符串或纯文本,下面单独讲。
StarterKit 里有什么
StarterKit 是一个「电池全包」的 bundle,一次 import 省掉二十多个独立包。它包含:
- 11 个 Node:
Blockquote、BulletList、CodeBlock、Document、HardBreak、Heading、HorizontalRule、ListItem、OrderedList、Paragraph、Text。 - 6 个 Mark:
Bold、Code、Italic、Link(v3 新增)、Strike、Underline(v3 新增)。 - 5 个 Extension:
Dropcursor、Gapcursor、Undo/Redo、ListKeymap(v3 新增)、TrailingNode(v3 新增)。
它是可配置、可扩展的——通过 .configure({}) 调整或关闭内置项,再追加自己的扩展。注意 v3 里关闭内置项用的是该项的配置键名(例如撤销/重做对应 undoRedo、标题对应 heading):
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
const editor = new Editor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] }, // 只保留 H1–H3
bulletList: false, // 关闭无序列表
codeBlock: false, // 关闭代码块
}),
Link, // 追加扩展
],
})注意:bulletList: false 这类关闭操作会把对应节点从 schema 里移除,从而引出下文最危险的踩坑——内容里若有该类型节点会被静默丢弃。
内容初始化:JSON vs HTML vs 纯文本
content 接受三种形态,语义不同,setContent() 命令同理:
- JSON(推荐):
{ type, content }的 PM 文档树。最快,因为不经过 HTML 解析——parseHTML完全不会被调用。 - HTML 字符串:走
parseHTML规则解析成 JSON,解析时做 schema 合规检查,不在 schema 内的内容默认会被静默丢弃(v3 可传errorOnInvalidContent: true改成抛错)。 - 纯文本:被包进 paragraph 节点。
这背后是一个刻意的解耦:存储格式(JSON)与表示格式(HTML)分离。JSON 直接就是内核的不可变文档树(见 PM 核心模型),所以无需解析;HTML 则需要经过 schema 的 parseDOM 通道。这也是为什么一个用 React 组件渲染的复杂 NodeView,导出时却可以是极简 HTML——内核存的始终是结构化数据。
第一轮到此为止:你现在可以创建编辑器、选择内容输入格式,并理解 JSON 是存储结构、HTML 是导入/导出表示。后续章节只在你要增加产品能力时再读。
第二轮:自定义内容模型与序列化
parseHTML / renderHTML:双向序列化,直通 PM schema
这是理解 TipTap 自定义扩展的核心枢纽。两者是 PM schema 里 parseDOM / toDOM 的直接映射(见 PM Schema):
parseHTML()→ 返回[{ tag, getAttrs?, ... }]规格数组,对应parseDOM。方向是 HTML → JSON。renderHTML(props)→ 返回[tagName, HTMLAttributes, contentHole?]元组,对应toDOM。方向是 JSON → HTML。元组末位的0(content hole)标记子内容插入在 DOM 树的哪个洞里。
关键时序(也是高频误解):两者并非对称触发。
parseHTML只在 paste 或用 HTML 初始化时触发。用 JSON 初始化时它根本不会被调用。renderHTML在编辑期渲染与 copy 时总会触发,决定 DOM 呈现与导出格式。
理解这条时序,是排查「内容丢失 / 渲染异常」的钥匙。
写自定义扩展:Node.create / Mark.create / Extension.create
三个工厂函数都返回扩展配置对象,丢进 extensions 数组即可:
Node.create(config)—— 块级内容类型,会生成 schema 条目。Mark.create(config)—— 行内格式,也生成 schema 条目;额外支持keepOnSplit、inclusive等。Extension.create(config)—— 通用扩展,不进 schema,适合做全局属性、命令、键位、中间件。
Node 与 Mark 的二分
这是 TipTap 从 PM 继承的根本对立(见 PM 核心模型):
- Node 是块级/结构性内容(段落、标题、代码块、自定义块),构成文档树的层级。它必须遵守 schema 的内容表达式(
content: 'block+'、'inline*'等),规定自己能装什么。 - Mark 是叠加在文字上的行内标注(粗体、链接、自定义高亮),不改变结构。Mark 之间可以重叠,但不能互相嵌套包含。
二者正交:Node 定义边界与层级,Mark 在节点内的文字上覆盖。
自定义 Node:属性 + parseHTML + renderHTML
import { Node } from '@tiptap/core'
const Alert = Node.create({
name: 'alert',
group: 'block',
content: 'inline*', // 关键:不写就退化成 atom(像 image 一样不可编辑、不可嵌套)
addAttributes() {
return {
type: {
default: 'info',
// parseHTML:paste/HTML init 时从 DOM 元素抽取
parseHTML: (element) => element.getAttribute('data-type') || 'info',
// renderHTML:把当前属性值发射到 HTML 属性
renderHTML: (attributes) => ({ 'data-type': attributes.type }),
},
}
},
parseHTML() {
return [{ tag: 'div[data-alert]' }]
},
renderHTML({ HTMLAttributes }) {
return ['div', { ...HTMLAttributes, 'data-alert': '' }, 0] // 末位 0 = 子内容洞
},
})addAttributes 用的是三段式配置(default / parseHTML / renderHTML),直接映射到 PM 的 attrs + parseDOM.getAttrs + toDOM:
default—— 初始值。parseHTML(element)—— paste / HTML init 时从 DOM 元素读出值。renderHTML(attributes)—— 把值发射成 HTML 属性。
属性最终存进内部 JSON,运行时通过 node.attrs.xxx(或 mark.attrs.xxx)访问。注意:只有在 addAttributes() 里声明过的属性才会在 paste/init 时被保留,未声明的属性会丢失——比如 paste 了 <p data-custom="foo"> 但没声明 data-custom,这个数据就没了。
自定义 Mark:配合输入规则
import { Mark, markInputRule } from '@tiptap/core'
const Highlight = Mark.create({
name: 'highlight',
parseHTML() {
return [{ tag: 'mark' }]
},
renderHTML({ HTMLAttributes }) {
return ['mark', HTMLAttributes, 0]
},
addInputRules() {
return [
markInputRule({
// 末尾的 $ 是关键:input rule 只看光标前刚输入的片段
find: /(?:^|\s)(==(?!\s)([^=]+)==)$/,
type: this.type,
}),
]
},
})第三轮:为产品增加行为与宿主状态
命令、输入规则、快捷键:都是 PM 原语的声明式包装
这三组 API 本质上都是对 PM 事务 / 输入状态 / keymap 的封装,约定都是返回 true(成功/消费事件)或 false(失败/放行)。
addCommands:可链式的事务构建器
命令是「返回命令函数」的函数:(commandProps) => ({ ctx }) => boolean。可通过 editor.commands.xxx() 或 editor.chain().a().b().run() 调用。chain 让多个命令共用同一个 Transaction,原子化提交、避免多次事务开销——这正对应 State 与 Transaction 里 Command 的函数式约定。
import { Extension } from '@tiptap/core'
const CustomExtension = Extension.create({
name: 'customExtension',
addCommands() {
return {
insertTimestamp: () => ({ commands }) =>
commands.insertContent(new Date().toISOString()),
}
},
addKeyboardShortcuts() {
return {
'Mod-Shift-t': () => this.editor.commands.insertTimestamp(),
}
},
})addInputRules / addPasteRules:正则驱动的转换
- Input rules 在敲键时触发:文本匹配正则就转换(如
**bold**→ 加粗)。用markInputRule()/nodeInputRule()/textblockTypeInputRule()等。 - Paste rules 在粘贴时触发,意图相同(
markPasteRule()/nodePasteRule()等)。
两者的正则差异是关键:input rule 的正则要以 $ 结尾(行尾断言,因为它只看光标前刚输入的片段);paste rule 不带 $(要扫描整段粘贴内容)。底层 PM 在输入处理期同步应用这些规则。
addKeyboardShortcuts:键位组合
映射 'Mod-b' 这类组合到命令。Mod = Mac 上的 Cmd、其他平台的 Control,所以单条 'Mod-...' 绑定天然跨平台。若 Mac 与 Windows 需要绑到不同的键,得拆成两条。快捷键按 extension priority 顺序求值,返回 true 消费事件、false 放行。
addGlobalAttributes:给多个类型一次性加属性
适合「文本对齐」这类要施加到多个节点的能力,放在 Extension.create 里。命令侧使用 TipTap 核心命令 updateAttributes(typeOrName, attributes) 逐个类型设置;它底层通过 ProseMirror transaction 写入节点或 Mark 属性。原生 ProseMirror 没有这个统一命令,也没有 setAttributes 这个核心命令:
import { Extension } from '@tiptap/core'
const TextAlign = Extension.create({
name: 'textAlign',
priority: 1000, // 高优先级,确保先加载
addOptions() {
return { types: ['heading', 'paragraph'] }
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
textAlign: {
default: 'left',
parseHTML: (element) => element.style.textAlign || 'left',
renderHTML: (attributes) => ({
style: `text-align: ${attributes.textAlign}`,
}),
},
},
},
]
},
addCommands() {
return {
setTextAlign:
(align) =>
({ commands }) =>
// 对每个配置过的类型分别 updateAttributes
this.options.types.every((type) =>
commands.updateAttributes(type, { textAlign: align }),
),
}
},
})priority:加载顺序与 schema 合并顺序
priority 默认 100,越高越先。它同时影响两件事:
- PM 插件执行顺序——高优先级的 plugin 先跑(见 Plugin 与 Decoration)。
- schema 合并顺序——影响 Mark 的嵌套/渲染包裹关系。当两个 Mark 能作用于同一段文字时,高优先级的 Mark 渲染在外层(这就是 Link 设了高
priority以包在其他 Mark 外面的原因)。
这点在没有竞争 Mark 时是隐形的,一旦出现重叠 Mark 才会暴露,务必心里有数。
受控 / 非受控内容与 onUpdate
两种范式
- 非受控(uncontrolled):编辑器是 source of truth,父组件通过
onUpdate监听。更简单,React/Vue 绑定默认就是这种(useEditor默认非受控)。 - 受控(controlled):父组件是 source of truth,主动通过
setContent()推内容。协同编辑或复杂同步逻辑才需要。
onUpdate 与事务上下文
onUpdate 在任何内容变更(输入、粘贴、删除、命令执行)时触发,回调收到 { editor, transaction }。坑:PM 是同步 dispatch 事务的,而 React 期望异步更新——直接在 onUpdate 里 setState 会触发 flushSync 警告。用 queueMicrotask() 把更新推迟到 PM 完成之后:
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useState } from 'react'
function MyEditor() {
const [content, setContent] = useState('')
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Start typing...</p>',
immediatelyRender: false, // SSR:跳过服务端立即渲染,避免水合不匹配
onUpdate: ({ editor }) => {
queueMicrotask(() => setContent(editor.getHTML()))
},
})
if (!editor) return null
return (
<>
<EditorContent editor={editor} />
<p>Output: {content}</p>
</>
)
}setContent 与 emitUpdate
setContent(content, options?) 替换整个文档。{ emitUpdate } 在 v3 默认是 true(会触发 onUpdate;v2 默认是 false)。受控模式必须用 emitUpdate: false,否则会形成死循环:父组件 → setContent → onUpdate → 父组件 → ……
const newContent = '<p>Updated by parent</p>'
// 父组件作为 source of truth 推内容时,关闭回调防止循环
editor.commands.setContent(newContent, { emitUpdate: false })
// 默认(v3)会触发 onUpdate
editor.commands.setContent(newContent, { emitUpdate: true })导出用 editor.getHTML() / editor.getJSON()。注意:getHTML() 是基于当前的 renderHTML() 动态生成的——之后若改了某个扩展的 renderHTML(),旧导出就对不上了。输出是动态的,不是冻结的。
第四轮:仅在复杂交互节点时进入 NodeView
NodeView 是某个 Node 的自定义渲染器(原生 DOM、React 组件或 Vue 组件)。它的核心价值是解耦「编辑期表示」与「导出格式」:编辑器里可以是带按钮、拖拽、输入框的复杂 React UI,而 renderHTML 决定导出的简单 HTML——NodeView 只活在编辑器内,不影响导出。这正是 PM NodeView 机制的框架化封装,见 View 与 NodeView。
React 用 ReactNodeViewRenderer(Component) 包裹组件;组件会收到 node、updateAttributes()、deleteNode()、getPos()、selected、editor、extension、decorations 等 props。Vue 3 组件可直接用作 NodeView。
import { Node } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
const VideoNode = Node.create({
name: 'video',
group: 'block',
atom: true, // 无可编辑子内容
addAttributes() {
return {
src: { default: '' },
controls: { default: true },
}
},
parseHTML() {
return [{ tag: 'div[data-video]' }]
},
renderHTML({ HTMLAttributes }) {
// 导出仍是极简 HTML,与编辑期的 React UI 无关
return ['div', { 'data-video': '', ...HTMLAttributes }, 0]
},
addNodeView() {
return ReactNodeViewRenderer(VideoComponent)
},
})
function VideoComponent({ node, updateAttributes }) {
return (
<div className="video-wrapper">
<video src={node.attrs.src} controls={node.attrs.controls} />
<input
type="text"
value={node.attrs.src}
onChange={(e) => updateAttributes({ src: e.target.value })}
placeholder="Enter video URL"
/>
</div>
)
}性能提醒:每个 NodeView 是一次独立的 React 渲染。列表里上百个 NodeView 会拖慢编辑器,考虑 memoization 或退回纯 DOM 渲染——细节见 性能与可访问性。
dispatchTransaction 中间件:事务级拦截
扩展可用 onTransaction 观察已完成 dispatch 的事务:Tiptap 已应用事务并更新 EditorView;它可读取事务与最新状态,却不能取消或改写当前这次 dispatch。若要在事务进入状态前检查、修改或阻断,则定义 dispatchTransaction({ transaction, next })。后者会按 priority 组成一条链:每个环节可以检查 / 修改 / 拦截事务,再交给下一环。高优先级先跑并包住后面的——比如 A(110)会包住 B(100),A 拦截一个事务就会阻断下游所有处理。这能做安全过滤、协同绑定等事务级中间件,模型对齐 PM 的 plugin 架构(见 Plugin 与 Decoration);协同场景的取舍见 协同编辑。
常见踩坑清单
content与 schema 不匹配 → 静默丢弃。 内容 JSON 里若含 schema 中没有的 Node/Mark 类型,setContent()默认会无声丢掉,不报错(v3 可传errorOnInvalidContent: true改成抛错)。最典型:你heading: false关掉标题后,内容里所有标题节点直接蒸发。务必确保 schema 覆盖所有想保留的内容类型。parseHTML/renderHTML时序非对称。 用 JSON 初始化时parseHTML永远不触发(只在 paste/HTML init 时跑);renderHTML在显示期与 copy 时总会跑。排查内容丢失时先确认是哪条路径。setContent默认触发onUpdate(v3)。 受控模式下不加{ emitUpdate: false }会死循环(v2 默认相反,是false)。- SSR / 水合。 编辑器依赖 DOM(
contentEditable、selection),服务端无法有意义地渲染它,必须immediatelyRender: false跳过服务端立即渲染;Next.js 里别忘了'use client'。 onUpdate里直接setState触发flushSync警告。 用queueMicrotask()推迟。- input rule 正则要以
$结尾,paste rule 不带$。 记混了规则就不触发。 - 未声明的属性在 paste/init 时丢失。 HTML 解析只抽取
addAttributes()里声明过的属性。 NodeView复杂 UI 不等于导出。 若renderHTML写得很简单,导出 HTML 就只有那点结构——这是刻意的(编辑期 UI ≠ 输出格式),但容易让人以为「导出丢了东西」。- Node 的
content默认undefined。 不显式写content: 'inline+'/'block+',节点会退化成 atom(像 image 一样不可编辑、不可嵌套)。 - 设置节点/Mark 属性时使用 TipTap 核心命令
updateAttributes(type, attrs),不是setAttributes。setAttributes不是 TipTap 或 ProseMirror 提供的核心命令;updateAttributes须指明目标类型(通常传类型名)。 priority同时影响插件顺序与 Mark 渲染包裹。 没有竞争 Mark 时隐形,一旦重叠就暴露。extensions至少要能撑起合法 schema。 最小集也要Document/Paragraph/Text,或直接StarterKit,否则无法构造文档。
参考
- TipTap Getting Started — Overview: https://tiptap.dev/docs/editor/getting-started/overview
- TipTap StarterKit Extension: https://tiptap.dev/docs/editor/extensions/functionality/starterkit
- TipTap Node API: https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new/node
- TipTap Mark API: https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new/mark
- TipTap Custom Extensions — Create New: https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new/extension
- TipTap Nodes and Marks — Core Concepts: https://tiptap.dev/docs/editor/core-concepts/nodes-and-marks
- TipTap Extensions — Core Concepts: https://tiptap.dev/docs/editor/core-concepts/extensions
- TipTap React Node Views: https://tiptap.dev/docs/editor/extensions/custom-extensions/node-views/react
- TipTap updateAttributes Command: https://tiptap.dev/docs/editor/api/commands/nodes-and-marks/update-attributes
- TipTap setContent Command: https://tiptap.dev/docs/editor/api/commands/content/set-content
- TipTap TextAlign Extension: https://tiptap.dev/docs/editor/extensions/functionality/textalign
- TipTap Keyboard Shortcuts: https://tiptap.dev/docs/editor/core-concepts/keyboard-shortcuts
- TipTap Next.js Integration: https://tiptap.dev/docs/editor/getting-started/install/nextjs
- TipTap React Integration: https://tiptap.dev/docs/editor/getting-started/install/react
- TipTap FAQ — parseHTML and renderHTML: https://tiptap.dev/docs/guides/faq
- TipTap Editor 3.0 Release Notes: https://tiptap.dev/tiptap-editor-v3