在现代 Web 应用中,评论功能是提升用户互动性的核心模块之一。它不仅需要满足用户发表评论、回复互动的基础需求,还需要兼顾易用性、视觉体验和功能完整性。本文将结合完整代码,详细分享基于 Vue3+TypeScript+Vant 组件库开发的评论系统实现方案,从组件设计、代码实现到状态管理,层层拆解核心细节。

一、整体架构设计
整个评论系统采用「组件化 + 状态管理」的架构模式,拆分为三个核心模块,各司其职且协同工作:
| 模块文件 | 核心职责 | 技术核心 |
|---|---|---|
| CommentInput.vue | 评论 / 回复输入弹窗,支持文本 + 表情输入 | Vue3 组合式 API、Vant Popup/Field |
| CommentList.vue | 评论列表展示,包含点赞、回复、删除等交互 | 条件渲染、事件监听、组件通信 |
| comments.ts(Pinia) | 全局评论状态管理,处理数据增删改查 | Pinia 状态管理、TypeScript 接口定义 |
这种拆分遵循「单一职责原则」,让每个模块专注于自身功能,既提升了代码可维护性,也便于后续扩展。
二、核心模块代码详解
(一)评论输入组件:CommentInput.vue
负责接收用户输入(文本 + 表情),是交互入口。核心需求:支持多行输入、表情选择、内容同步、发送逻辑。
1. 模板结构核心代码
vue
<van-popup v-model:show="show" position="bottom">
<div class="comment-input">
<!-- 文本输入框 -->
<van-field
type="textarea"
rows="2"
autosize
v-model="inputValue"
:placeholder="placeholder"
/>
<!-- 操作栏:表情按钮+发送按钮 -->
<div class="comment-actions">
<van-icon name="smile-o" @click="onEmoji" />
<van-button
class="send-btn"
round
type="primary"
:disabled="!inputValue"
@click="handleSend"
>发送</van-button>
</div>
<!-- 表情面板:折叠/展开切换 -->
<div class="emoji-mart-wrapper" :class="{ expanded: showAllEmojis }">
<div class="simple-emoji-list">
<span
v-for="(emoji, idx) in emojis"
:key="idx"
class="simple-emoji"
@click="addEmojiFromPicker(emoji)"
>{{ emoji }}</span>
</div>
</div>
</div>
</van-popup>
-
关键设计:
- 用
van-popup实现底部弹窗,position="bottom"确保滑入效果; - 文本框用
autosize自动适配高度,避免输入多行时滚动混乱; - 表情面板通过
expanded类控制高度过渡(48px→240px),配合overflow-y:auto支持滚动。
- 用
2. 逻辑核心代码
typescript
import { ref, watch, defineProps, defineEmits } from 'vue'
// 定义props和emit,实现父子组件通信
const props = defineProps({
show: Boolean,
modelValue: String,
placeholder: { type: String, default: '友善发言,理性交流' }
})
const emit = defineEmits(['update:show', 'update:modelValue', 'send'])
// 响应式变量
const show = ref(props.show) // 弹窗显示状态
const inputValue = ref(props.modelValue || '') // 输入内容
const showAllEmojis = ref(false) // 表情面板展开状态
// 表情库(包含表情、动物、食物等多分类)
const emojis = ['😀', '😁', '😂', ...] // 完整列表见原代码
// 监听props变化,同步到组件内部状态
watch(() => props.show, v => show.value = v)
watch(show, v => emit('update:show', v)) // 双向绑定弹窗状态
watch(() => props.modelValue, val => inputValue.value = val)
watch(inputValue, val => emit('update:modelValue', val)) // 同步输入内容
// 表情面板展开/收起切换
function onEmoji() {
showAllEmojis.value = !showAllEmojis.value
}
// 选择表情:拼接至输入框
function addEmojiFromPicker(emoji: string) {
inputValue.value += emoji
emit('update:modelValue', inputValue.value)
}
// 发送评论
function handleSend() {
if (!inputValue.value) return
emit('send', inputValue.value) // 向父组件传递输入内容
inputValue.value = '' // 清空输入框
emit('update:modelValue', '')
showAllEmojis.value = false // 收起表情面板
show.value = false // 关闭弹窗
}
-
关键逻辑:
- 用
watch实现 props 与组件内部状态的双向同步,确保父子组件数据一致; - 表情选择直接拼接字符串,无需处理光标位置,简化逻辑;
- 发送按钮通过
!inputValue控制禁用状态,避免空内容提交。
- 用
3. 样式优化(SCSS)
scss
.emoji-mart-wrapper {
background: #fff;
height: 48px;
max-height: 48px;
overflow-y: hidden;
transition: max-height 0.3s, height 0.3s; // 平滑过渡
&.expanded {
height: 240px;
max-height: 240px;
overflow-y: auto;
}
}
.simple-emoji {
font-size: 24px;
cursor: pointer;
transition: transform 0.1s;
&:hover {
transform: scale(1.2); // hover放大,提升交互反馈
}
}
(二)评论列表组件:CommentList.vue
核心展示与交互模块,负责评论列表渲染、回复、点赞、删除、长按操作等。
1. 模板结构核心代码
vue
<div class="comment-list">
<!-- 评论列表 -->
<div v-for="(comment, idx) in showComments" :key="comment.id" class="comment-item">
<!-- 评论者头像 -->
<img class="avatar" :src="comment.avatar" />
<div class="comment-main">
<div class="nickname">{{ comment.nickname }}</div>
<!-- 评论内容:支持@高亮,绑定点击/长按事件 -->
<div
class="content"
@click="openReply(idx, undefined, comment.userId)"
@touchstart="onTouchStart(idx, undefined, comment.content)"
@contextmenu.prevent="onContextMenu(idx, undefined, comment.content, $event)"
v-html="comment.content"
></div>
<!-- 操作栏:时间、回复、点赞 -->
<div class="meta">
<span class="time">{{ comment.time }}</span>
<span class="reply" @click="openReply(idx, undefined, comment.userId)">回复</span>
<span class="like" @click="likeComment(idx)" :class="{ 'liked-active': comment.liked }">
<van-icon name="good-job-o" />
{{ comment.likes }}
</span>
</div>
<!-- 回复列表:支持折叠/展开 -->
<div v-if="comment.replies && comment.replies.length" class="reply-list">
<div
v-for="(reply, ridx) in showAllReplies[idx] ? comment.replies : comment.replies.slice(0, 1)"
:key="reply.id"
class="comment-item reply-item"
>
<!-- 回复内容结构与评论一致,略 -->
</div>
<!-- 折叠/展开按钮 -->
<div v-if="comment.replies.length > 1" class="expand-reply" @click="toggleReplies(idx)">
{{ showAllReplies[idx] ? '收起' : `展开${comment.replies.length}条回复` }}
</div>
</div>
</div>
</div>
<!-- 输入回复弹窗(复用CommentInput组件) -->
<CommentInput
v-model="replyContent"
v-model:show="showReplyInput"
:placeholder="replyTarget ? `回复 @${getNicknameByUserId(replyTarget.userId)}:` : '请输入回复内容~'"
@send="sendReply"
/>
<!-- 长按/右键操作菜单 -->
<van-action-sheet
v-model:show="showActionSheet"
:actions="actionOptions"
@select="onActionSelect"
cancel-text="取消"
/>
</div>
-
关键设计:
- 评论与回复共用一套结构,通过
reply-item类区分样式,减少冗余; - 回复列表默认显示 1 条,超过 1 条显示「展开」按钮,优化视觉体验;
- 复用
CommentInput组件实现回复输入,提升代码复用率; - 用
v-html渲染内容,支持回复中的 @用户高亮(蓝色文本)。
- 评论与回复共用一套结构,通过
2. 核心逻辑代码
typescript
import { ref, watch, computed, PropType } from 'vue'
import CommentInput from '@/components/CommentInput.vue'
import { useCommentsStore, Comment, Reply } from '@/store/comments'
import { useUserStore } from '@/store/user'
import { showToast } from 'vant'
// Props定义:接收评论列表和是否显示全部
const props = defineProps({
comments: { type: Array as PropType<Comment[]>, required: true },
showAll: { type: Boolean, default: false }
})
const emit = defineEmits(['more'])
const commentsStore = useCommentsStore() // 评论状态管理
const userStore = useUserStore() // 用户状态(获取当前登录用户)
// 回复相关状态
const showReplyInput = ref(false) // 回复弹窗显示状态
const replyContent = ref('') // 回复内容
const replyTarget = ref<{ commentIdx: number; replyIdx?: number; userId: string } | null>(null) // 回复目标
// 控制回复列表折叠/展开
const showAllReplies = ref(props.comments.map(() => false))
watch(() => props.comments, val => {
showAllReplies.value = val.map(() => false) // 评论列表变化时重置折叠状态
}, { immediate: true })
// 评论列表分页:默认显示2条,showAll为true时显示全部
const showComments = computed(() => {
return props.showAll ? props.comments : props.comments.slice(0, 2)
})
// 当前登录用户ID(用于权限控制)
const currentUserId = computed(() => userStore.userInfo?.id?.toString() || 'anonymous')
// 1. 点赞评论
function likeComment(idx: number) {
const comment = showComments.value[idx]
commentsStore.likeComment(comment.id) // 调用Pinia Action修改状态
}
// 2. 回复评论/回复
function openReply(commentIdx: number, replyIdx?: number, userId?: string) {
replyTarget.value = { commentIdx, replyIdx, userId: userId || '' }
showReplyInput.value = true
replyContent.value = '' // 清空输入框
}
// 3. 发送回复
function sendReply(val: string) {
if (!val || !replyTarget.value) return
const { commentIdx, replyIdx } = replyTarget.value
const comment = showComments.value[commentIdx]
let content = val
// 回复某条回复时,添加@提及
if (replyIdx !== undefined && comment.replies[replyIdx]) {
content = `<span style='color:#409EFF'>@${comment.replies[replyIdx].nickname}</span> ${val}`
}
// 调用Pinia Action添加回复
const userInfo = userStore.userInfo
const reply: Reply = {
id: Date.now(), // 用时间戳作为唯一ID
avatar: userInfo?.avatar || getAssetUrl(userInfo?.gender === 'female' ? 'avatar_woman.svg' : 'avatar_man.svg'),
nickname: userInfo?.nickname || '匿名用户',
userId: userInfo?.id?.toString() || 'anonymous',
content,
time: new Date().toLocaleString(),
likes: 0
}
commentsStore.addReply(comment.id, reply)
showReplyInput.value = false
}
// 4. 长按/右键操作(复制/删除)
const showActionSheet = ref(false)
const actionOptions = ref([{ name: '复制' }, { name: '删除' }])
const actionTarget = ref<{ commentIdx: number; replyIdx?: number; content: string } | null>(null)
let touchTimer: any = null
// 设置操作菜单(只有自己的内容才显示删除)
function setActionOptions(commentIdx: number, replyIdx?: number) {
let canDelete = false
if (replyIdx !== undefined) {
const comment = showComments.value[commentIdx]
canDelete = comment.replies[replyIdx].userId === currentUserId.value
} else {
const comment = showComments.value[commentIdx]
canDelete = comment.userId === currentUserId.value
}
actionOptions.value = canDelete ? [{ name: '复制' }, { name: '删除' }] : [{ name: '复制' }]
}
// 移动端长按触发
function onTouchStart(commentIdx: number, replyIdx: number | undefined, content: string) {
setActionOptions(commentIdx, replyIdx)
touchTimer = setTimeout(() => {
actionTarget.value = { commentIdx, replyIdx, content }
showActionSheet.value = true
}, 500)
}
// 长按取消
function onTouchEnd() {
if (touchTimer) clearTimeout(touchTimer)
}
// PC端右键菜单
function onContextMenu(commentIdx: number, replyIdx: number | undefined, content: string, e: Event) {
e.preventDefault()
setActionOptions(commentIdx, replyIdx)
actionTarget.value = { commentIdx, replyIdx, content }
showActionSheet.value = true
}
// 操作菜单选择(复制/删除)
async function onActionSelect(action: { name: string }) {
if (!actionTarget.value) return
const { commentIdx, replyIdx, content } = actionTarget.value
if (action.name === '复制') {
// 提取纯文本(过滤HTML标签)
const tempDiv = document.createElement('div')
tempDiv.innerHTML = content
await navigator.clipboard.writeText(tempDiv.innerText)
showToast('已复制')
} else if (action.name === '删除') {
if (replyIdx !== undefined) {
commentsStore.deleteReply(showComments.value[commentIdx].id, showComments.value[commentIdx].replies[replyIdx].id)
} else {
commentsStore.deleteComment(showComments.value[commentIdx].id)
}
showToast('已删除')
}
showActionSheet.value = false
}
-
关键逻辑:
- 权限控制:通过
currentUserId与评论 / 回复的userId比对,仅显示自己内容的删除按钮; - 回复 @提及:回复特定用户时,自动拼接
<span>标签实现蓝色高亮; - 兼容移动端 / PC 端:通过
touchstart/touchend处理长按,contextmenu处理右键菜单; - 分页与折叠:评论列表默认显示 2 条,回复列表默认显示 1 条,优化长列表渲染性能。
- 权限控制:通过
(三)状态管理:comments.ts(Pinia)
负责管理评论全局状态,提供统一的数据操作 API,避免组件间数据传递混乱。
1. 数据模型定义(TypeScript 接口)
typescript
// 回复数据模型
export interface Reply {
id: number
avatar: string
nickname: string
userId: string
content: string
time: string
likes: number
liked?: boolean // 是否点赞
}
// 评论数据模型
export interface Comment {
id: number
avatar: string
nickname: string
userId: string
content: string
time: string
likes: number
liked?: boolean
replies: Reply[] // 关联的回复列表
}
- 用 TypeScript 接口定义数据结构,确保类型安全,减少开发时的类型错误。
2. Pinia Store 核心代码
typescript
import { defineStore } from 'pinia'
import { getAssetUrl } from '@/utils/index'
import { Comment, Reply } from './types'
export const useCommentsStore = defineStore('comments', {
state: () => ({
// 初始测试数据
comments: [
{
id: 1,
avatar: getAssetUrl('avatar_woman.svg'),
nickname: '徐济锐',
userId: 'xujirui',
content: '内容详细丰富,详细的介绍了电信业务稽核系统技术规范,条理清晰。',
time: '2025-06-09 17:08:17',
likes: 4,
replies: [
{
id: 11,
avatar: getAssetUrl('avatar_man.svg'),
nickname: '张亮',
userId: 'zhangliang',
content: '文本编辑调理清晰,很不错!',
time: '2025-06-09 17:08:17',
likes: 4
}
]
},
// 更多测试数据...
] as Comment[]
}),
actions: {
// 添加评论(插入到列表头部)
addComment(comment: Comment) {
this.comments.unshift(comment)
},
// 给指定评论添加回复
addReply(commentId: number, reply: Reply) {
const comment = this.comments.find(c => c.id === commentId)
if (comment) comment.replies.push(reply)
},
// 点赞/取消点赞评论
likeComment(id: number) {
const comment = this.comments.find(c => c.id === id)
if (comment) {
comment.liked = !comment.liked
comment.likes += comment.liked ? 1 : -1
}
},
// 点赞/取消点赞回复
likeReply(commentId: number, replyId: number) {
const comment = this.comments.find(c => c.id === commentId)
if (comment) {
const reply = comment.replies.find(r => r.id === replyId)
if (reply) {
reply.liked = !reply.liked
reply.likes += reply.liked ? 1 : -1
}
}
},
// 删除评论
deleteComment(id: number) {
this.comments = this.comments.filter(c => c.id !== id)
},
// 删除回复
deleteReply(commentId: number, replyId: number) {
const comment = this.comments.find(c => c.id === commentId)
if (comment) {
comment.replies = comment.replies.filter(r => r.id !== replyId)
}
}
}
})
-
关键设计:
- 所有数据操作都通过 Action 方法实现,组件无需直接修改 State,确保数据流向清晰;
- 点赞逻辑通过
liked状态切换,同步更新likes计数,避免重复点赞; - 初始测试数据模拟真实场景,便于开发调试。
三、核心技术亮点
- TypeScript 类型安全:从组件 Props 到 Pinia 状态,全程使用 TypeScript 接口约束,减少类型错误,提升开发体验;
- 组件复用 :
CommentInput组件同时支持评论和回复输入,避免重复开发; - 交互体验优化:表情面板平滑过渡、点赞状态切换反馈、长按防误触(500ms 延迟)、空状态提示;
- 性能优化:评论 / 回复列表分页渲染、折叠显示,减少 DOM 节点数量;;
- 权限控制:仅当前登录用户可删除自己的评论 / 回复,提升数据安全性。