表单

对一个Web开发者来说,处理HTML表单是一个最为普通又极具挑战的任务。Symfony整合了一个Form组件,让处理表单变得容易起来。在本章,你将从零开始创建一个复杂的表单,学习表单类库中的重要功能。

Symfony的Form组件是一个独立的类库,你可以在Symfony项目之外使用它。参考 Form组件文档 以了解更多。

创建一个简单的表单 

假设你正在构建一个简单的待办事项列表,来显示一些“任务”。你需要创建一个表单来让你的用户编辑和创建任务。在这之前,先来看看 Task 类,它可呈现和存储一个单一任务的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/AppBundle/Entity/Task.php
namespace AppBundle\Entity;
 
class Task
{
    protected $task;
    protected $dueDate;
 
    public function getTask()
    {
        return $this->task;
    }
 
    public function setTask($task)
    {
        $this->task = $task;
    }
 
    public function getDueDate()
    {
        return $this->dueDate;
    }
 
    public function setDueDate(\DateTime $dueDate = null)
    {
        $this->dueDate = $dueDate;
    }
}

这是一个原生的PHP对象类,因为它没有和Symfony互动也没有引用其它类库。它是非常简单的一个PHP对象类,直接解决了 程序中的 task (任务)之数据问题。当然,在本章的最后,你将能够通过HTML表单把数据提交到一个 Task 实例,验证它的值,并把它持久化到数据库。

构建表单 

现在你已经创建了一个 Task 类,下一步就是创建和渲染一个真正的html表单了。在Symfony中,这是通过构建一个表单对象并将其渲染到模版来完成的。现在,在控制器里即可完成所有这些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// src/AppBundle/Controller/DefaultController.php
namespace AppBundle\Controller;
 
use AppBundle\Entity\Task;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
 
class DefaultController extends Controller
{
    public function newAction(Request $request)
    {
        // create a task and give it some dummy data for this example
        // 创建一个task对象,赋一些例程中的假数据给它
        $task = new Task();
        $task->setTask('Write a blog post');
        $task->setDueDate(new \DateTime('tomorrow'));
 
        $form = $this->createFormBuilder($task)
            ->add('task', TextType::class)
            ->add('dueDate', DateType::class)
            ->add('save', SubmitType::class, array('label' => 'Create Task'))
            ->getForm();
 
        return $this->render('default/new.html.twig', array(
            'form' => $form->createView(),
        ));
    }
}

这个例子说明了如何直接在控制器中构建你的form(表单)。后面的 创建表单类 中,你将使用一个独立的类来构建表单,这种方法被推荐,因为表单可以复用。

创建表单不需要很多代码,因为Symfony的表单对象是通过一个“form builder(表单生成器)”来创建的。form builder的目的是让你编写简单的表单创建“指令”,而真实创建表单时的全部“重载”任务则交由builder完成。

本例中,你已经添加了两个字段到表单,即 taskdueDate 。对应的是 Task 类中的 taskdueDate 属性。你已为它们分别指定了FQCN(Full Quilified Class Name/完整路径类名)的“类型”(如 TextTypeDateType ),由类型决定为字段生成哪一种HTML表单标签(标签组)。

最后,你添加了一个带有自定义label的提交按钮以向服务器提交表单。

Symfony​​附带了许多内置类型,它们将被简短地介绍(见下面的内置表单类型)。

渲染表单 

表单创建之后,下一步就是渲染它。这是通过传递一个特定的表单“view”对象(注意上例控制器中的 $form->createView() 方法)到你的模板,并通过一系列的表单helper function(帮助函数)来实现的。

1
2
3
4
{# app/Resources/views/default/new.html.twig #}
{{ form_start(form) }}
{{ form_widget(form) }}
{{ form_end(form) }}
1
2
3
4
<!-- app/Resources/views/default/new.html.php -->
<?php echo $view['form']->start($form) ?>
<?php echo $view['form']->widget($form) ?>
<?php echo $view['form']->end($form) ?>

form simple

本例假设你以"POST"请求提交表单,并且提交到和“表单显示(页面)”相同的URL。后面你将学习如何改变请求方法(request method)和表单提交后的目标URL。

就是这样!只需要三行就可以渲染出完整的form表单:

form_start(form)
渲染表单的开始标签,包括在使用文件上传时的正确enctype属性。
form_widget(form)
渲染出全部字段,包含字段元素本身,字段label以及字段验证的任何错误信息。
form_end(form)
当你手动生成每个字段时,它可以渲染表单结束标签以及表单中所有尚未渲染的字段。这在渲染隐藏字段以及利用自动的 CSRF Protection 保护机制时非常有用。

就是这么简单,但不太灵活(暂时)。通常情况下,你希望单独渲染出表单中的每一个字段,以便控制表单的样式。你将在后面 如何去控制表单渲染 文章中掌握这种方法。

在继续下去之前,请注意,为什么渲染出来的 task 输入框中有一个来自 $task 对象的属性值(即“Write a blog post”)。这是表单的第一个任务:从一个对象中获取数据并把它转换成一种适当的格式,以便在HTML表单中被渲染。

表单系统足够智能,它们通过 getTask()setTask() 方法来访问 Task 类中受保护的 task 属性。除非是public属性,否则 必须 有一个 "getter" 和 "setter" 方法被定义,以便表单组件能从这些属性中获取和写入数据。对于布尔型的属性,你可以使用一个 "isser" 和 "hasser" 方法(如 isPublished()hasReminder() )来替代getter方法(getPublished()getReminder())。

处理表单提交 

默认时,表单会把POST请求,向“渲染它的同一个控制器”提交回去。

此处,表单的第二个任务就是把用户提交的数据传回到一个对象的属性之中。要做到这一点,用户提交的数据必须写入表单对象才行。向控制器(Controller)中添加以下功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// ...
use Symfony\Component\HttpFoundation\Request;
 
public function newAction(Request $request)
{
    // just setup a fresh $task object (remove the dummy data)
    // 直接设置一个全新$task对象(删除了假数据)
    $task = new Task();
 
    $form = $this->createFormBuilder($task)
        ->add('task', TextType::class)
        ->add('dueDate', DateType::class)
        ->add('save', SubmitType::class, array('label' => 'Create Task'))
        ->getForm();
 
    $form->handleRequest($request);
 
    if ($form->isSubmitted() && $form->isValid()) {
 
        // $form->getData() holds the submitted values
        // but, the original `$task` variable has also been updated
        //  $form->getData() 持有提交过来的值
        // 但是,原始的 `$task` 变量也已被更新了
        $task = $form->getData();
 
        // ... perform some action, such as saving the task to the database
        // for example, if Task is a Doctrine entity, save it!
        // 一些操作,比如把任务存到数据库中
        // 例如,如果Tast对象是一个Doctrine entity,存下它!
        // $em = $this->getDoctrine()->getManager();
        // $em->persist($task);
        // $em->flush();
 
        return $this->redirectToRoute('task_success');
    }
 
    return $this->render('default/new.html.twig', array(
        'form' => $form->createView(),
    ));
}

注意 createView() 方法应该在 handleRequest 被调用 之后 再调用。否则,针对 *_SUBMIT 表单事件的修改,将不会应用到视图层(比如验证时的错误信息)。

控制器(controller)在处理表单时遵循的是一个通用模式(common pattern),它有三个可能的途径:

  1. 当浏览器初始加载一个页面时,表单被创建和渲染。handleRequest() 意识到表单没有被提交进而什么都不做。如果表单未被提交,isSubmitted() 返回false;

  2. 当用户提交表单时,handleRequest() 会识别这个动作并立即将提交的数据写入到 $task 对象的 task and dueDate 属性。然后该对象被验证。如果它是无效的(验证在下一章),isValid() 会返回 false,进而表单被再次渲染,只是这次有验证错误;

  3. 当用户以合法数据提交表单的时,提交的数据会被再次写入到表单,但这一次 isValid() 返回 true。在把用户重定向到其他一些页面之前(如一个“谢谢”或“成功”的页面),你有机会用 $task 对象来进行某些操作(比如把它持久化到数据库)。

    表单成功提交之后的重定向用户,是为了防止用户通过浏览器“刷新”按钮重复提交数据。

如果你需要精确地控制何时表单被提交,或哪些数据被传给表单,你可以使用 submit()。更多信息请参考 手动调用Form::submit()

表单验证 

在上一节中,你了解了附带了有效或无效数据的表单是如何被提交的。在Symfony中,验证环节是在底层对象中进行的(例如 Task)。换句话说,问题不在于“表单”是否有效,而是 $task 对象在“提交的数据应用到表单”之后是否合法。调用 $form->isvalid() 是一个快捷方式,询问底层 $task 对象是否获得了合法数据。

验证(validation)是通过把一组规则(称之为“constraints/约束”)添加到一个类中来完成的。我们给 Task 类添加规则和约束,使task属性不能为空, duDate 字段不空且必须是一个有效的DateTime对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/AppBundle/Entity/Task.php
namespace AppBundle\Entity;
 
use Symfony\Component\Validator\Constraints as Assert;
 
class Task
{
    /**
     * @Assert\NotBlank()
     */
    public $task;
 
    /**
     * @Assert\NotBlank()
     * @Assert\Type("\DateTime")
     */
    protected $dueDate;
}
1
2
3
4
5
6
7
8
# src/AppBundle/Resources/config/validation.yml
AppBundle\Entity\Task:
    properties:
        task:
            - NotBlank: ~
        dueDate:
            - NotBlank: ~
            - Type: \DateTime
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- src/AppBundle/Resources/config/validation.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<constraint-mapping xmlns="http://symfony.com/schema/dic/constraint-mapping"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/constraint-mapping
        http://symfony.com/schema/dic/constraint-mapping/constraint-mapping-1.0.xsd">
 
    <class name="AppBundle\Entity\Task">
        <property name="task">
            <constraint name="NotBlank" />
        </property>
        <property name="dueDate">
            <constraint name="NotBlank" />
            <constraint name="Type">\DateTime</constraint>
        </property>
    </class>
</constraint-mapping>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/AppBundle/Entity/Task.php
use Symfony\Component\Validator\Mapping\ClassMetadata;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Type;
 
class Task
{
    // ...
 
    public static function loadValidatorMetadata(ClassMetadata $metadata)
    {
        $metadata->addPropertyConstraint('task', new NotBlank());
 
        $metadata->addPropertyConstraint('dueDate', new NotBlank());
        $metadata->addPropertyConstraint(
            'dueDate',
            new Type('\DateTime')
        );
    }
}

就是这样!如果你现在重新以非法数据提交表单,你将会看到相应的错误被输出到表单。

验证是Symfony一个非常强大的功能,它拥有自己的专属章节

html5验证

HTML5以来,许多浏览器都原生支持了客户端的验证约束。最常用的验证之激活方式,是在一个必填字段上渲染一个 required 属性(译注:文档中的“渲染”二字,对应英文rendering,可以理解为“输出”。在Symfony中,把从程序底层或控制中向视图层显示内容的过程,称为render)。对于支持HTML5的浏览器来说,如果用户尝试提交一个空字段到表单时,会有一条浏览器原生信息显示出来。

生成出来的表单充分利用了这个新功能,通过添加一些有意义的HTML属性来触发验证。客户端验证,也可通过把 novalidate 属性添加到 form 标签,或是把 formnovalidate 添加到submit标签来关闭之。这在你想要测试服务器端的验证规则(validation constraints)却被浏览器端阻止,例如,在提交空白字段时,就非常有用。

1
2
{# app/Resources/views/default/new.html.twig #}
{{ form(form, {'attr': {'novalidate': 'novalidate'}}) }}
1
2
3
4
<!-- app/Resources/views/default/new.html.php -->
<?php echo $view['form']->form($form, array(
    'attr' => array('novalidate' => 'novalidate'),
)) ?>

内置字段类型 

Symfony标准版内含巨量字段类型,涵盖了你所能遇到的全部常规表单字段和数据类型。

文本型字段 

选择型字段 

日期和时间字段 

其他类型字段 

字段群 

隐藏字段 

按钮 

表单字段基类 

你也可以定义自己的字段类型。参考 如何创建一个自定义的表单字段类型

字段类型选项 

每一种字段类型都有一定数量的选项用于配置。比如, dueDate 字段当前被渲染成3个选择框。而 DateType 日期字段可以被配置渲染成一个单一的文本框(用户可以输入字符串作为日期)。

1
->add('dueDate', DateType::class, array('widget' => 'single_text'))

form simple

每一种字段类型都有一系列不同的选项用于传入此类型。关于字段类型的细节都可以在每种类型的文档中找到。

required选项

最常用的是 required 选项,它可以应用于任何字段。默认情况下它被设置为 true 。这就意味着支持HTML5的浏览器会使用客户端验证来判断字段是否为空。如果你不想需要这种行为,要么 关闭HTML5验证,要么把字段的 required 选项设置为 false

1
2
3
4
->add('dueDate', DateType::class, array(
    'widget' => 'single_text',
    'required' => false
))

要注意设置 requiredtrue 意味着服务器端验证会被使用。换句话说,如果用户提交一个空值(blank)到该字段(比如在老旧浏览器中,或是使用web service时),这个空值当被作为有效值予以采纳,除非你使用了Symfony的 NotBlank 或者 NotNull 验证约束。

也就是说, required 选项是很 "nice",但是服务端验证却应该 始终 使用。

label选项

表单字段可以使用label选项来设置表单字段的label,它适用于任何字段:

1
2
3
4
->add('dueDate', DateType::class, array(
    'widget' => 'single_text',
    'label'  => 'Due Date',
))

字段的label也可以在模版渲染表单时进行设置,见下文。如果你不需要把label关联到你的input(标签),你可以设置选项值为 false

字段类型猜测 

现在你已经添加了验证元数据(译注:即annotation)到 Task 类,Symfony对于你的字段已有所了解。如果你允许,Symfony可以“猜到”你的字段类型并帮你设置好。在下面的例子中,Symfony可以根据验证规则猜测到 task 字段是一个标准的 TextType 字段, dueDateDateType 字段。

1
2
3
4
5
6
7
8
9
10
public function newAction()
{
    $task = new Task();
 
    $form = $this->createFormBuilder($task)
        ->add('task')
        ->add('dueDate', null, array('widget' => 'single_text'))
        ->add('save', SubmitType::class)
        ->getForm();
}

当你省略了 add() 方法的第二个参数(或者你输入 null )时,“猜测”会被激活。如果你输入一个选项数组作为第三个参数(比如上面的 dueDate),这些选项将应用于被猜测的字段。

如果你的表单使用了一个特定的验证组(validation group),猜测字段类型时仍将考虑 所有 验证约束(包括不属于这个“正在使用中”的验证组的约束)。

对字段类型的选项进行猜测 

除了猜测字段类型,Symfony还可尝试猜出字段选项的正确值。

当这些选项被设置时,字段将以特殊的HTML属性进行渲染,以用于HTML5的客户端验证。然而,它们不会在服务端生成相应的验证规则(如 Assert\Length )。尽管你需要手动地添加这些服务器端的规则,这些字段类型的选项接下来可以根据这些规则被猜出来。

required
required 选项可以基于验证规则 (如,该字段是否为 NotBlankNotNull) 或者是Doctrine的metadata元数据 (如,该字段是否为 nullable) 而被猜出来。这非常有用,因为你的客户端验证将自动匹配到你的验证规则。
max_length
如果字段是某些列文本型字段,那么 max_length 选项可以基于验证约束 (字段是否应用了 LengthRange) 或者是Doctrine元数据 (通过该字段的长度) 而被猜出来。

这些字段选项 在你使用Symfony进行类型猜测时(即,忽略参数,或传入null 作为 add() 方法的第二个参数)才会被猜测。

如果你希望改变某个被猜出来的(选项)值,可以在字段类型的选项数组中传入此项进行覆写。

1
->add('task', null, array('attr' => array('maxlength' => 4)))

创建表单类 

正如你看到的,表单可以直接在控制器中被创建和使用。然而,一个更好的做法,是在一个单独的PHP类中创建表单。它能在你程序中的任何地方复用。创建一个持有“构建task表单”所需逻辑的新类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/AppBundle/Form/Type/TaskType.php
namespace AppBundle\Form\Type;
 
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
 
class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('task')
            ->add('dueDate', null, array('widget' => 'single_text'))
            ->add('save', SubmitType::class)
        ;
    }
}

这个新类包含了创建task表单所需要的方方面面。它可用于在控制器中快速创建表单。

1
2
3
4
5
6
7
8
9
10
// src/AppBundle/Controller/DefaultController.php
use AppBundle\Form\Type\TaskType;
 
public function newAction()
{
    $task = ...;
    $form = $this->createForm(TaskType::class, $task);
 
    // ...
}

把表单逻辑置于它自己的类中,可以让表单很容易地在你的项目任何地方复用。这是创建表单最好的方式,但是决定权在你。

设置data_class

每个表单都需要知道“持有底层数据的类”的名称(如 AppBundle\Entity\Task )。通常情况下,这是根据传入 createForm 方法的第二个参数来猜测的(例如 $task )。以后,当你开始嵌入表单时,这便不再够用。因此,虽然不是绝对必须,但通过添加下面代码到你的表单类型类中,以显式地指定 data_class 选项是一个好办法。

1
2
3
4
5
6
7
8
use Symfony\Component\OptionsResolver\OptionsResolver;
 
public function configureOptions(OptionsResolver $resolver)
{
    $resolver->setDefaults(array(
        'data_class' => 'AppBundle\Entity\Task',
    ));
}

当把表单映射成对象时,所有的字段都将被映射。表单中的任何字段如果在映射对象上“不存在”,都会抛出异常。

当你需要在表单中使用附加字段(如,一个 “你是否同意这些声明?”的复选框)而这个字段将不被映射到底层对象时,你需要设置 mapped 选项为 false

1
2
3
4
5
6
7
8
9
10
use Symfony\Component\Form\FormBuilderInterface;
 
public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder
        ->add('task')
        ->add('dueDate', null, array('mapped' => false))
        ->add('save', SubmitType::class)
    ;
}

另外,若表单的任何字段未包含在提交过来的数据中,那么这些字段将被显式设置为 null

在控制器中我们可以访问字段的data(字段取值):

1
$form->get('dueDate')->getData();

此外,未被映射的字段之数据,也可直接修改:

1
$form->get('dueDate')->setData(new \DateTime());

最后的思考 

构建表单时,牢记首要目标是把一个对象(task)的数据转换成一个HTML表单,以便用户能够修改(表单)取值。第二个目标就是要取到用户提交的数据,并重新作用于该对象。

还有很多内容需要掌握,Form系统有大量 威力强大 的高级技巧。

Reference 

本文,包括例程代码在内,采用的是 Creative Commons BY-SA 3.0 创作共用授权。

登录symfonychina 发表评论或留下问题(我们会尽量回复)