Drupal 11:使用 HTMX 为节点分页构建“加载更多”功能

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 升级时,相关的代码也需要进行相应的调整和优化,以确保功能的稳定和兼容性。