通过使用 Prisma 和 GraphQL 设计文章嵌套评论功能,可以学习如何处理递归数据结构、实现层级化的评论关系,以及如何优化查询以支持高效的数据提取和更新。
1. 评论逻辑
- 评论支持回复,嵌套深度自定义,默认3级
- 评论作者和管理员有权删除
- 点击加载更多评论
2. 评论模型设计
prisma.schema
新增 Comment
模型,parent
表示父级评论,为空表示顶级评论。同步数据库 npx prisma migrate dev --name add_comment
ts
model Comment {
id String @id @default(cuid())
content String
postId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id])
createdById String
createdBy User @relation(fields: [createdById], references: [id])
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
parentId String?
replies Comment[] @relation("CommentReplies")
}
3. Graphql 增加评论接口
postComments
:每篇文章默认展示顶级评论,支持查看更多commentReplies
:评论回复列表查询,支持查看更多addComment
: 新增评论接口,parentId
为空表示顶级评论deleteComment
:父级评论删除,子评论都删除
ts
// graphql/types/Comment.ts
import { builder } from '../builder'
builder.prismaObject('Comment', {
fields: (t: any) => ({
id: t.exposeID('id'),
content: t.exposeString('content'),
post: t.relation('post'),
createdBy: t.relation('createdBy'),
createdAt: t.expose('createdAt', {
type: 'DateTime',
}),
updatedAt: t.expose('updatedAt', {
type: 'DateTime',
}),
parent: t.relation('parent', {
nullable: true,
}),
replies: t.relation('replies', {
nullable: true,
}),
replyCount: t.int({
resolve: async (root: any, args: any, ctx: any) => {
const count = await ctx.prisma.comment.count({
where: { parentId: root.id },
})
return count
},
}),
isTopLevel: t.expose('parent', {
type: 'Boolean',
resolve: (root: any) => !root.parent,
}),
}),
})
builder.queryType({
fields: (t) => ({
postComments: t.field({
type: ['Comment'] as any,
args: {
postId: t.arg.id(),
skip: t.arg.int({ defaultValue: 0 }),
take: t.arg.int({ defaultValue: 10 }),
},
resolve: async (_root: any, args: any, ctx: any) => {
const { postId, skip, take } = args
const comments = await ctx.prisma.comment.findMany({
where: { postId: postId, parentId: null }, // 只获取顶级评论
skip: skip || 0,
take: take || 10,
orderBy: {
createdAt: 'desc',
},
})
return comments
},
}),
commentReplies: t.field({
type: ['Comment'] as any,
args: {
commentId: t.arg.id(),
skip: t.arg.int({ defaultValue: 0 }),
take: t.arg.int({ defaultValue: 10 }),
},
resolve: async (_root: any, args: any, ctx: any) => {
const { commentId, skip, take } = args
const replies = await ctx.prisma.comment.findMany({
where: { parentId: commentId },
skip: skip || 0,
take: take || 10,
orderBy: {
createdAt: 'desc',
},
})
return replies
},
}),
}),
})
builder.mutationType({
fields: (t: any) => ({
addComment: t.field({
type: 'Comment' as any,
args: {
postId: t.arg.id(),
content: t.arg.string(),
parentId: t.arg.id({ nullable: true }), //可选 如果提供则创建回复
},
resolve: async (_root: any, args: any, ctx: any) => {
const { postId, content, parentId } = args
const comment = await ctx.prisma.comment.create({
data: { postId, content, createdById: ctx.user.id, parentId },
})
return comment
},
}),
deleteComment: t.field({
type: 'Comment' as any,
args: {
id: t.arg.id(),
},
resolve: async (_root: any, args: any, ctx: any) => {
const { id } = args
const comment = await ctx.prisma.comment.findUnique({
where: { id },
})
if (!comment) {
throw new Error('Comment not found')
}
if (comment.createdById !== ctx.user.id || ctx.user.role !== 'ADMIN') {
throw new Error('You are not allowed to delete this comment')
}
// 删除评论及其所有子评论
await ctx.prisma.comment.deleteMany({
where: { OR: [{ id }, { parentId: id }] },
})
return true
},
}),
}),
} as any)
4. 文章接口拿到评论数据
commentCount
:统计文章评论数量topLevelCommentsCount
:顶级评论数- 删除文章,把下面的评论一并删除
ts
// graphql/types/Post.ts
import prisma from '@/lib/prisma'
import { builder } from '../builder'
builder.prismaObject('Post', {
fields: (t: any) => ({
//...
comments: t.relation('comments'),
commentCount: t.int({
resolve: async (parent: any, _args: any, ctx: any) => {
const commentCount = await ctx.prisma.comment.count({
where: {
postId: parent.id,
},
})
return commentCount
},
}),
// 计算字段:顶级评论数(不包括回复)
topLevelCommentsCount: t.int({
resolve: async (parent: any, _args: any, ctx: any) => {
return ctx.prisma.comment.count({
where: {
postId: parent.id,
parentId: null,
},
})
},
}),
}),
})
// 定义添加链接的类型
builder.mutationType({
fields: (t: any) => ({
deletePost: t.field({
type: 'Post' as any,
args: {
id: t.arg.id(),
},
resolve: async (_root: any, args: any, ctx: any) => {
const { id } = args
// 删除与该 Post 相关的 Like 记录
await ctx.prisma.like.deleteMany({
where: { postId: id },
})
// 删除与该 Post 相关的 Comment 记录
await ctx.prisma.comment.deleteMany({
where: { postId: id },
})
// 然后删除 Post
await ctx.prisma.post.delete({
where: { id },
})
return true
},
}),
}),
} as any)
5. 前端评论组件
- 顶级评论组件
tsx
// CommentList.tsx
'use client'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { gql, useMutation, useQuery } from '@apollo/client'
import React, { useEffect, useState } from 'react'
import CommentItem from './CommentItem'
import { Loader, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { useSearchParams } from 'next/navigation'
// 创建评论变更
const ADD_COMMENT = gql`
mutation AddComment($content: String!, $postId: ID!, $parentId: ID) {
addComment(content: $content, postId: $postId, parentId: $parentId) {
id
}
}
`
const GET_COMMENTS = gql`
query GetComments($postId: ID!, $skip: Int, $take: Int) {
postComments(postId: $postId, skip: $skip, take: $take) {
id
content
createdAt
createdBy {
id
nickname
}
replyCount
}
}
`
type Props = {
post: any
currentUserId?: string
}
const COMMENTS_PER_PAGE = 10
const CommentList = ({ post, currentUserId }: Props) => {
const [newComment, setNewComment] = useState('')
const [commentsCount, setCommentsCount] = useState(post.topLevelCommentsCount)
const {
data: comments,
loading,
error,
refetch: refetchComments,
fetchMore,
} = useQuery(GET_COMMENTS, {
variables: {
postId: post.id,
skip: 0,
take: COMMENTS_PER_PAGE,
},
notifyOnNetworkStatusChange: true,
})
const [addComment, { loading: addingComment }] = useMutation(ADD_COMMENT, {
onCompleted: (data) => {
setNewComment('')
refetchComments()
setCommentsCount((prev: number) => prev + 1)
},
})
const handleCommentDeleted = () => {
setCommentsCount((prev: number) => prev - 1)
refetchComments()
}
const handleSubmitComment = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!currentUserId) {
toast.info('Please login to comment')
return
}
if (!newComment.trim()) return
await addComment({
variables: {
content: newComment.trim(),
postId: post.id,
parentId: null, // 创建顶级评论
},
})
}
const handleLoadMoreComments = async () => {
await fetchMore({
variables: {
skip: comments?.postComments.length ?? 0,
take: COMMENTS_PER_PAGE,
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev
return {
postComments: [...prev.postComments, ...fetchMoreResult.postComments],
}
},
})
}
const searchParams = useSearchParams()
useEffect(() => {
const scrollTo = searchParams.get('scrollTo')
if (scrollTo === 'comments') {
const commentsElement = document.getElementById('comments')
if (commentsElement)
commentsElement.scrollIntoView({ behavior: 'smooth' })
}
}, [searchParams])
return (
<div className="mt-8 space-y-4">
<h4 className="font-semibold" id="comments">
Comments
</h4>
{/* 评论表单 */}
<form onSubmit={handleSubmitComment} className="max-w-[800px] space-y-2">
<Textarea
placeholder="Enter your comment..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
rows={3}
/>
<Button
type="submit"
disabled={addingComment || !newComment.trim()}
size="sm"
className="cursor-pointer"
>
{addingComment ? 'Submitting...' : 'Submit'}
</Button>
</form>
{/* 评论列表 */}
{loading && (
<div className="flex justify-center">
<Loader className="h-4 w-4 animate-spin" />
</div>
)}
<div className="space-y-4">
{comments?.postComments.map((comment: any) => (
<CommentItem
key={comment.id}
comment={comment}
currentUserId={currentUserId}
onCommentDeleted={handleCommentDeleted}
postId={post.id}
level={0}
/>
))}
</div>
{commentsCount > comments?.postComments.length && !loading && (
<div className="flex justify-center pt-4">
<Button
variant="outline"
size="sm"
onClick={handleLoadMoreComments}
className="cursor-pointer text-gray-500 hover:bg-gray-100"
>
load more comments
</Button>
</div>
)}
</div>
)
}
export default CommentList
- 子级评论组件,支持嵌套
tsx
//CommentItem.tsx
import { Button } from '@/components/ui/button'
import {
ChevronDown,
ChevronUp,
Loader,
Reply,
Send,
Trash2,
User,
} from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import React, { useEffect, useState } from 'react'
import { gql, useMutation, useQuery } from '@apollo/client'
import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner'
type Props = {
comment: any
currentUserId?: string
onCommentDeleted?: () => void
level: number
postId: string
}
const DELETE_COMMENT = gql`
mutation DeleteComment($id: ID!) {
deleteComment(id: $id) {
id
}
}
`
const ADD_REPLY = gql`
mutation AddReply($content: String!, $postId: ID!, $parentId: ID) {
addComment(content: $content, postId: $postId, parentId: $parentId) {
id
}
}
`
const GET_REPLIES = gql`
query GetReplies($commentId: ID!, $skip: Int!, $take: Int!) {
commentReplies(commentId: $commentId, skip: $skip, take: $take) {
id
content
createdAt
createdBy {
id
nickname
}
replyCount
}
}
`
const REPLIES_PER_PAGE = 10
const CommentItem = ({
comment,
currentUserId,
onCommentDeleted,
level,
postId,
}: Props) => {
console.log('comment---', comment)
const isMaxLevel = level >= 2
const [showReplies, setShowReplies] = useState(false)
const [repliesCount, setRepliesCount] = useState(comment.replyCount)
const {
data: repliesData,
refetch: refetchReplies,
loading: repliesLoading,
fetchMore,
} = useQuery(GET_REPLIES, {
variables: { commentId: comment.id, skip: 0, take: REPLIES_PER_PAGE },
skip: !showReplies,
notifyOnNetworkStatusChange: true,
})
// 加载更多回复
const handleLoadMoreReplies = async () => {
const currentReplies = repliesData?.commentReplies || []
try {
await fetchMore({
variables: {
commentId: comment.id,
skip: currentReplies.length,
take: REPLIES_PER_PAGE,
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev
return {
...prev,
commentReplies: [
...prev.commentReplies,
...fetchMoreResult.commentReplies,
],
}
},
})
} catch (error) {
console.error('加载更多回复失败:', error)
}
}
const [showReplyForm, setShowReplyForm] = useState(false)
const [newReply, setNewReply] = useState('')
const handleSubmitReply = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
await addReply({
variables: {
content: newReply,
postId: postId,
parentId: comment.id,
},
})
}
const [deleteComment, { loading: deletingComment }] = useMutation(
DELETE_COMMENT,
{
onCompleted: (data) => {
onCommentDeleted?.()
},
},
)
const handleCommentDeleted = () => {
setRepliesCount((prev: number) => prev - 1)
refetchReplies()
}
const [addReply, { loading: addingReply }] = useMutation(ADD_REPLY, {
onCompleted: (data) => {
setShowReplyForm(false)
setNewReply('')
refetchReplies()
setRepliesCount((prev: number) => prev + 1)
},
})
const handleDeleteComment = async (id: string) => {
await deleteComment({
variables: { id },
})
}
return (
<div
key={comment.id}
className={`${level > 0 ? 'border-l-1 border-gray-200 pl-4' : ''} border-b-1 border-gray-200 pb-3`}
>
<div className="flex h-8 items-center gap-8">
<div className="flex items-center text-xs text-gray-500">
<User className="mr-1 size-3" />
<span>{comment.createdBy?.nickname || 'xxxx'}</span>
<span className="mx-2">•</span>
<span>
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
})}
</span>
</div>
{comment.createdBy?.id === currentUserId && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteComment(comment.id)}
disabled={deletingComment}
className="cursor-pointer text-gray-500 hover:bg-gray-100"
>
<Trash2 className="size-3" />
</Button>
)}
</div>
<p className="mt-1">{comment.content}</p>
<div className="mt-1 flex items-center gap-2">
{!isMaxLevel && (
<Button
variant="ghost"
size="sm"
className="cursor-pointer text-gray-500"
onClick={() => {
if (!currentUserId) {
toast.info('Please login to reply')
return
}
setShowReplyForm(!showReplyForm)
}}
>
<Reply className="size-3" />
</Button>
)}
{repliesCount > 0 && (
<div className="flex items-center gap-1">
<Button
variant="link"
size="sm"
className="cursor-pointer text-xs text-gray-500"
onClick={() => setShowReplies(!showReplies)}
>
{repliesCount} replies
{showReplies ? (
<ChevronDown className="size-3" />
) : (
<ChevronUp className="size-3" />
)}
</Button>
</div>
)}
</div>
<div className="ml-2">
{showReplyForm && (
<div className="">
<form className="max-w-[800px]" onSubmit={handleSubmitReply}>
<Textarea
placeholder="Enter your reply..."
name="reply"
value={newReply}
onChange={(e) => setNewReply(e.target.value)}
/>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="mt-2 cursor-pointer text-gray-500"
type="submit"
disabled={addingReply}
>
{addingReply ? 'replying...' : 'reply'}
</Button>
<Button
variant="link"
size="sm"
className="mt-2 cursor-pointer text-gray-500"
onClick={() => setShowReplyForm(false)}
>
cancel
</Button>
</div>
</form>
</div>
)}
{showReplies && (
<div className="mt-2">
{repliesLoading && (
<div className="flex justify-center">
<Loader className="h-4 w-4 animate-spin" />
</div>
)}
{repliesData?.commentReplies.length > 0 && (
<div className="replies">
{repliesData?.commentReplies.map((reply: any) => (
<CommentItem
key={reply.id}
comment={reply}
currentUserId={currentUserId}
onCommentDeleted={handleCommentDeleted}
level={level + 1}
postId={postId}
/>
))}
</div>
)}
{repliesCount > repliesData?.commentReplies.length &&
!repliesLoading && (
<Button
variant="outline"
size="sm"
className="mt-2 text-gray-500"
onClick={handleLoadMoreReplies}
>
load more replies
</Button>
)}
</div>
)}
</div>
</div>
)
}
export default CommentItem
6. Graphql Provider 支持加载更多
缓存评论查询的接口数据: postComments
和 commentReplies
tsx
// ApolloProvider.tsx
//...
// Function to create a new Apollo Client instance
function createApolloClient() {
return new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
postComments: {
keyArgs: ['id'],
merge(existing = [], incoming, { args }) {
if (!args?.skip || args.skip === 0) {
return incoming
}
const existingIds = new Set(
existing.map((item: any) => item.id),
)
const uniqueIncoming = incoming.filter(
(item: any) => !existingIds.has(item.id),
)
return [...existing, ...uniqueIncoming]
},
},
commentReplies: {
keyArgs: ['commentId'],
merge(existing = [], incoming, { args }) {
// 如果是第一页或者重新获取,直接返回新数据
if (!args?.skip || args.skip === 0) {
return incoming
}
// 创建一个 Set 来去重,基于 id
const existingIds = new Set(
existing.map((item: any) => item.id),
)
const uniqueIncoming = incoming.filter(
(item: any) => !existingIds.has(item.id),
)
return [...existing, ...uniqueIncoming]
},
},
},
},
},
}),
//...
})
}
// ...