阅见项目社区发帖功能实战:Tiptap富文本编辑器落地与实现

在本次开发的过程中,我主要负责的是完成社区发帖功能,其中包括富文本编辑框,图片上传,帖子的基础的增删改查功能以及详情页的展示功能,下面是具体的开发过程。

目录

一、项目架构与功能模块设计

1.1 核心功能模块划分

1.2 数据模型设计

二、核心实现:RichTextEditor.vue组件开发

2.1 组件设计思路

2.2 Tiptap扩展选型

2.3 工具栏与浮动菜单实现

2.3.1 工具栏实现

2.3.2 浮动菜单实现

2.4 图片上传全流程实现(前后端协同)

2.5 高级功能:表格操作实现

三、配套功能实现:发帖管理与内容展示(前后端协同)

3.1 帖子管理(MyPosts.vue)

3.1.1 发帖/编辑对话框

3.1.2 帖子删除逻辑

3.2 帖子列表与详情展示(前后端协同)

3.2.1 列表展示逻辑

3.2.2 详情页展示逻辑(PostsDetail.vue)

3.2.2.1 核心痛点与解决方案

3.2.2.2 DOMPurify配置与安全逻辑(前后端协同)

四、关键技术细节与踩坑记录

4.1 图片路径处理

4.2 代码块语法高亮的正确引入

4.3 DOMPurify配置与表格样式丢失

4.4 编辑器v-model双向绑定避免循环更新

五、前后端数据交互设计

5.1 RESTful API设计

5.2 HTTP请求封装

六、用户体验优化细节

七、实战总结


一、项目架构与功能模块设计

通过分析社区发帖的应用场景,我们把它拆分为了以下功能模块。

1.1 核心功能模块划分

共划分3个核心模块,各模块独立运行且相互联动,实现发帖功能闭环:

  1. 发帖与编辑(MyPosts.vue):用户个人发帖管理中心,核心承载Tiptap编辑器的调用,实现新帖创建、旧帖编辑、帖子删除、审核状态查看等功能,是编辑器的主要应用场景。

  2. 帖子详情(PostsDetail.vue):展示帖子完整内容,支持点赞、评论等互动功能,核心是安全渲染Tiptap生成的HTML内容,避免XSS攻击,同时保证排版美观。

  3. 富文本编辑器组件(RichTextEditor.vue):整个功能的核心组件,被发帖、编辑页面复用,封装Tiptap的配置、工具栏、交互逻辑,对外提供极简调用接口,降低使用成本。

1.2 数据模型设计

帖子数据核心是“内容存储”,Tiptap生成标准HTML字符串,我们直接将其存入数据库,减少前后端转换复杂度,同时做好双重安全防护。核心数据表(MySQL)设计贴合前端需求,前后端协同实现数据的存储与调用,具体逻辑如下:

前端层面,表单收集帖子标题、封面图片、Tiptap编辑的内容(HTML格式),封装成统一数据结构提交后端;后端层面,设计vr_community_post数据表,核心字段贴合前端提交的数据需求,同时预留扩展字段,具体实现如下:

  • 核心字段设计:id(帖子唯一标识)、user_id(关联发布用户,与用户表关联)、title(帖子标题)、content(存储Tiptap生成的HTML字符串,对应前端编辑内容)、img(封面图片URL,单独存储便于列表快速渲染)、tag(帖子标签)、like_count(点赞数)、comment_count(评论数)、status(审核状态,前端展示对应状态文案)、created_at/updated_at(创建/更新时间,用于前端格式化展示)。

  • 前后端数据协同:前端提交帖子数据时,将封面图片URL、HTML格式内容等参数封装成JSON对象,通过POST请求提交至后端;后端接收请求后,验证参数合法性(如标题、内容非空),将数据存入对应字段,同时对content字段的HTML内容进行转义处理,双重防护;查询时,后端直接返回数据表中的原始数据,前端无需额外转换,直接用于渲染或编辑回显。

二、核心实现:RichTextEditor.vue组件开发

该组件是发帖功能的核心,设计目标是功能完备、交互友好、易于复用、样式可定制,核心实现围绕Tiptap配置、工具栏/浮动菜单、图片上传、表格操作四大模块展开,同时兼顾前后端协同,具体如下:

2.1 组件设计思路

我们希望这个组件能满足“开箱即用”的需求,调用方无需关注Tiptap的内部实现,只需通过v-model绑定内容,通过props传递占位符、样式等参数即可。同时,组件内部要处理好编辑器的初始化、内容同步、交互反馈等细节。

2.2 Tiptap扩展选型

Tiptap的强大之处在于其模块化扩展,我们根据业务需求,选择性引入所需扩展,并进行个性化配置,核心代码如下:

// 初始化 lowlight(代码高亮)
// 关键修复:传入 common 对象,而不是 highlight.js 实例
const lowlight = createLowlight(common)

// 创建编辑器实例
const editor = useEditor({
  content: props.modelValue,
  extensions: [
    // 基础套件(包含加粗、斜体、标题、列表、代码块等)
    // 注意:StarterKit 已经包含了 Link 和 Underline,但我们需要自定义配置
    StarterKit.configure({
      codeBlock: false, // 禁用默认代码块,使用带高亮的版本
      link: false, // 禁用 StarterKit 的 link,使用自定义配置
      underline: false, // 禁用 StarterKit 的 underline,使用自定义配置
    }),
    
    // 占位符
    Placeholder.configure({
      placeholder: props.placeholder,
      emptyEditorClass: 'is-editor-empty',
    }),
    
    // 下划线扩展(StarterKit 不包含,需要单独添加)
    UnderlineExtension,
    
    // 图片扩展
    Image.configure({
      inline: false,
      allowBase64: true,
      HTMLAttributes: {
        class: 'editor-image',
      },
    }),
    
    // 链接扩展(自定义配置)
    Link.configure({
      openOnClick: false,
      HTMLAttributes: {
        class: 'editor-link',
        target: '_blank',
        rel: 'noopener noreferrer',
      },
    }),
    
    // 表格扩展
    Table.configure({
      resizable: true,
      HTMLAttributes: {
        class: 'editor-table',
      },
    }),
    TableRow,
    TableHeader,
    TableCell,
    
    // 带语法高亮的代码块
    CodeBlockLowlight.configure({
      lowlight,
      HTMLAttributes: {
        class: 'editor-code-block',
      },
    }),
  ],

这里有3个关键细节需要注意:

  1. StarterKit的部分功能需要禁用:因为我们要使用自定义配置的扩展(如CodeBlockLowlight、Link),如果不禁用默认功能,会导致冲突;

  2. lowlight的正确引入:不要直接导入整个highlight.js,而是通过createLowlight引入所需语言(common包含常用语言),减少打包体积;

  3. 图片扩展的inline配置:设为false确保图片独占一行,符合社区发帖的排版习惯,避免图文错乱。

2.3 工具栏与浮动菜单实现

工具栏与浮动菜单的核心目标是提升编辑效率,贴合主流编辑器交互习惯,前后端无需额外协同,仅前端实现交互逻辑即可,具体如下:

2.3.1 工具栏实现

按功能分组设计工具栏,清晰区分文本格式、段落结构、插入功能,便于用户快速找到所需操作,核心逻辑:

<div class="toolbar-group">
  <!-- 文本格式组:加粗、斜体、下划线等 -->
  <button
    type="button"
    @click="editor.chain().focus().toggleBold().run()"
    :class="{ 'is-active': editor.isActive('bold') }"
    title="加粗 (Ctrl+B)"
  >
    <Bold :size="18" />
  </button>
  <button
    type="button"
    @click="editor.chain().focus().toggleItalic().run()"
    :class="{ 'is-active': editor.isActive('italic') }"
    title="斜体 (Ctrl+I)"
  >
    <Italic :size="18" />
  </button>
  <!-- 更多按钮:下划线、删除线、行内代码 -->
</div>

<div class="toolbar-group">
  <!-- 段落结构组:H1、H2、H3、正文 -->
  <button @click="editor.chain().focus().setParagraph().run()" :class="{ 'is-active': editor.isActive('paragraph') }">正文</button>
  <button @click="editor.chain().focus().setHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }">H1</button>
  <!-- 更多标题按钮 -->
</div>

<div class="toolbar-group">
  <!-- 插入功能组:图片、链接、代码块、表格 -->
  <button @click="handleInsertImage()" title="插入图片"><ImageIcon :size="18" /></button>
  <button @click="handleInsertLink()" title="插入链接"><LinkIcon :size="18" /></button>
  <button @click="handleAddTable()" title="插入表格"><TableIcon :size="18" /></button>
</div>

关键API说明:

  • editor.chain():链式调用Tiptap命令,可连续执行多个操作(如先聚焦编辑器,再切换加粗);

  • editor.isActive():判断当前光标位置是否处于指定格式状态(如是否为加粗、是否为H1标题),用于切换按钮激活样式;

  • toggleBold()/setParagraph()等:Tiptap的核心命令,用于切换格式或设置段落类型。

2.3.2 浮动菜单实现

当用户选中文本时,弹出浮动菜单,提供快捷格式化操作(加粗、斜体等),提升编辑效率,这也是当前主流编辑器的常见设计。通过Tiptap的BubbleMenu扩展实现,核心代码如下:

<BubbleMenu
  v-if="editor"
  :editor="editor"
  :tippy-options="{ duration: 100 }"
  class="bubble-menu"
>
  <button
    type="button"
    @click="editor.chain().focus().toggleBold().run()"
    :class="{ 'is-active': editor.isActive('bold') }"
  >
    <Bold :size="16" />
  </button>
  <button
    type="button"
    @click="editor.chain().focus().toggleItalic().run()"
    :class="{ 'is-active': editor.isActive('italic') }"
  >
    <Italic :size="16" />
  </button>
  <button
    type="button"
    @click="editor.chain().focus().toggleUnderline().run()"
    :class="{ 'is-active': editor.isActive('underline') }"
  >
    <UnderlineIcon :size="16" />
  </button>
</BubbleMenu>

BubbleMenu会自动监听文本选区,选中文本时显示在选区上方,点击操作后自动隐藏,无需额外处理显示/隐藏逻辑,非常便捷。

2.4 图片上传全流程实现(前后端协同)

图片上传是社区发帖的高频需求,核心实现“前端选择-验证-上传-回显-提交”与“后端接收-存储-返回”的全流程,前后端协同确保安全、高效,具体逻辑如下:

  • 前端实现:工具栏插入图片按钮绑定点击事件,触发本地文件选择,先进行前端验证(格式限制为jpg、png、webp,大小不超过2MB),验证通过后,调用后端图片上传接口,携带图片文件(FormData格式)和请求头(Token鉴权);上传成功后,后端返回图片在线URL,前端通过Tiptap的命令将图片URL插入编辑器,同时显示预览图,确保插入的图片独占一行,贴合社区排版习惯。

  • 后端实现:提供专门的图片上传接口,接收前端传递的FormData格式图片文件,先验证文件合法性(格式、大小,与前端一致,双重校验),验证通过后,将图片存储至服务器指定目录(或对象存储,如OSS),生成唯一在线URL,同时存储URL至数据库并关联帖子ID;若上传失败,返回明确错误信息(如格式不合法、大小超限),前端接收后提示用户,便于用户调整图片。

  • 异常处理:前端针对网络异常、上传超时、后端返回错误等场景,添加对应的提示逻辑;后端针对文件损坏、存储失败等场景,返回对应错误码,确保前后端异常交互一致,提升用户体验。

2.5 表格操作实现

表格功能贴合社区“好书对比”“版本差异”等需求,基于Tiptap表格扩展实现完整操作,仅前端实现交互逻辑,后端无需额外开发,具体如下:

const handleAddTable = () => {
  if (editor.value.isActive('table')) {
    // 已存在表格:显示操作选项
    const action = window.prompt(
      '表格操作:\\n1. 添加行\\n2. 添加列\\n3. 删除行\\n4. 删除列\\n5. 删除表格\\n\\n请输入数字 (1-5):'
    )

    switch (action) {
      case '1':
        editor.value.chain().focus().addRowAfter().run()
        break
      case '2':
        editor.value.chain().focus().addColumnAfter().run()
        break
      case '3':
        editor.value.chain().focus().deleteRow().run()
        break
      case '4':
        editor.value.chain().focus().deleteColumn().run()
        break
      case '5':
        editor.value.chain().focus().deleteTable().run()
        break
    }
  } else {
    // 未存在表格:插入新表格(3行3列,包含表头)
    editor.value
      .chain()
      .focus()
      .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
      .run()
  }
}

  • 插入逻辑:点击工具栏表格按钮,默认插入3行3列且包含表头的表格,用户可根据需求调整行数和列数;插入后表格支持列宽调整(通过Tiptap的resizable配置实现),贴合排版需求。

  • 操作逻辑:检测当前编辑器是否已存在表格,若存在,弹出操作选项(添加行/列、删除行/列/表格),用户选择对应选项后,通过Tiptap的chain API执行对应命令;若不存在表格,则直接插入新表格,无需额外确认。

  • 样式适配:为表格添加自定义样式类,确保表格边框、单元格间距等样式与编辑器整体风格一致,同时与详情页渲染的表格样式保持统一,避免排版错乱。

三、配套功能实现:发帖管理与内容展示(前后端协同)

围绕Tiptap编辑器,实现发帖管理、列表展示、详情渲染三大配套功能,确保业务流程闭环,前后端协同实现各功能,核心逻辑如下:

3.1 帖子管理(MyPosts.vue)

页面采用“操作栏+表格+分页”结构,实现帖子的全生命周期管理,前后端协同逻辑清晰:

3.1.1 发帖/编辑对话框

集成RichTextEditor组件,同时包含标题输入、封面图片上传(与编辑器内图片上传逻辑一致,前后端复用图片上传接口),核心实现逻辑:

  • 前端逻辑:对话框采用destroy-on-close属性,关闭后销毁组件,避免内容残留;表单收集标题、封面图片、编辑器内容(HTML格式),通过props传递参数至RichTextEditor组件,实现编辑内容的回显(编辑旧帖时,后端返回帖子数据,前端将content字段(HTML)传递至编辑器,自动回显)。

<el-dialog
  v-model="dialogVisible"
  :title="dialogTitle"
  width="800px"
  :close-on-click-modal="false"
  destroy-on-close
>

  • 表单验证:前端对标题(2-100字符)、内容(纯文本5-20000字符,需去除HTML标签后验证)进行验证;后端同步验证参数合法性,避免前端绕过验证提交非法数据,双重保障数据规范。

<el-form :model="form" label-width="80px" :rules="rules" ref="formRef">
    <el-form-item label="标题" prop="title">
      <el-input
        v-model="form.title"
        placeholder="请输入帖子标题"
        maxlength="100"
        show-word-limit
      />
    </el-form-item>

  • 前后端提交/更新:新建帖子时,前端封装标题、封面图片URL、编辑器HTML内容等参数,通过POST请求提交至后端;后端接收参数,验证通过后存入数据库,返回成功提示;编辑旧帖时,前端携带帖子ID和更新后的参数,通过PUT请求提交,后端根据ID更新对应数据,同时更新updated_at字段,前端刷新列表展示最新数据。

3.1.2 帖子删除逻辑

前后端协同实现删除功能,确保数据安全,核心逻辑:

  • 前端逻辑:点击删除按钮,弹出二次确认弹窗(防止误操作),确认后携带帖子ID,通过DELETE请求提交至后端;删除成功后,前端提示用户,同时刷新帖子列表,同步展示删除后的结果。

  • 后端逻辑:接收前端传递的帖子ID,先验证该ID对应的帖子是否属于当前登录用户(通过user_id关联验证),验证通过后执行物理删除(或逻辑删除,根据业务需求),返回删除成功信息;若验证失败(如不属于当前用户),返回错误提示,前端接收后提示用户,禁止删除操作。

3.2 帖子列表与详情展示(前后端协同)

列表与详情页的核心需求是“安全、美观、高效”,前后端协同实现数据获取、格式化展示、安全渲染,具体逻辑如下:

3.2.1 列表展示逻辑

  • 后端逻辑:提供分页、筛选接口,接收前端传递的分页参数(页码、每页条数)、筛选参数(标签),查询数据库中的帖子数据,按创建时间倒序排序,封装成统一格式(包含帖子ID、标题、封面图片URL、点赞数、评论数、发布时间等),返回给前端。

  • 前端逻辑:调用后端接口获取帖子数据,对时间(格式化为相对时间,如“3小时前”“1天前”)、数字(点赞数、评论数过万显示为“1.2w”)进行格式化,提升浏览体验;同时实现两种布局切换,根据用户选择渲染瀑布流或卡片列表,分页加载数据,避免一次性加载过多数据导致页面卡顿。

3.2.2 详情页展示逻辑(PostsDetail.vue)

核心是安全渲染Tiptap生成的HTML内容,同时展示帖子互动信息,前后端协同实现安全与美观,具体逻辑:

核心痛点与解决方案

核心痛点是直接渲染HTML存在XSS风险,且Tiptap生成的标签(如表格、代码块)可能出现样式错乱;解决方案是前端使用DOMPurify过滤非法代码,同时精准配置允许的标签和属性,后端同步进行HTML转义,双重防护。

DOMPurify配置与安全逻辑(前后端协同)
  • 前端配置:自定义DOMPurify配置,明确允许的HTML标签和属性,完全覆盖Tiptap生成的所有合法标签(如文本格式、表格、图片、代码块相关标签)和必要属性(如图片src、链接href、表格colspan等),禁止data-*等可能存在风险的属性;同时设置KEEP_CONTENT为true,过滤非法标签时保留合法内容,避免内容丢失。

  • 后端防护:接收前端提交的HTML内容后,进行HTML转义处理,将特殊字符(如<、>)转义为实体字符,避免恶意代码注入;查询帖子详情时,返回转义后的HTML内容,前端再通过DOMPurify二次过滤,实现双重安全防护。

  • 渲染实现:前端通过computed计算属性处理帖子内容,先判断内容是否为空,不为空则通过DOMPurify过滤,生成安全的HTML内容,再通过v-html指令渲染至页面;同时引入与编辑器相同的样式类,确保表格、图片、代码块等样式与编辑时一致,避免排版错乱。

四、关键技术细节与踩坑记录

整理开发过程中4个高频踩坑点,结合前后端协同逻辑,提供可落地的解决方案,避免重复踩坑:

4.1 图片路径处理

后端返回的图片路径是相对路径(如`/uploads/2024/04/20/xxx.jpg`),前端直接使用会报错,我们采用“双保险”方案:

  1. 开发环境:Vite代理配置,将`/uploads`路径代理到后端服务,避免跨域和路径错误;

  2. 生产环境:数据加载时,判断图片路径是否为绝对路径,若不是则拼接后端API基础路径,兜底处理。

// vite.config.js 代理配置
export default defineConfig({
  server: {
    proxy: {
      '/uploads': {
        target: '<http://localhost:8080>',
        changeOrigin: true
      }
    }
  }
})

// 数据加载时兜底处理
const apiBase = import.meta.env.VITE_API_BASE_URL || ''
tableData.value = (response.data || []).map(post => {
  if (post.img && !post.img.startsWith('http')) {
    post.img = apiBase + post.img
  }
  return post
})

4.2 代码块语法高亮的正确引入

踩坑点:导入整个highlight.js导致打包体积过大,且易出现语法冲突;解决方案:采用createLowlight按需引入常用语言(如JavaScript、Python),减少打包体积;同时确保前端编辑器与详情页的语法高亮样式一致,后端无需额外处理,仅存储HTML内容即可。

// ❌ 错误写法:导入整个highlight.js,体积大且易出错
import hljs from 'highlight.js'
const lowlight = hljs

// ✅ 正确写法:使用createLowlight,按需引入语言
import { createLowlight, common } from 'lowlight'
const lowlight = createLowlight(common)

// 如需支持更多语言,单独引入并注册
import javascript from 'highlight.js/lib/languages/javascript'
import python from 'highlight.js/lib/languages/python'
lowlight.register({ javascript, python })

4.3 DOMPurify配置与表格样式丢失

踩坑点:DOMPurify默认过滤表格相关属性,导致表格渲染错乱;解决方案:前端在DOMPurify配置中,显式允许表格相关标签(table、tr、th、td等)和属性(colspan、rowspan等),同时为表格添加自定义样式类,确保渲染样式与编辑时一致;后端无需额外配置,仅需正常存储HTML内容即可。

4.4 编辑器v-model双向绑定避免循环更新

踩坑点:编辑器内容更新时,同步至父组件导致循环更新,出现页面卡顿;解决方案:前端监听父组件传入的modelValue变化,仅当编辑器当前内容与新传入的内容不一致时,才同步更新编辑器内容,同时设置setContent的第二个参数为false,禁止触发onUpdate回调,避免循环更新,确保交互流畅。

// 监听外部传入的modelValue变化,同步到编辑器
watch(
  () => props.modelValue,
  (newValue) => {
    if (editor.value) {
      const currentContent = editor.value.getHTML()
      // 避免循环更新:只有内容不同时才同步
      if (currentContent !== newValue) {
        editor.value.commands.setContent(newValue, false)
      }
    }
  }
)

// 编辑器内容更新时,同步给父组件
onUpdate: ({ editor }) => {
  emit('update:modelValue', editor.getHTML())
}

五、前后端数据交互设计

采用RESTful API规范,封装通用HTTP请求模块,统一处理Token携带、错误拦截等逻辑,前后端数据交互高效、规范,核心设计如下:

5.1 RESTful API设计

按业务功能设计接口,明确请求方法、路径、描述,前后端严格按此执行,具体接口如下:

请求方法

接口路径

接口描述

GET

/community/posts

获取社区帖子列表(分页、筛选)

GET

/community/posts/{id}

获取单个帖子详情

GET

/community/my-posts

获取当前用户发布的帖子

POST

/community/posts

创建新帖子(提交标题、内容等参数)

PUT

/community/posts/{id}

更新已有帖子(提交更新后的参数)

DELETE

/community/posts/{id}

删除帖子(验证当前用户权限)

POST

/upload/img

图片上传接口(接收前端FormData格式图片)

5.2 HTTP请求封装

前端封装axios,统一处理请求拦截、响应拦截,确保前后端交互规范,核心逻辑:

  • 请求拦截器:添加Token鉴权(从用户状态管理中获取Token),封装请求头,确保所有请求携带用户身份信息,后端通过Token验证用户权限,禁止未登录用户操作发帖、删除等功能。

  • 响应拦截器:统一处理响应错误(如401 Token过期、403权限不足、500服务器错误),弹出对应提示;同时提取响应体中的核心数据,避免前端重复处理,提升开发效率。

  • 请求方法封装:封装GET、POST、PUT、DELETE等常用请求方法,对外提供统一调用接口,传入路径、参数即可发起请求,无需重复编写axios配置。

六、用户体验优化细节

聚焦交互细节,提升用户编辑和浏览体验,无需后端协同,仅前端实现即可,核心优化点:

  1. 交互反馈优化:编辑器焦点高亮、工具栏按钮激活状态、操作结果提示(如发帖成功、删除成功),让用户清晰感知操作状态;

  2. 相对时间格式化:列表显示“3小时前”“1天前”,比绝对时间更直观,贴合社区用户浏览习惯;

  3. 数字格式化:点赞数、评论数过万显示为“1.2w”,优化排版,提升页面整洁度。

七、总结

通过实现社区发帖核心功能及Tiptap富文本编辑器的落地,我不仅熟悉了Tiptap的模块化配置、前端文件上传、HTML安全渲染等实战技巧,还理解了前后端协同开发的核心逻辑,包括数据交互规范、接口设计原则及双重安全防护实现方法。这些功能的实现为整个阅见社区系统的稳定运行奠定了核心基础,也为我个人在前端组件封装、前后端协同方面的技术能力提升,提供了宝贵的实践经验。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐