Nest.js Blog Schritt für Schritt: Kommentarantwort
Wenhao Wang
Dev Intern · Leapcell

Im vorherigen Artikel haben wir unserem Blog eine Kommentarfunktion hinzugefügt, die die erste Interaktion zwischen Lesern und dem Autor ermöglicht. Alle Kommentare wurden jedoch linear angezeigt, was es schwierig machte, Gesprächen zu folgen, wenn sie lebhaft wurden.
Um eine interaktivere Community aufzubauen, werden wir in diesem Tutorial das Kommentarsystem um eine Funktion für Kommentarantworten erweitern, die auch als "verschachtelte Kommentare" oder "thread-Kommentare" bekannt ist.
Wir werden folgende Ziele erreichen:
- Benutzern ermöglichen, auf bestehende Kommentare zu antworten.
- Die Antworthierarchie auf der Seite in einem verschachtelten (oder eingerückten) Format klar anzeigen.
- Die Benutzererfahrung mit einfachem clientseitigem JavaScript verbessern.
Schritt 1: Datenmodell aktualisieren
Um die Antwortfunktion zu implementieren, müssen wir eine Eltern-Kind-Beziehung zwischen den Kommentaren herstellen. Eine Antwort ist im Wesentlichen ein Kommentar, hat aber einen "Elternkommentar". Dies erreichen wir, indem wir der Comment
-Entität eine selbstreferenzierende Beziehung hinzufügen.
1. Datenbanktabelle ändern
Zuerst müssen wir die Struktur der comment
-Tabelle ändern, indem wir ein parentId
-Feld hinzufügen, das auf die ID seines Elternkommentars verweist.
Führen Sie die folgende ALTER TABLE
-Anweisung in Ihrer PostgreSQL-Datenbank aus:
ALTER TABLE "comment" ADD COLUMN "parentId" UUID REFERENCES "comment"("id") ON DELETE CASCADE;
- Die
parentId
-Spalte ist optional (erlaubtNULL
), da Top-Level-Kommentare keinen Elternteil haben. REFERENCES "comment"("id")
erstellt einen Fremdschlüssel, derparentId
mit derid
-Spalte derselben Tabelle verknüpft.
2. Comment-Entität aktualisieren
Öffnen Sie nun die Datei src/comments/comment.entity.ts
und fügen Sie die Eigenschaften parent
und replies
hinzu, um diese hierarchische Beziehung im Code widerzuspiegeln.
// src/comments/comment.entity.ts import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, ManyToOne, OneToMany } from 'typeorm'; import { User } from '../users/user.entity'; import { Post } from '../posts/post.entity'; @Entity() export class Comment { @PrimaryGeneratedColumn('uuid') id: string; @Column('text') content: string; @CreateDateColumn() createdAt: Date; @ManyToOne(() => User, user => user.comments) user: User; @ManyToOne(() => Post, post => post.comments) post: Post; // --- Neue Felder --- @ManyToOne(() => Comment, comment => comment.replies, { nullable: true }) parent: Comment; // Elternkommentar @OneToMany(() => Comment, comment => comment.parent) replies: Comment[]; // Liste der Kindkommentare (Antworten) }
Schritt 2: Kommentar-Dienst anpassen
Unsere Service-Schicht muss angepasst werden, um bei der Erstellung eines neuen Kommentare einen Elternkommentar zu verknüpfen und beim Abfragen eine flache Liste von Kommentaren in eine baumartige Struktur zu überführen.
Öffnen Sie src/comments/comments.service.ts
und nehmen Sie die folgenden Änderungen vor:
// src/comments/comments.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Comment } from './comment.entity'; import { Post } from '../posts/post.entity'; import { User } from '../users/user.entity'; @Injectable() export class CommentsService { constructor( @InjectRepository(Comment) private commentsRepository: Repository<Comment>, ) {} // Ändern Sie die Methode findByPostId async findByPostId(postId: string): Promise<Comment[]> { const comments = await this.commentsRepository.find({ where: { post: { id: postId } }, relations: ['user', 'parent'], // Lade Benutzer und Elternteil gleichzeitig order: { createdAt: 'ASC', }, }); return this.structureComments(comments); } // Fügen Sie eine neue private Methode hinzu, um eine flache Liste in eine Baumstruktur umzuwandeln private structureComments(comments: Comment[]): Comment[] { const commentMap = new Map<string, Comment>(); comments.forEach(comment => { comment.replies = []; // Initialisiere das Antworten-Array commentMap.set(comment.id, comment); }); const rootComments: Comment[] = []; comments.forEach(comment => { if (comment.parent) { const parentComment = commentMap.get(comment.parent.id); if (parentComment) { parentComment.replies.push(comment); } } else { rootComments.push(comment); } }); return rootComments; } // Ändern Sie die Methode create, um eine optionale parentId zu akzeptieren async create( content: string, user: User, post: Post, parentId?: string, ): Promise<Comment> { const newComment = this.commentsRepository.create({ content, user, post, parent: parentId ? { id: parentId } as Comment : null, }); return this.commentsRepository.save(newComment); } }
Erklärung der Logik:
findByPostId
ruft nun alle Kommentare für einen Beitrag ab (einschließlich Top-Level-Kommentare und aller Antworten).- Die neue Methode
structureComments
ist der Kern dieser Logik. Sie platziert zunächst alle Kommentare in einerMap
für schnelle Abfragen. Dann durchläuft sie alle Kommentare. Wenn ein Kommentar einenparent
hat, wird er in dasreplies
-Array des Elternteils eingefügt; andernfalls ist es ein Top-Level-Kommentar. - Die Methode
create
hat jetzt einen optionalenparentId
-Parameter. Wenn diese ID angegeben wird, wird der neu erstellte Kommentar mit dem entsprechenden Elternkommentar verknüpft.
Schritt 3: Controller aktualisieren
Der Controller muss die optionale parentId
aus dem Anfragetext empfangen und an den Dienst übergeben. Diese Änderung ist sehr einfach.
Öffnen Sie src/comments/comments.controller.ts
:
// src/comments/comments.controller.ts import { Controller, Post, Body, Param, Req, Res, UseGuards } from '@nestjs/common'; // ... Imports @Controller('posts/:postId/comments') export class CommentsController { constructor(private readonly commentsService: CommentsService) {} @UseGuards(AuthenticatedGuard) @Post() async create( @Param('postId') postId: string, @Body('content') content: string, @Body('parentId') parentId: string, // <-- parentId empfangen @Req() req: Request, @Res() res: Response, ) { const user = req.user as any; // parentId an den Dienst übergeben await this.commentsService.create(content, user, { id: postId } as any, parentId); res.redirect(`/posts/${postId}`); } }
Schritt 4: Frontend-Ansicht aktualisieren
Dies ist der interessanteste Teil. Wir müssen die post.ejs
-Vorlage aktualisieren, um Kommentare und ihre Antworten rekursiv zu rendern. Wir müssen auch etwas JavaScript hinzufügen, um das Antwortformular dynamisch anzuzeigen.
1. Ein wiederverwendbares Kommentar-Template erstellen
Für das rekursive Rendern ist es am besten, eine wiederverwendbare "Partial"-Vorlage zu erstellen. Erstellen Sie im Verzeichnis views
eine neue Datei namens _comment.ejs
:
<% comments.forEach(comment => { %> <div class="comment-item" style="margin-left: <%= depth * 20 %>px;"> <p class="comment-content"><%= comment.content %></p> <small> Von <strong><%= comment.user.username %></strong> am <%= new Date(comment.createdAt).toLocaleDateString() %> </small> <% if (user) { %> <button class="reply-btn" data-comment-id="<%= comment.id %>">Antworten</button> <% } %> </div> <% if (comment.replies && comment.replies.length > 0) { %> <%- include('_comment', { comments: comment.replies, user: user, post: post, depth: depth + 1 }) %> <% } %> <% }) %>``` Diese Vorlage iteriert durch das eingehende `comments`-Array und ruft sich selbst rekursiv für das `replies`-Array jedes Kommentars auf, während sie die `depth` erhöht, um eine stilistische Einrückung zu erzielen. ### 2. `post.ejs` aktualisieren Modifizieren Sie nun `views/post.ejs`, um diese neue `_comment.ejs`-Partial zu verwenden und ein universelles Antwortformular hinzuzufügen. ```html <section class="comments-section"> <h3>Kommentare</h3> <div class="comment-list"> <% if (comments.length > 0) { %> <%- include('_comment', { comments: comments, user: user, post: post, depth: 0 }) %> <% } else { %> <p>Noch keine Kommentare. Seien Sie der Erste, der kommentiert!</p> <% } %> </div> <% if (user) { %> <form id="comment-form" action="/posts/<%= post.id %>/comments" method="POST" class="comment-form"> <h4>Hinterlasse einen Kommentar</h4> <div class="form-group"> <textarea name="content" rows="4" placeholder="Schreibe deinen Kommentar hier..." required></textarea> <input type="hidden" name="parentId" id="parentIdInput" value="" /> </div> <button type="submit">Absenden</button> <button type="button" id="cancel-reply-btn" style="display: none;">Antwort abbrechen</button> </form> <% } else { %> <p><a href="/auth/login">Anmelden</a>, um einen Kommentar zu hinterlassen.</p> <% } %> </section> <script> document.addEventListener('DOMContentLoaded', () => { const commentForm = document.getElementById('comment-form'); const parentIdInput = document.getElementById('parentIdInput'); const formTitle = commentForm.querySelector('h4'); const cancelReplyBtn = document.getElementById('cancel-reply-btn'); const commentList = document.querySelector('.comment-list'); commentList.addEventListener('click', (e) => { if (e.target.classList.contains('reply-btn')) { const commentId = e.target.getAttribute('data-comment-id'); const commentItem = e.target.closest('.comment-item'); // Verschiebe das Formular unter den zu antwortenden Kommentar commentItem.after(commentForm); // Setze die parentId und den Formular-Titel parentIdInput.value = commentId; formTitle.innerText = 'Antworten an ' + commentItem.querySelector('strong').innerText; cancelReplyBtn.style.display = 'inline-block'; } }); cancelReplyBtn.addEventListener('click', () => { // Setze den Formularstatus zurück parentIdInput.value = ''; formTitle.innerText = 'Hinterlasse einen Kommentar'; cancelReplyBtn.style.display = 'none'; // Verschiebe das Formular zurück an das Ende des Kommentarbereichs document.querySelector('.comments-section').appendChild(commentForm); }); }); </script> <%- include('_footer') %>``` **Frontend-Logik erklärt**: 1. Auf der Seite gibt es nur ein einziges Kommentarformular. 2. Wenn ein Benutzer auf die Schaltfläche "Antworten" bei einem Kommentar klickt, wird Folgendes ausgeführt: - Die ID dieses Kommentars wird abgerufen. - Diese ID wird in das versteckte `parentId`-Eingabefeld des Formulars gesetzt. - Für eine bessere Benutzererfahrung wird das gesamte Formular direkt unter den zu beantwortenden Kommentar verschoben. - Eine Schaltfläche "Antwort abbrechen" wird angezeigt. 3. Nach dem Klicken auf "Antwort abbrechen" oder dem Absenden des Formulars kann das Formular zurückgesetzt und an seine ursprüngliche Position verschoben werden. ## Ausführen und Testen Starten Sie Ihre Anwendung neu (`npm run start:dev`) und aktualisieren Sie die Beitragsseite: 1. Posten Sie als angemeldeter Benutzer einen Top-Level-Kommentar. 2. Klicken Sie auf die Schaltfläche "Antworten" neben dem gerade geposteten Kommentar. Das Formular wird darunter verschoben. 3. Geben Sie Ihre Antwort in das Formular ein und senden Sie diese ab. 4. Nachdem die Seite aktualisiert wurde, sehen Sie Ihre Antwort eingerückt unter dem Elternkommentar. 5. Sie können weiterhin auf Antworten antworten und tiefere Konversationsebenen erstellen. ## Fazit In diesem Tutorial haben wir erfolgreich eine Funktion für verschachtelte Antworten zu unserem Blog hinzugefügt. Durch die Etablierung einer selbstreferenzierenden Beziehung im Datenmodell, die Anpassung des Backend-Dienstes zur Behandlung einer Baumstruktur und die Kombination mit einfachem clientseitigem JavaScript haben wir die Interaktivität unseres Blogs erheblich verbessert.