PurePHP 与 HTMX 集成
前置:组件;本页:用服务端 fragment、路由和安全状态变更实现 HTMX 交互。
PurePHP 在服务端渲染 HTML,HTMX 把返回的 HTML 换入页面。保持边界清晰:普通路由返回 完整文档,HTMX 路由只返回目标所需的 fragment。
Fragment 与编译 Shape
hx-* 是数据无关标签树上的普通属性。把它们放进编译 Shape,再用请求数据渲染同一个 Shape。下面的示例使用注册组件,让 fragment 契约保持可见。
快速开始
1. 安装并锁定 HTMX
先安装 PurePHP:
composer require yonld/purephp本指南锁定 HTMX 2.0.11。使用 CDN 时,必须固定精确文件及其公布的 SRI:
<script
src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
integrity="sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg"
crossorigin="anonymous"
></script>使用本地依赖时,把同一版本写入 package.json 和 package-lock.json,再复制精确文件到 Web 根目录:
npm install --save-exact htmx.org@2.0.11
mkdir -p public/assets
cp node_modules/htmx.org/dist/htmx.min.js public/assets/htmx.min.js用同源 URL 加载:
<script src="/assets/htmx.min.js" defer></script>SRI 只适用于 CDN 提供的精确字节。自托管时应使用锁定版本的 npm lockfile 和项目自己的资源 校验,不要把 CDN hash 套到另一个构建产物上。下面的 PHP 前端控制器使用本地 URL。
2. 定义计数器 fragment
计数器页面包含按钮和值。值拆成独立单元,因此 POST 端点可以只返回它,而不返回周围的页面。
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use Pure\Component\Call;
use Pure\Core\Slot;
use function Pure\Component\{component, register};
use function Pure\HTML\p;
function CounterValue(): Call
{
return component(__FUNCTION__);
}
register(
CounterValue(...),
factory: static fn () => p('Current count: ', Slot::value('count'))->id('counter'),
prepare: static fn (int $count): array => ['count' => $count],
);<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/CounterValue.cmp.php';
use Pure\Component\Call;
use Pure\Core\Slot;
use function Pure\Component\{component, register};
use function Pure\HTML\{button, div};
function Counter(): Call
{
return component(__FUNCTION__);
}
register(
Counter(...),
factory: static fn () => div(
Slot::raw('counter'),
button('Increment')
->type('button')
->hx_post('/increment')
->hx_target('#counter')
->hx_swap('outerHTML')
->hx_headers(Slot::value('csrfHeaders'))
)->class('counter'),
prepare: static fn (int $count, string $csrfHeaders): array => [
'counter' => CounterValue()->count($count),
'csrfHeaders' => $csrfHeaders,
],
);hx_swap="outerHTML" 会用端点返回的新 <p> 替换 <p id="counter">。端点绝不 echo 周围的页面。
3. 添加真正的分页
下面的列表从第 1 页开始。每次响应只追加 <li>,并使用 out-of-band swap 把 Load More 控件替换成下一页的控件。这是真正的分页:下一个 URL 来自当前页面;最后一页不足整页时会移除按钮。
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use function Pure\HTML\{button, div, li, span, ul};
const DEMO_TODO_PAGE_SIZE = 2;
function demoTodoRows(): array
{
return [
['title' => 'Read the request'],
['title' => 'Render a fragment'],
['title' => 'Validate the response'],
['title' => 'Ship the change'],
['title' => 'Review the logs'],
];
}
function demoTodos(int $page): array
{
$offset = max(0, $page - 1) * DEMO_TODO_PAGE_SIZE;
return array_slice(demoTodoRows(), $offset, DEMO_TODO_PAGE_SIZE);
}
function demoHasMore(int $page): bool
{
return $page * DEMO_TODO_PAGE_SIZE < count(demoTodoRows());
}
function todoItems(array $todos): array
{
$items = [];
foreach ($todos as $todo) {
$items[] = li($todo['title'])->class('todo-item');
}
return $items;
}
function TodoList(array $todos, int $nextPage, bool $hasMore)
{
$control = $hasMore
? button('Load More')
->type('button')
->id('load-more')
->hx_get('/todos?page=' . $nextPage)
->hx_target('#todos')
->hx_swap('beforeend')
: span('All items loaded')->id('load-more');
return div(
ul(...$todoItems($todos))->id('todos'),
$control
)->class('todo-list');
}
function todoFragment(array $todos, int $nextPage, bool $hasMore): string
{
$items = implode('', array_map(
static fn (array $todo): string => li($todo['title'])->class('todo-item')->render(),
$todos
));
$control = $hasMore
? button('Load More')
->type('button')
->id('load-more')
->hx_get('/todos?page=' . $nextPage)
->hx_target('#todos')
->hx_swap('beforeend')
: span('All items loaded')->id('load-more');
return $items . $control->hx_swap_oob('true')->render();
}hx-swap-oob 元素不会插入 <ul>:HTMX 会把它从普通响应中移除,再替换现有的 #load-more 控件。<li> 仍是唯一会正常追加的 fragment 内容。
4. 定义搜索 fragment
输入框有真实的 name、可见 label 和稳定的目标。端点从 query string 读取 q,并只返回 结果列表。
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use Pure\Component\Call;
use Pure\Core\Slot;
use function Pure\Component\{component, register};
use function Pure\HTML\div;
function SearchResult(): Call
{
return component(__FUNCTION__);
}
register(
SearchResult(...),
factory: static fn () => div(Slot::value('title'))->class('search-result'),
prepare: static fn (string $title): array => ['title' => $title],
);<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/SearchResult.cmp.php';
use Pure\Component\Call;
use Pure\Core\Slot;
use function Pure\Component\{component, register};
use function Pure\HTML\div;
function ResultList(): Call
{
return component(__FUNCTION__);
}
register(
ResultList(...),
factory: static fn () => div(Slot::raw('results')),
prepare: static function (array $results): array {
$items = [];
foreach ($results as $result) {
$items[] = SearchResult()->title($result['title']);
}
return ['results' => $items];
},
);<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/ResultList.cmp.php';
use Pure\Component\Call;
use Pure\Core\Slot;
use function Pure\Component\{component, register};
use function Pure\HTML\{div, input, label};
function SearchBox(): Call
{
return component(__FUNCTION__);
}
register(
SearchBox(...),
factory: static fn () => div(
label('Search')->for('q')->class('search-label'),
input()
->type('search')
->name('q')
->id('q')
->placeholder('Search...')
->autocomplete('off')
->hx_get('/search')
->hx_trigger('keyup changed delay:500ms')
->hx_target('#results'),
div(Slot::raw('list'))->id('results')->class('search-results')
)->class('search-box'),
prepare: static fn (iterable|string|Stringable $list): array => ['list' => $list],
);SearchBox 通过 Stringable 分支接收 ResultList 组件;列表已经由库渲染成标记, 所以用 Slot::raw() 插入,而不是把它当作用户文本。
5. 把路由放在页面之前
示例假定 components/ 和 public/ 是项目根目录下的目录。创建一个前端控制器。每个处理 HTMX 请求的分支都位于普通页面渲染之前,并在写出 fragment 后立即退出。 Vary: HX-Request 可避免共享缓存把文档响应和 fragment 响应混在一起, Cache-Control: private 则避免把带 token 的页面放进共享缓存。路径来自 parse_url(),因此匹配时不会包含 query string。如果应用挂在某个路径前缀下,应在匹配 路由前显式去掉该前缀。
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../components/CounterValue.cmp.php';
require_once __DIR__ . '/../components/Counter.cmp.php';
require_once __DIR__ . '/../components/SearchResult.cmp.php';
require_once __DIR__ . '/../components/ResultList.cmp.php';
require_once __DIR__ . '/../components/SearchBox.cmp.php';
require_once __DIR__ . '/../components/TodoList.php';
use Pure\Core\HTML;
use function Pure\HTML\{body, head, h1, html, meta, script, title};
use function Pure\Utils\renderHTML;
function requestUsesHttps(): bool
{
$https = $_SERVER['HTTPS'] ?? '';
return is_string($https) && $https !== '' && strtolower($https) !== 'off';
}
function startSession(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => requestUsesHttps(),
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
function csrfToken(): string
{
if (!isset($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrfHeaders(): string
{
return json_encode(['X-CSRF-Token' => csrfToken()], JSON_THROW_ON_ERROR);
}
function hasValidCsrf(mixed $token): bool
{
$expected = $_SESSION['csrf_token'] ?? null;
return is_string($expected)
&& $expected !== ''
&& is_string($token)
&& hash_equals($expected, $token);
}
function positiveInt(mixed $value, int $default = 1): int
{
if (is_string($value) && preg_match('/^\d+$/', $value) === 1) {
$number = (int)$value;
return $number > 0 ? $number : $default;
}
return $default;
}
function countCookie(mixed $value): int
{
if (!is_scalar($value)) {
return 0;
}
$count = filter_var($value, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 0, 'max_range' => 1000000],
]);
return $count === false ? 0 : $count;
}
function demoSearch(string $query): array
{
if ($query === '') {
return [];
}
$items = [
['title' => 'Alpha component'],
['title' => 'Beta component'],
['title' => 'Gamma endpoint'],
];
return array_values(array_filter(
$items,
static fn (array $item): bool => stripos($item['title'], $query) !== false
));
}
function documentHead(string $pageTitle): HTML
{
return head(
meta()->charset('utf-8'),
meta()->name('viewport')->content('width=device-width, initial-scale=1'),
title($pageTitle),
script()->src('/assets/htmx.min.js')->defer(true)
);
}
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$parsedPath = parse_url(is_string($requestUri) ? $requestUri : '/', PHP_URL_PATH);
$path = is_string($parsedPath) && $parsedPath !== '' ? $parsedPath : '/';
// 已存在的文件(assets、图片)交还给内置服务器直接提供。
$file = realpath(__DIR__ . $path);
if (
PHP_SAPI === 'cli-server'
&& $file !== false
&& is_file($file)
&& $file !== __FILE__
&& str_starts_with($file, __DIR__ . DIRECTORY_SEPARATOR)
) {
return false;
}
$method = is_string($_SERVER['REQUEST_METHOD'] ?? null) ? $_SERVER['REQUEST_METHOD'] : 'GET';
$isHtmx = ($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true';
startSession();
header('Content-Type: text/html; charset=utf-8');
header('Vary: HX-Request');
header('Cache-Control: private');
if ($path === '/increment') {
if ($method !== 'POST' || !$isHtmx) {
http_response_code(405);
header('Allow: POST');
exit;
}
if (!hasValidCsrf($_SERVER['HTTP_X_CSRF_TOKEN'] ?? null)) {
http_response_code(403);
exit;
}
$count = countCookie($_COOKIE['demo_count'] ?? 0) + 1;
setcookie('demo_count', (string)$count, [
'expires' => time() + 3600,
'path' => '/',
'secure' => requestUsesHttps(),
'httponly' => true,
'samesite' => 'Lax',
]);
echo CounterValue()->count($count);
exit;
}
if ($path === '/search' && $method === 'GET' && $isHtmx) {
$query = is_string($_GET['q'] ?? null) ? trim($_GET['q']) : '';
echo ResultList()->results(demoSearch($query));
exit;
}
if ($path === '/todos' && $method === 'GET' && $isHtmx) {
$page = positiveInt($_GET['page'] ?? null);
echo todoFragment(demoTodos($page), $page + 1, demoHasMore($page));
exit;
}
if ($path === '/') {
$count = countCookie($_COOKIE['demo_count'] ?? 0);
echo renderHTML(
html(
documentHead('Counter'),
body(Counter()->count($count)->csrfHeaders(csrfHeaders()))
)
);
exit;
}
if ($path === '/search') {
echo renderHTML(
html(
documentHead('Search'),
body(SearchBox()->list(ResultList()->results([])))
)
);
exit;
}
if ($path === '/todos') {
$page = positiveInt($_GET['page'] ?? null);
echo renderHTML(
html(
documentHead('Todos'),
body(TodoList(demoTodos($page), $page + 1, demoHasMore($page)))
)
);
exit;
}
http_response_code(404);
echo renderHTML(
html(
documentHead('Not found'),
body(h1('Not found'))
)
);计数器和搜索端点分支只写出 CounterValue 或 ResultList;列表分支只写出列表项和 OOB 控件。只有普通页面分支和 404 分支调用 renderHTML()。
用 php -S localhost:8000 -t public public/index.php 启动示例并打开 http://localhost:8000/。其中的 cli-server 分支会把 public/ 下已存在的文件 (例如 assets/htmx.min.js)交还给内置服务器;生产环境的 Web 服务器会直接提供它们。
安全与状态边界
POST /increment是状态变更,因此同时要求HX-Request: true和绑定 session 的X-CSRF-Token。hash_equals()负责比较;只依赖 cookie 不能防御 CSRF。session_start()前设置 session cookie 的HttpOnly、SameSite=Lax,HTTPS 请求再启用Secure。本地明文 HTTP 必须关闭Secure,否则浏览器不会返回 session cookie。如果由 代理终止 TLS,应先配置可信的代理检测,再决定这个标志。demo_count明确只是非敏感演示 cookie。浏览器可以修改它,绝不能用它做授权或权威状态; 真实状态应放在服务端 session 或数据库,并为拥有它的 session 使用同样的 cookie 标志。Slot::value()会转义文本和属性值,但Slot::raw()是信任边界。只传应用自己生成的标记, 把 URL 放进href或src前先验证,并让不可信输入远离动态标签名、事件处理器和 CSS。- 不要把 CSRF token 和 session cookie 放进客户端可见存储。示例把 token 放在渲染进页面的请求 header 中,而
HttpOnly让 JavaScript 无法读取 session cookie。