【项目实训(个人)】1:完成发帖以及查看修改功能
阅见项目社区发帖功能实战:Tiptap富文本编辑器落地与实现
在本次开发的过程中,我主要负责的是完成社区发帖功能,其中包括富文本编辑框,图片上传,帖子的基础的增删改查功能以及详情页的展示功能,下面是具体的开发过程。
目录
3.2.2 详情页展示逻辑(PostsDetail.vue)
3.2.2.2 DOMPurify配置与安全逻辑(前后端协同)
一、项目架构与功能模块设计
通过分析社区发帖的应用场景,我们把它拆分为了以下功能模块。
1.1 核心功能模块划分
共划分3个核心模块,各模块独立运行且相互联动,实现发帖功能闭环:
-
发帖与编辑(MyPosts.vue):用户个人发帖管理中心,核心承载Tiptap编辑器的调用,实现新帖创建、旧帖编辑、帖子删除、审核状态查看等功能,是编辑器的主要应用场景。

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

-
富文本编辑器组件(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个关键细节需要注意:
-
StarterKit的部分功能需要禁用:因为我们要使用自定义配置的扩展(如CodeBlockLowlight、Link),如果不禁用默认功能,会导致冲突;
-
lowlight的正确引入:不要直接导入整个highlight.js,而是通过createLowlight引入所需语言(common包含常用语言),减少打包体积;
-
图片扩展的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`),前端直接使用会报错,我们采用“双保险”方案:
-
开发环境:Vite代理配置,将`/uploads`路径代理到后端服务,避免跨域和路径错误;
-
生产环境:数据加载时,判断图片路径是否为绝对路径,若不是则拼接后端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配置。
六、用户体验优化细节
聚焦交互细节,提升用户编辑和浏览体验,无需后端协同,仅前端实现即可,核心优化点:
-
交互反馈优化:编辑器焦点高亮、工具栏按钮激活状态、操作结果提示(如发帖成功、删除成功),让用户清晰感知操作状态;
-
相对时间格式化:列表显示“3小时前”“1天前”,比绝对时间更直观,贴合社区用户浏览习惯;
-
数字格式化:点赞数、评论数过万显示为“1.2w”,优化排版,提升页面整洁度。
七、总结
通过实现社区发帖核心功能及Tiptap富文本编辑器的落地,我不仅熟悉了Tiptap的模块化配置、前端文件上传、HTML安全渲染等实战技巧,还理解了前后端协同开发的核心逻辑,包括数据交互规范、接口设计原则及双重安全防护实现方法。这些功能的实现为整个阅见社区系统的稳定运行奠定了核心基础,也为我个人在前端组件封装、前后端协同方面的技术能力提升,提供了宝贵的实践经验。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)