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 插件项目中,体验更高效的开发流程!
- xiunobbs论坛的表情包在哪里下载呀 2023-9-18
- 求一个最新最好用的TinyMCE编辑器,要功能强大齐全的,其他编辑器也可 2020-8-30
- Windows server 2008 搭建DNS服务 2020-8-17
- 如何赚积分 11月前