步骤 10: 构建用户界面

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

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

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

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

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

    把 Twig 用于模板

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

    templates/base.html.twig

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

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

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

    templates/conference/index.html.twig

    1. {% extends 'base.html.twig' %}
    2. {% block title %}Conference Guestbook{% endblock %}
    3. {% block body %}
    4. <h2>Give your feedback!</h2>
    5. {% for conference in conferences %}
    6. <h4>{{ conference }}</h4>
    7. {% endfor %}
    8. {% endblock %}

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

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

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

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

    patch_file

    这里涉及很多的内容。

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

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

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

    创建会议页面

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

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

    patch_file

    1. --- a/src/Controller/ConferenceController.php
    2. +++ b/src/Controller/ConferenceController.php
    3. @@ -2,6 +2,8 @@
    4. namespace App\Controller;
    5. +use App\Entity\Conference;
    6. +use App\Repository\CommentRepository;
    7. use App\Repository\ConferenceRepository;
    8. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    9. use Symfony\Component\HttpFoundation\Response;
    10. @@ -17,4 +19,13 @@ class ConferenceController extends AbstractController
    11. 'conferences' => $conferenceRepository->findAll(),
    12. ]));
    13. }
    14. +
    15. + #[Route('/conference/{id}', name: 'conference')]
    16. + public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
    17. + {
    18. + return new Response($twig->render('conference/show.html.twig', [
    19. + 'conference' => $conference,
    20. + 'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
    21. + ]));
    22. + }
    23. }

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

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

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

    templates/conference/show.html.twig

    1. {% extends 'base.html.twig' %}
    2. {% block title %}Conference Guestbook - {{ conference }}{% endblock %}
    3. <h2>{{ conference }} Conference</h2>
    4. {% if comments|length > 0 %}
    5. {% for comment in comments %}
    6. {% if comment.photofilename %}
    7. <img src="{{ asset('uploads/photos/' ~ comment.photofilename) }}" />
    8. {% endif %}
    9. <h4>{{ comment.author }}</h4>
    10. <small>
    11. {{ comment.createdAt|format_datetime('medium', 'short') }}
    12. </small>
    13. <p>{{ comment.text }}</p>
    14. {% endfor %}
    15. {% else %}
    16. <div>No comments have been posted yet for this conference.</div>
    17. {% endif %}
    18. {% endblock %}

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

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

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

    1. $ symfony composer req "twig/intl-extra:^3"

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

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

    patch_file

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

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

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

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

    对评论分页

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

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

    patch_file

    1. --- a/src/Repository/CommentRepository.php
    2. +++ b/src/Repository/CommentRepository.php
    3. @@ -3,8 +3,10 @@
    4. namespace App\Repository;
    5. use App\Entity\Comment;
    6. +use App\Entity\Conference;
    7. use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
    8. use Doctrine\Persistence\ManagerRegistry;
    9. +use Doctrine\ORM\Tools\Pagination\Paginator;
    10. /**
    11. * @method Comment|null find($id, $lockMode = null, $lockVersion = null)
    12. @@ -14,11 +16,27 @@ use Doctrine\Persistence\ManagerRegistry;
    13. */
    14. class CommentRepository extends ServiceEntityRepository
    15. {
    16. + public const PAGINATOR_PER_PAGE = 2;
    17. +
    18. public function __construct(ManagerRegistry $registry)
    19. {
    20. parent::__construct($registry, Comment::class);
    21. }
    22. + public function getCommentPaginator(Conference $conference, int $offset): Paginator
    23. + {
    24. + $query = $this->createQueryBuilder('c')
    25. + ->andWhere('c.conference = :conference')
    26. + ->setParameter('conference', $conference)
    27. + ->setMaxResults(self::PAGINATOR_PER_PAGE)
    28. + ->setFirstResult($offset)
    29. + ->getQuery()
    30. + ;
    31. +
    32. + return new Paginator($query);
    33. + }
    34. +
    35. // * @return Comment[] Returns an array of Comment objects
    36. // */

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

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

    patch_file

    1. --- a/src/Controller/ConferenceController.php
    2. +++ b/src/Controller/ConferenceController.php
    3. @@ -6,6 +6,7 @@ use App\Entity\Conference;
    4. use App\Repository\CommentRepository;
    5. use App\Repository\ConferenceRepository;
    6. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    7. +use Symfony\Component\HttpFoundation\Request;
    8. use Symfony\Component\HttpFoundation\Response;
    9. use Symfony\Component\Routing\Annotation\Route;
    10. use Twig\Environment;
    11. @@ -21,11 +22,16 @@ class ConferenceController extends AbstractController
    12. }
    13. #[Route('/conference/{id}', name: 'conference')]
    14. - public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
    15. + public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
    16. {
    17. + $offset = max(0, $request->query->getInt('offset', 0));
    18. + $paginator = $commentRepository->getCommentPaginator($conference, $offset);
    19. +
    20. return new Response($twig->render('conference/show.html.twig', [
    21. 'conference' => $conference,
    22. - 'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
    23. + 'comments' => $paginator,
    24. + 'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
    25. + 'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
    26. ]));
    27. }
    28. }

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

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

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

    patch_file

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

    步骤 10: 构建用户界面 - 图2

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

    patch_file

    1. --- a/src/Controller/ConferenceController.php
    2. +++ b/src/Controller/ConferenceController.php
    3. @@ -13,21 +13,28 @@ use Twig\Environment;
    4. class ConferenceController extends AbstractController
    5. {
    6. + private $twig;
    7. +
    8. + public function __construct(Environment $twig)
    9. + {
    10. + $this->twig = $twig;
    11. + }
    12. +
    13. #[Route('/', name: 'homepage')]
    14. - public function index(Environment $twig, ConferenceRepository $conferenceRepository): Response
    15. + public function index(ConferenceRepository $conferenceRepository): Response
    16. {
    17. - return new Response($twig->render('conference/index.html.twig', [
    18. + return new Response($this->twig->render('conference/index.html.twig', [
    19. 'conferences' => $conferenceRepository->findAll(),
    20. ]));
    21. }
    22. #[Route('/conference/{id}', name: 'conference')]
    23. - public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
    24. + public function show(Request $request, Conference $conference, CommentRepository $commentRepository): Response
    25. {
    26. $offset = max(0, $request->query->getInt('offset', 0));
    27. $paginator = $commentRepository->getCommentPaginator($conference, $offset);
    28. - return new Response($twig->render('conference/show.html.twig', [
    29. + return new Response($this->twig->render('conference/show.html.twig', [
    30. 'conference' => $conference,
    31. 'comments' => $paginator,

    深入学习