回到未来:我为什么用 htmx、模板引擎和 Tailwind 构建现代 Web 应用
一个关于“复古工具”为何在今天依然成立的故事。
1. 一种似曾相识的感觉
最近我接了一个实时数据仪表盘。它需要用户登录、权限分级(管理员、志愿者、客户端各看各的)、一个几秒刷新一次的实时卡片网格、还有一堆密集的表单操作。听起来像是一个典型的 2026 年项目。
我的第一反应也是典型的 2026 年反应:React 加 React Router,React Query 管服务端状态,Zustand 管客户端状态,某个表单库,某个 CSS-in-JS 方案,再配一套 WebSocket 客户端。写到 npm create 的那一步,我停住了。
因为我的脑海里突然闪过 20 年前的解法。那时候页面是服务器给的,表单是真的 <form>,出错是真的 HTTP 状态码。用户提交,页面导航,就这么简单。当然,那个时代有它自己的地狱——ViewState、回传、服务器控件层层嵌套的不可预测性——所以我并不是在怀念它。我怀念的是那个心智模型:服务器懂页面,状态只有一个,HTML 是状态的投影。
然后我意识到,这十几年里我们不是”放弃了”那个模型,而是被浏览器当时的能力不足逼着离开了它。当时的浏览器只有粗笨的全页导航——XHR 虽然早就存在,但真正成熟、声明式的局部更新当时还不存在——所以我们才发明了客户端路由、客户端状态、客户端渲染,把一个完整的应用运行时塞进浏览器,只为了回避”每次交互都重新加载整个页面”的原始。
而今天,htmx 把这个漏洞补上了。
htmx 不是复古。htmx 补完了 HTML 当年没有兑现的承诺:让任何元素都能发起请求,让响应的 HTML 替换目标片段,让服务器推送局部更新,而不是整页刷新。你不需要为此引入一个笨重的框架,你只需要给普通的 HTML 加上几个属性。
这篇文章想讲清楚的,是一套设计模式:为什么不做一个 SPA,为什么 htmx + 模板引擎 + Tailwind 这套组合能打,登录登出、错误处理、实时更新和交互式组件分别怎么落地。它跟具体语言无关——我用的是 Rust 和 axum,但你换成 Django、Rails、Phoenix、ASP.NET,论证依然成立。
2. 为什么不做一个 SPA
我不想用”SPA 是坏的”来立论——它不是,它在很多场景下是正解。我想说的是:在这个应用里,SPA 的每一层抽象,我都要付两次钱。
状态的双胞胎
这是最根本的一条。在一个 SPA 里,你永远在维护两份状态:服务器上的真实状态,和浏览器里的镜像状态。用户的积分是 100 分,这 100 分存在于数据库里,也存在于某个 React 组件树的 state 里。为了让这两份保持一致,你需要 cache invalidation、query keys、乐观更新、revalidation——一整套专门的库和心智负担,去解决一个你自己制造出来的同步问题。
而在服务端渲染的应用里,状态只有一个。页面就是状态的投影,仅此而已。服务器说”这个卡片现在的状态是 X”,浏览器就把 X 显示出来。没有第二份状态需要同步,因为从来没有第二份状态。
flowchart TD
subgraph SPA["SPA — 两份状态"]
direction TD
A["服务器状态"] <-->|"同步:缓存失效 / query keys / 乐观更新"| B["客户端镜像 state"]
end
subgraph HTMX["htmx — 一份状态"]
direction TD
C["服务器状态"] -->|"渲染 HTML"| D["页面"]
end
路由的双胞胎
SPA 有两条路由系统:客户端路由(用户点链接,浏览器地址栏变化,React Router 决定渲染哪个组件),和服务端 API 路由(客户端去取数据的那套端点)。这两套路由要各自维护权限、各自的错误形态、各自的加载态。一个”只有管理员能看”的页面,你既要在前端守卫(不渲染),又要在后端守卫(不返回数据)——因为前端守卫永远可以被绕过。
服务端渲染的应用只有一套路由。权限在一个地方检查,页面在一个地方组装,链接就是真的链接,导航就是真的 HTTP 请求。
构建成本
SPA 的前端是一条独立的生产线:打包链、Tree Shaking、代码分割、类型同步(前端 TypeScript 类型要和后端 DTO 对齐)、API 版本协商。这些工作的本质是:把 HTML 从服务器搬走,再把数据搬回来。 如果你本来的目标就是”服务器有状态,浏览器显示状态”,那你搬来搬去图什么呢?
SPA 把浏览器当成一个应用运行时;htmx 把浏览器当成一个 HTML 渲染器。前者你需要一个框架,后者你只需要一个 Web 框架。
为了公平,我得说清楚什么时候 SPA 仍然是对的:离线优先的应用、复杂的客户端状态机(比如一个有几十种拖拽交互的编辑器)、富文本/协作编辑、以及那些”页面本身就是一个本地应用”的产品。这些场景里,客户端确实是一个运行时,而不是一个渲染器。但我这个仪表盘不是。
3. htmx + 模板引擎 + Tailwind:三件套为什么能搭
单独看这三个工具,每个都不起眼。它们的威力来自组合。
模板引擎:服务端的组件
模板引擎(Askama、Jinja、ERB、Tera——随你喜欢哪个)给了你编译期的类型检查和自动 HTML 转义,更重要的是:它让你在服务端组装片段。
这里有个关键洞察:在 SPA 里你写一个 <Component> 返回 JSX;在 htmx 里你写一个模板函数,返回一段 HTML。粒度完全一样,但平台换成了 HTTP。 一个”卡片组件”就是一段模板,一个 handler 调它、填好数据、返回 HTML。
htmx:请求与替换的声明化
htmx 把”发请求、换内容”这件事声明成了几个 HTML 属性。看一个最典型的例子:
<form hx-post="/sessions/42/assignments"
hx-target="#card-17"
hx-swap="outerHTML">
<input name="exercise" placeholder="exercise" required>
<button>Assign</button>
</form>
用户点下按钮,htmx 拦截提交,POST 到服务器。服务器返回一段新的卡片 HTML——就是原来那个卡片,但内容更新了。htmx 把这段 HTML 替换到 #card-17 的位置。没有 JSON,没有 client store,没有重渲染调度,没有 diff 算法。整个”更新一个卡片”的交互,就这三行属性加一个服务端模板函数。
Tailwind:模板即组件
因为样式写在模板里,模板就变成了组件。每个片段自带它的样式,你不再在 CSS 文件、JS 文件、组件文件之间跳来跳去。改一个卡片的样式,你改的是渲染那张卡片的模板。
而 utility class 的好处在这里会被无限放大——它让你几乎不用写 CSS。没有语义化类名、没有 BEM 那套命名、没有选择器层级,flex、gap-3、text-sm、font-mono 直接写在元素上。这个好处在别处只是”少敲几个字”;但到了模板的世界里,你的”组件”就是一段服务端片段,样式住在片段里、跟着片段一起被渲染和替换。一张卡片从服务器回来,样式已经齐了;改一张卡片的样式,改的就是那张卡片的模板。一个片段是自包含的—。结构、行为、样式都在同一个地方,你不需要在大脑里维护一张”这个 class 定义在哪、又在哪一层被覆盖”的地图,心智负担小得多。
更妙的是 Tailwind 的构建模型。它在构建时扫描你的模板文件,编译出一份静态 CSS。浏览器拿到的是一份带指纹缓存的 app.css。没有 runtime 的 CSS-in-JS,没有运行时注入 <style> 的成本,没有水合。样式的问题在构建期就全部解决完了,运行时干干净净。
现代 SSR:为了刷墙,把房子拆了
有人可能会说:Next.js、Remix 不也做服务器渲染吗?
它们当然做。但它们的路子是既想要框架,又想要 SSR,于是把两者缝在一起:你照样写 React/Vue 组件、维护客户端状态、跑一整套打包链,然后框架再把这些东西”水合”(hydrate)到服务器渲染出来的 HTML 上。水合本身就是一笔开销;于是又冒出 Server Components、island 划分这些机制来减少水合——但这套东西一层套一层,边界和规则越来越多,全是为了调和”客户端框架”和”服务器渲染”这两股天然的张力。
这就是我说的”为了刷一面墙,把房子拆了”。你想让首屏快一点、SEO 好一点,结果引入了一整个 hydration 机制,去救那个本可以不存在的客户端运行时。htmx 走的是另一条路:根本不要客户端框架,于是”水合”这件事压根不存在——HTML 从服务器来,就是它最终的样子,没有第二次初始化,没有两份状态的交接。
(Astro 是这一族里最清醒的:默认零 JS、按需挂载 island。但它仍然要求你拥抱组件框架和构建链;而 htmx 的答案更彻底——连那个组件框架都不要。)
复古的不是工具,是心智模型
这套东西写起来确实有 aspx 和 php 的既视感。因为当年 ASPX 和 PHP 那一代人不是笨,他们是对的:服务器负责状态,HTML 负责表达。 他们只是被工具拖累了:没有类型安全的模板,没有声明式的局部更新,没有一个真正可用的 CSS 系统,所以只好手写 Response.Write 和 echo,然后在全页回传的泥潭里挣扎。
今天我们用同样的心智模型,但配上了 2026 年该有的工具:类型安全的模板、属性驱动的局部更新、构建期编译的 CSS。复古的不是工具,是”服务器懂页面”这个想法——而它从来没错过。
这些想法不是我发明的——Carson Gross 的《Hypermedia Systems》和 htmx 官网的 essays 专栏,早就把”超媒体作为应用状态的引擎”(HATEOAS)这件事讲得比我透彻得多。我在这里做的,只是把这些原则落进一个真实项目,然后诚实记录哪些地方顺利、哪些地方得补 JavaScript。文末附了深入阅读的链接。
4. 登录与登出:当表单变回真的表单
登录是”传统 Web”和 SPA 分歧最大的地方,也是最能说明”什么时候用 htmx、什么时候不用”的地方。
一个反直觉的决定:登录表单故意不用 htmx
我的登录表单就是一个朴素的 <form method="post">,没有 hx-* 属性。
<form method="post" action="/login" id="login-form">
<label>Username
<input name="username" autocomplete="username" required>
</label>
<label>Password
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button type="submit">Log in</button>
</form>
为什么?因为登录成功之后的动作——写入会话、跳转到仪表盘——天然就是一次完整的页面导航,而不是一次局部片段替换。登录不是”把这段 HTML 换成那段 HTML”的操作,它是”把我从匿名状态切换成已登录状态”的身份转换;转换完成之后,整页都该重新渲染,而不是 swap 一个小片段。
更实际的理由来自浏览器的行为。一次真正的表单提交会让密码管理器正确地对 autocomplete="current-password" 自动填充,会让浏览器认得”这是一个登录表单”。而且——这一点对登录至关重要——一个普通的 <form> 在没有 JavaScript、或者 htmx 还没加载完成时,依然能工作。登录是用户进入你系统的第一道门,你不希望这道门依赖一个刚加载的 JS 库才能打开。
所以登录表单走真导航。htmx 不是”所有地方都要 htmx”,而是”在该用的地方用”。 登录这个场景,传统表单是更对的工具。
同一个端点,两种响应形态
但问题来了:同一个 /login 端点,可能被浏览器导航调用,也可能被 htmx 调用(如果你在别处用 htmx 触发了它)。服务器怎么区分?
答案是 HX-Request 这个请求头。htmx 会在它发出的每个请求里带上 HX-Request: true,服务器据此分支:
def login_redirect(headers, dest, cookie):
if is_htmx(headers):
# htmx 调用:200 + HX-Redirect,让浏览器做真导航
return Response(200, {"HX-Redirect": dest, "Set-Cookie": cookie})
else:
# 普通浏览器导航:经典 303
return Response(303, {"Location": dest, "Set-Cookie": cookie})
两边最终都导向同一次真导航,区别只是触发方式不同。这个分支模式——根据 HX-Request 决定响应形态——会贯穿整个错误处理章节,是这套架构的基石之一。
权限分级:用提取器,而不是中间件
登录成功之后,就是权限。这个应用有三种角色,看到完全不同的页面。我的做法是把”当前是谁、什么角色”做成一个请求提取器(extractor),路由声明自己需要哪种身份:
class AdminPageAuth:
# 从请求里解析出"管理员身份",用于完整页面路由
def from_request(state, request):
match AdminAuth.from_request(state, request):
case Ok(auth):
return Ok(auth)
case Err(Unauthorized(_)):
# 未登录访问页面:303 到 /login,而不是一个 JSON 401
return Err(Response(303, {"Location": "/login"}))
case Err(e):
return Err(e.into_response())
同一个”未授权”,有两种响应形态:浏览器访问页面时,它需要被导航到登录表单;而 API 客户端调用时,它需要的是一个 401 加 JSON。原因很简单——浏览器需要的是页面,客户端需要的是结构化数据。提取器在这里做了正确的事:完整页面路由的未授权变成 303,API 路由的未授权变成 401 JSON,两者都源自同一个 Unauthorized 错误。
角色不对又是另一回事。一个志愿者访问管理员页面,正确的响应不是”请登录”,而是 403——你已经登录了,只是没权限。这个区分(401 未登录 vs 403 无权限)在很多系统里被糊在一起,在这里被明确地分开了。
登出:就这么多
登出是一个 POST,服务器端吊销 token、清除 cookie,然后 303 回登录页。
def logout(state, request):
if session_cookie(request):
revoke_token(session_cookie(request))
return Response(303, {"Location": "/login", "Set-Cookie": clear_session_cookie()})
就这么多。没有 localStorage.removeItem('jwt'),没有 Redux reset,没有”清除所有 client-side 缓存”的仪式。因为状态本来就在服务器上,登出就是删掉服务器上的那条会话记录,然后清掉浏览器里的那个 cookie。
几个自然浮现的安全细节
这些细节是”传统表单世界”里非常自然的做法,却在 SPA + JWT 的世界里反而会变得别扭:
- HttpOnly cookie:会话 token 放在 HttpOnly cookie 里,JavaScript 根本读不到,XSS 也偷不走。
- 密码哈希在后台线程做:argon2 这种哈希要跑约半秒,绝不能阻塞事件循环,丢进一个后台任务里。
- 速率限制按 (用户名, IP) 维度:防暴力破解,限制器在哈希之前先拦一道。
- 人机验证用显式渲染:因为登录是纯表单(真导航),所以 CAPTCHA 组件要每次 fresh render、token 单次消费——这正好配合”每次失败都重新渲染登录页”的流程。
这些东西没有任何一个是 htmx 的专属功能,但它们都因为回到了真表单的模型而变得顺理成章。
CSRF:旧威胁,旧解法
用 session cookie 做鉴权,就必须重新面对一个老朋友:CSRF。攻击者诱导用户的浏览器,在不知情的情况下向你的站点发出一个携带 session cookie 的状态变更请求——因为你用的是 cookie,浏览器会自动把它带上。
SPA + JWT 的世界里,这个问题基本被绕开了:token 放在 localStorage 里,由 JavaScript 手动塞进请求头,浏览器不会自动发送。但请注意,这只是把风险挪了地方——XSS 现在能直接偷走那个 token。而 cookie + HttpOnly 的路线里,token 是 JavaScript 读不到的,XSS 偷不走它——当然,XSS 仍能借受害者的已登录会话代为操作,只是拿不走凭证本身。代价是你得正面处理 CSRF。
好消息是,CSRF 的解法又老又成熟,而且因为表单是服务器渲染的,落地起来格外自然:
- SameSite 属性:给 cookie 标上
SameSite=Lax(现代浏览器的默认值)或Strict,浏览器就不会在跨站请求里带上它——这已经挡住了绝大多数 CSRF。 - CSRF token:一个随会话生成的密钥,服务器渲染表单时把它写进隐藏字段,提交时校验。因为是服务器渲染,塞进这个字段就是一行模板变量的事,不需要任何客户端逻辑。
所以这又是一个”回到传统表单世界反而更省事”的地方:威胁是旧的,解法也是旧的,而且两者都已经被验证了几十年。
5. 错误处理:htmx 最深的坑,也是最有价值的契约
如果你只想从这篇文章里带走一件事,那就是这一节。因为 htmx 的错误处理藏着一个非常反直觉的坑——它会让你的应用在某些时刻静默地失败,而用户看到的只是一个什么都没发生的按钮。你一旦理解了这个问题,也就同时理解了这套架构里最优雅的部分:错误不再是一个散落在前端各处的杂务,而是一份可以放在一个函数里、被所有端点共享的契约。
那个坑:htmx 只 swap 成功响应
先想象一个最普通的场景。页面上有一张卡片,上面是一个表单,用户点下”确认”按钮,期待卡片被更新。在 SPA 里,你的 fetch 会拿到一个 Promise,不管是成功还是失败,你都能在 .catch 里做点什么。但在 htmx 里,流程是这样的:
- htmx 拦截表单提交,发一个 XHR 请求到服务器。
- 服务器返回响应。
- htmx 拿响应的 body 去替换你指定的目标元素。
关键在第三步。htmx 的默认行为是:只有 2xx 的响应才会被 swap 进页面。如果你的端点返回了一个 400 或者 500,htmx 会触发一个 htmx:responseError 事件,然后……什么都不做。原来的 DOM 原封不动,你的错误信息躺在响应的 body 里,只有在浏览器的 devtools 网络面板里才能看到。
对于一个”用户点了按钮、然后盯着屏幕等反馈”的表单,这是灾难性的。没有红色提示,没有 toast,甚至没有控制台报错。用户会以为按钮坏了,再点一次,又点一次——每次服务器都在正确地拒绝这个请求,但每次拒绝都消失在空气里。
问题的根源在于:htmx 把”错误”默认当成了一件不该呈现给用户的事。这个默认值适合那种”后台轮询失败了就安静地重试”的场景,却完全不适合”用户在表单里填错了一个字段”的场景。
那个解法:双形态响应
所以我给自己定了一条规则,覆盖所有的 htmx 片段端点:
对 htmx 调用方,永远返回 200,把结果——无论成功还是失败——放进响应体里;对其他人,返回真实的状态码。
拆开来看,这条规则有两个分支。
成功的时候,返回 200,响应体就是要替换的那段新 HTML,再附带一个 HX-Trigger 头,通知浏览器弹一个成功的 toast。用户看到卡片变了,右上角还闪过一句”已保存”。
失败的时候,仍然返回 200,但响应体里带一段”出错了”的提示,同时用一个 HX-Reswap: none 头告诉 htmx 不要动目标元素。这样一来,卡片不会被清空,用户填的内容还在,而错误信息通过一个 out-of-band(OOB)的补丁单独写到页面的 toast 容器里。
而非 htmx 的调用方——没有 JavaScript 的浏览器、直接调 API 的客户端、跑测试的代码——它们根本不该知道 toast 这种东西存在。它们要的就是一个正经的 HTTP 状态码和一个结构化的 JSON 错误体。
整个机制的核心,是把”错误怎么呈现”这个决策收敛到一个函数里:
def form_err(headers, error):
if is_htmx(headers):
# htmx 片段请求:永远 200,保留原 DOM,错误走 toast
return Response(
status=200,
headers={
"HX-Trigger": toast_trigger(error.message, "error"),
"HX-Reswap": "none",
},
body=toast_oob(error.message, "error"), # 一段 OOB 的 #toast HTML
)
else:
# 普通调用方:真实状态码 + JSON 错误体
return error.into_response()
失败分支返回的 200 是故意的,而不是偷懒。正是因为 htmx 只 swap 2xx,我们才需要把”失败”伪装成一个”成功拿到的响应”,让 htmx 愿意把它交给页面去处理。真正的失败信息通过 HX-Trigger 这个头,而不是状态码来传递。
一个可能的反驳:200 违反 HTTP 语义吗?
看到”永远返回 200”这条规则,一定会有人皱眉:这不是在说谎吗?错误就是错误,凭什么用 200 包装?
这其实是把两件不同的事混在了一起。关键要分清:这个请求要的”资源”是什么。
对一个 htmx 片段请求来说,客户端要的不是”这次操作成功与否”这个事实,而是一段能放进那个 div 里的 HTML。服务器确实成功地生产出了这段 HTML——只不过这段 HTML 渲染出来的是一句错误提示。从这个角度看,200 是完全诚实的:请求成功了,响应体就是”渲染好的错误 DOM”。真正的业务错误(”字段填错了”这类)则通过 HX-Trigger(toast 消息)在带内传递。
而真正需要状态码语义的地方——API 客户端、没有 JS 的浏览器、测试、监控——我们没有破坏它们:这些调用方走的是另一条分支,拿到的是真实的 400/401/422 加 JSON。所以 HTTP 语义在它该生效的地方一分不少,只在”客户端明确要 HTML 片段”这一种情况里做了让步。
我得把话说准确:这不是在声称 HTTP 语义作废——404、409、422 这些状态码本来就是为业务结果准备的,它们也仍在错误枚举里照常用着。我们只是对”客户端要一段 HTML 片段”这一种情况做了一次务实的让步:htmx 只 swap 2xx(除非你调整 htmx.config.responseHandling),所以把”业务失败了”放进响应体、而不是状态码。另一条同样合理的路是监听 htmx:responseError、把真实的 4xx 响应体也 swap 进去;我们只是挑了更直白的这一条。
一个意想不到的细节:Latin-1 头
HX-Trigger 这个头看着简单,实际用起来有一个坑会咬你一口——而且它只在非英文内容上才会暴露。
浏览器底层用 XHR 来发请求,而 XHR 拿到的响应头会被按 Latin-1(ISO-8859-1) 解码。这意味着如果你在 HX-Trigger 里塞了一段 UTF-8 文本——比如一句中文提示,或者一个 en-dash 字符——它到达浏览器的时候会变成乱码。一个 Zone 60–120 bpm 会变成 Zone 60â120 bpm。
我一开始完全没意识到这件事,直到某条包含非 ASCII 字符的 toast 在浏览器里显示成乱码。调试了好一会儿才定位到:不是我的服务器编码错了,是响应头这条通道本身就只能安全地承载 ASCII。
解法很直接:把 HX-Trigger 里的 JSON 序列化之后,把所有非 ASCII 字符转义成 \uXXXX 形式。结构化的 JSON 字符(引号、冒号、花括号)本来就是 ASCII,所以这个转换是安全的;而客户端用 JSON.parse 解析的时候,会自动把 \uXXXX 还原成真正的字符。乱码消失了,服务器发出去的东西和客户端解析出来的东西分毫不差。
# 服务器端:任何非 ASCII 字符都转成 \uXXXX
def toast_trigger(message, kind):
payload = json.dumps({"toast": {"msg": message, "kind": kind}})
return ascii_safe(payload) # "Zone 60\u2013120 bpm \u00b7 done"
# 客户端:JSON.parse 自动还原
# "Zone 60–120 bpm · done"
这个细节代表的,其实是这类架构里一整个类别的经验:你不再和框架的抽象层搏斗,而是和 HTTP 本身的真实约束搏斗。这些约束是真实的、稳定的、可测试的,而一旦你理解了它们,你的代码就再也不会有这一类 bug。
一份单一的来源:错误枚举
当错误处理被收敛到 form_err 一个地方之后,你自然会产生下一个需求:让”错误”这个概念本身也有一个单一的来源。
在 SPA 里,错误往往是散装的——前端有前端的错误常量,后端有后端的异常类,中间靠 HTTP 状态码这一层薄薄地对接。而在服务端渲染的架构里,你可以让一个枚举(或者一个异常层级)同时决定三件事:
- 错误码(
bad_request、unauthorized、not_found、conflict……) - HTTP 状态码(400、401、404、409……)
- 给用户看的人类可读信息
下面这个简化的枚举就是这种思路的写照:
enum AppError:
case BadRequest(message)
case Unauthorized(message)
case Forbidden(message)
case NotFound(message)
case RateLimited(message)
case Internal(message)
def code(self):
# "bad_request", "unauthorized", ...
def status(self):
# 400, 401, 403, 404, 429, 500
def message(self):
# 人类可读的信息
于是整条链路就通了:一个 handler 里任何地方 raise NotFound("no such session"),它就会自动变成——对 htmx 一个红色 toast、对 API 一个 404 加 {"type": "error", "code": "not_found", "message": "no such session"}、对无 JS 的浏览器一个重渲染的 404 页面。三种出口,一个定义。
内部错误和用户错误必须被区别对待。Internal 这类错误里可能藏着数据库的细节、栈信息、内部路径——这些东西永远不该出现在响应体里。正确的做法是在响应边界处把它们拦住:记录日志(让 Internal 的细节进日志),然后只给客户端一句笼统的 "internal error"。
def into_response(self):
status = self.status()
if self is Internal(detail):
log.error(detail) # 细节只进日志
message = "internal error" # 客户端只看得到这句
else:
message = self.message()
return Response(status, json({"type": "error", "code": self.code(), "message": message}))
这一行 log.error(detail) 放在响应边界,还有一个附带的好处:即使某个 handler 忘了在出错时打日志,只要它返回了 Internal,日志里就一定会有一条记录。你把”内部错误一定要被记录”这件纪律性的事,从”每个开发者都要记得”变成了”架构自动保证”。
为什么这套东西在这里成立
回头看你可能会问:这些东西 SPA 里不是也能做吗?fetch 的 .catch 里弹个 toast 不就行了?
当然能。区别不在于能不能做,而在于做多少次。
在 SPA 里,错误处理是每个调用点各自的事。你有一个全局的拦截器,但每个组件仍然要决定自己失败时的样子——loading 态、error 态、重试按钮、边界组件。错误是前端应用里最容易悄悄漏掉的一块。
而在 htmx 架构里,错误的呈现被推回了服务器。服务器本来就知道发生了什么错、错到什么程度、该给用户看什么。而 htmx 的响应契约让你能把这个知识一次性地编码进 form_err 和 AppError 两个地方,然后每一个端点——不管是编辑卡片、批量分配、还是删除记录——都自动继承同一套行为。
这其实触及了这套架构最根本的哲学:把关于”状态”和”错误”的决策留在服务器端,让 HTML 只负责表达结果。 错误处理之所以重要,是因为它是这个哲学第一次变得具体、变得可触摸的地方。
6. 实时更新:SSE 而不是 WebSocket
仪表盘里最难的一环是实时性。卡片要几秒刷新一次,反映现场设备上传的数据。SPA 的标准答案是 WebSocket + 一个客户端渲染层。而在这里,答案简单得让人怀疑自己是不是漏了什么。
<body hx-ext="sse">
<div id="grid" sse-connect="/stream" sse-swap="snapshot">
</div>
</body>
htmx 的 SSE 扩展维护一条到 /stream 的服务端推送连接。sse-swap="snapshot" 声明的是:服务器会推送一个名为 snapshot 的事件,事件的数据体就是要替换进 #grid 的 HTML。所以服务器这一侧推送的不是 JSON 数据,而是已经渲染好的卡片 HTML:
event: snapshot
data: <div id="grid">...完整的卡片 HTML...</div>
客户端拿到就 swap。当实时数据只是”服务器状态的投影”时,渲染这件事应该发生在拥有状态的那一侧——服务器——而不是在一个需要先重建状态的浏览器里再做一遍。这样你就少了一层客户端渲染:不再有”收到 JSON → 更新 store → 触发重渲染”这条链路,只有”收到 HTML → 替换 DOM”。
代价在服务器这一侧:你得管理这条长连接的生命周期——谁在听、连接断了要不要清掉、数据一变怎么广播给所有监听的客户端。这听起来吓人,但本质就是一个”订阅者列表 + 一个广播循环”,是每个后端框架都能做的老把戏,几十行就够(生产上当然还要处理慢消费者、断线重连的雪崩这些边角,但那是另一个话题)。而且它只做单向——数据从服务器流向浏览器,任何客户端主动发起的命令(比如编辑一张卡片)仍然走普通的 HTTP POST,实时通道不承担”往回传指令”的职责。
为什么是 SSE 而不是 WebSocket
因为实时更新在这个应用里是单向的——数据从服务器流向浏览器,浏览器几乎不往回发指令。SSE 就是为这个形状设计的:它是 HTTP 之上的一条长连接,EventSource 会自动重连,连接天然携带同源 cookie,所以它能无缝地复用你现有的 HTTP 鉴权——一个已经登录的用户,它的会话 cookie 会自动跟着 SSE 连接走,不需要额外的认证步骤。
WebSocket 的代价在于,它把”双向”这个你根本用不上的能力,作为你必须支付的成本塞了回来:断了不会自动重连,你要自己实现重试和心跳;它虽然同样是从 HTTP 握手升级而来(所以也能带 cookie),但它更像一条独立的双向管道,和”请求-响应-推送”这套 HTTP 生命周期是两张皮。当你只需要一条单向的推送流时,WebSocket 是杀鸡用牛刀。
诚实一点:DOM 也是状态
我在第二节说”状态只有一个”。这句话被理想化了。
因为 DOM 本身就是第二份状态。用户正在一张卡片里填表单、展开了一个下拉框、焦点停在某个输入框里——这些都不是服务器知道的事,但它们是真真切切、用户正在持有的状态。这时候服务器推来一个 snapshot,把整个 #grid 换掉,用户的输入、展开的菜单、焦点,全都没了。比一张”过期的卡片”糟糕得多。
所以并发写入、多标签页、后退按钮这些”隐性的双状态”问题,并没有在服务端渲染架构里消失——它们只是换了形态。诚实一点的说法是:状态同步这件事,从”框架帮你管”变成了”你自己用 HTTP 原语管”。SSE 事件、hx-swap、OOB 补丁,就是你的原语;而”什么时候该让推送让步于用户正在做的事”,是你的责任,框架不会替你判断。
我们是这样管的:swap 的时候,跳过用户正在编辑的卡片。
# 一张卡片"正在被编辑",如果满足任一条件:
def card_is_editing(card):
focus = document.active_element
if focus 落在 card 内,且 focus 是输入框(input / textarea / select):
return true
if card 内有一个打开的编辑表单:
return true
return false
于是每条 card 事件到达时,先检查目标卡片是否在编辑——是,就 preventDefault(),跳过这次 swap,让那一秒的更新作废。而整网的 snapshot 则更谨慎:只要有任何一张卡片在编辑,就把这次 snapshot 暂存起来,等编辑结束再应用——代价是编辑期间整张网格都跟着停在旧状态。广播是一秒一拍,所以在编辑的卡片不会永远停在旧状态:编辑一结束,下一拍就带来一张新鲜的。
你确实要为此写一小段 JavaScript,专门在”实时推送”和”用户在做的交互”之间做仲裁。它不优雅,但它诚实——它承认了 DOM 是状态,然后显式地决定这两份状态谁在什么时候让谁。这正是这套架构真正的样子:没有魔法,只有把 HTTP 原语一件一件摆清楚。
7. 岛屿:当一块页面真的需要”活着”
写到这里,一个读者会问:这一切都很好,但我要一个真正的交互式组件怎么办——带标签切换、能实时刷新的图表,那种点一下 tab 就换一组数据、鼠标划过能看 tooltip 的东西?htmx 的局部替换能处理表单和列表,但处理不了”浏览器里有一个需要自己管理状态的 widget”。
答案是承认它,然后给它划出一块小小的、自包含的”岛屿”(island)。这不是对前面论证的背叛,而是把那条界线画清楚。
大多数页面是服务器渲染的,少数不是
在我的仪表盘里,绝大多数页面遵循同一套规则:服务器渲染 HTML,htmx 负责局部更新。但有一处例外——参与者的历史详情页,里面有一张图表。它有”本次会话 / 跨会话”两个 tab、一个实时跳动的帧计数、一条汇总行,还有一张 Chart.js 画的折线图。
这个 widget 需要一种服务器渲染很难提供的东西:客户端状态。用户点 tab,图表切换数据源,但服务器并不需要知道”当前显示哪个 tab”——这是纯呈现层的状态,和服务器无关。强行用 htmx 去做,每切一次 tab 都要服务器往返一次,为了一个本地 UI 状态去打断用户体验,得不偿失。
所以这张图表是一座岛屿:页面上一个自包含、带自己运行时的小块,周围的一切仍然是服务器渲染的。
flowchart TD
Page["详情页(服务器渲染)"]
Page --> Header["头部与静态字段"]
Page --> Panel["#detail-panel 表单区<br/>(htmx 局部替换)"]
Page --> Island["历史图表 island<br/>(petite-vue + Chart.js)"]
<div
v-scope="HistoryChart({ pid: 42, cutoff: 0.6 })"
@vue:mounted="start"
@vue:unmounted="stop"
>
<button @click="setTab('session')" :disabled="tab === 'session'">This session</button>
<button @click="setTab('all')" :disabled="tab === 'all'">Across sessions</button>
<span v-if="headerCode"> · frames</span>
<canvas></canvas>
</div>
这里我用的是 petite-vue——一个 6KB、无构建步骤的迷你 Vue,专为”给一小块 HTML 加上反应性”而生。它不是第二套完整框架,只是一个让岛屿内部能响应用户输入的小工具。
岛屿仍然尊重核心原则
岛屿里可以有客户端状态,但数据仍然来自服务器。这张图表初始化时用同源 fetch 拉历史记录,带的是现有的会话 cookie——鉴权没有任何额外复杂度。而它的实时性走的是上一节讲的那条 EventSource(SSE)——同一条流上承载着多个命名事件:网格收到 snapshot 去换 HTML,岛屿收到 card 就重新拉取自己的数据。数据仍然只有一个来源,岛屿只是那个来源的一个活跃视图。
这是一条分界线:岛屿持有的是短暂的、呈现层的状态——哪个 tab 被选中、图表当前画到哪一帧。服务器的状态——记录本身、质量阈值、哪些帧有效——仍然是唯一的事实来源,仍然由服务器拥有。你引进了一个小小的运行时,但没有引进第二份需要同步的真相。
岛屿必须住在 swap 之外
岛屿还有一个容易踩的纪律:它不能待在 htmx 的 swap 目标里。
原因在挂载时机。petite-vue 的 createApp().mount() 只在页面加载时执行一次,它编译的是那一刻 DOM 里已经存在的 [v-scope] 元素。而 htmx 是之后才把新 HTML swap 进页面的——如果这段 swap 进来的 HTML 里含有一个 v-scope,petite-vue 根本不知道它的存在,它只是一堆不会被响应式化的死标签。想让它活过来,你就得在每个 swap 之后手动重新挂载,同时还得自己处理上一次挂载的销毁、事件监听和 EventSource 的释放。这条路一旦走进去,你就把两套运行时缝合在了一起,复杂度会报复性地涨回来。
所以实际操作里,swap 目标和岛屿在 DOM 里是并列的兄弟,而不是嵌套的父子。回到那张详情页:上面是一个 #detail-panel 的 swap 目标,里面是纯表单——设置区间、分配动作,这些走 htmx 局部替换;下面是一张图表的 island,它不靠 htmx 换,而是靠自己的同源 fetch 加 EventSource 刷新自己。两条更新通道各管各的,互不嵌套。
这条纪律和上一节是同一件事的两个面:岛屿的数据不来自 htmx 的响应体,而来自它自己拉的那条线。正因为这样,它才能安心地待在 swap 之外,不需要被任何局部替换连根拔起。
一个熟悉的代价
岛屿不是免费的。你重新引入了客户端状态、一个手写的 JS 文件、以及第二个运行时(petite-vue)。”SPA 的成本”回来了,只是被严格限制在了一个几十行的小方块里,而不是弥漫到整个应用。
还有那种只有踩过才会知道的坑。petite-vue 会把作用域里的每个属性深度包装成 Proxy 来做反应性,这通常很好;但如果你把 Chart.js 的实例或 EventSource 放进响应式作用域,它们的内部槽位会被 Proxy 破坏,方法调用直接报错。解法是把这些实例放在闭包局部变量里,让它们对反应性系统不可见。这和前面那个 Latin-1 响应头是同一类经验:你搏斗的对象从”框架的抽象”变成了”某个具体机制的边界”,而后者一旦理解,就再也不会出错。
岛屿架构,而不是”全有或全无”
如果你熟悉 Astro,你会在”岛屿架构”这个词里认出这个模式。区别只是:Astro 把它做成了框架的一等公民,而这里是手搓的、更小的一撮。两者背后的判断是同一个——决定哪些东西需要客户端运行时,是一个一个组件做的,而不是整个应用一起做的。
最重要的判断,其实是这个:htmx 的价值从来不在”拒绝 JavaScript”,而在于”把 JavaScript 的使用推迟到真正需要它的地方”。绝大多数交互——一个表单、一次列表刷新、一个错误提示——用 HTML 和 HTTP 就够;只有当你遇到一个真正需要自己管理状态的 widget 时,才划出一座岛屿。默认服务器渲染,按需引入运行时,而不是反过来。
8. 什么时候这个模式是对的
我不想把它写成一封 htmx 的劝退信或安利信。它是工具,工具要放在对的场景里。这张清单是我的判断:
这个模式适合你,当:
- 应用本质上是”服务器状态的多个视图”——仪表盘、后台管理、内容展示。
- 表单密集、CRUD 主导。
- 实时性以推送为主,不在客户端做复杂交互。
- 团队后端强于前端,或者你根本不想维护两套代码和两条构建链。
- 你希望”登出就真的登出了”,”状态就真的只有一份”。
这个模式不适合你,当:
- 需要离线优先。
- 客户端有复杂的本地状态机(拖拽、富文本编辑、协作编辑)。
- 页面本身就是一个本地应用,而不是服务器状态的投影。
在这些场景里,浏览器确实是一个运行时,SPA 就是正解。
结语:那个心智模型一直是对的
ASPX 和 PHP 那一代人不是笨,只是合适的工具还没有发展出来。他们的直觉——服务器负责状态,HTML 负责表达——从头到尾都没错,错的是他们手里的工具:没有类型安全的模板,没有声明式的局部更新,没有真正可用的 CSS 系统,没有一条干净的实时推送通道。
htmx 补上了局部更新,模板引擎补上了类型安全,Tailwind 补上了样式,SSE 补上了实时。当这些工具到齐了,那个旧的心智模型突然又变得现代了——不是因为世界倒退回了 2005 年,而是因为”服务器懂页面”这个想法,一直在那里等着被重新兑现。
如果你也曾经在 node_modules 的深处、在两条路由表之间、在缓存失效的泥潭里感到过疲惫,也许值得回头看一眼。你会发现,你并不是在往后退,而是在找回一条本来就对的路。
进一步阅读
- 《Hypermedia Systems》——Carson Gross、Adam Stepinski、Denis Pashevsky 著,免费在线阅读:hypermedia.systems。这本书是上面所有想法的系统论述,比这篇文章完整得多。
- htmx 官网的 essays 专栏(htmx.org/essays)——”超媒体作为应用状态的引擎”(HATEOAS)、什么时候该用超媒体、什么时候该用 SPA,那里有更严谨的讨论。
- htmx 官方文档(htmx.org/docs)——本文提到的
hx-swap、HX-Trigger、SSE 扩展等所有属性的权威参考。