步骤 10: 构建用户界面

5.0 version
Maintained

构建用户界面

一切准备就绪,可以去创建网站用户界面的初版。我们不会去美化它,只是先让它可以用起来。

你是否还记得,在之前的彩蛋环节,我们不得不在控制器中添加转义来避免安全问题吗?由于这个原因,我们在模板里不会用 PHP,而是用 Twig。Twig 除了帮我们处理转义之外,还有很多我们可以利用的好功能,比如模板继承。

安装 Twig

我们不需要把 Twig 作为依赖包来安装,因为在安装 EasyAdmin 的时候它作为一个 传递性依赖 (即依赖包的依赖包)已经被安装过了。但是如果你以后想要切换到另一个管理后台 bundle 会怎么样?比如说切换到一个提供 API 和用 React 作为前端的 bundle?这种 bundle 很可能不再依赖 Twig,所以在移除 EasyAdmin 的时候 Twig 会被自动移除。

为了万无一失,让我们再告诉 Composer,不管用不用 EasyAdmin,我们的项目确实依赖 Twig。把它和其它依赖一样加进来就够了:

1
$ symfony composer req twig

现在的 composer.json 文件里,Twig 是项目的直接依赖之一了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,7 @@
         "symfony/framework-bundle": "4.4.*",
         "symfony/maker-bundle": "^[email protected]",
         "symfony/orm-pack": "dev-master",
+        "symfony/twig-pack": "^1.0",
         "symfony/yaml": "4.4.*"
     },
     "require-dev": {

把 Twig 用于模板

网站的所有页面会共享一样的 布局。当安装 Twig 时,它自动创建了 templates/ 目录,而且 base.html.twig 文件里新建了一个样本布局。

templates/base.html.twig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% block stylesheets %}{% endblock %}
    </head>
    <body>
        {% block body %}{% endblock %}
        {% block javascripts %}{% endblock %}
    </body>
</html>

布局能定义一些 block 元素 ,子模板 在这些元素里 扩展 布局,加入它们自己的内容。

让我们在 templates/conference/index.html.twig 文件里为项目首页创建一个模板。

templates/conference/index.html.twig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{% extends 'base.html.twig' %}

{% block title %}Conference Guestbook{% endblock %}

{% block body %}
    <h2>Give your feedback!</h2>

    {% for conference in conferences %}
        <h4>{{ conference }}</h4>
    {% endfor %}
{% endblock %}

这个模板 扩展base.html.twig,并且重新定义了 titlebody 块。

模板中 {% %} 的写法代表 行为结构

{{ }} 的写法用来 显示 内容。{{ conference }} 显示代表会议的字符串(即在 Conference 对象上调用 __toString 方法的结果)。

在控制器中使用 Twig

更新控制器来渲染 Twig 模板:

patch_file
 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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -2,24 +2,21 @@

 namespace App\Controller;

+use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
+use Twig\Environment;

 class ConferenceController extends AbstractController
 {
     /**
      * @Route("/", name="homepage")
      */
-    public function index()
+    public function index(Environment $twig, ConferenceRepository $conferenceRepository)
     {
-        return new Response(<<<EOF
-<html>
-    <body>
-        <img src="/images/under-construction.gif" />
-    </body>
-</html>
-EOF
-        );
+        return new Response($twig->render('conference/index.html.twig', [
+            'conferences' => $conferenceRepository->findAll(),
+        ]));
     }
 }

这里涉及很多的内容。

我们需要 Twig 的 Environment 对象(Twig 的主入口)才能渲染一个模板。注意一下,我们在控制器方法中用类型提示来获取 Twig 的实例。Symfony 很聪明,它知道如何注入正确的对象。

我们也需要会议的 repository 对象,用它从数据库中获取所有会议。

在控制器代码中,render() 方法会渲染模板,并且传入一组变量到模板中。我们通过 conferences 变量把一组 Conference 对象传入模板。

控制器是一个标准的 PHP 类。如果想把它所依赖的类明确写在代码里的话,我们甚至不需要让它继承自 AbstractController 类。在控制器里你可以移除 AbstractController (但不要那样做,因为在后面的步骤中,我们会用到它提供的一些不错的快捷方法)。

创建会议页面

每个会议都要有单独的页面来列出关于它的评论。增加一个页面就是增加一个控制器方法,定义它的路由以及创建一个相关的模板。

src/Controller/ConferenceController.php 增加一个 show() 方法:

patch_file
 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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -2,7 +2,9 @@

 namespace App\Controller;

+use App\Entity\Conference;
+use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
@@ -19,4 +21,15 @@ class ConferenceController extends AbstractController
             'conferences' => $conferenceRepository->findAll(),
         ]));
     }
+
+    /**
+     * @Route("/conference/{id}", name="conference")
+     */
+    public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository)
+    {
+        return new Response($twig->render('conference/show.html.twig', [
+            'conference' => $conference,
+            'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
+        ]));
+    }
 }

这个方法还有一个我们没见过的特殊行为。我们要求在该方法中注入一个 Conference 实例。但是数据库里可能有很多个会议。Symfony 能根据请求路径中的 {id} 来判断你要的是哪一个会议实例(id 是数据库中 conference 表的主键)。

可以通过 findBy() 方法来获取与这次会议相关的评论,该方法接受一个过滤标准作为参数。

最后一步就是创建 templates/conference/show.html.twig 文件:

templates/conference/show.html.twig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{% extends 'base.html.twig' %}

{% block title %}Conference Guestbook - {{ conference }}{% endblock %}

{% block body %}
    <h2>{{ conference }} Conference</h2>

    {% if comments|length > 0 %}
        {% for comment in comments %}
            {% if comment.photofilename %}
                <img src="{{ asset('uploads/photos/' ~ comment.photofilename) }}" />
            {% endif %}

            <h4>{{ comment.author }}</h4>
            <small>
                {{ comment.createdAt|format_datetime('medium', 'short') }}
            </small>

            <p>{{ comment.text }}</p>
        {% endfor %}
    {% else %}
        <div>No comments have been posted yet for this conference.</div>
    {% endif %}
{% endblock %}

在模板中,我们使用 | 的写法来调用 Twig 的 过滤器。过滤器用来转换一个值。comments|length 返回评论的数量,comment.createdAt|format_datetime('medium', 'short') 会把日期格式化为人们可读的形式。

通过 /conference/1 路径来访问“第一个”会议,注意下面这个错误:

这个错误是由于 format_datetime 过滤器并不是 Twig 核心的一部分。错误信息提示你要安装哪个包来解决问题。

1
$ symfony composer require twig/intl-extra

现在页面可以正常显示了。

把页面链接在一起

完成用户界面初版的最后一步,就是从首页链接到会议页。

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -7,5 +7,8 @@

     {% for conference in conferences %}
         <h4>{{ conference }}</h4>
+        <p>
+            <a href="/conference/{{ conference.id }}">View</a>
+        </p>
     {% endfor %}
 {% endblock %}

但出于多个原因,硬编码页面路径是个坏主意。最重要的原因是,如果你改变了路径(比如从 /conference/{id} 改到 /conferences/{id}),那所有链接都需要手工去更新。

我们不用硬编码的方式,而是用 Twig 的 path() 函数,并引用 路径名

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -8,7 +8,7 @@
     {% for conference in conferences %}
         <h4>{{ conference }}</h4>
         <p>
-            <a href="/conference/{{ conference.id }}">View</a>
+            <a href="{{ path('conference', { id: conference.id }) }}">View</a>
         </p>
     {% endfor %}
 {% endblock %}

path() 函数会根据路径名来生成到一个页面的路径。路由的参数值通过一个 Twig 映射来传入。

对评论分页

如果有成百上千的参会者,可以想见他们会留下非常多的评论。如果我们在同一个页面展示所有评论,那么这个页面会很快变得巨大无比。

在评论的 Repository 类里增加一个 getCommentPaginator() 方法,它根据具体的会议实例和偏移量(即从哪里开始算起)来返回一个评论 Paginator

patch_file
 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
41
--- a/src/Repository/CommentRepository.php
+++ b/src/Repository/CommentRepository.php
@@ -3,8 +3,10 @@
 namespace App\Repository;

 use App\Entity\Comment;
+use App\Entity\Conference;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 use Doctrine\Persistence\ManagerRegistry;
+use Doctrine\ORM\Tools\Pagination\Paginator;

 /**
  * @method Comment|null find($id, $lockMode = null, $lockVersion = null)
@@ -14,11 +16,27 @@ use Doctrine\Persistence\ManagerRegistry;
  */
 class CommentRepository extends ServiceEntityRepository
 {
+    public const PAGINATOR_PER_PAGE = 2;
+
     public function __construct(ManagerRegistry $registry)
     {
         parent::__construct($registry, Comment::class);
     }

+    public function getCommentPaginator(Conference $conference, int $offset): Paginator
+    {
+        $query = $this->createQueryBuilder('c')
+            ->andWhere('c.conference = :conference')
+            ->setParameter('conference', $conference)
+            ->orderBy('c.createdAt', 'DESC')
+            ->setMaxResults(self::PAGINATOR_PER_PAGE)
+            ->setFirstResult($offset)
+            ->getQuery()
+        ;
+
+        return new Paginator($query);
+    }
+
     // /**
     //  * @return Comment[] Returns an array of Comment objects
     //  */

我们让每页最多可显示 2 条评论,这样方便测试。

把 Doctrine 的 Paginator 对象传入 Twig 来取代 Doctrine 的 Collection 对象,从而对模板中的分页进行管理。

patch_file
 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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -6,6 +6,7 @@ use App\Entity\Conference;
 use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
 use Twig\Environment;
@@ -25,11 +26,16 @@ class ConferenceController extends AbstractController
     /**
      * @Route("/conference/{id}", name="conference")
      */
-    public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository)
+    public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository)
     {
+        $offset = max(0, $request->query->getInt('offset', 0));
+        $paginator = $commentRepository->getCommentPaginator($conference, $offset);
+
         return new Response($twig->render('conference/show.html.twig', [
             'conference' => $conference,
-            'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
+            'comments' => $paginator,
+            'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
+            'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
         ]));
     }
 }

控制器从 Request 对象里的查询字符串($request->query)获取 offset 值,如果这个值不存在就用默认值 0。

previousnext 的偏移量会根据分页器提供的所有信息计算出来。

最后,更新模板,来添加上一页和下一页的链接。

patch_file
 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
index 0c9e7d2..14b51fd 100644
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -6,6 +6,8 @@
     <h2>{{ conference }} Conference</h2>

     {% if comments|length > 0 %}
+        <div>There are {{ comments|length }} comments.</div>
+
         {% for comment in comments %}
             {% if comment.photofilename %}
                 <img src="{{ asset('uploads/photos/' ~ comment.photofilename) }}" />
@@ -18,6 +20,13 @@

             <p>{{ comment.text }}</p>
         {% endfor %}
+
+        {% if previous >= 0 %}
+            <a href="{{ path('conference', { id: conference.id, offset: previous }) }}">Previous</a>
+        {% endif %}
+        {% if next < comments|length %}
+            <a href="{{ path('conference', { id: conference.id, offset: next }) }}">Next</a>
+        {% endif %}
     {% else %}
         <div>No comments have been posted yet for this conference.</div>
     {% endif %}

你现在可以通过“上一页”和“下一页”的链接来浏览评论了。

重构控制器

你可能注意到了,ConferenceController 类里的两个方法都用到了 Twig 的 Environment 实例作为参数。我们可以不用把它注入到每个方法里,而是用构造函数注入来代替(这样可以让参数列表更短,而且减少重复):

patch_file
 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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -13,12 +13,19 @@ use Twig\Environment;

 class ConferenceController extends AbstractController
 {
+    private $twig;
+
+    public function __construct(Environment $twig)
+    {
+        $this->twig = $twig;
+    }
+
     /**
      * @Route("/", name="homepage")
      */
-    public function index(Environment $twig, ConferenceRepository $conferenceRepository)
+    public function index(ConferenceRepository $conferenceRepository)
     {
-        return new Response($twig->render('conference/index.html.twig', [
+        return new Response($this->twig->render('conference/index.html.twig', [
             'conferences' => $conferenceRepository->findAll(),
         ]));
     }
@@ -26,12 +33,12 @@ class ConferenceController extends AbstractController
     /**
      * @Route("/conference/{id}", name="conference")
      */
-    public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository)
+    public function show(Request $request, Conference $conference, CommentRepository $commentRepository)
     {
         $offset = max(0, $request->query->getInt('offset', 0));
         $paginator = $commentRepository->getCommentPaginator($conference, $offset);

-        return new Response($twig->render('conference/show.html.twig', [
+        return new Response($this->twig->render('conference/show.html.twig', [
             'conference' => $conference,
             'comments' => $paginator,
             'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.