ByteNoteByteNote
在 Next.js 里集成 Tiptap:SSR 适配与插件实战
字

字节笔记本

2026年10月7日 · 约 12 分钟读完

在 Next.js 里集成 Tiptap:SSR 适配与插件实战

API中转
¥120

在 Next.js 项目里需要一个富文本编辑器时,可选项其实比想象中少:一边是功能齐全但样式被焊死的「全家桶」编辑器,一边是灵活却处处要自己兜底的方案。Tiptap 属于后者——它把老牌编辑器内核 ProseMirror 包装成 React 友好的声明式 API,编辑器「长什么样」完全由你决定。本文以一次完整的集成为主线,走一遍安装、SSR 适配、插件扩展、样式定制四步,最后汇总几个实战中高频踩到的坑。

一、Tiptap 是什么,适合谁用

Tiptap 是构建在 ProseMirror 之上的无头(headless)富文本编辑器框架。ProseMirror 以严谨的文档模型和事务机制著称,是编辑器领域的底层基石,但原生 API 偏函数式,上手门槛高。Tiptap 做的事就是把这套内核封装成「扩展 + 命令链」的模型:加粗、列表、图片,每个功能都是一个可插拔的扩展,按需组合。

「无头」意味着框架只负责数据结构与编辑行为,不渲染任何工具栏和皮肤,视觉层完全交给使用者的 CSS。这在今天比几年前更重要:设计系统普及之后,编辑器不再是孤立的组件,它要跟着暗色模式切换、要复用设计令牌,自带皮肤的编辑器反而成了改造负担。

横向对比常见方案:Quill、CKEditor 自带 UI 和默认样式,集成快,但深度定制时得和它们的 DOM 结构较劲;Slate、Lexical 更底层、更灵活,代价是自己补齐大量细节。Tiptap 站在中间偏灵活的一侧:行为正确性交给 ProseMirror 保证,外观完全交给你的 CSS。对 CMS、笔记类产品、后台评论管理等「编辑器即核心体验」的场景,这笔账通常划算。

二、最小可用集成

装两个包即可:@tiptap/react 提供 React 绑定,@tiptap/starter-kit 是官方预置的扩展合集,涵盖加粗、斜体、标题、列表、代码块、引用、撤销重做等高频功能。

jsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

const TiptapEditor = () => {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World!</p>',
  })

  return <EditorContent editor={editor} />
}

整个编辑器只有两个核心 API:useEditor 创建并持有实例,返回的 editor 是后续一切交互的句柄;EditorContent 把实例挂载到真实 DOM 上。初始 content 接受 HTML 字符串,但正式项目更建议存取 JSON 格式——结构化数据入库,不怕脏 HTML 混进来。后续要回填内容时用 editor.commands.setContent();只读展示场景则根本不需要编辑器实例,直接渲染存好的 HTML 或 JSON 即可。

三、SSR 这道坎

在 Next.js 里集成编辑器,第一坑必然是服务端渲染。Tiptap 依赖浏览器环境,需要在挂载后测量、操作 contentEditable 的真实 DOM,服务端拿不到这些能力,直接 SSR 轻则水合(hydration)警告,重则整页报错。

Tiptap 在 Next.js 中的 SSR 适配路径

为什么偏偏是编辑器过不了 SSR 这一关?普通组件的首帧只是一棵静态 HTML,服务端和客户端各渲染一次也能对上;编辑器不同,contentEditable 节点携带选区、撤销栈这类只在浏览器里存在的运行时状态,ProseMirror 初始化时还要读取真实 DOM 做测量。服务端渲染出的空壳与客户端渲染出的完整结构对不上,水合阶段自然报错。

两种路由器,两种解法。Pages Router 用动态导入关掉 SSR:

jsx
import dynamic from 'next/dynamic'

const TiptapEditor = dynamic(
  () => import('../components/TiptapEditor'),
  { ssr: false }
)

App Router 里 next/dynamic 的 ssr: false 已经不可用,正确姿势是给编辑器组件加 'use client' 声明,把它整体划入客户端边界;配合较新版本的 @tiptap/react,再给 useEditor 传 immediatelyRender: false,让编辑器等到客户端水合阶段再渲染,规避服务端与客户端首帧不一致的问题。这个选项是 Tiptap v2 后期为适配 App Router 加入的,老版本只能自己用 useEffect 加 mounted 状态兜底。

四、插件:扩展即功能

StarterKit 之外的官方扩展按需安装,常用的有 @tiptap/extension-highlight(文本高亮)、extension-underline(下划线)、extension-text-align(对齐)、extension-image(图片)等,装完挂进 extensions 数组即生效。

有了扩展,工具栏就只是一层薄薄的 UI。Tiptap 的命令链(command chain)把每个操作表达成一条链式调用:

jsx
const MenuBar = ({ editor }) => {
  if (!editor) return null

  return (
    <button
      onClick={() => editor.chain().focus().toggleBold().run()}
      className={editor.isActive('bold') ? 'is-active' : ''}
    >
      Bold
    </button>
  )
}

chain() 开启一个事务,focus() 聚焦编辑器,toggleBold() 声明意图,run() 提交执行——原子命令自由串接,多次操作在撤销栈里只记一次。editor.isActive('bold') 则用于回推当前选区状态,工具栏按钮的高亮效果就靠它。对齐这类作用于块级元素的扩展,需要显式声明作用对象:

Tiptap 命令链与扩展清单

js
TextAlign.configure({ types: ['heading', 'paragraph'] })

顺带一个实用细节:StarterKit 里的扩展都能单独关掉,比如 StarterKit.configure({ heading: false }),用来避免与自己手动引入的扩展重复注册——同一类扩展挂两次,行为会变得难以预测。此外还有 placeholder(占位提示)、character-count(字数统计)、task-list(任务列表)等官方扩展,代码块高亮、表格也有对应包,按需取用即可。

五、样式定制:无头的代价与回报

选了无头编辑器,样式就得自己写——这是代价,也是回报。Tiptap 渲染出的内容都挂在 .ProseMirror 容器下,针对它写一段排版 CSS 即可:段落间距、标题行高、行内代码底色、代码块的深色背景、图片自适应宽度、引用块的左边线,都是常规套路,百来行 CSS 就能让编辑区达到生产级观感。

css
.ProseMirror {
  > * + * { margin-top: 0.75em; }

  pre {
    background: #0d0d0d;
    color: #fff;
    font-family: 'JetBrains Mono', monospace;
    padding: 0.75rem 1rem;
    border-radius: 0.5rem;
  }

  img { max-width: 100%; height: auto; }

  blockquote {
    padding-left: 1rem;
    border-left: 2px solid rgba(13, 13, 13, 0.1);
  }
}

除了内容排版,编辑器容器本身的「壳」也要自己搭:边框、圆角、聚焦时的描边、工具栏与内容区的分隔线,这些属于产品层样式,和设计系统对齐即可。另外建议给内容区设一个合理的最大行宽,长文编辑的阅读体验会好很多。移动端别忘了响应式:小屏下缩小编辑区字号、隐藏部分低频按钮,一段媒体查询就能覆盖。

六、几个高频踩坑点

最后把实战里最容易踩的坑汇总一下:

  1. editor 首帧为空。水合完成前 useEditor 返回 undefined,所有依赖 editor 的子组件(典型如工具栏)都要做空值守卫,否则一上来就报错。
  2. 内容回存时机。编辑器不会自动同步内容,要监听 onUpdate 回调,用 editor.getHTML() 或 getJSON() 取出内容入库;JSON 结构化程度更高,配合防抖写入本地存储,还能顺手做草稿恢复。
  3. Image 扩展只管渲染。它定义了图片节点的数据结构与序列化,真正的上传逻辑(选文件、传对象存储、回填 URL)要自己接,通常通过自定义粘贴或拖放 handler 实现。
  4. 样式作用域。ProseMirror 产出的是裸 HTML,若把 getHTML() 的结果直接渲染到阅读页,编辑区的排版 CSS 要能同时作用于阅读页。建议把编辑器样式抽成共享样式表,保证「编辑所见」与「读者所得」一致。
  5. 实例销毁与 StrictMode。若绕开 useEditor 手动 new Editor,务必在组件卸载时调用 editor.destroy() 释放事件监听与 ProseMirror 插件;React 18 开发模式的 StrictMode 会故意双跑 effect,销毁逻辑没写对,就会出现重复实例或内存泄漏,而且只在生产环境之外复现,排查起来很费时间。

写在最后

Tiptap 在 Next.js 里的集成路径其实很短:useEditor 建实例、EditorContent 挂 DOM、动态导入或 'use client' 解决 SSR、extensions 数组堆功能、.ProseMirror 挂样式。真正的工作量在集成之后——上传链路、协同编辑、内容校验才是深水区。选型判断也简单:如果编辑器只是表单里的一个备注框,全家桶更快;如果内容编辑是产品核心链路,Tiptap 的可控性值得这点前期投入。

相关文章

分享: