我们都知道,要为您的网站创建前端交互,您需要 JavaScript。请注意,不仅仅是普通的 JS:我们在 2022 年,要创建可接受的 UI,您需要使用 React 或 Vue.js 等框架。正确的?
错误的
近年来,一些特立独行者和叛离者开始远离JS框架的世界和不可避免的臃肿node_modules文件夹。但如果你想要流畅的单页应用体验,而不是每次点击按钮都要等待整个页面呈现,该怎么办?当然,没有人愿意为每一个小的交互写一堆JS样板文件。这就是htmx和超文本形式的超媒体出现的地方。
这两个开源工具包都是由Big Sky Software及其合作者开发的,它们提供了大量HTML属性,以清晰、用户友好的语法处理AJAX请求、部分DOM更新、CSS转换、事件处理、服务器发送事件和WebSockets。有许多优秀的在线教程展示了这些工具的功能;我特别喜欢BugBytes在YouTube上的教程。在这篇文章中,我将向你展示我是如何在我的项目中使用它们的,这个项目是用Django创建的一个简单的会员/订阅跟踪网站。
我的目标是允许用户通过模态(弹出)对话框中的表单向他们的个人列表添加成员资格,并从表中编辑或删除现有的成员资格,该表将不需要重新加载页面。出发点是Benoit Blanchon的一篇优秀文章;在Benoit的文章中,他使用了htmx,但避开了不太成熟的超文本,而是使用一些简单的JS函数。因为htmx和hyperscript是基于相同的理念开发的,并且能够很好地协同工作,所以我决定将所有的精力都投入到超媒体宣传中,并尝试着不使用任何一行“纯粹”的JavaScript。另一个细微的区别是,我使用的是TailwindCSS,而Benoit使用的是Bootstrap,所以一些实用程序类的名称会有所不同。
模态对话框和表单提交
第一步是允许打开和关闭包含表单的模态对话框,并允许提交表单。因为我使用的是daisyUI,所以可以使用现成的模态组件,它可以通过添加或删除.modal-open类来打开或关闭。
这是超文本的一个经典用例;'New'按钮将类添加到模态中,而'Close'按钮将其移除。
<!-- 'New' 按钮在 'my-memberships' 页面-->
<button _="on htmx:afterRequest add .modal-open to #modal"
hx-get="{% url 'my-memberships' %}" hx-select="#modal-box" hx-target="#modal"
class="mx-auto md:ml-2 btn btn-primary btn-square border-none basis-14">New
</button>
<!-- 模式对话框右上角的'Close'按钮 -->
<button _="on click remove .modal-open from #modal"
class="btn btn-sm btn-circle absolute right-2 top-2">✕
</button>
注意在“New”按钮上使用htmx:afterRequest事件,而不是在“vClosv”按钮上简单地单击事件。这是因为在显示表单之前,我们要等待新的、空的表单从后端'my-membership '返回(否则,在从服务器返回'clean'表单之前,可能会显示带有先前条目和验证错误的'dirty'版本的表单)。还要注意的是,我们使用了hx-select属性来从响应中只选择#modal-box元素,并使用hx-target将其放在#modal元素中(GET请求的响应否则会包含整个“my-membership”页面,这不是我们在模态中想要的!)
表单上还有一个'Save'按钮,它通过POST请求将表单提交到'my-membership '后端。Hyperscript用于在响应加载之前禁用该按钮,以防止重复提交。
<button type="submit" class="btn btn-primary border-none"
_="on click toggle @disabled until htmx:afterOnLoad">Save
</button>
与'my-membership ' URL相关联的基于类的视图如下所示
class MembershipView(LoginRequiredMixin, TemplateView):
template_name = 'memberships.html'
extra_context = {'form': MembershipEditForm()}
def post(self, request, *args, **kwargs):
form = MembershipEditForm(request.POST)
success = False
if form.is_valid():
membership = form.save(commit=False)
if kwargs:
if kwargs['update']:
membership.pk = kwargs['pk']
membership.user = request.user
membership.save()
success = True
self.request.path = reverse_lazy('my-memberships')
form = MembershipEditForm()
response = render(request, 'partials/modal-form.html', {'form': form})
if success:
response['HX-Trigger'] = 'membershipsChanged'
return response
表单提交有两种可能的结果:
1.提交的表单会返回验证错误,在这种情况下,HTMX会将现有的模态对话框与响应交换并显示错误:
<div id="modal-box" class="modal-box p-4 scrollbar-thin" hx-target="this" hx-swap="outerHTML">
...
{% if form.non_field_errors %}
<div class="mt-2">
{{ form|as_crispy_errors }}
</div>
{% else %}
<p class="pt-2 pb-4">Enter the details of your subscription below</p>
{% endif %}
...
</div>
2.新的成员关系被保存到数据库中,并返回一个干净的表单。在本例中,我们将值membershipsChanged的'HX-Trigger'头文件附加到响应中。这是提示我们的前端关闭模态和更新表显示用户的成员:
<table class="table table-fixed grow">
<thead class="w-auto">
...
</thead>
<tbody id="membership-table-body"
hx-trigger="load, membershipsChanged from:body"
hx-get="{% url 'update-memberships' %}"
hx-target=this
_="on htmx:afterOnLoad add .hidden to #spinner">
</tbody>
</table>
当然,我们也会在加载事件发生时(在页面加载时)将成员关系加载到表中,并在htmx将响应加载到表中时隐藏“loading”转轮。
更新成员关系表
如果收到membershipsChanged事件,我们就知道新成员已经成功保存,可以更新表单。我包括了一个小(超)脚本在'我的会员证的页面,暂时显示一个成功的警告在这种情况下:
<script type="text/hyperscript">
on membershipsChanged
remove .modal-open from #modal
show #alert-success
wait 3s
hide #alert-success
end
</script>
表格显示包括一个列,允许提醒(续费,免费试用期满等)切换或关闭与一个简单的点击。htmx用于向服务器发送一个PATCH请求,该请求带有URL中包含的相关成员的主键。
<input type="checkbox"
class="checkbox checkbox-primary border-gray-400 mt-1"
{% if membership.reminder %}checked{% endif %}
hx-patch="{% url 'toggle-reminders' membership.pk %}"
hx-swap="none"/>
这在后端通过一个简单的函数来切换给定成员的提醒状态:
@login_required()
def toggle_reminders(request, pk):
if request.method == 'PATCH':
membership = Membership.objects.get(pk=pk)
if membership.user == request.user:
membership.reminder = not membership.reminder
membership.save()
没有返回值,htmx也不期望有任何返回值,因为我们指定了hx-swap="none"(在这种情况下,没有内容被交换到目标中,即使这样的内容出现在响应体中)。如果我们想处理没有找到对象的可能性,我们可以使用get_object_or_404()并在响应中发送一个“HX-Redirect”头来提示htmx重定向到404页面。
我们也有一个下拉菜单,通过点击每个成员的名字,这允许我们编辑或删除该成员。
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-24">
<li>
<a hx-get="{% url 'edit-membership' membership.pk %}" hx-target="#modal"
_="on htmx:afterRequest add .modal-open to modal">Edit
</a>
</li>
<li>
<a hx-post="{% url 'delete-membership' membership.pk %}"
hx-confirm="Are you sure you want to delete the membership '{{ membership.membership_name }}'?"
hx-target="closest tr" hx-swap="delete">Delete
</a>
</li>
</ul>
点击“Edit”会弹出一个包含现有成员详细信息的模态;该请求的结果是,表单提交的目标URL被更新为特定于该成员的URL(尽管最后POST请求被转发到与创建相同的函数,只是带有可选的'update'关键字)。
class EditMembershipView(LoginRequiredMixin, UpdateView):
model = Membership
fields = "__all__"
template_name = 'partials/modal-form.html'
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.user == request.user:
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.user == request.user:
return MembershipView.as_view()(request, update=True, pk=self.object.pk)
点击'Delete'会导致页面提示确认,然后将请求发送到后端(我们这里使用POST而不是Delete,因为视图扩展了Django的'DeleteView',它需要POST请求)。
class DeleteMembershipView(LoginRequiredMixin, DeleteView):
model = Membership
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.user == self.request.user:
self.object.delete()
return HttpResponse()
# Unfortunately we cannot return status 204 or else htmx will ignore the response (see docs at htmx.org)
return redirect('my-memberships')
成功的响应代码是200 - OK,而不是204 - No Content,因为否则htmx将不会触发最近的tr的删除,如hx-swap和hx-target属性所请求的(我们总是可以通过在204响应中触发一个带有'HX-Trigger'头的事件并使用超脚本来实现这一点,但这增加了一个额外的步骤,没有功能收益)。
最终的想法
这样就得到了:一个模态表单和一个表,完全用htmx和超脚本处理,没有JavaScript或页面重载。一旦你进入超媒体的思维模式,它就会变成一种相当直观、极其强大和灵活的构建响应式ui的方式。有时候,内置的htmx属性并不具有理想的行为(例如在删除时对204响应的反应),但可用的头文件允许我们触发事件并在前端处理这些情况。这使得htmx和hyperscript的组合更加强大,而且hyperscript的可读性是首屈一指的。
htmx和hyperscript都可以通过公共的unpkg CDN获得,或者作为独立的.js或npm包。只要遵循一些教程就可以很容易地开始,我鼓励任何想要构建一个不需要绑定React、Vue或Angular的网站的人尝试一下。像我这样的项目的下一步可能包括对表格进行分页的无限滚动,单击列标题对结果进行排序,而无需重新加载页面,或者添加选项卡在表格视图和日历视图之间顺畅切换。