Drupal 11:使用 HTMX 创建标签式界面的分步指南

Drupal 11:使用 HTMX 创建标签式界面

这是一系列探讨 Drupal 中 HTMX 应用文章的第三部分。上次我介绍了如何在 Drupal 页面上使用 HTMX 实现“加载更多”功能。在开始研究表单之前,我想先给出一个使用 HTMX 和控制器来实现特定操作的最终示例。

帮助我理解 HTMX 的一个关键示例是,它能在不重新加载页面的情况下创建标签式界面。在 Drupal 中重现这个功能很简单,只需一个控制器即可。

在本文中,我们将在 Drupal 11 里创建一个标签式界面,运用 HTMX 在不重新加载页面的情况下加载类似标签界面中的数据。我公司在 Drupal 开发领域经验丰富,像这种 Drupal 模块开发在实际项目中很常见,同时也会涉及到 Drupal 升级等工作以确保系统的稳定性和性能。

本文包含的所有代码虽然可以在 Drupal HTMX 示例项目中找到,但在这里我们会详细介绍代码的功能以及它是如何生成内容的。

首要任务是为我们的控制器创建路由。

一、路由

我们创建的这个路由只是指向控制器中的一个操作。

drupal_htmx_examples_tabbed:
  path: '/drupal-htmx-examples/tabbed'
  defaults:
    _title: 'HTMX 标签式界面'
    _controller: '\Drupal\drupal_htmx_examples\Controller\TabbedController::action'
  requirements:
    _permission: 'access content'

当用户(假设他们拥有 访问内容权限)访问路径 /drupal-htmx-examples/tabbed 时,将触发控制器中的 action() 方法。

接下来,让我们构建这个路由所指向的控制器。

二、控制器

此路由指向一个控制器,由于这里只有一个端点,我们需要使用 Drupal\Core\Htmx\HtmxRequestInfoTrait 特性。为了使用这个特性,我们要注入 request_stack 服务,并创建一个名为 getRequest() 的方法,该方法将从请求栈中返回当前请求。

除了这个特性,我将使用 Drupal\Core\Render\MainContent\HtmxRenderer 对象来响应 HTMX 请求。这样我们就可以从响应中返回 HTMX 友好的标记,而无需在链接中添加 _wrapper_format 参数。虽然这种技术并非总是必要的,但这个示例将展示在需要时如何实现。

为了能够使用 HtmxRender 对象,我们需要包含 main_content_renderer.htmx 服务,它是一个可用的 HtmxRender 对象。此外,我们还需要一个 current_route_match 服务,调用该对象的 renderResponse() 方法并返回内容时需要用到它。

控制器的初始框架包含了一些服务,这样我们就具备了实现其余功能所需的一切。

<?php

namespace Drupal\drupal_htmx_examples\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Htmx\HtmxRequestInfoTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * 控制器,用于使用 HTMX 在页面上显示标签式区域。
 */
class TabbedController extends ControllerBase {

  use HtmxRequestInfoTrait;

  /**
   * 请求栈服务。
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected $requestStack;

  /**
   * HTMX 渲染器服务。
   *
   * @var \Drupal\Core\Render\MainContent\HtmxRenderer
   */
  protected $htmxRenderer;

  /**
   * 路由匹配服务。
   *
   * @var \Drupal\Core\Routing\RouteMatchInterface
   */
  protected $currentRouteMatch;

  /**
   * 数据库服务。
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected $database;

  public static function create(ContainerInterface $container) {
    $instance = new self();
    $instance->requestStack = $container->get('request_stack');
    $instance->htmxRenderer = $container->get('main_content_renderer.htmx');
    $instance->currentRouteMatch = $container->get('current_route_match');
    $instance->database = $container->get('database');
    return $instance;
  }

  /**
   * {@inheritdoc}
   */
  protected function getRequest() {
    return $this->requestStack->getCurrentRequest();
  }

  /**
   * 路由 drupal_htmx_examples_tabbed 的回调函数。
   *
   * @return array|\Drupal\Core\Render\HtmlResponse|\Symfony\Component\HttpFoundation\Response
   *   渲染数组,或 HTMX 渲染器响应。
   */
  public function action() {
  }
}

这将构成控制器的基础。

接下来,让我们看看如何从数据库中加载数据。

三、从数据库加载页面

我在这个控制器中包含的一个服务是与数据库的连接。这用于从数据库中获取最新的文章,以便我们可以在标签内容中加载它们。这里的想法是,我希望有一个易于复制的系统,用于从数据库中获取内容页面。加载前几篇文章似乎是一个不错的方法,任何人都可以在自己的网站上轻松实现。

以下函数接受一个数字,并将返回数据库中按创建日期排序的该位置的(已发布)文章页面。

  /**
   * 加载按创建日期降序排列的第 "nth" 个节点。
   *
   * @param int $nth
   *   要加载的节点位置,按创建日期降序排列。
   *
   * @return \Drupal\Core\Entity\EntityInterface|null
   *   节点,如果节点加载失败则返回 null。
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   */
  protected function loadNthNode(int $nth): ?EntityInterface {
    $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->where('nfd.status = 1');
    $query->range($nth, 1);
    $nid = $query->execute()->fetchField();

    // 然后我们相应地加载数据。
    $nodeStorage = $this->entityTypeManager()->getStorage('node');
    return $nodeStorage->load($nid);
  }

这是否有用呢?也许不一定,但对于大多数 Drupal 网站(只要它们有一个名为“article”的内容类型)来说是可行的,因此这是一个在不知道网站当前内容的情况下加载内容页面的好例子。

四、控制器操作

控制器操作大致可以分为两部分。首先,我们需要加载内容页面,然后我们需要响应由该页面创建的 HTMX 请求。

首先,让我们为页面创建输出。我们这里只需要设置一个从 1 到 5 的数字数组,然后使用这些数字渲染一个链接列表。列表中的每个链接都有一个 name 属性,并标记有用于驱动标签式元素的 HTMX 属性。

    $range = range(1, 5);

    $items = [];

    foreach ($range as $item) {
      $id = 'page_' . $item;
      $items[$id] = [
        '#type' => 'html_tag',
        '#tag' => 'a',
        '#value' => $this->t('Page @number', ['@number' => $item]),
        '#attributes' => [
          'name' => $id,
          'href' => '#',
        ],
      ];

      (new Htmx())
        ->get()
        ->swap('outerHTML')
        ->target('#detail')
        ->trigger('click')
        ->applyTo($items[$id]);
    }

    $output['list_of_items'] = [
      '#theme' => 'item_list',
      '#title' => 'Links',
      '#items' => $items,
      '#type' => 'ul',
    ];

这里使用 Htmx 类为每个链接添加了以下属性。

  • data-hx-get="" - 这将向当前 URL 发送一个 GET 请求。
  • data-hx-swap="outerHTML" - 这个属性意味着我们将用返回的 HTML(记住 HTMX 使用纯 HTML 工作)替换这个元素。换句话说,我们移除这个元素并用响应中的数据替换它。
  • data-hx-target="#detail" - 这设置了请求响应将被放置的目标元素。我们还没有将这个元素添加到代码中,接下来会添加。
  • data-hx-trigger="click" - 这意味着当用户点击链接时,将运行 GET 请求。

注意,所有的链接都获得了完全相同的属性。我们将在文章后面详细介绍 HTMX 如何区分是哪个元素发起的请求。

页面渲染的其余部分用于生成带有 detail id 的 div 元素,内容将被放置在这个元素中。我没有让这个元素为空,而是决定加载列表中的第一个元素,并将其渲染为页面的默认内容。为此,我们使用实体类型管理器服务以摘要模式渲染节点,并将其作为 div 元素的子元素添加。

    // 加载数据库中的第一个节点。
    $node = $this->loadNthNode(1);

    // 将节点转换为视图模式 "teaser" 的渲染数组。
    $viewBuilder = $this->entityTypeManager()->getViewBuilder('node');
    $renderArray = $viewBuilder->view($node, 'teaser');

    $output['tab_content'] = [
      '#type' => 'html_tag',
      '#tag' => 'div',
      '#value' => '',
      '#attributes' => [
        'id' => 'detail',
      ],
      'children' => $renderArray,
    ];

为了响应请求,我们需要使用在控制器创建方法开始时添加到控制器的 request_stackmain_content_renderer.htmxcurrent_route_match 服务。

响应的创建方式如下。

return $this->htmxRenderer->renderResponse(
  $output,
  $this->requestStack->getCurrentRequest(),
  $this->currentRouteMatch);

当我们从 HTMX 收到请求时,请求包含多个头信息,由于我们为链接元素添加了 name 属性,头信息中包含一个 HX-Trigger-Name 头。我们可以使用 HtmxRequestInfoTrait 特性的 getHtmxTriggerName() 方法读取这个头信息,该方法返回一个类似 "page_1" 的字符串。

利用这个信息,我们可以提取用户点击的页面 ID,并开始将该页面加载到页面上。

$trigger = $this->getHtmxTriggerName();
$number = str_replace('page_', '', $trigger);

响应 HTMX 请求的完整代码如下。

    if ($this->isHtmxRequest()) {
      // 这是一个 HTMX 请求,因此我们创建一些输出并以完整的 HTMX 渲染器响应。
      // 首先,我们找到触发请求的元素。
      $trigger = $this->getHtmxTriggerName();

      // 然后根据点击的标签,通过查找数据库中的第 n 项来映射到一个节点。
      $number = str_replace('page_', '', $trigger);

      $node = $this->loadNthNode($number);

      $viewBuilder = $this->entityTypeManager()->getViewBuilder('node');
      $renderArray = $viewBuilder->view($node, 'teaser');

      // 然后,设置 detail div 并渲染它。
      $output['tab_content'] = [
        '#type' => 'html_tag',
        '#tag' => 'div',
        '#attributes' => [
          'id' => 'detail',
        ],
        'children' => $renderArray,
      ];

      $output['#cache'] = [
        'contexts' => [
          'url:path',
          'url:path.query',
        ],
        'tags' => $node->getCacheTags(),
      ];

      return $this->htmxRenderer->renderResponse(
        $output,
        $this->requestStack->getCurrentRequest(),
        $this->currentRouteMatch);
    }

除了一些缓存处理,以确保节点在其他地方发生更改时能够更新之外,控制器中的代码基本就是这些了。

让我们来看看这里的 HTMX 工作流程。

五、HTMX 工作流程

当用户首次加载页面时,他们将看到一个包含 6 个链接的列表,后面跟着一个包含第一篇文章的 div 元素。

<ul>
<li><a name="page_1" href="#" data-hx-get="" data-hx-swap="outerHTML ignoreTitle:true" data-hx-target="#detail" data-hx-trigger="click">Page 1</a></li>
... 为简洁起见,省略部分链接 ...
<li><a name="page_6" href="#" data-hx-get="" data-hx-swap="outerHTML ignoreTitle:true" data-hx-target="#detail" data-hx-trigger="click">Page 6</a></li>
</ul>

<div id="detail">
... 文章 1 ...
</div>

假设用户点击了“Page 6”链接,HTMX 将向 /drupal-htmx-examples/tabbed 路由发出请求,该路由将返回以下 HTML 响应。

<div id="detail">
... 文章 6 ...
</div>

由于我们对这个请求使用了 outerHTML 交换策略,HTMX 将用服务器返回的 div 元素替换当前的 detail div 元素。

<ul>
<li><a name="page_1" href="#" data-hx-get="" data-hx-swap="outerHTML ignoreTitle:true" data-hx-target="#detail" data-hx-trigger="click">Page 1</a></li>
...
<li><a name="page_6" href="#" data-hx-get="" data-hx-swap="outerHTML ignoreTitle:true" data-hx-target="#detail" data-hx-trigger="click">Page 6</a></li>
</ul>

<div id="detail">
... 文章 6 ...
</div>

现在用户可以点击页面上的链接来加载不同的内容页面。HTMX 在整个代码中所占的比例很小。