跳轉到

表單與資料驗證

開始之前

任務目標

在這個章節中,我們會完成:

  • 了解為什麼需要表單驗證
  • 手寫 HTML 表單並在 view 中手動驗證資料
  • 使用 Django Form 類別處理驗證邏輯
  • 使用 Form 類別渲染表單 HTML
  • 使用 ModelForm 簡化表單建立
  • 掌握表單處理的最佳實踐

為什麼需要表單驗證?

在網頁開發中,使用者輸入是不可信任的

常見的問題

問題 範例 可能造成的後果
空值 標題欄位留空 建立無意義的資料
格式錯誤 Email 格式不正確 無法聯絡使用者
過長的內容 標題超過 200 字元 資料庫錯誤
惡意輸入 SQL Injection、XSS 安全漏洞
不合邏輯的值 負數的價格 業務邏輯錯誤

永遠不要信任使用者輸入

即使前端有驗證,後端也必須再次驗證

  • 使用者可以停用 JavaScript
  • 可以透過開發者工具修改 HTML
  • 可以直接發送 HTTP 請求繞過前端
  • 前端驗證只是為了提升使用者體驗

後端驗證是必須的,前端驗證是可選的。

情境:建立文章功能

讓我們實作一個建立文章的功能,從最基本的方式開始,逐步優化。

需求分析

建立文章時需要:

  1. 必填欄位:標題、內容
  2. 選填欄位:作者
  3. 驗證規則
  4. 標題不能空白
  5. 標題最多 200 字元
  6. 內容不能空白

方法一:手寫表單 + 手動驗證

讓我們從最基礎的方式開始:手寫 HTML 表單,並在 view 中手動處理驗證。

建立 View

新增 article_create view 到 blog/views.py

blog/views.py
from django.shortcuts import get_object_or_404, redirect, render

from blog.models import Article, Author


def article_list(request):
    articles = Article.objects.select_related("author").prefetch_related("tags")
    return render(request, "blog/article_list.html", {"articles": articles})


def article_detail(request, article_id):
    article = get_object_or_404(
        Article.objects.select_related("author").prefetch_related("tags"),
        id=article_id,
    )
    return render(request, "blog/article_detail.html", {"article": article})


def article_create(request):
    authors = Author.objects.all()  # (1)!
    errors = {}

    if request.method == "POST":
        title = request.POST.get("title", "").strip()  # (2)!
        content = request.POST.get("content", "").strip()
        author_id = request.POST.get("author")

        if not title:  # (3)!
            errors["title"] = "標題不能空白"
        elif len(title) > 200:
            errors["title"] = "標題最多 200 字元"

        if not content:
            errors["content"] = "內容不能空白"

        if not errors:  # (4)!
            article = Article.objects.create(
                title=title,
                content=content,
                author_id=author_id if author_id else None,
            )
            return redirect("blog:article_detail", article_id=article.id)

    return render(
        request,
        "blog/article_create.html",
        {
            "authors": authors,
            "errors": errors,
            "title": request.POST.get("title", ""),
            "content": request.POST.get("content", ""),
            "author_id": request.POST.get("author", ""),
        },
    )
  1. 準備作者列表供下拉選單使用
  2. 取得表單資料
  3. 驗證資料
  4. 如果沒有錯誤,儲存資料

程式碼解析

這個 view 處理兩種 HTTP 請求:

  1. GET 請求:顯示空白表單

    • 準備作者列表
    • 渲染表單 template
  2. POST 請求:處理表單提交

    • 取得表單資料
    • 手動驗證每個欄位
    • 如果有錯誤,顯示錯誤訊息並保留使用者輸入
    • 如果沒有錯誤,建立文章並重導向到文章詳情頁面

建立 Template

建立 blog/templates/blog/article_create.html

blog/templates/blog/article_create.html
{% extends "blog/base.html" %}

{% block title %}
  建立文章 - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <div class="card">
    <div class="card-body">
      <h2 class="card-title">
        建立文章
      </h2>

      <form method="post">
        {% csrf_token %}

        <div class="mb-3">
          <label for="title" class="form-label">標題</label>
          <input type="text"
                 class="form-control{% if errors.title %} is-invalid{% endif %}"
                 id="title"
                 name="title"
                 value="{{ title }}"
                 required />
          {% if errors.title %}
            <div class="invalid-feedback">
              {{ errors.title }}
            </div>
          {% endif %}
        </div>

        <div class="mb-3">
          <label for="content" class="form-label">內容</label>
          <textarea class="form-control{% if errors.content %} is-invalid{% endif %}"
                    id="content"
                    name="content"
                    rows="10"
                    required>{{ content }}</textarea>
          {% if errors.content %}
            <div class="invalid-feedback">
              {{ errors.content }}
            </div>
          {% endif %}
        </div>

        <div class="mb-3">
          <label for="author" class="form-label">作者(選填)</label>
          <select class="form-select" id="author" name="author">
            <option value="">未指定</option>
            {% for author in authors %}
              <option value="{{ author.id }}"
                      {% if author_id|stringformat:"s" == author.id|stringformat:"s" %}selected{% endif %}>
                {{ author.name }}
              </option>
            {% endfor %}
          </select>
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">
            建立文章
          </button>
          <a href="{% url 'blog:article_list' %}" class="btn btn-secondary">
            取消
          </a>
        </div>
      </form>
    </div>
  </div>
{% endblock blog_content %}

Template 重點

  1. {% csrf_token %}:防止 CSRF 攻擊的 token,Django 必須
  2. 錯誤顯示:使用 errors 字典顯示驗證錯誤
  3. 保留輸入:驗證失敗時,保留使用者輸入的值
  4. Bootstrap 樣式:使用 is-invalid 類別標示錯誤欄位

設定 URL

blog/urls.py 加入新的 URL pattern:

blog/urls.py
from django.urls import path

from blog import views

app_name = "blog"

urlpatterns = [
    path("articles/", views.article_list, name="article_list"),
    path("articles/create/", views.article_create, name="article_create"),
    path("articles/<int:article_id>/", views.article_detail, name="article_detail"),
]

測試功能

啟動開發伺服器:

uv run manage.py runserver

訪問 http://127.0.0.1:8000/blog/articles/create/

試試看:

  1. 提交空白表單:應該看到「標題不能空白」和「內容不能空白」的錯誤
  2. 輸入超長標題:應該看到「標題最多 200 字元」的錯誤
  3. 正確填寫表單:應該成功建立文章並重導向

問題分析

手動驗證的方式可以運作,但有很多問題:

手動驗證的缺點

  1. 重複的程式碼:每個表單都要寫類似的驗證邏輯
  2. 容易出錯:可能忘記驗證某些欄位
  3. 難以維護:驗證規則分散在各個 view 中
  4. 測試困難:需要測試整個 view,無法單獨測試驗證邏輯
  5. 不一致:不同開發者可能寫出不同的驗證方式

Django 提供了更好的解決方案:Form 類別

方法二:使用 Django Form 類別

Django 的 Form 類別可以幫我們處理驗證邏輯。

建立 Form 類別

建立 blog/forms.py

blog/forms.py
from django import forms


class ArticleForm(forms.Form):
    title = forms.CharField(
        max_length=200,
        required=True,
        error_messages={
            "required": "標題不能空白",
            "max_length": "標題最多 %(limit_value)d 字元",
        },
    )
    content = forms.CharField(
        widget=forms.Textarea,
        required=True,
        error_messages={
            "required": "內容不能空白",
        },
    )
    author = forms.IntegerField(
        required=False,
    )

Form 欄位說明

  • CharField:文字欄位

    • max_length:最大長度限制
    • required:是否必填
    • error_messages:自訂錯誤訊息
  • Textarea widget:多行文字輸入框

    • widget=forms.Textarea 讓 content 渲染成 textarea
  • IntegerField:整數欄位

    • required=False:選填欄位

Django 會自動驗證這些規則!

重構 View

修改 blog/views.py,使用 Form 類別:

blog/views.py
from django.shortcuts import get_object_or_404, redirect, render

from blog.forms import ArticleForm
from blog.models import Article, Author


def article_list(request):
    articles = Article.objects.select_related("author").prefetch_related("tags")
    return render(request, "blog/article_list.html", {"articles": articles})


def article_detail(request, article_id):
    article = get_object_or_404(
        Article.objects.select_related("author").prefetch_related("tags"),
        id=article_id,
    )
    return render(request, "blog/article_detail.html", {"article": article})


def article_create(request):
    authors = Author.objects.all()

    if request.method == "POST":
        form = ArticleForm(request.POST)
        if form.is_valid():
            article = Article.objects.create(  # (1)!
                title=form.cleaned_data["title"],
                content=form.cleaned_data["content"],
                author_id=form.cleaned_data["author"],
            )
            return redirect("blog:article_detail", article_id=article.id)
    else:
        form = ArticleForm()

    return render(
        request,
        "blog/article_create.html",
        {
            "form": form,
            "authors": authors,
        },
    )
  1. 取得驗證後的資料

程式碼變簡潔了!

對比手動驗證的版本:

  • 減少了 20+ 行程式碼
  • 不需要手動取得每個欄位form.cleaned_data 自動提供驗證後的資料
  • 不需要手動驗證form.is_valid() 自動執行所有驗證
  • 不需要手動處理錯誤:Form 會自動記錄錯誤訊息

更新 Template

修改 blog/templates/blog/article_create.html,使用 Form 物件:

blog/templates/blog/article_create.html
{% extends "blog/base.html" %}

{% block title %}
  建立文章 - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <div class="card">
    <div class="card-body">
      <h2 class="card-title">
        建立文章
      </h2>

      <form method="post">
        {% csrf_token %}

        <div class="mb-3">
          <label for="{{ form.title.id_for_label }}" class="form-label">標題</label>
          <input type="text"
                 class="form-control{% if form.title.errors %} is-invalid{% endif %}"
                 id="{{ form.title.id_for_label }}"
                 name="{{ form.title.name }}"
                 value="{{ form.title.value|default:'' }}"
                 required />
          {% if form.title.errors %}
            <div class="invalid-feedback">
              {{ form.title.errors.0 }}
            </div>
          {% endif %}
        </div>

        <div class="mb-3">
          <label for="{{ form.content.id_for_label }}" class="form-label">內容</label>
          <textarea class="form-control{% if form.content.errors %} is-invalid{% endif %}"
                    id="{{ form.content.id_for_label }}"
                    name="{{ form.content.name }}"
                    rows="10"
                    required>{{ form.content.value|default:'' }}</textarea>
          {% if form.content.errors %}
            <div class="invalid-feedback">
              {{ form.content.errors.0 }}
            </div>
          {% endif %}
        </div>

        <div class="mb-3">
          <label for="{{ form.author.id_for_label }}" class="form-label">作者(選填)</label>
          <select class="form-select"
                  id="{{ form.author.id_for_label }}"
                  name="{{ form.author.name }}">
            <option value="">未指定</option>
            {% for author in authors %}
              <option value="{{ author.id }}"
                      {% if form.author.value == author.id %}selected{% endif %}>
                {{ author.name }}
              </option>
            {% endfor %}
          </select>
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">
            建立文章
          </button>
          <a href="{% url 'blog:article_list' %}" class="btn btn-secondary">
            取消
          </a>
        </div>
      </form>
    </div>
  </div>
{% endblock blog_content %}

Form 物件的屬性

  • form.title.id_for_label:欄位的 HTML id
  • form.title.name:欄位的 HTML name
  • form.title.value:欄位的值(保留使用者輸入)
  • form.title.errors:欄位的錯誤訊息列表

測試功能

重新整理頁面,測試驗證功能:

  1. 提交空白表單:應該看到自訂的錯誤訊息
  2. 輸入超長標題:應該看到「標題最多 200 字元」
  3. 正確提交:成功建立文章

使用 Form 的優勢

驗證邏輯集中:所有驗證規則在 Form 類別中

可重用:可以在不同 view 中使用同一個 Form

可測試:可以單獨測試 Form 的驗證邏輯

自動類型轉換cleaned_data 中的資料已經是正確的類型

安全:Django 會自動處理 XSS 等安全問題

方法三:使用 Form 渲染表單

目前我們還在手寫 HTML,Django Form 可以幫我們生成 HTML。

簡化 Template

修改 blog/templates/blog/article_create.html

blog/templates/blog/article_create.html
{% extends "blog/base.html" %}

{% block title %}
  建立文章 - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <div class="card">
    <div class="card-body">
      <h2 class="card-title">
        建立文章
      </h2>

      <form method="post">
        {% csrf_token %}

        {{ form.as_p }}

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">
            建立文章
          </button>
          <a href="{% url 'blog:article_list' %}" class="btn btn-secondary">
            取消
          </a>
        </div>
      </form>
    </div>
  </div>
{% endblock blog_content %}

只要一行!

{{ form.as_p }} 會自動生成:

  • 所有欄位的 label
  • 所有欄位的 input
  • 所有錯誤訊息
  • 包裹在 <p> 標籤中

從 60+ 行縮減到 1 行!

Form 渲染方法

Django 提供了幾種渲染方式:

方法 輸出格式 說明
{{ form.as_p }} <p> 標籤 每個欄位包裹在段落中
{{ form.as_table }} <tr> 標籤 適合用在表格中
{{ form.as_div }} <div> 標籤 Django 4.1+ 可用
{{ form.as_ul }} <li> 標籤 適合用在列表中

測試渲染結果

重新整理頁面,你會看到表單已經自動生成,包括:

  • Label 標籤
  • Input 欄位
  • 錯誤訊息

樣式可能不理想

{{ form.as_p }} 生成的 HTML 是最基本的,沒有 Bootstrap 樣式。

如果需要客製化樣式,有幾種選擇:

  1. 手動渲染欄位(前面的方法)
  2. 使用 django-widget-tweaks:在 template 中加入 CSS 類別
  3. 使用 django-crispy-forms:自動套用 Bootstrap 樣式
  4. 自訂 Form widget:在 Form 類別中設定樣式

我們會在後續章節介紹如何整合 Bootstrap。

處理作者下拉選單

目前的實作有個問題:作者欄位顯示的是 ID,不是下拉選單。

讓我們修改 Form 類別,使用 ChoiceField

blog/forms.py
from django import forms

from blog.models import Author


class ArticleForm(forms.Form):
    title = forms.CharField(
        max_length=200,
        required=True,
        label="標題",
        error_messages={
            "required": "標題不能空白",
            "max_length": "標題最多 %(limit_value)d 字元",
        },
    )
    content = forms.CharField(
        widget=forms.Textarea,
        required=True,
        label="內容",
        error_messages={
            "required": "內容不能空白",
        },
    )
    author = forms.ChoiceField(
        choices=[],
        required=False,
        label="作者",
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.fields["author"].choices = [("", "未指定")] + [  # (1)!
            (author.id, author.name) for author in Author.objects.all()
        ]
  1. 動態載入作者選項

程式碼解析

  1. ChoiceField:下拉選單欄位

    • choices:選項列表,格式為 [(value, label), ...]
    • 初始設定為空列表
  2. __init__ 方法:Form 的建構子

    • 在建立 Form 時動態載入作者選項
    • 這樣可以確保每次都取得最新的作者列表
  3. choices 格式

    • ("", "未指定"):空值選項
    • (author.id, author.name):作者 ID 和名稱
  4. label 設定: 設定顯示的 label

更新 View

因為現在不需要手動傳遞 authors,可以簡化 view:

blog/views.py
from django.shortcuts import get_object_or_404, redirect, render

from blog.forms import ArticleForm
from blog.models import Article


def article_list(request):
    articles = Article.objects.select_related("author").prefetch_related("tags")
    return render(request, "blog/article_list.html", {"articles": articles})


def article_detail(request, article_id):
    article = get_object_or_404(
        Article.objects.select_related("author").prefetch_related("tags"),
        id=article_id,
    )
    return render(request, "blog/article_detail.html", {"article": article})


def article_create(request):
    if request.method == "POST":
        form = ArticleForm(request.POST)
        if form.is_valid():
            article = Article.objects.create(
                title=form.cleaned_data["title"],
                content=form.cleaned_data["content"],
                author_id=form.cleaned_data["author"] or None,  # (1)!
            )
            return redirect("blog:article_detail", article_id=article.id)
    else:
        form = ArticleForm()

    return render(
        request,
        "blog/article_create.html",
        {"form": form},
    )
  1. 將空字串轉成 None

View 變得更簡潔

  • 不需要手動查詢 authors
  • 不需要傳遞 authors 到 template
  • Form 自己處理所有邏輯

測試功能

重新整理頁面,現在應該可以看到:

  • 完整的表單欄位
  • 作者下拉選單
  • 驗證錯誤訊息

方法四:使用 ModelForm

目前的 Form 類別和 Article Model 有很多重複的定義。Django 提供了 ModelForm,可以自動從 Model 生成 Form。

建立 ModelForm

修改 blog/forms.py

blog/forms.py
from django import forms

from blog.models import Article


class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = ["title", "content", "author"]
        labels = {
            "title": "標題",
            "content": "內容",
            "author": "作者",
        }
        error_messages = {
            "title": {
                "required": "標題不能空白",
                "max_length": "標題最多 %(limit_value)d 字元",
            },
            "content": {
                "required": "內容不能空白",
            },
        }
        widgets = {
            "content": forms.Textarea(attrs={"rows": 10}),
        }

ModelForm 的優勢

相比手動定義 Form:

  • 自動欄位:根據 Model 自動生成欄位類型
  • 自動驗證:根據 Model 的限制自動驗證(如 max_length)
  • 簡化建立:可以直接使用 form.save() 建立物件
  • 減少重複:不需要重複定義欄位

Meta 類別說明

  • model:對應的 Model
  • fields:要包含的欄位列表
    • 可以用 "__all__" 包含所有欄位
    • 可以用 exclude 排除特定欄位
  • labels:自訂欄位標籤
  • error_messages:自訂錯誤訊息
  • widgets:自訂欄位的 HTML widget

簡化 View

使用 ModelForm 的 save() 方法:

blog/views.py
from django.shortcuts import get_object_or_404, redirect, render

from blog.forms import ArticleForm
from blog.models import Article


def article_list(request):
    articles = Article.objects.select_related("author").prefetch_related("tags")
    return render(request, "blog/article_list.html", {"articles": articles})


def article_detail(request, article_id):
    article = get_object_or_404(
        Article.objects.select_related("author").prefetch_related("tags"),
        id=article_id,
    )
    return render(request, "blog/article_detail.html", {"article": article})


def article_create(request):
    if request.method == "POST":
        form = ArticleForm(request.POST)
        if form.is_valid():
            article = form.save()
            return redirect("blog:article_detail", article_id=article.id)
    else:
        form = ArticleForm()

    return render(
        request,
        "blog/article_create.html",
        {"form": form},
    )

更簡潔了!

form.save() 會自動:

  1. cleaned_data 取得資料
  2. 建立 Article 物件
  3. 儲存到資料庫
  4. 回傳建立的物件

不需要手動呼叫 Article.objects.create()

測試功能

重新整理頁面,功能應該完全相同,但程式碼更簡潔了。

新增導覽連結

讓我們在文章列表頁面加入「建立文章」按鈕。

修改 blog/templates/blog/article_list.html

blog/templates/blog/article_list.html
{% extends "blog/base.html" %}

{% block title %}
  文章列表 - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <div class="d-flex justify-content-between align-items-center mb-4">
    <h2>
      文章列表
    </h2>
    <a href="{% url 'blog:article_create' %}" class="btn btn-primary">
      建立文章
    </a>
  </div>

  <div class="row">
    {% for article in articles %}
      {% include "blog/components/article_card.html" %}
    {% empty %}
      <div class="col-12">
        <div class="alert alert-info">
          目前沒有文章
        </div>
      </div>
    {% endfor %}
  </div>
{% endblock blog_content %}

現在在文章列表頁面就能看到「建立文章」按鈕了!

表單處理的最佳實踐

1. POST-Redirect-GET 模式

永遠在 POST 後重導向

if request.method == "POST":
    form = ArticleForm(request.POST)
    if form.is_valid():
        article = form.save()
        return redirect("blog:article_detail", article_id=article.id)  # 重導向

為什麼要重導向?

  • 避免重複提交:使用者重新整理頁面不會重複建立資料
  • 改善使用者體驗:顯示成功訊息的頁面
  • 符合 HTTP 語意:POST 用於修改資料,GET 用於查看資料

2. 永遠驗證資料

後端必須驗證

# ❌ 錯誤:直接使用 request.POST
Article.objects.create(
    title=request.POST.get("title"),
    content=request.POST.get("content"),
)

# ✅ 正確:透過 Form 驗證
form = ArticleForm(request.POST)
if form.is_valid():
    article = form.save()

永遠不要直接使用 request.POST 的資料!

3. 使用 cleaned_data

使用驗證後的資料

# ❌ 錯誤:使用原始資料
title = request.POST.get("title")

# ✅ 正確:使用 cleaned_data
title = form.cleaned_data["title"]

cleaned_data 的資料:

  • 已經驗證
  • 已經轉換成正確的類型
  • 已經去除空白
  • 已經經過安全處理

4. 選擇合適的 Form 類別

情境 使用 理由
直接對應 Model ModelForm 最簡單,減少重複
不對應 Model Form 例如登入表單、搜尋表單

5. 自訂驗證

如果需要自訂驗證邏輯,可以覆寫 clean_<field_name> 方法:

blog/forms.py
from django import forms

from blog.models import Article


class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = ["title", "content", "author"]
        labels = {
            "title": "標題",
            "content": "內容",
            "author": "作者",
        }
        error_messages = {
            "title": {
                "required": "標題不能空白",
                "max_length": "標題最多 %(limit_value)d 字元",
            },
            "content": {
                "required": "內容不能空白",
            },
        }
        widgets = {
            "content": forms.Textarea(attrs={"rows": 10}),
        }

    def clean_title(self):
        title = self.cleaned_data["title"]
        if "測試" in title:
            error_message = "標題不能包含「測試」"
            raise forms.ValidationError(error_message)

        return title

自訂驗證方法

  • clean_<field_name>():驗證單一欄位
  • clean():驗證多個欄位的關聯性

例如:

def clean(self):
    cleaned_data = super().clean()
    title = cleaned_data.get("title")
    content = cleaned_data.get("content")

    if title and content and title in content:
        raise forms.ValidationError("內容不應該包含標題")

    return cleaned_data

6. 簡化 View 寫法

可以使用 request.POST or None 來簡化 view 的程式碼:

blog/views.py
from django.shortcuts import get_object_or_404, redirect, render

from blog.forms import ArticleForm
from blog.models import Article


def article_list(request):
    articles = Article.objects.select_related("author").prefetch_related("tags")
    return render(request, "blog/article_list.html", {"articles": articles})


def article_detail(request, article_id):
    article = get_object_or_404(
        Article.objects.select_related("author").prefetch_related("tags"),
        id=article_id,
    )
    return render(request, "blog/article_detail.html", {"article": article})


def article_create(request):
    form = ArticleForm(request.POST or None)
    if form.is_valid():
        article = form.save()
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_create.html", {"form": form})

為什麼這樣可以運作?

原本的寫法

if request.method == "POST":
    form = ArticleForm(request.POST)
    if form.is_valid():
        article = form.save()
        return redirect("blog:article_detail", article_id=article.id)
else:
    form = ArticleForm()

簡化後的寫法

form = ArticleForm(request.POST or None)
if form.is_valid():
    article = form.save()
    return redirect("blog:article_detail", article_id=article.id)

運作原理

  1. GET 請求

    • request.POST 是空的 QueryDict(類似空字典)
    • 空 QueryDict 在 Python 中被視為 False
    • request.POST or None 回傳 None
    • ArticleForm(None) 等同於 ArticleForm(),建立空白表單
  2. POST 請求

    • request.POST 包含資料
    • 有資料的 QueryDict 被視為 True
    • request.POST or None 回傳 request.POST
    • ArticleForm(request.POST) 建立包含資料的表單
  3. is_valid() 的行為

    • 空白表單(GET):is_valid() 回傳 False,不會進入處理邏輯
    • 包含資料的表單(POST):執行驗證,根據結果回傳 TrueFalse

優點與缺點

優點

  • ✅ 程式碼更簡潔(減少 5-6 行)
  • ✅ 減少縮排層級
  • ✅ 更符合 Python 的風格

缺點

  • ❌ 對初學者可能不夠直觀
  • ❌ 如果需要在 POST/GET 執行不同邏輯,還是需要判斷

建議

  • 初學時使用明確的 if request.method == "POST" 判斷
  • 熟悉後可以改用 request.POST or None 簡化程式碼
  • 如果邏輯複雜,仍然使用明確判斷比較清楚

常見問題

ModelForm 和 Form 有什麼差別?

特性 Form ModelForm
基礎類別 forms.Form forms.ModelForm
用途 任何表單 對應 Model 的表單
欄位定義 手動定義 自動從 Model 生成
儲存 手動處理 form.save()
適用場景 登入、搜尋等 建立、編輯 Model 物件

form.save() 做了什麼?

form.save() 會:

  1. cleaned_data 取得資料
  2. 建立或更新 Model 物件
  3. 儲存到資料庫
  4. 回傳物件

也可以使用 commit=False 來延遲儲存:

article = form.save(commit=False)  # (1)!
article.is_published = True  # (2)!
article.save()  # (3)!
  1. 建立物件但不儲存到資料庫
  2. 修改其他欄位
  3. 手動儲存到資料庫

處理多對多關係時的注意事項

如果表單包含多對多欄位(如 tags),使用 commit=False 時需要額外處理:

form = ArticleForm(request.POST)
if form.is_valid():
    article = form.save(commit=False)  # (1)!
    article.is_published = True  # (2)!
    article.save()  # (3)!
    form.save_m2m()  # (4)!
  1. 建立物件但不儲存到資料庫
  2. 修改其他欄位
  3. 先儲存物件到資料庫,取得 ID
  4. 儲存多對多關係

為什麼需要 save_m2m()

  • 多對多關係需要透過中介表連接
  • 中介表需要兩個物件的 ID
  • 使用 commit=False 時,物件還沒有 ID
  • 必須先 save() 取得 ID,再呼叫 save_m2m() 儲存關聯

完整流程:

  1. form.save(commit=False) - 建立物件但不儲存
  2. 修改額外欄位
  3. article.save() - 儲存物件到資料庫(取得 ID)
  4. form.save_m2m() - 儲存多對多關係

可以不用 Form 嗎?

技術上可以,但強烈不建議

  • 失去自動驗證
  • 失去自動類型轉換
  • 失去安全保護
  • 程式碼難以維護

Form 是 Django 的核心功能,應該總是使用它。

任務結束

完成!

恭喜你完成了這個章節!現在你已經:

  • 了解為什麼需要表單驗證
  • 手寫 HTML 表單並在 view 中手動驗證資料
  • 使用 Django Form 類別處理驗證邏輯
  • 使用 Form 類別渲染表單 HTML
  • 使用 ModelForm 簡化表單建立
  • 掌握表單處理的最佳實踐

表單處理的演進

回顧我們的學習歷程:

  1. 手動處理:完全控制,但重複且容易出錯
  2. Form 類別:集中驗證邏輯,可重用
  3. Form 渲染:自動生成 HTML,減少重複
  4. ModelForm:進一步簡化,與 Model 緊密整合

從 60+ 行程式碼優化到不到 10 行,這就是 Django 的威力!

記住關鍵原則:

  1. 永遠使用 Form:不要手動驗證
  2. 優先使用 ModelForm:如果對應 Model
  3. POST 後重導向:避免重複提交
  4. 使用 cleaned_data:不要直接使用 request.POST
  5. 後端必須驗證:不要依賴前端驗證

掌握了 Django Form,你就掌握了安全、可維護的資料處理方式!