基于 Vue3+TypeScript+Vant 的评论组件开发实践

在现代 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计数,避免重复点赞;
    • 初始测试数据模拟真实场景,便于开发调试。

三、核心技术亮点

  1. TypeScript 类型安全:从组件 Props 到 Pinia 状态,全程使用 TypeScript 接口约束,减少类型错误,提升开发体验;
  2. 组件复用CommentInput组件同时支持评论和回复输入,避免重复开发;
  3. 交互体验优化:表情面板平滑过渡、点赞状态切换反馈、长按防误触(500ms 延迟)、空状态提示;
  4. 性能优化:评论 / 回复列表分页渲染、折叠显示,减少 DOM 节点数量;;
  5. 权限控制:仅当前登录用户可删除自己的评论 / 回复,提升数据安全性。
相关推荐
user_admin_god4 小时前
基于Layui Vue Admin + Spring Boot 3.x 的企业级前后端分离管理系统
vue.js·spring boot·layui
李剑一4 小时前
mitt和bus有什么区别
前端·javascript·vue.js
F_Director5 小时前
简说Vue3 computed原理
前端·vue.js·面试
Wang's Blog7 小时前
前端FAQ: 描述⼀下你最近使⽤过的前端框架,并解释为何选择它们?
前端·vue.js·faq
callmeSoon7 小时前
Solid 初探:启发 Vue Vapor 的极致框架
vue.js·前端框架·响应式设计
小二·8 小时前
从零到上线:Spring Boot 3 + Spring Cloud Alibaba + Vue 3 构建高可用 RBAC 微服务系统(超详细实战)
vue.js·spring boot·微服务
xiaohe06019 小时前
🥳 Uni ECharts 2.1 发布:正式支持鸿蒙,零成本迁移、全平台兼容、跨端开发零负担!
vue.js·uni-app·echarts
RAY_CHEN.9 小时前
vue递归组件-笔记
前端·javascript·vue.js
毕设十刻20 小时前
基于Vue的学分预警系统98k51(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js