步骤 10: 构建用户界面
你是否还记得,在之前的彩蛋环节,我们不得不在控制器中添加转义来避免安全问题吗?由于这个原因,我们在模板里不会用 PHP,而是用 Twig。Twig 除了帮我们处理转义之外,还有很多我们可以利用的好功能,比如模板继承。
我们不需要把 Twig 作为依赖包来安装,因为在安装 EasyAdmin 的时候它作为一个 传递性依赖 (即依赖包的依赖包)已经被安装过了。但是如果你以后想要切换到另一个管理后台 bundle 会怎么样?比如说切换到一个提供 API 和用 React 作为前端的 bundle?这种 bundle 很可能不再依赖 Twig,所以在移除 EasyAdmin 的时候 Twig 会被自动移除。
为了万无一失,让我们再告诉 Composer,不管用不用 EasyAdmin,我们的项目确实依赖 Twig。把它和其它依赖一样加进来就够了:
现在的 文件里,Twig 是项目的直接依赖之一了:
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,7 @@
"symfony/framework-bundle": "4.4.*",
"symfony/maker-bundle": "^",
"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
<!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
{% 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
,并且重新定义了 title
和 body
块。
模板中 {% %}
的写法代表 行为 和 结构。
{{ }}
的写法用来 显示 内容。{{ conference }}
显示代表会议的字符串(即在 Conference
对象上调用 __toString
方法的结果)。
更新控制器来渲染 Twig 模板:
patch_file
这里涉及很多的内容。
我们需要 Twig 的 Environment
对象(Twig 的主入口)才能渲染一个模板。注意一下,我们在控制器方法中用类型提示来获取 Twig 的实例。Symfony 很聪明,它知道如何注入正确的对象。
我们也需要会议的 repository 对象,用它从数据库中获取所有会议。
控制器是一个标准的 PHP 类。如果想把它所依赖的类明确写在代码里的话,我们甚至不需要让它继承自 AbstractController
类。在控制器里你可以移除 AbstractController
(但不要那样做,因为在后面的步骤中,我们会用到它提供的一些不错的快捷方法)。
创建会议页面
每个会议都要有单独的页面来列出关于它的评论。增加一个页面就是增加一个控制器方法,定义它的路由以及创建一个相关的模板。
在 src/Controller/ConferenceController.php
增加一个 show()
方法:
patch_file
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -2,6 +2,8 @@
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;
@@ -17,4 +19,13 @@ class ConferenceController extends AbstractController
'conferences' => $conferenceRepository->findAll(),
]));
}
+
+ #[Route('/conference/{id}', name: 'conference')]
+ public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
+ {
+ return new Response($twig->render('conference/show.html.twig', [
+ 'conference' => $conference,
+ 'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
+ ]));
+ }
}
这个方法还有一个我们没见过的特殊行为。我们要求在该方法中注入一个 Conference
实例。但是数据库里可能有很多个会议。Symfony 能根据请求路径中的 来判断你要的是哪一个会议实例(id
是数据库中 conference
表的主键)。
可以通过 findBy()
方法来获取与这次会议相关的评论,该方法接受一个过滤标准作为参数。
最后一步就是创建 templates/conference/show.html.twig
文件:
templates/conference/show.html.twig
{% extends 'base.html.twig' %}
{% block title %}Conference Guestbook - {{ conference }}{% endblock %}
<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 核心的一部分。错误信息提示你要安装哪个包来解决问题。
$ symfony composer req "twig/intl-extra:^3"
现在页面可以正常显示了。
完成用户界面初版的最后一步,就是从首页链接到会议页。
patch_file
但出于多个原因,硬编码页面路径是个坏主意。最重要的原因是,如果你改变了路径(比如从 /conference/{id}
改到 /conferences/{id}
),那所有链接都需要手工去更新。
我们不用硬编码的方式,而是用 Twig 的 path()
函数,并引用 路径名:
--- 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
--- 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)
+ ->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
--- 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;
@@ -21,11 +22,16 @@ class ConferenceController extends AbstractController
}
#[Route('/conference/{id}', name: 'conference')]
- public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
+ public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
{
+ $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。
previous
和 next
的偏移量会根据分页器提供的所有信息计算出来。
最后,更新模板,来添加上一页和下一页的链接。
patch_file
你现在可以通过“上一页”和“下一页”的链接来浏览评论了。
你可能注意到了,ConferenceController
类里的两个方法都用到了 Twig 的 Environment 实例作为参数。我们可以不用把它注入到每个方法里,而是用构造函数注入来代替(这样可以让参数列表更短,而且减少重复):
patch_file
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -13,21 +13,28 @@ 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): Response
+ public function index(ConferenceRepository $conferenceRepository): Response
{
- return new Response($twig->render('conference/index.html.twig', [
+ return new Response($this->twig->render('conference/index.html.twig', [
'conferences' => $conferenceRepository->findAll(),
]));
}
#[Route('/conference/{id}', name: 'conference')]
- public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
+ public function show(Request $request, Conference $conference, CommentRepository $commentRepository): Response
{
$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,
深入学习
- ;
- 在 Symfony 应用中 创建和使用模板;
- ;
- AbstractController 控制器基类。