一个待办请求,两种架构:htmx从CRUD框架中移除了什么
追踪一个添加项请求在 React SPA 和 htmx 页面中的处理过程,查看哪些环节会消失、服务器会接管什么任务,以及 htmx 在何处不再适用。
来源: Stop Over-Engineering CRUD Apps: Why HTMX Might Be All You Need
大多数内部工具和管理面板都执行相同的四项操作:创建、读取、更新和删除记录。然而,构建这类工具的默认方式却演变成了完整的单页应用,需要组件框架、状态管理器、数据获取库、JSON接口,以及负责将JSON数据转换回HTML的客户端代码。在这个过程中,原本仅用于插入一条数据库记录的表单最终却依赖于六七个不同的包和两种构建工具。
本文通过 React 实现和 htmx 实现,围绕一个具体功能——向待办列表添加项目——进行对比展示。通过并列查看请求生命周期,可以轻松判断自己的 CRUD 应用真正需要哪些功能层,以及何时仍需使用更复杂的客户端框架。如需进一步了解超媒体与 SPA 默认模式的对比,可参阅htmx vs the SPA default。
大多数 CRUD 应用实际需要的功能
将一个典型的商业应用简化为用户实际执行的操作:登录、创建记录、编辑记录以及删除记录。这类应用通常没有离线模式,也没有需要实时协作的功能,更不存在必须立即处理而无需往返服务器的快速交互。上述任何情况都不足以成为使用虚拟 DOM、在客户端进行路由处理,或为重建一个按钮而等待近一分钟的工具链的依据。
人们往往是在尚未明确应用功能需求之前,就出于习惯选择了框架。而先从需求出发通常会带来更精简的工具集。
htmx 如何改变与服务器的交互方式
htmx 允许普通的 HTML 元素通过属性发送 HTTP 请求。服务器会以 HTML 片段而非 JSON 的形式回复,htmx 会将该片段插入到用户指定的页面位置。
其结果是无需管理客户端的数据副本。服务器已经知道列表应有的样子,因此会直接渲染出完整的标记并发送过来。浏览器只需将其放置到正确的位置即可。
两种风格中的相同功能
React版本
这是一个用于添加待办事项的常规React组件。它将输入文本和列表保存在状态中,向API发送JSON数据,解析响应结果,然后将新项添加到列表中并清空输入框。
function AddTodo() {
const [text, setText] = useState("")
const [todos, setTodos] = useState([])
const addTodo = async () => {
const res = await fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ text }),
})
const todo = await res.json()
setTodos([...todos, todo])
setText("")
}
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map(t => <li key={t.id}>{t.text}</li>)}
</ul>
</div>
)
}
即便是一个简单的示例,也包含许多必须处理正确的细节。该请求发送的 JSON 数据没有 Content-Type 标头,许多服务器不会将其解析为 JSON。更新操作 setTodos([...todos, todo]) 会从触发点击的渲染结果中读取 todos,因此快速连续提交两次可能会导致某个项目丢失;而函数式写法 setTodos(prev => [...prev, todo]) 可以避免这个问题。此外,该列表仅包含当前会话中添加的项目,因为要加载现有的项目还需要额外的效应处理和接口。
htmx 版本
htmx 对应的实现是一个表单和一个空列表。
<form hx-post="/todos" hx-target="#list" hx-swap="beforeend">
<input name="text" />
<button>Add</button>
</form>
<ul id="list"></ul>
有三个属性决定了其行为。hx-post="/todos"用于在表单提交时将表单字段发送到该URL。hx-target="#list"指定响应应显示的位置,而hx-swap="beforeend"则会让响应内容追加到该元素内的现有子节点之后,而非替换其内容。由于表单中的按钮默认就会触发提交操作,因此无需额外的处理函数。
在服务器端,路由会读取表单字段,保存待办事项,并从模板中渲染出一行列表内容。
@app.route("/todos", methods=["POST"])
def add_todo():
text = request.form["text"]
todo = save_todo(text)
return render_template("todo_row.html", todo=todo)
这就是该功能的完整前端实现:没有钩子函数,没有客户端状态,也没有需要在深夜调试时重新渲染的功能。todo_row.html模板仅为新项目渲染一个单独的<li>元素。由于在页面首次加载时也可以使用同一模板,因此初始列表与新添加的行共享相同的标记结构,而无需像传统方式那样使用一个JSX组件和一个需要保持同步的服务器视图。
在实际部署中还有几点需要注意。涉及状态变更的请求仍需CSRF保护,通常是通过在隐藏字段或请求头中添加令牌来实现。此外,如果为表单赋予常规的action和method属性,那么在JavaScript无法加载时,它仍能作为普通表单提交正常工作。
追踪两种请求的生命周期
当逐步骤绘制每个流程时,差异最为明显。首先是 SPA 路由。
Browser Server
-------- --------
Click button
|
v
JS event handler
|
v
fetch() call -------> API endpoint
|
v
Query database
|
v
Return JSON
^ |
|__________________________|
|
v
Parse JSON
|
v
Update state
|
v
Re-render component
|
v
Diff virtual DOM
|
v
Patch real DOM
数据库返回 JSON 后,客户端仍需对其进行解析、更新状态、重新渲染,并对比差异后修改 DOM。
现在是同一次点击的 htmx 路由处理方式。
Browser Server
-------- --------
Click button
|
v
htmx request -------> Route handler
|
v
Query database
|
v
Render HTML piece
^ |
|__________________________|
|
v
Swap HTML into page
多个环节被省去了:客户端端的 JSON 解析、状态更新、差异对比,以及为保持客户端数据一致性所做的各种努力。这些数据从一开始就由服务器端的数据库掌控。htmx 方法不再需要维护该数据的第二个客户端版本。
实际应用中的变化
在某次客户管理项目的迁移中,用服务端渲染模板结合htmx取代React前端后,JavaScript包的大小从大约340 KB缩减到12 KB左右,而在一款中端手机上的交互响应时间也从近三秒降至不到一秒。由于需要审查的代码减少了,拉取请求的数量也随之下降;而且由于代码库主要是HTML加上少量额外属性,新开发者只需一个下午就能熟悉其结构。
请将这些数字视为示例而非基准值。它们来自在某台机器和网络环境下非正式测试的单一项目。对于那些客户端代码大多仅用于在JSON与DOM之间传输数据的应用来说,这种效果是合理的,但应自行对应用程序进行测试,而非直接假设能获得同样的收益。
何时htmx不是合适工具
这并不是要主张从所有应用中移除 React。那些需要复杂客户端交互的应用,比如设计工具、电子表格或实时协作编辑器,都需要大量的客户端状态以及专门用于管理这些状态的框架。若强制让这类应用采用请求与响应式的交互方式,将会带来持续的困扰。
一个快速的判断方法是为当前的功能提出几个问题:
- 该交互是否需要比网络往返时间更快的响应速度,例如拖动、绘图或输入时能立即得到反馈?
- 它是否必须能在离线状态下运行,或者需要实时同步多个用户的编辑内容?
- 客户端是否需要在本地存储并处理大量数据?
如果答案大多是否,那么该功能很可能是用于将数据保存到数据库的表单、表格和按钮。实际上这类软件的数量远超人们愿意承认的范围,而为此添加的客户端组件往往只会增加维护成本,却无法真正解决问题。
总结
- 根据需求选择工具;简单的CRUD操作通常不需要客户端状态管理。
- 使用htmx后,服务器负责渲染内容片段,属性则决定其显示位置,从而避免了JSON转换以及数据来源重复的问题。
- 要保持CSRF防护、真正的
action属性以及通用的行模板。 - 对于那些真正依赖大量客户端操作的场景,再选用完整的客户端框架。
学习 React 依然是明智的职业选择,许多项目都需要它。这足以成为掌握该工具的理由;但并不能成为那些实际只需要表单和表格的应用也必须使用它的理由。