Django搭建個人部落格:用django-mptt實現多級評論功能

杜賽_dusai發表於2019-05-04

現在我們的部落格已經具有評論功能了。隨著文章的評論者越來越多,有的時候評論者之間也需要交流,甚至部分評論還能合併成一個小的整體。因此最好是有某種方法可以將相關的評論聚集到一起,這時候多級評論就非常的有用了。

多級評論意味著你需要將模型重新組織為樹形結構。“樹根”是一級評論,而眾多“樹葉”則是次級評論。本教程會以第三方庫django-mptt為基礎,開發多級評論功能。

django-mptt模組包含了樹形資料結構以及查詢、修改樹形資料的眾多方法。

任何需要樹形結構的地方,都可以用 django-mptt 來搭建。比如目錄。

**注意:**本章新知識點較多,請讀者做好心理準備,一定要耐心閱讀。

重構模型

既然要建立樹形結構,老的評論模型肯定是要修改了。

首先安裝django-mptt

(env) > pip install django-mptt
複製程式碼

安裝成功後,在配置中註冊

my_blog/settings.py

...
INSTALLED_APPS = [
    ...
    'mptt',

    ...
]
...
複製程式碼

這些你已經輕車熟路了。

接下來,修改評論模型

comment/models.py

...
# django-mptt
from mptt.models import MPTTModel, TreeForeignKey

# 替換 models.Model 為 MPTTModel
class Comment(MPTTModel):
    ...
    
    # 新增,mptt樹形結構
    parent = TreeForeignKey(
        'self',
        on_delete=models.CASCADE,
        null=True,
        blank=True,
        related_name='children'
    )

    # 新增,記錄二級評論回覆給誰, str
    reply_to = models.ForeignKey(
        User,
        null=True,
        blank=True,
        on_delete=models.CASCADE,
        related_name='replyers'
    )
    
    # 替換 Meta 為 MPTTMeta
    # class Meta:
    #     ordering = ('created',)
    class MPTTMeta:
        order_insertion_by = ['created']

    ...

複製程式碼

先引入MPTT相關模組,然後改動下列幾個位置:

  • 模型不再繼承內建的models.Model類,替換為MPTTModel,因此你的模型自動擁有了幾個用於樹形演算法的新欄位。(有興趣的讀者,可以在遷移好資料之後在SQLiteStudio中檢視)
  • parent欄位是必須定義的,用於儲存資料之間的關係,不要去修改它。
  • reply_to外來鍵用於儲存被評論人
  • class Meta替換為class MPTTMeta,引數也有小的變化,這是模組的預設定義,實際功能是相同的。

這些改動大部分都是django-mptt文件的預設設定。需要說明的是這個reply_to

先思考一下,多級評論是否允許無限級數?無限級數聽起來很美好,但是巢狀的層級如果過多,反而會導致結構混亂,並且難以排版。所以這裡就限制評論最多隻能兩級,超過兩級的評論一律重置為兩級,然後再將實際的被評論人儲存在reply_to欄位中。

舉例說明:一級評論人為 a,二級評論人為 b(parent 為 a),三級評論人為 c(parent 為 b)。因為我們不允許評論超過兩級,因此將 c 的 parent 重置為 a,reply_to 記錄為 b,這樣就能正確追溯真正的被評論者了。

模型修改完了,新增了很多非空的欄位進去,因此最好先清空所有的評論資料,再進行資料遷移。

遷移時出現下面的提示也不要慌,一律選第 1 項、填入資料 0 就可以了:

(env) > python manage.py makemigrations

You are trying to add a non-nullable field 'level' to comment without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit, and let me add a default in models.py

Select an option: 1
Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now
Type 'exit' to exit this prompt
>>> 0
複製程式碼

要還不行,就把資料庫檔案刪了重新遷移吧。開發階段用點笨辦法也沒關係。

資料遷移還是老規矩:

(env) > python manage.py makemigrations
(env) > python manage.py migrate
複製程式碼

這就完成了。

檢視

前面章節已經寫過一個檢視post_comment用於處理評論了,我們將複用它,以求精簡程式碼。

改動較大,程式碼全貼出來,請對照改動:

comment/views.py

...
# 記得引入 Comment !
from .models import Comment

...
@login_required(login_url='/userprofile/login/')
# 新增引數 parent_comment_id
def post_comment(request, article_id, parent_comment_id=None):
    article = get_object_or_404(ArticlePost, id=article_id)

    # 處理 POST 請求
    if request.method == 'POST':
        comment_form = CommentForm(request.POST)
        if comment_form.is_valid():
            new_comment = comment_form.save(commit=False)
            new_comment.article = article
            new_comment.user = request.user

            # 二級回覆
            if parent_comment_id:
                parent_comment = Comment.objects.get(id=parent_comment_id)
                # 若回覆層級超過二級,則轉換為二級
                new_comment.parent_id = parent_comment.get_root().id
                # 被回覆人
                new_comment.reply_to = parent_comment.user
                new_comment.save()
                return HttpResponse('200 OK')

            new_comment.save()
            return redirect(article)
        else:
            return HttpResponse("表單內容有誤,請重新填寫。")
    # 處理 GET 請求
    elif request.method == 'GET':
        comment_form = CommentForm()
        context = {
            'comment_form': comment_form,
            'article_id': article_id,
            'parent_comment_id': parent_comment_id
        }
        return render(request, 'comment/reply.html', context)
    # 處理其他請求
    else:
        return HttpResponse("僅接受GET/POST請求。")
複製程式碼

主要變化有3個地方:

  • 檢視的引數新增parent_comment_id=None。此引數代表父評論id值,若為None則表示評論為一級評論,若有具體值則為多級評論。
  • 如果檢視處理的是多級評論,則用MPTTget_root()方法將其父級重置為樹形結構最底部的一級評論,然後在reply_to中儲存實際的被回覆人並儲存。檢視最終返回的是HttpResponse字串,後面會用到。
  • 新增處理GET請求的邏輯,用於給二級回覆提供空白的表單。後面會用到。

很好,現在檢視中有一個parent_comment_id引數用於區分多級評論,因此就要求有的url傳入此引數,有的不傳入,像下面這樣:

comment/urls.py

...
urlpatterns = [
    # 已有程式碼,處理一級回覆
    path('post-comment/<int:article_id>', views.post_comment, name='post_comment'),
    # 新增程式碼,處理二級回覆
    path('post-comment/<int:article_id>/<int:parent_comment_id>', views.post_comment, name='comment_reply')
]
複製程式碼

兩個path都使用了同一個檢視函式,但是傳入的引數卻不一樣多,仔細看。第一個path沒有parent_comment_id引數,因此檢視就使用了預設值None,達到了區分評論層級的目的。

前端渲染

在前端的邏輯上,我們的理想很豐滿:

  • 二級回覆同樣要使用富文字編輯器
  • 回覆時不能離開當前頁面
  • 多個ckeditor載入時,不能有效能問題

然而理想越豐滿,程式碼寫得就越痛苦。

首先就是detail.html的程式碼要大改,主要集中在顯示評論部分以及相關的JavaScript

需要改動的地方先全部貼出來:

templates/article/detail.html

...

<!-- 改動 顯示評論 部分 -->
<!-- 不要漏了 load mptt_tags! -->
{% load mptt_tags %}
<h4>共有{{ comments.count }}條評論</h4>
<div class="row">
    <!-- 遍歷樹形結構 -->
    {% recursetree comments %}
        <!-- 給 node 取個別名 comment -->
        {% with comment=node %}
            <div class="{% if comment.reply_to %}
                        offset-1 col-11
                        {% else %}
                        col-12
                        {% endif %}"
            >
                <hr>
                <p>
                    <strong style="color: pink">
                        {{ comment.user }}
                    </strong> 

                    {% if comment.reply_to %}
                        <i class="far fa-arrow-alt-circle-right" 
                           style="color: cornflowerblue;"
                        ></i>
                        <strong style="color: pink">
                            {{ comment.reply_to }}
                        </strong> 
                    {% endif %}

                </p>
                <div>{{ comment.body|safe }}</div>

                <div>
                    <span style="color: gray">
                        {{ comment.created|date:"Y-m-d H:i" }}
                    </span>

                    <!-- modal 按鈕 -->
                    <button type="button" 
                            class="btn btn-light btn-sm text-muted" 
                            onclick="load_modal({{ article.id }}, {{ comment.id }})"
                    >
                        回覆
                    </button>
                </div>

                <!-- Modal -->
                <div class="modal fade" 
                     id="comment_{{ comment.id }}" 
                     tabindex="-1" 
                     role="dialog" 
                     aria-labelledby="CommentModalCenter" 
                     aria-hidden="true"
                >
                    <div class="modal-dialog modal-dialog-centered modal-lg" role="document">
                        <div class="modal-content" style="height: 480px">
                            <div class="modal-header">
                                <h5 class="modal-title" id="exampleModalCenterTitle">回覆 {{ comment.user }}:</h5>
                            </div>
                            <div class="modal-body" id="modal_body_{{ comment.id }}"></div>
                        </div>

                    </div>
                </div>

                {% if not comment.is_leaf_node %}
                    <div class="children">
                        {{ children }}
                    </div>
                {% endif %}
            </div>
            

        {% endwith %}
    {% endrecursetree %}
</div>

...

{% block script %}
...

<!-- 新增程式碼,喚醒二級回覆的 modal -->
<script>
    // 載入 modal
    function load_modal(article_id, comment_id) {
        let modal_body = '#modal_body_' + comment_id;
        let modal_id = '#comment_' + comment_id;
        
        // 載入編輯器
        if ($(modal_body).children().length === 0) {
            let content = '<iframe src="/comment/post-comment/' + 
                article_id + 
                '/' + 
                comment_id + 
                '"' + 
                ' frameborder="0" style="width: 100%; height: 100%;" id="iframe_' + 
                comment_id + 
                '"></iframe>';
            $(modal_body).append(content);
        };

        $(modal_id).modal('show');
    }
</script>
{% endblock script %}
複製程式碼

這麼大段肯定把你看暈了,不要急,讓我們拆開來講解。

遍歷樹

第一個問題,如何遍歷樹形結構?

django-mptt提供了一個快捷方式:

{% load mptt_tags %}
<ul>
    {% recursetree objs %}
        <li>
            {{ node.your_field }}
            {% if not node.is_leaf_node %}
                <ul class="children">
                    {{ children }}
                </ul>
            {% endif %}
        </li>
    {% endrecursetree %}
</ul>
複製程式碼

內部的實現你不用去管,當成一個黑盒子去用就好了。objs是需要遍歷的資料集node是其中的單個資料。有兩個地方要注意:

  • {% load mptt_tags %}不要忘記寫
  • node這個變數名太寬泛,用{% with comment=node %}給它起了個別名

Modal

ModalBootstrap內建的彈窗。本文相關程式碼如下:

<!-- modal 按鈕 -->
<button type="button" 
        class="btn btn-light btn-sm text-muted" 
        onclick="load_modal({{ article.id }}, {{ comment.id }})"
>
    回覆
</button>

<!-- Modal -->
<div class="modal fade" 
     id="comment_{{ comment.id }}" 
     tabindex="-1" 
     role="dialog" 
     aria-labelledby="CommentModalCenter" 
     aria-hidden="true"
     >
    <div class="modal-dialog modal-dialog-centered modal-lg" role="document">
        <div class="modal-content" style="height: 480px">
            <div class="modal-header">
                <h5 class="modal-title" id="exampleModalCenterTitle">回覆 {{ comment.user }}:</h5>
            </div>
            <div class="modal-body" id="modal_body_{{ comment.id }}"></div>
        </div>
    </div>
</div>
複製程式碼

它幾乎就是從Bootstrap官方文件抄下來的(所以讀者要多瀏覽官網啊)。有點不同的是本文沒有用原生的按鈕,而是用JavaScript載入的Modal;還有就是增加了幾個容器的id屬性,方便後面的JavaScript查詢。

和之前章節用的Layer.js相比,Bootstrap的彈窗更笨重些,也更精緻些,很適合在這裡使用。

載入Modal

最難理解的可能就是這段載入Modal的JavaScript程式碼了:

// 載入 modal
function load_modal(article_id, comment_id) {
    let modal_body = '#modal_body_' + comment_id;
    let modal_id = '#comment_' + comment_id;

    // 載入編輯器
    if ($(modal_body).children().length === 0) {
        let content = '<iframe src="/comment/post-comment/' + 
            article_id + 
            '/' + 
            comment_id + 
            '" frameborder="0" style="width: 100%; height: 100%;"></iframe>';
        $(modal_body).append(content);
    };

    $(modal_id).modal('show');
}
複製程式碼

實際上核心邏輯只有3步:

  • 點選回覆按鈕時喚醒了load_modal()函式,並將文章id、父級評論id傳遞進去
  • $(modal_body).append(content)找到對應Modal的容器,並將一個iframe容器動態新增進去
  • $(modal_id).modal('show')找到對應的Modal,並將其喚醒

為什麼iframe需要動態載入?這是為了避免潛在的效能問題。你確實可以在頁面初始載入時把所有iframe都渲染好,但是這需要花費額外的時間,並且絕大部分的Modal使用者根本不會用到,很不划算。

if語句的作用是判斷Modal中如果已經載入過,就不再重複載入了。

最後,什麼是iframe?這是HTML5中的新特性,可以理解成當前網頁中巢狀的另一個獨立的網頁。既然是獨立的網頁,那自然也會獨立的向後臺請求資料。仔細看src中請求的位置,正是前面我們在urls.py中寫好的第二個path。即對應了post_comment檢視中的GET邏輯:

comment/views.py

def post_comment(request, article_id, parent_comment_id=None):
    ...
    # 處理 GET 請求
    elif request.method == 'GET':
        ...
        return render(request, 'comment/reply.html', context)
    ...
複製程式碼

檢視返回的comment/reply.html模板還沒有寫,接下來就把它寫好。

老實說用iframe來載入ckeditor彈窗並不是很“優雅”。單頁面上多個ckeditor的動態載入、取值、傳參,博主沒能嘗試成功。有興趣的讀者可以和我交流。

Ajax提交表單

templates中新建comment目錄,並新建reply.html,寫入程式碼:

templates/comment/reply.html

<!-- 載入靜態檔案 -->
{% load staticfiles %}

<!DOCTYPE html>
<html lang="zh-cn">
<head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
</head>

<body>
    <form 
    action="." 
    method="POST"
    id="reply_form" 
    >
        {% csrf_token %}
        <div class="form-group">
            <div id="test">
                {{ comment_form.media }}
                {{ comment_form.body }}
            </div>
        </div>
    </form>
    <!-- 提交按鈕 -->
    <button onclick="confirm_submit({{ article_id }}, {{ parent_comment_id }})" class="btn btn-primary">傳送</button>

    <script src="{% static 'jquery/jquery-3.3.1.js' %}"></script>
    <script src="{% static 'popper/popper-1.14.4.js' %}"></script>
    <script src="{% static 'bootstrap/js/bootstrap.min.js' %}"></script>

    <!-- csrf token -->
    <script src="{% static 'csrf.js' %}"></script>
    
    <script>
    $(function(){
        $(".django-ckeditor-widget").removeAttr('style');
    });

    function confirm_submit(article_id, comment_id){
        // 從 ckeditor 中取值
        let content = CKEDITOR.instances['id_body'].getData();
        // 呼叫 ajax 與後端交換資料
        $.ajax({
            url: '/comment/post-comment/' + article_id + '/' + comment_id,
            type: 'POST',
            data: {body: content},
            // 成功回撥
            success: function(e){
                if(e === '200 OK'){
                    parent.location.reload();
                }
            }
        })
    }
    </script>

</body>
</html>
複製程式碼

這個模板的作用是提供一個ckeditor的編輯器,所以沒有繼承base.html。讓我們拆開來講。

Ajax是什麼

Ajax技術來提交表單,與傳統方法非常不同。

傳統方法提交表單時向後端提交一個請求。後端處理請求後會返回一個全新的網頁。這種做法浪費了很多頻寬,因為前後兩個頁面中大部分內容往往都是相同的。與此不同,AJAX技術可以僅向伺服器傳送並取回必須的資料,並在客戶端採用JavaScript處理來自伺服器的回應。因為在伺服器和瀏覽器之間交換的資料大量減少,伺服器回應更快了。

雖然本教程只用到Ajax的一點皮毛,但是Ajax的應用非常廣泛,建議讀者多瞭解相關知識。

這裡會用到Ajax,倒不是因為其效率高,而是因為Ajax可以在表單提交成功後得到反饋,以便重新整理頁面。

核心程式碼如下:

function confirm_submit(article_id, comment_id){
    // 從 ckeditor 中取值
    let content = CKEDITOR.instances['id_body'].getData();
    // 呼叫 ajax 與後端交換資料
    $.ajax({
        url: '/comment/post-comment/' + article_id + '/' + comment_id,
        type: 'POST',
        data: {body: content},
        // 成功回撥
        success: function(e){
            if(e === '200 OK'){
                parent.location.reload();
            }
        }
    })
}
複製程式碼
  • CKEDITOR是編輯器提供的全域性變數,這裡用CKEDITOR.instances['id_body'].getData()取得當前編輯器中使用者輸入的內容。
  • 接下來呼叫了Jquery的ajax方法與檢視進行資料交換。ajax中定義了檢視的url、請求的方法、提交的資料。
  • success是ajax的回撥函式。當得到檢視的相應後執行內部的函式。

前面寫檢視的時候,二級評論提交成功後會返回200 OK,回撥函式接收到這個訊號後,就會呼叫reload()方法,重新整理當前的父頁面(即文章所在的頁面),實現了資料的更新。

csrf問題

程式碼中有這麼一行:

<script src="{% static 'csrf.js' %}"></script>
複製程式碼

沒有這一行,後端會返回403 Forbidden錯誤,並且表單提交失敗。

還記得之前提交傳統表單時的{% csrf_token %}嗎?Django為了防止跨域攻擊,要求表單必須提供這個token,驗證提交者的身份。

問題是在Ajax中怎麼解決這個問題呢?一種方法就是在頁面中插入這個csrf.js模組。

在static目錄中將csrf.js檔案貼上進去,並在頁面中引用,就可以解決此問題了。

csrf.js檔案可以在我的GitHub倉庫下載

測試!

進入文章頁面,評論的邊上多出一個按鈕,可以對評論者進行評論了:

Django搭建個人部落格:用django-mptt實現多級評論功能

點選回覆按鈕,彈出帶有富文字編輯器的彈窗:

Django搭建個人部落格:用django-mptt實現多級評論功能

點選傳送按鈕,頁面會自動重新整理,並且二級評論也出現了:

Django搭建個人部落格:用django-mptt實現多級評論功能

還可以繼續對二級評論者評論,不過更高階的評論會被強制轉換為二級評論:

Django搭建個人部落格:用django-mptt實現多級評論功能

功能正常執行了。

有興趣的讀者可以開啟SQLiteStudio,研究一下comment資料表的結構。

總結

認真看完本章並實現了多級評論的同學,可以給自己點掌聲了。本章應該是教程到目前為止知識點最多、最雜的章節,涵蓋了MTV、Jquery、Ajax、iframe、modal等多種前後端技術。

沒成功實現也不要急躁,web開發嘛,走點彎路很正常的。多觀察Django和控制檯的報錯資訊,找到問題並解決它。


相關文章