Nest.jsブログをステップバイステップで: コメントの返信
Wenhao Wang
Dev Intern · Leapcell

前の記事では、読者と著者間の最初のやり取りを可能にするコメント機能ブログに追加しました。しかし、すべてのコメントが線形に表示されていたため、議論が活発になった場合に会話を追うのが困難でした。
よりインタラクティブなコミュニティを構築するために、このチュートリアルではコメントシステムをアップグレードして、「ネストされたコメント」または「スレッドコメント」としても知られるコメントの返信機能を実装します。
以下の目標を達成します。
- ユーザーが既存のコメントに返信できるようにする。
- ページ上の返信階層をネスト(またはインデント)形式で明確に表示する。
- シンプルなクライアントサイドJavaScriptでユーザーエクスペリエンスを向上させる。
ステップ1: データモデルの更新
返信機能を実装するには、コメント間に親子関係を確立する必要があります。返信は基本的にコメントですが、「親コメント」を持っています。これは、Comment
エンティティに自己参照関係を追加することによって実現します。
1. データベーステーブルの変更
まず、parentId
フィールドを追加して、親コメントのIDを指すようにcomment
テーブルの構造を変更する必要があります。
PostgreSQLデータベースで次のALTER TABLE
ステートメントを実行します。
ALTER TABLE "comment" ADD COLUMN "parentId" UUID REFERENCES "comment"("id") ON DELETE CASCADE;
parentId
列は、トップレベルのコメントには親がないため、オプショナル(NULL
を許可)です。REFERENCES "comment"("id")
は、parentId
を同じテーブルのid
列にリンクする外部キーを作成します。
2. Commentエンティティの更新
次に、src/comments/comment.entity.ts
ファイルを開き、コードでこの階層関係を反映するためにparent
とreplies
プロパティを追加します。
// 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; // --- 新しいフィールド --- @ManyToOne(() => Comment, comment => comment.replies, { nullable: true }) parent: Comment; // 親コメント @OneToMany(() => Comment, comment => comment.parent) replies: Comment[]; // 子コメント(返信)のリスト }
ステップ2: Commentサービスの設定
サービスレイヤーは、新しいコメントを作成する際に親コメントを関連付け、クエリを実行する際にコメントのフラットリストをツリー状の構造に整理するように調整する必要があります。
src/comments/comments.service.ts
を開き、以下の変更を行います。
// 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>, ) {} // findByPostId メソッドを変更 async findByPostId(postId: string): Promise<Comment[]> { const comments = await this.commentsRepository.find({ where: { post: { id: postId } }, relations: ['user', 'parent'], // ユーザーと親を同時にロード order: { createdAt: 'ASC', }, }); return this.structureComments(comments); } // フラットなリストをツリー構造に変換する新しいプライベートメソッドを追加 private structureComments(comments: Comment[]): Comment[] { const commentMap = new Map<string, Comment>(); comments.forEach(comment => { comment.replies = []; // replies 配列を初期化 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; } // オプショナルな parentId を受け入れるように create メソッドを変更 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); } }
ロジックの説明:
findByPostId
は、トップレベルのコメントとすべての返信を含む、投稿のすべてのコメントを取得するようになりました。- 新しい
structureComments
メソッドがこのロジックの中心です。まず、すべてのコメントをMap
に配置して、すばやく検索できるようにします。次に、すべてのコメントを反復処理します。コメントにparent
がある場合、そのコメントは親のreplies
配列にプッシュされます。それ以外の場合はトップレベルのコメントです。 create
メソッドには、オプショナルなparentId
パラメータが追加されました。このIDが提供されると、新しく作成されたコメントは対応する親コメントに関連付けられます。
ステップ3: コントローラーの更新
コントローラーは、オプショナルなparentId
をリクエストボディから受け取り、サービスに渡す必要があります。この変更は非常に簡単です。
src/comments/comments.controller.ts
を開きます。
// src/comments/comments.controller.ts import { Controller, Post, Body, Param, Req, Res, UseGuards } from '@nestjs/common'; // ... import statements @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 を受け取る @Req() req: Request, @Res() res: Response, ) { const user = req.user as any; // parentId をサービスに渡す await this.commentsService.create(content, user, { id: postId } as any, parentId); res.redirect(`/posts/${postId}`); } }
ステップ4: フロントエンドビューのアップグレード
これは最も興味深い部分です。コメントとその返信を再帰的にレンダリングするためにpost.ejs
テンプレートを更新し、返信フォームを動的に表示するためのJavaScriptを追加する必要があります。
1. 再利用可能なコメントテンプレートの作成
再帰的なレンダリングには、再利用可能な「部分テンプレート」を作成するのが最善の方法です。views
ディレクトリに_comment.ejs
という名前の新しいファイルを作成します。
<% comments.forEach(comment => { %> <div class="comment-item" style="margin-left: <%= depth * 20 %>px;"> <p class="comment-content"><%= comment.content %></p> <small> By <strong><%= comment.user.username %></strong> on <%= new Date(comment.createdAt).toLocaleDateString() %> </small> <% if (user) { %> <button class="reply-btn" data-comment-id="<%= comment.id %>">Reply</button> <% } %> </div> <% if (comment.replies && comment.replies.length > 0) { %> <%- include('_comment', { comments: comment.replies, user: user, post: post, depth: depth + 1 }) %> <% } %> <% }) %>
このテンプレートは、受信したcomments
配列を反復処理し、各コメントのreplies
配列に対して再帰的に自身を呼び出し、depth
をインクリメントしてスタイル上のインデントを実現します。
2. post.ejs
の更新
次に、views/post.ejs
をこの新しい_comment.ejs
部分テンプレートを使用するように変更し、ユニバーサルな返信フォームを追加します。
<section class="comments-section"> <h3>Comments</h3> <div class="comment-list"> <% if (comments.length > 0) { %> <%- include('_comment', { comments: comments, user: user, post: post, depth: 0 }) %> <% } else { %> <p>No comments yet. Be the first to comment!</p> <% } %> </div> <% if (user) { %> <form id="comment-form" action="/posts/<%= post.id %>/comments" method="POST" class="comment-form"> <h4>Leave a Comment</h4> <div class="form-group"> <textarea name="content" rows="4" placeholder="Write your comment here..." required></textarea> <input type="hidden" name="parentId" id="parentIdInput" value="" /> </div> <button type="submit">Submit</button> <button type="button" id="cancel-reply-btn" style="display: none;">Cancel Reply</button> </form> <% } else { %> <p><a href="/auth/login">Login</a> to leave a comment.</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'); // 返信対象のコメントの直下にフォームを移動 commentItem.after(commentForm); // parentId とフォームのタイトルを設定 parentIdInput.value = commentId; formTitle.innerText = 'Replying to ' + commentItem.querySelector('strong').innerText; cancelReplyBtn.style.display = 'inline-block'; } }); cancelReplyBtn.addEventListener('click', () => { // フォームの状態をリセット parentIdInput.value = ''; formTitle.innerText = 'Leave a Comment'; cancelReplyBtn.style.display = 'none'; // フォームをコメントセクションの末尾に戻す document.querySelector('.comments-section').appendChild(commentForm); }); }); </script> <%- include('_footer') %>
フロントエンドロジックの説明:
- ページにはコメントフォームが1つだけあります。
- コメントの「返信」ボタンをクリックすると、JavaScriptは次の処理を行います。
- そのコメントのIDを取得します。
- このIDをフォームの隠し
parentId
入力フィールドに設定します。 - ユーザーエクスペリエンスを向上させるために、フォーム全体を返信対象のコメントの直下に移動します。
- 「返信をキャンセル」ボタンを表示します。
- 「返信をキャンセル」をクリックした後、またはフォームを送信すると、フォームはリセットされ、元の位置に戻すことができます。
実行とテスト
アプリケーションを再起動し(npm run start:dev
)、投稿ページを更新します。
- ログインユーザーとして、トップレベルのコメントを投稿します。
- 投稿したコメントの横にある「返信」ボタンをクリックします。フォームがそのコメントの下に移動します。
- フォームに返信を入力して送信します。
- ページがリフレッシュされた後、返信が親コメントの下にインデントされて表示されます。
- 返信を続け、より深い会話レベルを作成できます。
結論
このチュートリアルでは、ブログにスレッド返信機能を追加することに成功しました。データモデルで自己参照関係を確立し、ツリー構造を処理するようにバックエンドサービスを調整し、それを簡単なクライアントサイドJavaScriptと組み合わせることで、ブログのインタラクティビティを大幅に向上させました。