htmx、Datastar 与 Alpine AJAX
主线位置:请求主线的两端。浏览器端换成了第三方库,topcoat 这一侧只是在 handler 里读几个请求头、在响应上加几个头或换一种响应体,路由、layout、
OriginLayer都照常走。和 05 浏览器运行时 是两条并列的路。
flowchart LR
subgraph browser["浏览器"]
hx["htmx:hx-get、hx-post"]
ds["Datastar:data-on、@post"]
al["Alpine AJAX:x-target"]
end
hx -->|"HX-Request: true"| layer["OriginLayer、layer、layout"]
ds -->|"Datastar-Request: true"| layer
al -->|"X-Alpine-Request: true"| layer
layer --> h["handler:hx_request、Signals、ajax_targets"]
h -->|"HTML 片段 + HX-* 响应头"| hx
h -->|"text/event-stream"| ds
h -->|"HTML 片段"| al
三个 crate 加起来 2.4k 行
| crate | src 行数(含测试) | 请求一侧 | 响应一侧 | feature |
|---|---|---|---|---|
topcoat-htmx | 1.0k | hx_request、hx_target、hx_trigger 等 8 个函数 | HxPushUrl、HxRetarget、HxReswap、HxResponseTrigger、HxLocation 等,都实现 IntoResponseParts | htmx |
topcoat-datastar | 1.2k | datastar_request、Signals<T> 提取器 | PatchElements、PatchSignals、ExecuteScript 是 SSE 事件;DatastarSelector、DatastarMode 等是普通响应的头 | datastar(隐含 sse) |
topcoat-alpine-ajax | 0.2k | ajax_request、ajax_targets、ajax_target | 没有 | alpine-ajax |
三个都不带浏览器端脚本。上游文档的写法是在 layout 里用 asset! 引 CDN 地址,例如 asset!("https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"):bundler 打包时下载,之后从 /_topcoat/assets/ 发出去(asset! 与 bundler)。Alpine AJAX 的文档示例直接写 CDN 的 src,没有经过 asset!。
请求一侧的函数全是同一个样子,htmx 的 hx_request:
#[track_caller]
fn header<'cx>(cx: &'cx Cx, name: &HeaderName) -> Option<&'cx str> {
request_context::<Parts>(cx)
.headers
.get(name)?
.to_str()
.ok()
}
pub fn hx_request(cx: &Cx) -> bool {
header(cx, &header::HX_REQUEST) == Some("true")
}
从 Cx 里取请求的 Parts,读一个头,和 "true" 比。Alpine AJAX 的 ajax_request 一字不差,只是头名换成 X-Alpine-Request;ajax_targets 把 X-Alpine-Target 按空白切开。这些函数可以在 page、layout、component 里随处调用,是 03 章「函数,而不是中间件」的又一个例子。
实验
依赖:topcoat =0.10.0(full)、tokio =1.54.1、serde、futures-util。一个 layout 按请求头决定给片段还是整页(照上游文档的写法),几条路由各演示一种响应:
#[layout]
async fn root(cx: &Cx, slot: Slot<'_>) -> Result<impl View> {
Ok(view! {
if hx_request(cx) || ajax_request(cx) {
(slot)
} else {
<!DOCTYPE html>
<html><body><nav>"nav"</nav><main>(slot)</main></body></html>
}
})
}
#[route(POST "./save")]
async fn save(cx: &Cx) -> Result<(HxRetarget, HxReswap, HxResponseTrigger, ViewHandle)> {
let body = view! { cx => <div>"Saved!"</div> }.single().await?;
Ok((
HxRetarget::from("#status"),
HxReswap(SwapOption::InnerHtml),
HxResponseTrigger::after_swap([HxEvent::with_data("show-toast", "Saved!")?]),
body,
))
}
#[route(POST "./ds/inc")]
async fn ds_inc(Signals(counter): Signals<Counter>) -> Result<PatchSignals> {
PatchSignals::json(&Counter { count: counter.count + 1 })
}
// ... GET 版本的 ds_inc、PatchElements、一个 Sse 流
curl 的结果(去掉了 date):
### GET /items
vary: Accept
vary: accept-encoding
<!DOCTYPE html><html><body><nav>nav</nav><main><ul id="items"><li>one</li></ul>...</main></body></html>
### GET /items -H 'HX-Request: true' -H 'HX-Target: list'
vary: Accept
vary: accept-encoding
<ul id="items"><li>one</li></ul><p>hx-target=list alpine-targets=</p>
### GET /items -H 'X-Alpine-Request: true' -H 'X-Alpine-Target: items status'
<ul id="items"><li>one</li></ul><p>hx-target=- alpine-targets=items,status</p>
### POST /save -H 'HX-Request: true'
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
vary: Accept
hx-retarget: #status
hx-reswap: innerHTML
hx-trigger-after-swap: {"show-toast":"Saved!"}
<div>Saved!</div>
响应头就是这些类型各自 IntoResponseParts 插进去的:元组里排在 body 前面的元素依次改 Parts(请求、响应与错误)。HxResponseTrigger 的 header_value 在所有事件都没有数据时写成逗号分隔的事件名,有一个带数据就整体写成 JSON 对象。
片段和整页共用一个 URL,却没有 Vary: HX-Request
/items 对同一个 URL 给出两种内容:普通导航拿到整页,htmx 请求拿到片段。两个响应的 Vary 都只有 Accept 和 accept-encoding,没有 HX-Request。topcoat-htmx 里没有任何地方写 Vary。前面有 CDN 或反向代理缓存时,缓存分不清这两种响应,可能把片段当整页发给下一个访客;浏览器自己的 HTTP 缓存也按 URL 加 Vary 区分响应,道理相同。在 layer 里补一个就行:
#[layer]
async fn vary_hx(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
let mut response = next.run(cx, body).await?;
response.headers_mut().append(header::VARY, HeaderValue::from_static("HX-Request"));
Ok(response)
}
vary: Accept
vary: HX-Request
vary: accept-encoding
Alpine AJAX 的 X-Alpine-Request 同理。
Datastar:一次补丁也是一条 SSE
### POST /ds/inc -H 'Datastar-Request: true' -d '{"count":41}'
HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-cache
transfer-encoding: chunked
event: datastar-patch-signals
data: signals {"count":42}
### GET /ds/inc?datastar=%7B%22count%22%3A1%7D
event: datastar-patch-signals
data: signals {"count":2}
### GET /ds/inc
HTTP/1.1 400 Bad Request
bad request: failed to read the `datastar` query parameter: missing field `datastar`
### POST /ds/entry
event: datastar-patch-elements
data: selector #feed
data: mode prepend
data: elements <li>A new entry</li>
Signals<T>是一个FromRequest提取器:GET从查询参数datastar里读 JSON,其他方法读请求体。它不看Datastar-Request头,不带头的POST一样能解析;只有Option<Signals<T>>(OptionalFromRequest)先看这个头,不是 Datastar 请求就给None。signals 和 topcoat 自己的 signal 一样是浏览器送来的,要当用户输入校验。PatchElements直接作为返回值时,single_event_response把它包成只有一个事件的Sse流。转成事件时 HTML 按行拆开,每行一个data: elements ...;默认模式(按 id morph)不写mode行。- 选择器里不能有换行,
selector()里有assert!,带换行直接 panic(#[track_caller]指向调用处),不会变成一个可以注入额外 SSE 字段的事件。
持续推送就返回 Sse 流。一个每 200 毫秒发一个进度的流,用一个打印每次 read 的小客户端读:
[ 0.4ms] + 161 total=161
[ 202.9ms] + 65 total=226
[ 405.2ms] + 66 total=292
[ 607.6ms] + 72 total=364
响应头立刻发出,事件逐个到达。带上 accept-encoding: gzip, br 结果一样,没有 content-encoding:压缩的判定照搬 tower-http 的默认值,text/event-stream 不压缩,否则压缩器会攒数据,事件就不再是实时的了(tower 互操作与可信代理的压缩一节)。KeepAlive::new() 默认 15 秒发一个空注释保活。
Origin 检查照常生效
这三个库发的都是同源的 fetch / XHR,浏览器带 Sec-Fetch-Site: same-origin,OriginLayer 放行。同一个 POST /ds/inc 加上 Sec-Fetch-Site: cross-site 是 403 forbidden,和普通表单一样(Origin 检查、cookie 与 session)。
和自带运行时怎么选
| topcoat 运行时(05 章) | htmx / Alpine AJAX | Datastar | |
|---|---|---|---|
| 交互写在哪 | $(...) 里的 Rust 子集,编译期检查 | HTML 属性 | HTML 属性 + Datastar 表达式 |
| 局部刷新 | shard 端点或整页重跑,返回 HTML morph | 任意路由返回片段,按 hx-target / x-target 替换 | SSE 事件补丁元素或 signal |
| 浏览器状态 | signal,重跑时随请求带回 | 基本没有,状态在 DOM 和服务端 | signal,@get 时放查询参数,其他方法放请求体 |
| 服务端要写的 | shard / procedure,各有端点 | 普通路由,按请求头分支 | 普通路由,返回 SSE |
| 成熟度 | 上游标「highly experimental」 | 第三方库自己的版本节奏,topcoat 只做头的读写 | 同左 |
两者可以混用:自带运行时的脚本只在 script() 放进 <head> 时才加载,不用它就完全不出现。选择的依据主要是团队已经熟悉什么:已有 htmx 习惯的项目,topcoat 提供的是组件、路由、流式和安全默认值,交互层照旧;想把交互逻辑也写成类型检查的 Rust,再上自带运行时。
mail!:同一套 view! 渲染邮件
mail feature 不是交互集成,放在这里顺带讲。mail! 的 html 字段接受 view! 语法,send 从应用上下文取 MailConfig、交给其中的 Transport:
pub async fn send(cx: &Cx, mail: Mail) -> Result<Receipt> {
let config: &MailConfig = app_context(cx);
config.transport.send(cx, mail).await
}
没注册 .mail(...) 就是 03 章那种 app_context panic。传输有三种:SmtpTransport(mail-smtp feature)、写 .eml 文件的 FileTransport、给测试用的 MemoryTransport,自己实现 Transport 就能接别的发信服务。用 FileTransport 发一封:
let m = mail! {
from: ("Topcoat", "welcome@example.com"),
to: "ada@example.com",
subject: "Welcome, Ada!",
html: {
<h1>"Welcome, " (name) "!"</h1>
<p>"Your account is "<strong>"ready"</strong>"."</p>
},
}?;
let receipt = send(cx, m).await?;
写出的文件(Message-ID 里的主机名换成了占位):
From: Topcoat <welcome@example.com>
To: ada@example.com
Subject: Welcome, Ada!
Message-ID: <lzqVebl268sfPVa8zcySSaQ5oYTmORsUiduN@<主机名>>
MIME-Version: 1.0
Content-Type: multipart/alternative;
boundary="mlOrGosUZMfGCHTPNpQ44ta8vIVC2NPHx7CJjluu"
--mlOrGosUZMfGCHTPNpQ44ta8vIVC2NPHx7CJjluu
Content-Type: text/plain; charset=utf-8
Welcome, Ada!
Your account is ready.
--mlOrGosUZMfGCHTPNpQ44ta8vIVC2NPHx7CJjluu
Content-Type: text/html; charset=utf-8
<h1>Welcome, Ada!</h1><p>Your account is <strong>ready</strong>.</p>
纯文本部分是从 HTML 推出来的(没有写 text 字段时的默认行为)。Message-ID 没有显式指定时在发送时生成(邮件组装用的是 lettre),实测域名部分是本机主机名;MailBuilder::message_id 可以显式指定,生产环境发信前值得确认一下。Receipt 只表示传输接受了这封信,不表示送达。
小结
- htmx、Datastar、Alpine AJAX 三个 crate 都不带脚本,只读请求头、构造响应头或 SSE 事件;脚本自己在 layout 里引,可以用
asset!打包 CDN 地址。 - 同一 URL 按
HX-Request/X-Alpine-Request给片段或整页时,响应不带对应的Vary,前面有缓存要自己在 layer 里补。 - Datastar 的
Signals<T>在GET时读查询参数datastar,其他方法读请求体;PatchElements、PatchSignals单独返回也是一条 SSE;SSE 不被压缩,事件实时到达。 - 三者发的请求都过
OriginLayer,跨站的照样 403;和自带运行时可以混用。 mail!用view!写 HTML 正文,自动推出纯文本;三种传输,Transport可自己实现。