尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3+Tiptap富文本编辑器实战:扩展机制、AI集成与私有部署

Vue3+Tiptap富文本编辑器实战:扩展机制、AI集成与私有部署 简介Umo Editor是一款基于Vue3和Tiptap的本土化开源文档编辑器具备分页模式、Markdown语法、富文本编辑、AI创作及页面样式自定义等能力代码完全开源且支持私有部署重点解决国内用户对编辑器安全性、可控性与本地化体验的需求。包内为完整的项目源码共454个文件核心以Vue单文件组件、TypeScript逻辑代码、SVG图标资源和JSON配置文件构成辅以Less样式与PNG图片压缩包整体仅392KB结构目录清晰便于快速启动和二次开发。目前已有492人学习下载适合有前端基础的技术人员、博客作者或需要集成在线文档能力的团队学习使用。通过阅读源码可以深入掌握Tiptap扩展开发、Markdown与富文本协同编辑、分页排版、文档导出打印以及暗色主题切换等核心模块的实现思路同时可直接将项目私有化部署到自己的服务器获得完全自主可控的文档编辑方案。1. 为什么是 Vue3 TiptapUmo Editor 的编辑内核与选型逻辑很多人看到 Umo Editor 的第一反应是「又一个 Notion 克隆」但它真正解决的痛点是在私有部署环境里把 Markdown 快捷输入、富文本排版、分页预览、AI 续写和 PDF 导出同时放进同一个编辑内核自己从零搭至少要两个月。Umo Editor 基于 Vue3 和 Tiptap底层文档模型由 ProseMirror 管理UI 和应用层由 Vue 组件接管。所以它既能像 Typora 一样用 Markdown 语法直接转格式又能像 Word 一样分页和设置页面样式还保留 Tiptap 扩展机制方便接入自己的 AI 接口和存储。适合正在做在线文档、知识库、CMS 富文本模块的团队二开也适合想研究 Tiptap 插件机制的前端工程师。2. Tiptap 扩展机制与富文本编辑器的初始化搭建2.1 ProseMirror 文档模型与 Tiptap 的封装边界先理清这一层关系后面改需求时才知道往哪儿下手。Tiptap 不是从 DOM 上直接读写内容的编辑器它是 ProseMirror 之上的 Vue 封装。ProseMirror 维护的是一棵符合 schema 约束的文档树每一次编辑都是一个 transaction从旧 state 派生出新 state再同步渲染到 DOM。这个模型带来的直接好处是撤销、协作、AI 批量写入这类操作都有了稳定的事务层接口而不是靠对比 DOM diff 去猜用户改了啥。Umo Editor 选 Tiptap 而不是自己写 contenteditable核心原因是 schema。比如你允许插入图片节点那图片的 src、alt、width 这些属性就在 schema 里被声明过了粘贴外部 HTML 时不符合 schema 的标签会被剥离或降级从结构上避免了 XSS 和脏数据。这在国内内容管理场景里特别实用因为从公众号、Word 复制过来的 HTML 经常带大量内联样式和危险标签。Tiptap 的封装边界也很清晰文档状态归 ProseMirror扩展逻辑通过 Extension 注册界面层完全用 Vue 组件写。也就是说工具栏、气泡菜单、斜杠菜单这些全是 Vue 组件不涉及 ProseMirror 内部 API。这正好是 Umo Editor 这类项目能快速做本土化的原因——改按钮、改面板、加 AI 对话框都是在 Vue 层做事不碰内核。2.2 从零初始化一个 Vue3 Tiptap 编辑器实例如果你已经装好了 Vue3 环境直接建一个空白组件下面是最小可用的编辑器实例。script setup langts import { useEditor, EditorContent } from tiptap/vue-3 import StarterKit from tiptap/starter-kit import { onBeforeUnmount } from vue const editor useEditor({ content: h2从这里开始写/h2p/p, extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, codeBlock: { languageClassPrefix: language- }, }), // Umo Editor 的分页、AI、表格等扩展在这里统一注册 ], editorProps: { attributes: { class: umo-content, }, }, }) onBeforeUnmount(() { editor.value?.destroy() }) /script template EditorContent :editoreditor / /templatecontent是初始文档内容Tiptap 会按已注册扩展的 schema 解析它。extensions数组决定了编辑器的能力边界没注册的节点即使 HTML 里有也会被剥掉。editorProps.attributes.class会挂到可编辑区域的 DOM 上分页样式的容器选择器就是用它来锚定的。组件卸载时调用destroy()释放编辑器事件监听否则在路由切换或热更新场景下会出现事件泄漏。拿到 Umo Editor 源码后你会发现它的扩展集合已经被组织成一层聚合配置而不是让你自己逐个拼 StarterKit。实际集成时一般只需要替换上面代码里的 extensions 数组再传入 Umo 暴露的默认配置项比如工具栏按钮开关、AI 接口地址、分页模式默认值。每个配置项都有默认值这意味着开箱即用不需要理解 ProseMirror 细节就能先跑起来。2.3 StarterKit 裁剪与 Umo 扩展注册的参数对照StarterKit 是 Tiptap 官方聚合包里面包含 heading、bulletList、codeBlock、history 等常用扩展。但直接全量引入会有问题六级标题、链接点击跳转、历史深度这些行为不一定符合中文文档编辑习惯。我在做这类编辑器时习惯先列一张参数对照表再决定去留。配置项默认值场景建议说明heading.levels[1,2,3,4,5,6]文档类只留 1-3 级层级太深会导致目录和分页导航混乱history.depth100长文档调到 200单位是事务数不是步数codeBlock.languageClassPrefixlanguage-与高亮插件保持一致换成 Shiki 时前缀必须匹配link.openOnClickfalse编辑器内禁止跳转防止误点导致文档内容丢失paragraph默认启用保留整个 schema 的根基节点表格里的每一项都有实际意义。比如把 heading 限制在 1-3 级是因为国内在线文档产品基本只暴露三级标题超出三级一般用有序列表或加粗替代。link.openOnClick默认关闭是安全考虑用户正在编辑时误点链接会直接跳走体验很差Umo Editor 这类编辑器通常会让用户按住 Ctrl 再点击才跳转。另一个需要关注的是history.depth。分页模式下每次图片加载、页面重排都可能产生额外事务撤销栈会消耗得比普通编辑器快。如果文档里大量插入截图100 的深度可能不够用。这里可以按团队实际测试结果去调不需要迷信默认值。3. Markdown 实时语法、分页模式与复杂节点插入的实现3.1 input rules 里的 Markdown 语法与表格粘贴处理Umo Editor 支持 Markdown 语法工作方式不是「先输 Markdown 再转换」而是输入过程中实时把语法标记替换为富文本节点。这个机制在 Tiptap 里叫 input rules。它监听每次字符输入用正则匹配当前行尾的文本命中后替换为对应节点。import { Mark, markInputRule } from tiptap/core // 示例输入 **粗体** 时实时加粗 const BoldMark Mark.create({ name: bold, parseHTML() { return [{ tag: strong }, { tag: b }] }, renderHTML() { return [strong] }, addInputRules() { return [ markInputRule({ find: /\*\*([^*])\*\*$/, type: this.type, }), ] }, })正则末尾的$是必须的它把匹配范围锚定到光标所在行尾避免把正文中间已经存在的**也误判成语法标记。比如一段代码里写了a ** b如果没有$锚定会触发意外的加粗。中文输入法选词时偶尔会触发 input rule 误匹配所以实际产品里会做一层「composition 期间不执行 input rules」的判断防止输入法候选词被编辑器吞掉。表格的粘贴逻辑比输入规则复杂得多。从 Excel 或网页复制的表格在剪贴板里是以text/html形式存在的里面可能是 table 标签也可能是内联样式堆出来的假表格。只走 Markdown parser 会丢掉合并单元格、宽度等关键信息。常见做法是给 table 节点单独注册 paste rules优先解析text/html里的真实 table 结构解析失败再降级为 Markdown 文本。3.2 分页模式的内核思路与页面样式设置分页模式是这个编辑器区别于普通 Markdown 编辑器的关键能力。这里的实现难点不在 CSS而在于内容如何在页面容器之间流动。目前两种主流路线paged.js 这类真实分页媒体渲染或者基于 block 切片的软分页。我的建议是软分页它和编辑器光标的兼容性最好。分页模式下每个页面容器是一个固定 A4 尺寸的 block。内容按 block 节点切分逐个测量高度超过页面阈值就放进下一页容器。图片加载完成后 block 高度会变化所以必须监听ResizeObserver再触发一次重排否则会出现图片溢出页面边界或大面积留白。.umo-page { width: 210mm; min-height: 297mm; margin: 0 auto 16px; padding: 25.4mm 31.7mm; background: var(--umo-surface); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); box-sizing: border-box; break-inside: avoid; }这里有几个参数值得解释。210mm和297mm是 A4 纸的标准尺寸25.4mm是 Word 默认的一英寸页边距左右31.7mm则对应常见的 1.25 英寸页边距设置。break-inside: avoid是告诉浏览器不要在段落或列表中间强行断页这对中文长段落特别重要。页面之间用margin-bottom拉开间距让编辑态看起来像是纸页悬浮在灰色画布上。页面样式设置功能对应的就是这些参数的可配置化。用户改页边距实际改的是渲染层的 padding用户改纸张大小改的是 page 容器的宽高。实现时把这几个值抽成响应式配置对象通过 Vue 的 reactive 注入到样式绑定里不需要动 ProseMirror 层任何代码。3.3 数学公式、图片和表格节点的注册与渲染数学公式是技术文档的刚需。Tiptap 里数学公式需要实现成独立节点而不是存成纯文本这样导出时才能区分「这是公式」和「这是一段包含美元符号的文字」。常见约定是$...$表示行内公式$$...$$表示块级公式。import { Node, InputRule } from tiptap/core export const InlineMath Node.create({ name: inlineMath, inline: true, group: inline, atom: true, addAttributes() { return { expression: { default: , parseHTML: (el) el.getAttribute(data-math), renderHTML: (attrs) ({ data-math: attrs.expression }), }, } }, parseHTML() { return [{ tag: span[data-math] }] }, renderHTML({ node }) { return [span, { data-math: node.attrs.expression }, node.attrs.expression] }, addInputRules() { return [ new InputRule({ find: /\$([^$\s][^$]*)\$$/, handler: ({ state, range, match }) { const expression match[1] // dispatch 一个替换事务把匹配文本替换为 math 节点 }, }), ] }, })渲染公式时通常接 KaTeX编辑态展示源码还是渲染结果取决于产品偏好。我一般建议编辑态显示源码预览态渲染公式因为公式源码里有大量反斜杠实时渲染的视觉反馈是滞后的也容易让光标定位产生偏移。图片节点的坑在粘贴。中国用户习惯直接截图后 CtrlV 粘到文档里但 Tiptap 默认不处理剪贴板文件。需要在扩展里注册handlePaste读取event.clipboardData.files将图片转成 base64 或上传得到 URL 后再插入 image 节点。直接粘贴 base64 有个隐患一张截图可能 2MB一旦超过浏览器存储配额文档就打不开了。所以私有部署场景下图片应当优先走上传接口。4. AI 创作功能的插件化设计与私有部署链路4.1 为什么 AI 功能要放进 Tiptap 插件而不是普通 Vue 组件AI 创作功能最容易做错的地方是把生成结果作为字符串塞回编辑器。这样做会丢掉光标位置、破坏撤销栈、遇到正在选区替换时还会出现内容错位。问题的根源是普通 Vue 组件拿不到编辑器的事务层权限。AI 创作这个场景需要三个能力读取当前光标附近的上下文、在指定位置插入生成内容、在生成过程中保持用户可以撤销。这三个能力都依赖 ProseMirror 的 state 和 transaction所以 AI 功能必须做成 Tiptap 插件。界面层仍然是 Vue 组件但组件只负责展示对话流和触发命令真正读写文档的是插件里的 command。组件、命令、插件三者的分工是Vue 组件渲染 AI 面板command 接收 prompt 并触发请求ProseMirror Plugin 负责在生成期间对文档加 loading 标记。这样设计还有一个好处AI 功能的入口可以有很多个——工具栏按钮、斜杠菜单、快捷键——但底层逻辑只有一份。4.2 一个最小可用的 AI 续写扩展实现下面是一个能跑通的 AI 续写 Extension 骨架。它做的事情是读取光标前面的文本作为上下文请求后端接口然后把返回内容逐段插入到当前光标位置。import { Extension } from tiptap/core import { Plugin, PluginKey } from tiptap/pm/state export interface AiWriteOptions { apiUrl: string locale: zh-CN | en-US maxContextChars: number } export const AiWrite Extension.createAiWriteOptions({ name: aiWrite, addOptions() { return { apiUrl: /api/ai/complete, locale: zh-CN, maxContextChars: 600, } }, addCommands() { return { aiWrite: () ({ editor, tr, dispatch }) { const { from } editor.state.selection const start Math.max(0, from - this.options.maxContextChars) const context editor.state.doc.textBetween(start, from, \n) const apiUrl this.options.apiUrl queueAiCompletion( { prompt: context, locale: this.options.locale }, apiUrl, (chunk) { editor.chain().focus().insertContent(chunk).run() }, ) return dispatch ? dispatch(tr) : true }, } }, addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey(aiWrite), // 生成期间可通过 decorations 在光标处显示 loading 状态 }), ] }, })apiUrl指向后端代理接口而不是直接请求模型服务商因为模型密钥不能出现在前端代码里。locale参数会传给后端由后端决定用中文还是英文 prompt 模板。maxContextChars控制上下文窗口600 字约等于一般模型单轮输入的性价比区间太长会拖慢响应太短则生成内容缺乏上下文连贯性。流式回填是另一个关键点。常见的做法是用fetch读取流式响应每拿到一段文本就用insertContent插入一次。async function queueAiCompletion( payload: { prompt: string; locale: string }, apiUrl: string, onChunk: (text: string) void, ) { const res await fetch(apiUrl, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload), }) const reader res.body?.getReader() const decoder new TextDecoder() if (!reader) return while (true) { const { done, value } await reader.read() if (done) break onChunk(decoder.decode(value, { stream: true })) } }逐段插入比一次性插入体验好得多用户能看到文字依次生成心理上更容易接受 AI 输出的节奏。但要注意事务频率每段都发起独立事务会拖慢渲染。实际处理时可以在前端做缓冲大概 50ms 内的多个 chunk 合并成一次insertContent既保持流畅度又不至于压垮编辑器的渲染循环。4.3 私有部署构建与 API 代理配置Umo Editor 支持私有部署这个特性在企业场景里很重要文档数据不出内网AI 请求也在内网完成。构建产物是纯静态文件部署方式和普通 Vue3 项目没有区别。构建命令通常会输出 dist 目录里面是打包好的静态资源。接下来需要一个 Web 服务器托管这些文件同时把 AI 和上传接口做反向代理。server { listen 80; server_name docs.example.local; root /var/www/umo; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html是 SPA 部署的标配它保证前端路由在刷新时不会 404。proxy_pass把/api/路径下的请求转发给本地后端服务AI 密钥、文件存储密钥都只存在于后端环境变量里前端永远接触不到。这里有一个容易踩的坑如果后端服务返回的响应体很大或者是流式响应nginx 默认配置下可能会缓冲整个响应导致 AI 输出的第一个字迟迟不出现。碰到这种情况可以在 location /api/ 里关掉代理缓冲或者调大缓冲阈值。另外所有走代理的请求都要确认超时时间AI 生成可能超过默认的 60 秒需要把proxy_read_timeout调长。5. 文档导出 PDF、打印样式与 Markdown 迁移的坑5.1 导出链路的取舍Markdown 与 HTMLUmo Editor 支持多种导出格式但要理解这些导出不是同一条代码链路。Markdown 导出是把 ProseMirror 文档树序列化成 AST再转成 Markdown 文本HTML 导出是直接调用编辑器实例的getHTML()PDF 导出走的是打印样式加浏览器打印引擎。// 导出 Markdown 文件 const md editor.getMarkdown() const blob new Blob([md], { type: text/markdown;charsetutf-8 }) const url URL.createObjectURL(blob) const link document.createElement(a) link.href url link.download export-${Date.now()}.md link.click() URL.revokeObjectURL(url)Blob的 MIME 类型里必须带charsetutf-8否则 Windows 记事本打开时中文会乱码。URL.revokeObjectURL要在点击下载后立即调用及时释放内存。editor.getMarkdown()依赖编辑器实例上注册的 Markdown 序列化扩展如果 Markdown 语法在输入规则里生效但序列化扩展缺失导出的 Markdown 会丢失表格或公式。三种导出链路各有适用场景做选型时按这个思路判断。导出格式生成链路适用场景主要限制Markdowndoc 转 AST 再转文本Git 管理、二次编辑公式和表格依赖专用序列化器HTMLeditor.getHTML()邮件正文、嵌入页面样式依赖编辑器自带 CSSPDF打印样式 window.print()存档、打印分页控制受浏览器引擎影响5.2 打印样式与分页控制PDF 导出最省事的路径是把编辑器切到分页模式然后用window.print()打印当前页面。但默认打印会把编辑器的工具栏、气泡菜单、页面阴影全部打进去所以必须针对打印媒介单独写一套覆盖样式。media print { body { background: #fff !important; } .umo-toolbar, .umo-ai-panel, .umo-sidebar { display: none !important; } .umo-page { width: auto; min-height: auto; margin: 0; box-shadow: none; break-inside: avoid; page-break-after: always; } pre, blockquote, table, img { break-inside: avoid; page-break-inside: avoid; } }display: none把编辑态的交互组件全部隐藏用户在打印对话框中看到的就是干净的文档。.umo-page去掉了固定宽高和阴影让它回归正常文档流的页面。page-break-after: always是旧语法break-inside: avoid是新语法两者共存是为了兼容不同版本的浏览器内核。图片和表格的break-inside: avoid防止一个元素被截断打印到两页。打印样式里有一个经常被忽略的点背景色。编辑器在暗色主题下文字是浅色的直接打印会把一整页深色背景也打出来耗费大量墨粉。所以打印样式里必须强制把背景设为白色文字颜色恢复成深色。如果产品需要支持「打印时保留代码块背景色」那要加上print-color-adjust: exact并让用户明确勾选浏览器的背景图形选项。5.3 导出踩坑图片路径、中文字体与外部工具依赖导出 Markdown 时最常踩的坑是图片路径。用户在编辑态用的是相对路径或本地 base64导出后放到别的地方就打不开。相对路径的处理逻辑一般是导入时把图片转成标准 URL 或 base64导出前检查一遍。const md editor.getMarkdown() const fixed md.replace(/!\[(.*?)\]\((.*?)\)/g, (raw, alt, src) { return src.startsWith(http) ? raw : ![${alt}](${toAbsoluteUrl(src)}) })这个正则只对 Markdown 导出有效HTML 导出需要遍历文档里的 img 节点改src属性。toAbsoluteUrl的具体实现取决于你的资源存储位置私有部署一般指向文件服务域名。注意正则在这里是兜底方案如果编辑器内部有更好的序列化钩子建议在序列化阶段就处理好而不是事后用字符串替换。中文文档导出的另一个痛点是字体。浏览器打印时中文字体用的是系统字体不同操作系统的字体回退规则不一样导致同一个 PDF 在 Windows 和 macOS 上打开字体不一致。做法是在打印样式中显式声明中文字体栈比如font-family: Source Han Sans SC, Microsoft YaHei, sans-serif同时避免在 CSS 里使用过细的 font-weight很多中文字体在 300 字重下会糊成一团。相比在 VSCode 里导出 PDF 需要额外安装 PrinceXML 等外部渲染工具Umo Editor 这类网页编辑器直接在编辑态做分页渲染打印时把页面容器交给浏览器整个链路少了一层外部依赖部署环境更干净。6. 多语言资源切换与暗色主题的进阶扩展技巧6.1 语言包动态加载与业务词条拆分Umo Editor 的仓库里能看到 zh-CN.json、en-US.json、ru-RU.json 这样的语言包文件还有 bo.json 这类业务词条配置。多语言切换如果全部用静态 import会把所有语言的文案一次性打进主包对编辑器这种需要快速加载首屏的场景不划算。所以语言包要走动态加载。const locales import.meta.glob(../locales/*.json) export async function loadLocale(lang: string) { const mod await locales[../locales/${lang}.json]() const messages mod.default ?? mod document.documentElement.lang lang return messages }import.meta.glob是 Vite 提供的按需加载机制它会把匹配到的每个 JSON 文件拆成独立 chunk只有在loadLocale(ru-RU)被调用时才加载对应的资源。这里的mod.default是处理 JSON 模块的默认导出差异不同构建工具和配置下 JSON 模块的导出结构不完全一致。语言包拆分的逻辑也值得注意。bo.json 这类业务词条和 zh-CN.json 这类界面文案分离是有实际好处的接手团队可以只覆盖业务词条文件完成品牌化定制而不需要动编辑器核心的界面文案同时业务词条通常变化频繁拆分后单独发版不会污染主要语言包。切换语言时还要注意编辑器历史记录里已有的节点属性比如有些节点渲染时会读取语言包的文案旧文档需要触发一次重渲染才能生效。6.2 用 CSS 变量做暗色主题浮层才能不翻车暗色主题如果只做在编辑器页面上AI 对话框、工具栏下拉菜单、气泡菜单等浮层会原形毕露——因为它们通常挂在 body 或编辑器根节点之外的容器里页面级主题类名无法覆盖到。正确做法是把主题作用域收敛到编辑器根节点并且所有浮层都挂在根节点内部这样 CSS 变量才能统一传递。[data-themedark] { --umo-bg: #1e1e1e; --umo-surface: #252525; --umo-text: rgba(255, 255, 255, 0.86); --umo-border: #3a3a3a; --umo-accent: #4f8cff; } .umo-editor { background: var(--umo-bg); color: var(--umo-text); border-color: var(--umo-border); } .umo-editor .ProseMirror-selectednode { outline-color: var(--umo-accent); }使用style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
返回列表