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 可以帮助我们:
- 
无刷新页面更新: - 实现局部内容更新而无需整页刷新
- 如:点赞、收藏等交互操作
 
- 
表单提交优化: - 异步提交表单并更新指定区域
- 如:评论提交、设置保存
 
- 
动态内容加载: - 按需加载内容片段
- 如:无限滚动、标签页切换
 
- 
UI 交互增强: - 实现平滑过渡效果
- 如:模态框、下拉菜单
 
- 
实时功能: - 通过 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-target、hx-swap、hx-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)写入内容
- 如果你先指定了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}}
然后再输出“‘更新完成的内容’”。
- 如果只有message()的话,如:message(0, '设置完成');HX-Trigger: {"showToast":{"type":"success","title":"提示","content":"设置完成","delay":5000}}
(以下统一用HX-Trigger表示这个部分)
- 如果message的第一个参数不是数字,如message('username','用户名不存在'),会显示模态框作为强提醒:
HX-Trigger: {"showModalSimple":{"title":"警告","subtitle":"username","content":"用户名不存在"}}
- 如果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):
- 
设计一个get请求的页面。 
- 
用来显示用户没有带上必要的参数(例如URL是 post-delete.htm的get请求,应该返回message(1,'未指定帖子')
- 
用来询问用户是否继续该操作 
- 
如果用户在该页面中选择“是”,则给相同的URL发送post请求,这个请求才会真的进行删除操作,最后需要执行两条: 
- 
header('HX-Trigger: '.json_encode(['closeModal' => true]));会关闭前端还没关闭的模态框(因为我们不需要了),同时在这里也可以添加其他的event用来触发删除前端内容的event
- 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 实现:
- (尽量)无JS
- 异步加载内容
- 无刷新表单提交
- 平滑的内容更新效果
- 同时兼容非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. 添加新的大事记
点击“添加事件”按钮 -> 在弹出的模态框中填写标题和内容 -> 点击“提交”。
预期效果:
- 点击“提交”后,表单数据通过 hx-post属性异步提交给服务器。
- 成功时:
- 服务器处理完数据,返回一个包含最新完整列表的 HTML 片段。
- HX-Trigger事件被触发,添加模态框自动关闭,并弹出一个“添加成功”的吐司框。
- 用户会看到新添加的大事记条目出现在列表顶部(由 hx-swap="afterbegin"实现)。
- 整个过程无刷新,一气呵成。
 
- 失败时 (如未填写标题):
- 应该会看到类似青色的“请输入标题!”的吐司框
 
3. 编辑现有的大事记
点击某条大事记的“编辑”按钮 -> 在弹出的模态框中修改内容 -> 点击“提交”。
预期效果:
- 提交后,流程与“添加”功能完全一致。
- 成功时:
- 服务器返回更新后的单个事件 HTML 片段。
- HTMX 用新片段替换旧事件的HTML,用户会看到对应的大事记条目内容已被更新。
- 编辑模态框自动关闭,并弹出“更新成功”的提示。
 
- 失败时:
- 应该会看到带有错误信息的吐司框,提示用户修正。
 
4. 删除大事记
点击某条大事记的“删除”按钮 -> 在浏览器弹出的确认框中点击“确定”。
预期效果:
- 点击“确定”后,HTMX (hx-delete) 向服务器发送删除请求。
- 成功时:
- 服务器处理删除并返回空响应,和在头部的成功消息。
- HTMX (hx-target="closest .card"和hx-swap="outerHTML swap:1s") 会找到该条大事记的整个.card容器,并将其以淡出的动画效果(1秒)从页面上移除,并弹出“删除成功”的提示。
 
- 失败时:
- 会弹出“删除失败”的提示,但卡片本身不会被移除。
 
5. 查看详情
点击任何大事记的“查看更多”按钮。
- 预期效果:
- 点击按钮后,HTMX (hx-get) 向服务器请求该条记录的详细内容。
- 服务器 (case 'view') 直接输出content字段的 HTML 内容。
- HTMX (hx-swap="outerHTML") 用返回的内容直接替换“查看更多”这个按钮本身。
- 用户会看到按钮消失,其位置被完整的文本内容所取代,实现了内容的“就地展开”。
 
- 点击按钮后,HTMX (
五、注意事项
1. 安全性:
(仅仅是多提醒一句,别忘了这个哦)
仍然需要使用 param() 函数处理输入
对输出内容进行适当的转义
2. 兼容性:
确保与现有 jQuery 代码无冲突
测试不同浏览器的表现
3. 性能:
避免过度使用 HTMX 导致过多小请求
合理设置服务器端缓存策略
六、总结
通过 HTMX,我们可以大幅简化 Xiuno BBS 插件中的动态交互实现,减少 JavaScript 代码量,同时保持出色的用户体验。
现在,你可以尝试将 HTMX 应用到你的下一个 Xiuno BBS 插件项目中,体验更高效的开发流程!
- 子目录伪静态怎么写规则 2022-11-9
- 有没有人接仿站 2024-1-7
- 发现标签增强版有个bug 2020-8-27
- 免费域名 2022-8-25
 Xiuno BBS开源程序交流论坛
Xiuno BBS开源程序交流论坛 
         
    
 
	 
	 
	