TipTap 实战与自定义扩展(自定义 Node / Mark / Command)

返回 🗂 富文本编辑专题 · 相关私有笔记

这篇是动手向:从 new Editor({ extensions, content }) 起步,逐步写出自定义 Node / Mark / Extension,挂上命令、输入规则与快捷键,再用框架 NodeView 做交互节点,最后收一遍踩坑。TipTap 的每个 API 几乎都是 ProseMirror 内核某个概念的声明式封装,但第一次照着跑不必先学完整内核;每个用法都保留了可按需回看的底层链接。

产品路线起点:这是第一次接触富文本时可以直接开始的一篇。先完成“第一轮”,你就有一个可输入、可加粗、可导出内容的编辑器;不要为了理解每个回调而卡在第一段代码前。

最小先修:会 JavaScript / TypeScript 和浏览器 DOM 即可。StarterKit 会提供最小合法 Schema;若“浏览器 DOM 为什么不是文档真相”这件事仍陌生,先花几分钟读 ProseMirror 入门

按需继续:

  • 想知道 Extension 如何被编译成 Schema、Plugin、keymap,在跑通最小编辑器后读 TipTap 架构
  • 只有真的要多人同时编辑时才进入 协同编辑
  • 只有测到大文档卡顿、NodeView 重渲染或需要无障碍验收时才进入 性能与可访问性
  • CJK 换行、分页、高度预测和只读测量属于 排版与布局 的任务范围,不阻塞本篇的首个编辑器。

第一轮:先运行一个编辑器

起步: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:BlockquoteBulletListCodeBlockDocumentHardBreakHeadingHorizontalRuleListItemOrderedListParagraphText
  • 6 个 Mark:BoldCodeItalicLink(v3 新增)、StrikeUnderline(v3 新增)。
  • 5 个 Extension:DropcursorGapcursorUndo/RedoListKeymap(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 条目;额外支持 keepOnSplitinclusive 等。
  • 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,越高越先。它同时影响两件事:

  1. PM 插件执行顺序——高优先级的 plugin 先跑(见 Plugin 与 Decoration)。
  2. 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 期望异步更新——直接在 onUpdatesetState 会触发 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>
    </>
  )
}

setContentemitUpdate

setContent(content, options?) 替换整个文档。{ emitUpdate } 在 v3 默认是 true(会触发 onUpdate;v2 默认是 false)。受控模式必须用 emitUpdate: false,否则会形成死循环:父组件 → setContentonUpdate → 父组件 → ……

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) 包裹组件;组件会收到 nodeupdateAttributes()deleteNode()getPos()selectededitorextensiondecorations 等 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(contentEditableselection),服务端无法有意义地渲染它,必须 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,否则无法构造文档。

参考