Xiuno BBS 开发实践教程 - 5 - HTMX - 网站大事记
Tillreetree 1天前

Xiuno BBS 开发实践教程 - HTMX - 改造网站大事记

我本人允许本教程被AI作为训练材料之一使用。

如果您认为本教程对您来说有用的话,不妨请作者喝杯奶茶?

零、引言

"如果jQuery是瑞士军刀,那么HTMX就像是开发者口袋里的魔法卡片——轻巧却总能变出惊喜"

本文要求你看过《Xiuno BBS 开发实践教程》系列教程,最好看一看htmx.org上的示范。

一、让我们有请新朋友——HTMX

它是什么

HTMX 是一个轻量级的 JavaScript 库,它的核心理念是 “让 HTML 变得更强大”。它允许你直接在 HTML 标签的属性中声明式地添加动态行为,而无需编写复杂的 JavaScript 代码。

回顾我们之前的教程,在《网站大事记》中,我们为了实现“无刷新删除”功能,不得不在 list.htm 页面中嵌入一段 JavaScript 代码,使用 $.xpost 函数来发送 Ajax 请求,并手动操作 DOM 来移除被删除的元素。这个过程虽然有效,但需要开发者对 JavaScript 和 DOM 操作有相当的了解。

HTMX 的出现,旨在简化这一过程。它通过几个核心的 HTML 属性,让你可以直接在标签上“告诉”浏览器: “当这个按钮被点击时,向这个 URL 发送一个 POST 请求,并用返回的内容替换这个元素。” 无需一行 JavaScript,即可实现复杂的动态交互。

它能做什么

在 Xiuno BBS 插件开发中,HTMX 可以帮助我们:

  1. 无刷新页面更新

    • 实现局部内容更新而无需整页刷新
    • 如:点赞、收藏等交互操作
  2. 表单提交优化

    • 异步提交表单并更新指定区域
    • 如:评论提交、设置保存
  3. 动态内容加载

    • 按需加载内容片段
    • 如:无限滚动、标签页切换
  4. UI 交互增强

    • 实现平滑过渡效果
    • 如:模态框、下拉菜单
  5. 实时功能

    • 通过 WebSocket 或 SSE 实现实时更新
    • 如:新消息通知

与 jQuery Ajax 的对比

在之前的教程中,我们使用了 Xiuno BBS 封装的 $.xpost 函数来实现 Ajax。虽然它简化了流程,但本质上还是命令式的:你需要选择元素、绑定事件、序列化数据、发送请求、处理回调、更新 DOM。

而 HTMX 是声明式的。你不需要告诉浏览器“如何做”,而是直接告诉它“做什么”。这使得代码更加简洁、易读和易于维护。

特性 jQuery Ajax ($.xpost) HTMX
编程范式 命令式 (Imperative) 声明式 (Declarative)
代码位置 JavaScript 代码块中 HTML 标签属性中
学习曲线 需要理解 JS、DOM、事件循环 只需学习几个 HTML 属性
代码量 相对较多 极少
可读性 需要阅读 JS 逻辑 一目了然,行为与元素同在

通过引入 HTMX,我们将把之前教程中复杂的 JavaScript 逻辑,转化为简洁、直观的 HTML 属性,让开发变得更轻松、更高效。

二、熟悉新的范式

前端(浏览器)

1. hx-get 替代 $.xget

<!-- 传统方式 -->
<button id="the_button">获取</button>
<div id="result"></div>
<script>
var jbtn = $('#the_button');
jbtn.on('click', function () {
    $.xget('content.htm', function(code, msg){
        $('#result').html(msg);
    });
});
</script>

<!-- HTMX方式 -->
<button hx-get="content.htm" hx-target="#result" hx-swap="innerHTML">获取</button>
<div id="result"></div>

2. hx-post 替代 $.xpost

<!-- 传统方式 -->
<form id="form" action="submit.htm">...</form>
<div id="result"></div>

<script>
var jform = $('#form');
var jsubmit = $('#submit');
jform.on('submit', function () {
    jform.reset();
    jsubmit.button('loading');
    var postdata = jform.serialize();
    $.xpost(jform.attr('action'), postdata, function (code, message) {
        if (code == 0) {
            jsubmit.button('reset');
            $('#result').html(message);
        } else if (xn.is_number(code)) {
            $.alert(message);
            jsubmit.button('reset');
        } else {
            jform.find('[name="' + code + '"]').alert(message).focus();
            jsubmit.button('reset');
        }
    });
    return false;
});
</script>

<!-- HTMX方式 -->
<form hx-post="submit.htm" hx-target="#result">...</form>
<div id="result"></div>

3. 智能历史管理

手动对你希望更新地址栏,并浏览器添加到历史记录的元素,添加hx-push-url属性

<!-- 让浏览器记住状态 -->
<a href="thread-123.htm" hx-get="thread-123.htm" hx-push-url="true" hx-target="#main">
  点击加载的内容会更新地址栏URL
</a>

4. 全局增强模式

想要偷懒?可以在内含有一系列操作的容器上添加hx-boost="true",这样可以自动增强容器内所有链接和表单;

其内的链接和表单的参数,如hx-targethx-swaphx-push-url等,会跟随带有hx-boost的容器的定义。

<div hx-boost="true">
    <a href="my.htm">Page 1</a> <!-- 自动变为hx-get -->
    <form action="thread-create-1.htm" method="post">...</form> <!-- 自动变为hx-post -->
</div>

5. 让插件本身提供的页面兼容HTMX请求

<?php
if(!$IS_HTMX){
// 如果不是HTMX请求的话,才引入主题的页眉
include _include(APP_PATH . 'view/htm/header.inc.htm'); 
}
// 剩下的部分就是HTMX可以获取到的内容了。
?>

<!-- 页面内容 -->

<?php 
/* 有些内容是为了让正常访问的页面看上去更合理、美观而加上去的,但HTMX不需要这些,所以可以在页面内容里继续使用这样的if判断 */ 
if(!$IS_HTMX): ?>
<h1 class="text-center"><?= $page_title ?></h1>
<?php endif; ?>

<?php if(!$IS_HTMX): ?>
<section class="card">
    <div class="card-body">
<?php endif; ?>

        <!-- 最终,会得到这里的内容【开始】 -->
        <p>我的页面内容(列表、详情、表单等等)</p>
        <!-- 最终,会得到这里的内容【结束】 -->

        <!-- 如果要显示列表的话,请参考xiuno自带的view/htm/forum.htm风格的获取与呈现 -->
        <?php 
        include _include(APP_PATH.'plugin/yuur_plugin/view/htm/item_list.inc.htm'); 
        /* 这类文件的内容应该类似于xiuno自带的thread_list.inc.php  */
        ?>

<?php if(!$IS_HTMX): ?>
    </div>
</section>
<?php endif; ?>

<?php 
if(!$IS_HTMX){
// 如果不是HTMX请求的话,才引入主题的页脚
include _include(APP_PATH . 'view/htm/footer.inc.htm'); 
}
?>

<!-- 在include footer.inc.htm之后这里添加该页面所需的JS代码 -->

后端(PHP)

现在的插件开发流程基本上没有变化。

在目前版本的HTMX整合中,有这些地方和原来的xiuno bbs不同:

新增了这些全局变量

/**
 * @var bool 是HTMX发起的请求吗?
 */
$IS_HTMX = isset($_SERVER['HTTP_HX_REQUEST']) || (isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST'] == 'true');
/**
 * @var bool 是通过翻页器访问的吗?
 */
$IS_IN_PAGINATION = isset($_REQUEST['IS_IN_PAGINATION']) && boolval($_REQUEST['IS_IN_PAGINATION']);

新增了这些函数

/**
 * 将翻页器转换成适合HTMX的Hx-Trigger的内容
 *
 * @param string $html 输入为pagination函数的输出结果(Bootstrap分液器)
 * @return array
 */
function process_pagination_to_htmx_trigger(string $html) : string

请求的页面内容通常会被装进一个容器里

如目前的#body

但这部分由前端决定(说不定会是其他的形态呢),所以请不要擅自在自己的输出中增加类似class="container"的东西。

现在message()函数会往响应的头部(Header)写入内容

  1. 如果你先指定了HX-Trigger,然后使用message()的话,则message()会写入HX-Trigger-After-Swap来避让你的HX-Trigger;否则会使用HX-Trigger

如:

header('HX-Trigger: ' . json_encode(['closeModal' => true]));
echo(‘更新完成的内容’);
message(0, '设置完成');

会写入这些头:

HX-Trigger: {'closeModal' : true}
HX-Trigger-After-Swap: {"showToast":{"type":"success","title":"提示","content":"设置完成","delay":5000}}

然后再输出“‘更新完成的内容’”。

  1. 如果只有message()的话,如:
    message(0, '设置完成');
    
    会写入这些头:
    HX-Trigger: {"showToast":{"type":"success","title":"提示","content":"设置完成","delay":5000}}
    

(以下统一用HX-Trigger表示这个部分)


  1. 如果message的第一个参数不是数字,如message('username','用户名不存在'),会显示模态框作为强提醒:
HX-Trigger: {"showModalSimple":{"title":"警告","subtitle":"username","content":"用户名不存在"}}
  1. 如果message的第一个参数是数字(推荐),如message(0,'操作成功'),会显示吐司框作为正常提醒:
    HX-Trigger: {"showToast":{"type":"success","title":"提示","content":"操作成功","delay":5000}}
    
message()的第一个参数会带来的效果详解:
取值 含义 type title
-1 服务端出错,如数据库连接失败等 danger(红色) 警告
0 正常、成功 success(绿色) 提示
1 用户输入导致的错误,例如发帖没写标题 warning(橙色) 警告
2 自定义,表示提示性信息 info(青色) 提示
其他数字 其他类型的用户错误 secondary(灰色) 提示

请不要为了选择颜色或者为了强提醒而故意使用错误的“第一个参数值”,因为在前端的定义可能会发生改变。

例如,按照xiuno bbs的定义,第一个参数如果是string的话,就会寻找ID为该值的元素,然后显示Tooltip,而如果有主题做到了这一点的话就不会出现弹窗的效果。

想要显示自定义HTML弹窗的话需要这样做

前端按钮

<button 
    class="btn btn-primary" 
    hx-get="<?= url('会返回模态框内容的地址');?>" 
    hx-include="如有必要,在这里提供想要同时传递给那个地址的参数" 
    hx-target="#htmx-modal .modal-body" 
    onclick="showModal()">
    按钮文字
</button>

后端业务逻辑文件

    include _include( APP_PATH . 'plugin/your_plugin/view/htm/modal_content.htm'); // 在该文件里通常只有你希望显示的那一部分HTML
    exit;

前端模态框内容

<?php
if(!$IS_HTMX){
// 如果不是HTMX请求的话,才引入主题的页眉
include _include(APP_PATH . 'view/htm/header.inc.htm'); 
}
?>

<?php 
/* 有些内容是为了让正常访问的页面看上去更合理、美观而加上去的,但HTMX不需要这些,所以可以在页面内容里继续使用这样的if判断 */ 
if(!$IS_HTMX): ?>
<section class="card">
    <div class="card-body">
<?php endif; ?>

<!-- 最终,会得到这里的内容【开始】 -->
<form action="<?= url("route-action");?>" method="post" hx-post="<?= url("route-action");?>" hx-trigger="submit">

  <div class="form-group row">
    <label class="col-4" for="parameter">字段名称</label>
    <div class="col-8">
        <input type="text" name="parameter" placeholder="字段的值" class="form-control" id="parameter">
    </div>
  </div>

  <footer class="row">
    <div class="col-6">
        <button type="submit" class="btn btn-primary btn-block">提交</button>
    </div>
    <div class="col-6">
    <!-- 虽然模态框本身的右上角或左上角也有个X按钮用来关闭,但我们不能假设所有用户都能看清楚 -->
        <button type="button" class="btn btn-outline-secondary btn-block" data-target="htmx-modal" onClick="toggleModal(event)">关闭</button>
    </div>
</footer>
</form>
<!-- 最终,会得到这里的内容【结束】 -->

<?php if(!$IS_HTMX): ?>
    </div>
</section>
<?php endif; ?>

<?php 
if(!$IS_HTMX){
// 如果不是HTMX请求的话,才引入主题的页脚
include _include(APP_PATH . 'view/htm/footer.inc.htm'); 
}
?>

<!-- 在include footer.inc.htm之后这里添加该页面所需的JS代码 -->

交互流程的改变

如果信息本身可以被编辑,则应在编辑完成后返回编辑好的信息

这部分请参考htmx的示例

如果信息需要确认,请这样设计:

以删除回帖为例(post-delete-{pid}.htm):

  1. 设计一个get请求的页面。

  2. 用来显示用户没有带上必要的参数(例如URL是post-delete.htm的get请求,应该返回 message(1,'未指定帖子')

  3. 用来询问用户是否继续该操作

  4. 如果用户在该页面中选择“是”,则给相同的URL发送post请求,这个请求才会真的进行删除操作,最后需要执行两条:

  5. header('HX-Trigger: '.json_encode(['closeModal' => true]));会关闭前端还没关闭的模态框(因为我们不需要了),同时在这里也可以添加其他的event用来触发删除前端内容的event

  6. message(0,'删除成功') 会显示土司框

如果是短路逻辑类的,正常在每一个短路逻辑的if语句内使用message函数(和按需的exit)即可。

如果是会抛出错误的:

try {
    // 业务逻辑
} catch (Exception $e) {
    message(-1, '系统错误: '.$e->getMessage());
    exit;
}

新的路由范式

<?php
/**
 * HTMX兼容路由处理器样板
 * 
 * @var string $action 路由动作参数
 * @var string $method 请求方法(GET/POST等)
 * @var bool $IS_HTMX 是否为HTMX请求
 * @var bool $ajax 是否为AJAX请求(用于区分API调用与jQuery调用)
 */

$action = param(1, '');

switch ($action) {
    case 'action':
        if ($method == "POST") {
            // ======================
            // POST请求处理
            // ======================

            /**
             * @var mixed 与请求一并传来的其他参数
             * 传统表单与hx-include都能被param()识别到
             */
            $some_data = param('some_data', ''); 

            // 输入验证
            if (empty($some_data)) {
                message(1, '数据不能为空');
            }

            // 如果你要使用的业务逻辑中有会抛出异常的,请套一层try catch;否则不用套
            try {
                // 在这里业务处理逻辑
                $result = 写入业务表($some_data);

                // HTMX专用响应
                if ($IS_HTMX) {
                    // 情况1:通过弹窗发生的请求,需要关闭模态框并刷新部分内容【开始】
                    header('HX-Trigger: '.json_encode([
                        'closeModal' => true
                    ]));
                    include _include(APP_PATH.'plugin/your_plugin/view/htm/updated_content.htm');
                    message(0, '操作成功');
                    exit;
                    // ——情况1【结束】

                    // 情况2:直接返回更新后的HTML片段【开始】
                    include _include(APP_PATH.'plugin/your_plugin/view/htm/updated_content.htm');
                    exit;
                    // ——情况2【结束】
                } 
                // AJAX API响应
                elseif ($ajax) {
                    message(0, ['data' => $result]);
                }
                // 传统表单提交响应
                else {
                    message(0, '操作成功');
                }

            } catch (Exception $e) {
                // message函数同时支持HTMX、AJAX API、HTML
                message(-1, '操作失败: '.$e->getMessage());
            }

        } else {
            // ======================
            // GET请求处理
            // ======================

            /**
             * @var mixed 与请求一并传来的其他参数
             * 传统表单与hx-include都能被param()识别到
             */
            $condition = param('condition', '');

            // 准备页面数据
            $data = 获取数据($condition);
            $total_data_count = 计数数据($condition);

            // 如果有分页需求的话,需要增加以下这些
            $page = param(2,1);
            $pagination = pagination(url("route-action-{page}"), $total_data_count, $page, $pagesize);

            // HTMX片段请求
            if ($IS_HTMX) {
                if($IS_IN_PAGINATION){
                    header("Hx-Trigger: " . json_encode(['updatePagination' => process_pagination_to_htmx_trigger($pagination)]));
                }
                // 返回部分HTML片段
                ob_start(); 
                ?>

                <?php 
                    include _include(APP_PATH.'plugin/yuur_plugin/view/htm/item_list.inc.htm'); 
                    /* 这类文件的内容应该类似于xiuno自带的thread_list.inc.php  */
                ?>

                <?php ob_end_flush(); 
                exit;
            }
            // AJAX API请求
            elseif ($ajax) {
                message(0, ['data' => $data]);
            }
            // 完整页面请求
            else {
                include _include(APP_PATH.'plugin/yuur_plugin/view/htm/page.htm');
            }
        }
        break;

    case 'other_action':
        // ======================
        // 其他动作处理
        // ======================
        // 【参考刚才的action写法】

        break;

    // 允许其他插件扩展路由
    // hook your_plugin_route_case_end.php

    default:
        // ======================
        // 默认路由处理
        // 【高度建议你将default作为“动作不存在”的用法,即使xiuno bbs在好几处都将default情况视作获取内容】
        // ======================
        message(2, '请求的动作不存在');
}

?>

三、实战:改造网站大事记插件

(如果你还没有这个插件的话,请去看《Xiuno BBS 开发实践教程 - 2 - 网站大事记》,然后跟着教程步骤制作)

目标

将之前的网站大事记插件改造为使用 HTMX 实现:

  1. (尽量)无JS
  2. 异步加载内容
  3. 无刷新表单提交
  4. 平滑的内容更新效果
  5. 同时兼容非HTMX环境

-1. 安装环境

在这里下载整合了HTMX的清爽蓝色主题 并安装。

然后就可以开工了。

0. 为了区分之前制作的版本,修改自述文件 conf.json

内容如下:

{
    "name":"网站大事记 HTMX版", 
    "brief":"教程插件", 
    "version":"2.0.0", 
    "bbs_version":"4.0.4",
    "installed":0, 
    "enable":0, 
    "hooks_rank":[], 
    "overwrites_rank":[], 
    "dependencies":[] 
}

1. 分析现有的操作链条

查看

原先的list.htm直接将列表项就地使用foreach遍历了,这对HTMX很不友好,需要单独出去。

添加

在之前的版本中,我们最后用到了这样的流程:

  • 点击添加按钮
  • 显示一个预先写好的表单
  • 使用xiuno的$.xpost提交表单
    • 如果成功,刷新整个页面
    • 如果失败,则使用xiuno的$.alert显示错误信息

编辑

在之前的版本中,点击按钮会直接进入编辑页面,在那边编辑完成后会回到列表页面。这就有了两次全页刷新

删除

在之前的版本中,在点击按钮后会调用processDelete(id)

  • 使用浏览器的confirm()来确认操作
  • 如果用户点击“确定”,则使用xiuno的$.xpost提交表单(ID为传入的参数)
    • 如果成功,则删除对应ID(ID为传入的参数)的内容(DOM)
    • 如果失败,则使用xiuno的$.alert显示错误信息

进行专项修改

事件列表

创建文件item_list.inc.htm,内容如下:

<?php /* 如果有事件的话 */ if ($events): ?>
    <?php /* 遍历每个事件 */ foreach ($events as $event): ?>
        <!-- 单个事件 -->
        <article class="content card mb-1 mb-md-2 mb-lg-3" data-id="<?= $event['id'] ?>">
            <div class="card-body">
                <!-- 标题 -->
                <h3 class="card-title"><?= $event['title'] ?></h3>
                <!-- 时间 -->
                <p><?= date('Y-m-d H:i', $event['create_time']) ?></p>
                <!-- 
                    查看详情按钮 
                    增加了增加了hx-get属性定义从“events_log-view.htm?id={id}”获取内容
                    增加了hx-target和hx-swap属性定义将获取到的内容放进自身(this)并替换按钮本身(outerHTML)
                -->
                <a
                    href="<?= url('events_log-view', ['id' => $event['id']]) ?>"
                    hx-get="<?= url('events_log-view', ['id' => $event['id']]) ?>"
                    hx-target="this"
                    hx-swap="outerHTML"
                    class="btn btn-link">查看详情</a>
            </div>
            <?php /* 只有管理员可用的操作 */ if ($uid && intval($gid) === 1): ?>
                <div class="card-footer">
                    <!-- 
                        编辑按钮
                        增加了hx-get属性定义从“events_log-edit.htm?id={id}”获取内容
                        增加了hx-target和hx-swap属性定义将获取到的内容放进id="htmx-modal" 中的class="modal-body"元素的内部(innerHTML)
                        增加了onclick属性定义点击该按钮后立即显示模态框,showModal函数的第一个参数为空字符串,会被忽略(来让HTMX填充内容),第二个参数为模态框的标题
                    -->
                    <a 
                    href="<?= url('events_log-edit', ['id' => $event['id']]) ?>" 
                    hx-get="<?= url('events_log-edit', ['id' => $event['id']]) ?>" 
                    hx-target="#htmx-modal .modal-body" 
                    hx-swap="innerHTML" 
                    class="btn btn-xs btn-warning" 
                    onclick="showModal('','编辑')">编辑</a>
                    <!-- 
                        删除按钮
                        增加了hx-post属性定义发送请求到“events_log-delete.htm?id={id}”
                        增加了hx-target和hx-swap属性定义将获取到的内容(服务器会返回空白内容)放进“从当前按钮开始,距离它最近的article class="content"”元素,替换它本身(意思是,删除这个元素,就像是乘以零一样,没了)
                        增加了hx-confirm属性,这样在点击按钮的时候会先让浏览器显示确认提示框,用户点击确定之后再真的发送POST请求
                    -->
                    <button 
                    type="button" 
                    class="btn btn-xs btn-danger" 
                    hx-confirm="确认删除吗?" 
                    hx-post="<?= url('events_log-delete', ['id' => $event['id']]) ?>" 
                    hx-target="closest article.content" 
                    hx-swap="outerHTML">删除</button>
                </div>
            <?php endif; ?>
        </article>
    <?php endforeach; ?>
<?php endif; ?>

添加页面

编辑文件plugin/my_events_log/view/htm/add.htm,内容如下:

<?php 
/*
如果不是来自HTMX的请求,则输出网站页眉、页脚和其他改善外观设计的内容
*/
if(!$IS_HTMX): include _include(APP_PATH.'view/htm/header.inc.htm');?>

<div class="container">
    <h2 class="page-header">添加新事件</h2>
    <a href="<?= url('events_log-list') ?>" class="btn btn-default">返回列表</a>
    <div class="card card-body">
        <?php endif;?>
        <!--
            表单
            增加了hx-post属性定义发送请求到“events_log-add.htm”
            增加了hx-target和hx-swap属性定义将获取到的内容(新的单个事件HTML片段)放进class="timeline"里面的开头位置(afterbegin)
        -->
        <form
              method="post"
              action="<?= url('events_log-add') ?>"
              hx-post="<?= url('events_log-add') ?>"
              hx-target=".timeline"
              hx-swap="afterbegin">
            <div class="form-group">
                <label>标题</label>
                <input type="text" name="title" class="form-control" required>
            </div>
            <div class="form-group">
                <label>内容</label>
                <textarea name="content" class="form-control" rows="6" required></textarea>
            </div>
            <button type="submit" name="submit" class="btn btn-primary">提交</button>
        </form>
        <?php if(!$IS_HTMX): ?>
    </div>
</div>

<?php include _include(APP_PATH.'view/htm/footer.inc.htm');?>
<?php endif;?>

编辑页面

编辑文件plugin/my_events_log/view/htm/edit.htm,内容如下:

<?php 
/*
如果不是来自HTMX的请求,则输出网站页眉、页脚和其他改善外观设计的内容
*/
if(!$IS_HTMX): include _include(APP_PATH.'view/htm/header.inc.htm');?>

<div class="container">
    <h2 class="page-header">编辑事件 <?= $event['id'] ?></h2>
    <a href="<?= url('events_log-list') ?>" class="btn btn-default">返回列表</a>
    <div class="card card-body">
        <?php endif;?>
        <!--
            表单
            增加了hx-post属性定义发送请求到“events_log-add.htm”
            增加了hx-swap属性定义将获取到的内容(新的单个事件HTML片段)放进服务器指定的元素,并替换自身(outerHTML)
        -->
        <form
              method="post"
              action="<?= url('events_log-edit') ?>"
              hx-post="<?= url('events_log-edit') ?>"
              hx-swap="outerHTML">
            <input type="hidden" name="id" value="<?= $event['id'] ?>">
            <div class="form-group">
                <label>标题</label>
                <input type="text" name="title" class="form-control" value="<?= $event['title'] ?>" required>
            </div>
            <div class="form-group">
                <label>内容</label>
                <textarea name="content" class="form-control" rows="6" required><?= $event['content'] ?></textarea>
            </div>
            <button type="submit" name="submit" class="btn btn-primary">更新</button>
        </form>
        <?php if(!$IS_HTMX): ?>
    </div>
</div>

<?php include _include(APP_PATH.'view/htm/footer.inc.htm');?>
<?php endif;?>

列表页面

编辑文件plugin/my_events_log/view/htm/list.htm,内容如下:

<?php include _include(APP_PATH.'view/htm/header.inc.htm');?>

<div class="container">

    <header class="d-flex flex-wrap align-items-center justify-content-between mb-3">
        <h2 class="page-header m-0">网站大事记列表</h2>
        <?php /* 只有管理员可用的操作 */ if($uid && intval($gid) === 1): ?>
        <!-- 
            添加新事件按钮 
            从button改成了a
            但增加了role="button"保持按钮的属性
            增加了href属性用来兼容非HTMX(或JS不可用)的场合
            增加了hx-get属性定义从“events_log-add.htm”获取内容
            增加了hx-target和hx-swap属性定义将获取到的内容放进id="htmx-modal" 中的class="modal-body"元素的内部(innerHTML)
            增加了onclick属性定义点击该按钮后立即显示模态框,showModal函数的第一个参数为空字符串,会被忽略(来让HTMX填充内容),第二个参数为模态框的标题
        -->
        <a
           href="<?= url('events_log-add') ?>"
           role="button"
           hx-get="<?= url('events_log-add') ?>"
           hx-target="#htmx-modal .modal-body"
           hx-swap="innerHTML"
           onclick="showModal('','添加新事件')"
           class="btn btn-success">+ 添加事件</a>
        <?php endif; ?>
    </header>

    <!-- 事件列表 -->
    <section class="timeline">
        <!-- 抽离事件列表循环到单独的文件 -->
        <?php include _include(APP_PATH.'plugin/my_events_log/view/htm/item_list.inc.htm');?>
    </section>

    <!-- 分页导航 -->
    <div class="text-center">
        <!--
            hx-boost属性用来给它之内的元素自动增加hx-get(从“该链接的href属性的值”获取内容)
            hx-target属性定义将获取到的内容放进class="timeline"的元素中
            hx-push-url属性表示将点击到的链接更改到地址栏里
            翻页器里的链接都加上了 hx-include="[name='IS_IN_PAGINATION']" 属性来指示请求从哪里来,好让服务器提供更准确的HTML片段
        -->
        <ul
            class="pagination my-4 justify-content-center flex-wrap"
            hx-boost="true"
            hx-target=".timeline"
            hx-push-url="true">
            <!-- IS_IN_PAGINATION 表示点击到的链接是来自翻页器的 -->
            <input type="hidden" name="IS_IN_PAGINATION" value="1">
            <?= $pagination ?>
        </ul>
    </div>
</div>

<!-- 引入提前写好的时间线样式 -->
<link rel="stylesheet" href="./plugin/my_events_log/view/css/timeline.css">
<?php include _include(APP_PATH.'view/htm/footer.inc.htm');?>

业务逻辑

编辑文件plugin/my_events_log/route/events_log.php,内容如下:

<?php
// 获取操作类型
// 这样会对应events_log-{action}.htm的请求
$action = param(1, 'list');

switch ($action) {
    case 'add':
        // 添加新事件
        if ($uid && intval($gid) === 1) {
            // 确保用户已经登陆,并且是管理员,来控制权限


            if (isset($_POST['submit'])) {
                // 更新的数据
                $data = [
                    'title' => strip_tags(param('title', '')), // 标题不需要HTML标签
                    'content' => param('content', ''),
                ];
                // 在前端虽然用required可以保证用户输入对应框的内容,但用户不一定会用网页提交请求,所以在后端进行检查是有必要的
                if (empty($data['title'])) {
                    http_response_code(400);
                    message(2, "请输入标题!");
                }
                if (empty($data['content'])) {
                    http_response_code(400);
                    message(2, "请输入内容!");
                }

                $r = events_log_create($data["title"], $data['content']);
                if ($r) {
                    // 【如果是HTMX的请求】
                    if ($IS_HTMX) {
                        /**
                         * @var array 用于给item_list.inc.htm提供数据
                         */
                        $events = [[
                            'id' => $r,
                            'title' => $data['title'],
                            'create_time' => time(),
                        ]];
                        /**
                         * @var bool 让message函数知道还有HTML内容即将输出,这样message不会输出自己的文本内容
                         */
                        $HOLD_ON_FOR_LATER_HTML = true;
                        // 告诉前端在我(后端)输出完成后关闭打开的弹窗
                        header('HX-Trigger-After-Swap: ' . json_encode(['closeModal' => true], JSON_FORCE_OBJECT));
                        // 使用输出缓冲记录HTML片段
                        ob_start(); ?>
                    <?php include APP_PATH . 'plugin/my_events_log/view/htm/item_list.inc.htm'; ?>
                    <?php
                        // 输出刚刚的HTML片段
                        ob_end_flush();
                        // 最后弹出吐司框
                        message(0, "添加成功!");
                    } else {
                        // 【标准请求】
                        message(0, "添加成功!");
                    }
                } else {
                    http_response_code(400);
                    message(1, "添加失败,请重试!");
                }
            } else {
                // 是在对应文件里面支持了标准请求和HTMX请求的
                include APP_PATH . 'plugin/my_events_log/view/htm/add.htm';
            }
        } else {
            // 输出误导人的错误信息,不能让用户知道实际情况,否则会定向攻破
            http_response_code(400);
            message(-1, lang('user_group_insufficient_privilege'));
        }
        break;

    case 'edit':
        // 编辑现有事件
        if ($uid && intval($gid) === 1) {
            // 确保用户已经登陆,并且是管理员,来控制权限
            $id = param('id', 0);
            $events_log = events_log_read($id);
            if ($events_log) {
                // 当有数据时才能编辑
                if (isset($_POST['submit'])) {
                    // 更新的数据
                    $data = [
                        'title' => strip_tags(param('title', '')), // 标题不需要HTML标签
                        'content' => param('content', ''),
                    ];

                    // 在前端虽然用required可以保证用户输入对应框的内容,但用户不一定会用网页提交请求,所以在后端进行检查是有必要的
                    if (empty($data['title'])) {
                        http_response_code(400);
                        message(2, "请输入标题!");
                    }

                    if (empty($data['content'])) {
                        http_response_code(400);
                        message(2, "请输入内容!");
                    }

                    $r = events_log_update($id, $data["title"], $data['content']);
                    if ($r) {
                        // 【如果是HTMX的请求】
                        if ($IS_HTMX) {
                            /**
                             * @var array 用于给item_list.inc.htm提供数据
                             */
                            $events = [[
                                'id' => $r,
                                'title' => $data['title'],
                                'create_time' => time(),
                            ]];
                            /**
                             * @var bool 让message函数知道还有HTML内容即将输出,这样message不会输出自己的文本内容
                             */
                            $HOLD_ON_FOR_LATER_HTML = true;
                            // 重新将替换目标定义为对应ID的class="content"的元素(替换方式是在前端定义的outerHTML)
                            // 因为新的事件HTML片段就是从class="content"开始的
                            header('HX-Retarget: .content[data-id="' . $id . '"]');
                            // 告诉前端在我(后端)输出完成后关闭打开的弹窗
                            header('HX-Trigger-After-Swap: ' . json_encode(['closeModal' => true], JSON_FORCE_OBJECT));
                            // 使用输出缓冲记录HTML片段
                            ob_start(); ?>
                        <?php include APP_PATH . 'plugin/my_events_log/view/htm/item_list.inc.htm'; ?>
                        <?php
                            // 输出刚刚的HTML片段
                            ob_end_flush();
                            // 最后弹出吐司框
                            message(0, "更新成功!");
                        } else {
                            message(0, "更新成功!");
                        }
                    } else {
                        // 【标准请求】
                        http_response_code(400);
                        message(1, "更新失败,请重试!");
                    }
                } else {
                    $id = param('id', 0);
                    $event = events_log_read($id);
                    // 是在对应文件里面支持了标准请求和HTMX请求的
                    include APP_PATH . 'plugin/my_events_log/view/htm/edit.htm';
                }
            } else {
                // 否则立刻提示错误
                http_response_code(400);
                message(1, '事件记录不存在');
            }
        } else {
            // 输出误导人的错误信息,不能让用户知道实际情况,否则会定向攻破
            http_response_code(400);
            message(-1, lang('user_group_insufficient_privilege'));
        }
        break;

    case 'delete':
        // 删除事件
        if ($uid && intval($gid) === 1) {
            // 确保用户已经登陆,并且是管理员,来控制权限
            $r = events_log_delete(param('id', 0));
            if ($r) {
                /**
                 * @var bool 让message函数知道还有HTML内容即将输出,这样message不会输出自己的文本内容
                 * 我们需要这一点是因为我们要输出空白内容
                 */
                $HOLD_ON_FOR_LATER_HTML = true;
                echo '';
                message(0, "删除成功!");
            } else {
                http_response_code(400);
                message(1, "删除失败,可能已经删掉了");
            }
        } else {
            // 输出误导人的错误信息,不能让用户知道实际情况,否则会定向攻破
            http_response_code(400);
            message(-1, lang('user_group_insufficient_privilege'));
        }

        break;

    case 'list':
        // 列出所有事件

        // 当前页码
        $page = param(2, 1);
        // 每页显示多少条
        $pagesize = 10;
        // 事件总数
        $tmp_events_result = events_log_find([], ['create_time' => 0], 1, 1000);
        if (is_array($tmp_events_result)) {
            $total = count($tmp_events_result);
        } else {
            $total = 0;
        }
        // 事件数据
        $events = events_log_find([], ['create_time' => 0], $page, $pagesize);
        // 分页HTML
        $pagination = pagination(url("events_log-list-{page}"), $total, $page, $pagesize);

        // 【如果是HTMX请求,并且是来自翻页器】只输出那一页的大事记HTML内容就行
        if ($IS_HTMX && $IS_IN_PAGINATION) {
            // 告诉前端更新翻页器的页码和激活的页码数字
            header("Hx-Trigger: " . json_encode(['updatePagination' => process_pagination_to_htmx_trigger($pagination)]));
            // 使用输出缓冲记录HTML片段
            ob_start(); ?>
            <?php include _include(APP_PATH . 'plugin/my_events_log/view/htm/item_list.inc.htm'); ?>
            <?php
            // 输出刚刚的HTML片段
            ob_end_flush();
            // 然后就没了
            die;
        } else {
            // 【如果用户刚进入这个页面】输出完整页面
            include APP_PATH . 'plugin/my_events_log/view/htm/list.htm';
        }
        break;
    case 'view':
        // 查看事件
        $id = param('id', 0);
        $events_log = events_log_read($id);
        // 当有数据时才能查看
        if ($events_log) {
            // 【如果是HTMX的请求】
            if ($IS_HTMX) {
                // 在HTMX里,我们直接架空了view.htm,输出这个事件的正文
                // 因为在前端,点击查看更多按钮的功能是将后端输出的内容取代按钮自身
                // 因为在前端,每个事件已经输出了标题和时间,只有内容没有输出
                echo $events_log['content'];
                die;
            } else {
                // 【标准请求】还是需要保留这个文件作为后备
                include APP_PATH . 'plugin/my_events_log/view/htm/view.htm';
            }
        } else {
            // 否则立刻提示错误
            http_response_code(400);
            message(1, '事件记录不存在');
        }
        break;
    default:
        // 当未提供任何操作时,立刻结束;我们不能惯着用户,以为“这能访问所有事件列表”
        // 因为Xiuno BBS会在论坛版块等场合默认执行类似于list的操作
        http_response_code(400);
        message(1, '未知的操作');
        break;
}
/* 
当未来的你看到这里:
我正从2025年向你问好!
这段代码或许已经过时,
但追求更好的心永远年轻。
- Tillreetree 2025.07 
*/

四、测试效果

完成所有代码的编写和配置后,我们可以通过一系列操作来测试 HTMX 插件的实际效果。

与传统的、需要整页刷新的插件相比,HTMX 版本将带来丝滑、快速、无感的交互体验

1. 访问大事记列表页

点击导航菜单上的“大事记”菜单项,服务器返回一个包含页面标题、添加事件按钮和所有大事记条目的 HTML 片段,HTMX 将其无缝地填充到页面主体容器中。

整个过程没有整页刷新

2. 添加新的大事记

点击“添加事件”按钮 -> 在弹出的模态框中填写标题和内容 -> 点击“提交”。

预期效果:

  1. 点击“提交”后,表单数据通过 hx-post 属性异步提交给服务器。
  2. 成功时:
    • 服务器处理完数据,返回一个包含最新完整列表的 HTML 片段。
    • HX-Trigger 事件被触发,添加模态框自动关闭,并弹出一个“添加成功”的吐司框。
    • 用户会看到新添加的大事记条目出现在列表顶部(由 hx-swap="afterbegin" 实现)。
    • 整个过程无刷新,一气呵成。
  3. 失败时 (如未填写标题):
    • 应该会看到类似青色的“请输入标题!”的吐司框

3. 编辑现有的大事记

点击某条大事记的“编辑”按钮 -> 在弹出的模态框中修改内容 -> 点击“提交”。

预期效果:

  1. 提交后,流程与“添加”功能完全一致。
  2. 成功时:
    • 服务器返回更新后的单个事件 HTML 片段。
    • HTMX 用新片段替换旧事件的HTML,用户会看到对应的大事记条目内容已被更新
    • 编辑模态框自动关闭,并弹出“更新成功”的提示。
  3. 失败时:
    • 应该会看到带有错误信息的吐司框,提示用户修正。

4. 删除大事记

点击某条大事记的“删除”按钮 -> 在浏览器弹出的确认框中点击“确定”。

预期效果:

  1. 点击“确定”后,HTMX (hx-delete) 向服务器发送删除请求。
  2. 成功时:
    • 服务器处理删除并返回空响应,和在头部的成功消息。
    • HTMX (hx-target="closest .card"hx-swap="outerHTML swap:1s") 会找到该条大事记的整个 .card 容器,并将其以淡出的动画效果(1秒)从页面上移除,并弹出“删除成功”的提示。
  3. 失败时:
    • 会弹出“删除失败”的提示,但卡片本身不会被移除。

5. 查看详情

点击任何大事记的“查看更多”按钮。

  • 预期效果:
    1. 点击按钮后,HTMX (hx-get) 向服务器请求该条记录的详细内容。
    2. 服务器 (case 'view') 直接输出 content 字段的 HTML 内容。
    3. HTMX (hx-swap="outerHTML") 用返回的内容直接替换“查看更多”这个按钮本身
    4. 用户会看到按钮消失,其位置被完整的文本内容所取代,实现了内容的“就地展开”。

五、注意事项

1. 安全性:

(仅仅是多提醒一句,别忘了这个哦)

仍然需要使用 param() 函数处理输入

对输出内容进行适当的转义

2. 兼容性:

确保与现有 jQuery 代码无冲突

测试不同浏览器的表现

3. 性能:

避免过度使用 HTMX 导致过多小请求

合理设置服务器端缓存策略

六、总结

通过 HTMX,我们可以大幅简化 Xiuno BBS 插件中的动态交互实现,减少 JavaScript 代码量,同时保持出色的用户体验。

现在,你可以尝试将 HTMX 应用到你的下一个 Xiuno BBS 插件项目中,体验更高效的开发流程!

上传的附件:
最新回复 (1)
全部楼主
  • Laity
    1天前 2
    0
    不错的帖子!
返回