Drupal 11:使用 HTMX 为节点分页构建“加载更多”功能
在上一篇介绍 Drupal 中 HTMX 的文章之后,我打算开始探讨 HTMX 以不同方式为 Drupal 提供交互性的示例。
我觉得从一个简单的控制器中使用 HTMX 入手研究是个不错的选择。通过为控制器创建路由,我们能够渲染内容,接着注入 HTMX 属性,在同一个控制器中执行操作。
在本文中,我会创建一个控制器操作,用于加载一些内容页面并将其显示为列表。包含 HTMX 属性的元素将向同一控制器操作发送请求,从而在列表中生成更多项目。这些新项目会附加到现有列表中,同时还会有另一个包含 HTMX 属性的元素,可用于请求更多项目。
这个 HTMX 元素将充当“加载更多”按钮,只要有内容可加载,它就会持续加载更多内容。
本文包含的所有代码都能在 Drupal HTMX 示例项目中找到,这里我们将详细介绍代码的功能以及它为生成内容所执行的操作。
首先,我们来为控制器创建路由。
一、路由
我们在此创建的路由只是将请求的路径与控制器类关联起来。由于在这个示例中我们仅使用一个操作,所以无需为 HTMX 请求提供第二条路由。
drupal_htmx_examples_add_more_nodes:
path: '/drupal-htmx-examples/add-more-nodes'
defaults:
_title: 'HTMX Add More Nodes'
_controller: '\Drupal\drupal_htmx_examples\Controller\HtmxAddMoreNodesController::action'
requirements:
_permission: 'access content'
当用户(假设他们具有 访问内容权限)访问路径 /drupal-htmx-examples/add-more-nodes 时,将触发控制器中的 action() 方法。
接下来,我们构建这个路由指向的控制器。
二、控制器
要创建一个能够识别 HTMX 请求的控制器,我们需要使用 Drupal\Core\Htmx\HtmxRequestInfoTrait 特性。为了使用这个特性,我们要注入 request_stack 服务,并创建一个名为 getRequest() 的方法,该方法将从请求栈中返回当前请求。我们在这里需要包含这个特性,因为我们为这个控制器使用了一个单一的路由,如果为 HTMX 请求使用单独的路由,就可以不使用这个特性(假设我们不需要该特性提供的其他功能)。
以下控制器类是创建能够检测 HTMX 请求的控制器的典型模板,它将构成其余代码的基础。
<?php
namespace Drupal\drupal_htmx_examples\Controller;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\Query\PagerSelectExtender;
use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Htmx\HtmxRequestInfoTrait;
use Drupal\Core\Url;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* 用于展示节点无限滚动的类。
*/
class HtmxAddMoreNodesController extends ControllerBase {
use HtmxRequestInfoTrait;
public function __construct(
protected RequestStack $requestStack,
protected Connection $database,
) {}
/**
* {@inheritdoc}
*/
protected function getRequest() {
return $this->requestStack->getCurrentRequest();
}
public function action() {
// 控制器操作。
}
}
需要注意的是,这个类头部的 use 语句并非都在当前使用,但在我们完成向 action() 方法添加代码后,它们都会被用到。我在这里完整列出这些语句,这样在文章的其余部分就无需随意添加 use 语句了。
现在,我们逐步完善这个操作,引入所需的功能。
三、控制器操作
有了操作方法后,我们可以使用 $this->isHtmxRequest() 方法来检查当前请求是否来自 HTMX 回调。在这个操作中,我们首先要检测这是否是一个 HTMX 请求,并从当前请求中获取一个页面查询参数,我们将用它来存储从控制器操作请求的当前页面项目。
如果这不是一个 HTMX 请求,那么它就是普通的页面请求,所以我们将页码设置为 0(即第一页)。
if ($this->isHtmxRequest()) {
// 如果这是一个 HTMX 请求,从查询中获取页面变量。
$page = $this->getRequest()->query->get('page');
}
else {
// 默认设置为第一页。
$page = 0;
}
页面变量仅在 HTMX 元素中使用,用于告知这个端点当前结果页面的编号。它实际上并不用于查询以查找当前页面(嗯,不是直接使用,但我们稍后会讲到)。
接下来,我们需要设置一些变量。这包括每页的项目数量(目前设置为 2)、节点存储对象、这个节点列表所需的缓存标签,以及渲染数组的初始化。
// 页面限制变量是每页要显示的项目数量。
$pageLimit = 2;
// 加载节点存储。
$nodeStorage = $this->entityTypeManager()->getStorage('node');
// 为这个列表包含 node_list 和 node_view 缓存标签。
$cacheTags = ['node_list', 'node_view'];
// 设置我们的渲染数组。
$output = [];
现在我们准备好从数据库中查询我们的内容项目了。
以下查询使用了一种相当高级的技术,我们扩展了查询对象,使用 PagerSelectExtender 类作为查询引擎。这将自动计算出所有信息,如当前页面(从当前请求的 page 查询参数中获取)、基于这个页码的偏移量,以及列表中总共可用的项目数量。我们向数据库请求一个文章页面列表,按创建日期降序排列。
这里我们最终得到的是项目总数的计数,以及当前页面中节点 ID 的列表。
// 使用 PagerSelectExtender 查询来查询数据库。这种类型的分页器
// 将自动查找传递给响应的查询字符串 "page",并将其用作查询的当前分页器。
$query = $this->database->select('node', 'n')
->fields('n', ['nid']);
$query->join('node_field_data', 'nfd', '[nfd].[nid] = [n].[nid] AND [nfd].[langcode] = [n].[langcode]');
$query->orderBy('nfd.created', 'desc');
$query->where('n.type = :type', [':type' => 'article']);
// 为查询添加分页器。
$query = $query->extend(PagerSelectExtender::class);
$query->limit($pageLimit);
// 设置计数查询并执行。
$query->setCountQuery($query->countQuery());
$queryResult = $query->execute();
$results = $queryResult->fetchAll();
$totalItems = $query->getCountQuery()->execute()->fetchField();
现在我们需要遍历这些结果并生成页面输出。这由实体类型管理器服务提供便利,我们使用它来准备内容项目,以便在 teaser 视图模式下进行渲染。
一旦我们生成了项目列表,我们要确保还没有到列表的末尾,然后创建一个链接元素,我们将使用它作为 HTMX 类来注入所需的属性。
foreach ($results as $id => $result) {
// 加载当前节点。
$node = $nodeStorage->load($result->nid);
// 使用 teaser 视图模式渲染节点。
$entityType = 'node';
$viewMode = 'teaser';
$viewBuilder = $this->entityTypeManager()->getViewBuilder($entityType);
$output['node-' . $node->id()] = $viewBuilder->view($node, $viewMode);
// 将这个节点的缓存标签与本页的列表合并。
$cacheTags = Cache::mergeTags($cacheTags, $node->getCacheTags());
if ($id == $pageLimit - 1 && $page * $pageLimit 'link',
'#url' => Url::fromRoute(''),
'#title' => 'Load more...',
];
// 为链接应用 HTMX 属性。
$htmx = new Htmx();
$htmx->get(Url::fromRoute(route_name: 'drupal_htmx_examples_add_more_nodes', options: [
'query' => [
'page' => ++$page,
'_wrapper_format' => 'drupal_htmx',
],
]))
// 将交换值设置为 outerHTML 意味着我们用 HTMX 请求的结果替换链接。
->swap('outerHTML')
->trigger('click once')
->applyTo($output['add_more_nodes']);
}
}
如你所见,这里为这个页面添加 HTMX 属性的代码非常少。我们只是创建一个链接元素,然后将其传递给 Htmx 对象的 applyTo() 方法,为元素添加以下属性:
data-hx-get="/drupal-htmx-examples/add-more-nodes?page=1&_wrapper_format=drupal_htmx"- 这是触发操作时我们将发送 GET 请求的路径。我们设置要请求的下一页的页码,并将包装格式设置为drupal_htmx。通过设置包装格式,我们告诉 Drupal 响应需要是 HTMX 友好的,即它只是一个包含我们感兴趣元素的纯 HTML 响应。data-hx-swap="outerHTML"- 这个属性意味着我们将用返回的 HTML(记住 HTMX 使用纯 HTML 工作)替换这个元素。换句话说,我们移除这个元素,并用响应中的数据替换它。data-hx-trigger="click once"- 这意味着当用户点击链接时,将执行 GET 请求。链接元素本身已经有点击事件,所以它可以“免费”获得点击事件,但我们通过添加once标志来增强它,以防止按钮被点击两次并导致分页出现问题。
最后一步是在返回渲染数组之前,将缓存标签和上下文值添加到其中。
通过这种方式设置缓存,我们可以确保如果列表中的任何项目发生变化,页面可以重新生成,而无需刷新网站的缓存。这使得页面更具动态性,更易于管理。
// 为这个请求设置缓存。
$output['#cache'] = [
'contexts' => [
'url:path',
'url:path.query',
],
'tags' => $cacheTags,
];
return $output;
现在我们准备好加载页面并开始请求数据页面了。接下来,让我们来看看它是如何工作的。
四、HTMX 工作流程
当我们加载页面时,控制器操作将在页面上渲染以下内容(简化以去除文章标签中的一些标记)。
<article>... 文章 1...</article> <article>... 文章 2...</article> <a href="/drupal-htmx-examples/add-more-nodes" data-hx-get="/drupal-htmx-examples/add-more-nodes?page=1&_wrapper_format=drupal_htmx" data-hx-swap="outerHTML ignoreTitle:true" data-hx-trigger="click once">加载更多...</a>
当用户点击链接时,HTMX 库将向控制器发送请求,传递页码 1 并将包装格式设置为 drupal_htmx。
这个请求的响应将包含以下 HTML。
<article>... 文章 3...</article> <article>... 文章 4...</article> <a href="/drupal-htmx-examples/add-more-nodes" data-hx-get="/drupal-htmx-examples/add-more-nodes?page=2&_wrapper_format=drupal_htmx" data-hx-swap="outerHTML ignoreTitle:true" data-hx-trigger="click once">加载更多...</a>
Drupal 将作为完整的 HTML 页面返回响应,其中 <head> 元素包含渲染页面所需的任何样式或 JavaScript。htmx-assets.js 脚本(它是 Drupal HTMX 实现的一部分)将从页面中获取任何资源,并将它们添加到我们当前的页面中,这意味着我们可以毫无问题地渲染页面。在这种情况下,我们实际上没有任何新的样式要添加,因为它们已经是主页的一部分,但了解这个过程很有用。
由于我们将元素的交换样式设置为 outerHTML,这意味着响应将替换被点击的链接,结果会在页面上渲染以下标记。
<article>... 文章 1...</article> <article>... 文章 2...</article> <article>... 文章 3...</article> <article>... 文章 4...</article> <a href="/drupal-htmx-examples/add-more-nodes" data-hx-get="/drupal-htmx-examples/add-more-nodes?page=2&_wrapper_format=drupal_htmx" data-hx-swap="outerHTML ignoreTitle:true" data-hx-trigger="click once">加载更多...</a>
现在用户可以点击页面底部的链接,从我们的分页控制器请求第 2 页。他们可以一直这样做,直到网站没有更多节点,或者用户失去耐心。
在 Drupal 开发中,像这样利用 HTMX 为节点分页构建“加载更多”功能是 Drupal 模块开发的一个典型实践。同时,随着 Drupal11 的不断发展,这种功能的实现也能更好地适配 Drupal11 的特性,并且在进行 Drupal 升级时,相关的代码也需要进行相应的调整和优化,以确保功能的稳定和兼容性。


