实战|使用 Node.js 和 htmx 构建全栈应用程序

在本教程中,我将演示如何使用 Node 作为后端和 htmx 作为前端来构建功能齐全的 CRUD 应用程序。这将演示 htmx 如何集成到全栈应用程序中,使您能够评估其有效性并确定它是否是您未来项目的不错选择。

htmx 是一个现代 JavaScript 库,旨在通过实现部分 HTML 更新来增强Web应用,而无需重新加载整个页面。与传统前端框架中的 JSON 有效载荷不同,它通过有线方式发送 HTML 来实现这一功能。

我们将要构建什么

我们将开发一个简单的联系人管理器,能够执行所有 CRUD 操作:创建、读取、更新和删除联系人。通过利用 htmx,该应用程序将提供单页应用程序 (SPA) 的感觉,从而增强交互性和用户体验。

如果用户禁用 JavaScript,应用程序将以整页刷新的方式运行,从而保持可用性和可发现性。这种方法展示了 htmx 创建现代 Web 应用程序的能力,同时保持它们的可访问性和 SEO 友好性。

这就是我们最终得到的结果。

本文的代码可以在随附的 GitHub 存储库中找到。

先决条件

要学习本教程,您需要在 PC 上安装 Node.js。如果您尚未安装 Node,请前往官方 Node 下载页面并获取适合您系统的正确二进制文件。或者,您可能想使用版本管理器安装 Node。这种方法允许您安装多个 Node 版本并在它们之间随意切换。

除此之外,熟悉 Node、Pug(我们将使用它们作为模板引擎)和 htmx 会有所帮助,但不是必需的。如果您想复习以上任何内容,请查看我们的教程:使用 Node 构建简单的初学者应用程序、Pug HTML 模板预处理器指南和 htmx 简介。

在开始之前,请运行以下命令:

node -v
npm -v

您应该看到如下输出:

v20.11.1
10.4.0

这确认了 Node 和 npm 已安装在您的计算机上,并且可以从命令行环境进行访问。

设置项目

让我们从搭建一个新的 Node 项目开始:

mkdir contact-manager
cd contact-manager
npm init -y

这应该在项目根目录中创建一个 package.json 文件。

接下来,让我们安装我们需要的依赖项:

npm i express method-override pug

在这些包中,Express 是我们应用程序的支柱。它是一个快速且简约的 Web 框架,提供了一种简单的方法来处理请求和响应,并将 URL 路由到特定的处理函数。 Pug 将充当我们的模板引擎,而我们将使用方法覆盖在客户端不支持的地方使用 HTTP 动词,例如 PUT 和 DELETE。

接下来,在根目录中创建一个 app.js 文件:

touch app.js

并添加以下内容:

const express = require('express');
const path = require('path');
const routes = require('./routes/index');const app = express();app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');app.use(express.static('public'));
app.use('/', routes);const server = app.listen(3000, () => {console.log(`Express is running on port ${server.address().port}`);
});

在这里,我们正在设置 Express 应用程序的结构。这包括将 Pug 配置为渲染视图的视图引擎、定义静态资产的目录以及连接路由器。

该应用程序侦听端口 3000,并使用控制台日志来确认 Express 正在运行并准备好处理指定端口上的请求。此设置构成了我们应用程序的基础,并准备好通过更多功能和路由进行扩展。

接下来,让我们创建路由文件:

mkdir routes
touch routes/index.js

打开该文件并添加以下内容:

const express = require('express');
const router = express.Router();// GET /contacts
router.get('/contacts', async (req, res) => {res.send('It works!');
});

在这里,我们在新创建的路由目录中设置基本路由。此路由在 /contacts 端点侦听 GET 请求,并使用简单的确认消息进行响应,表明一切正常。

接下来,使用以下内容更新 package.json 文件的“scripts”部分:

"scripts": {"dev": "node --watch app.js"
},

这利用了 Node.js 中的新监视模式,只要检测到任何更改,该模式就会重新启动我们的应用程序。

最后,使用 npm run dev 启动所有内容,然后在浏览器中访问 http://localhost:3000/contacts/。您应该会看到一条消息“It works!”。

激动人心的时刻!

显示所有联系人

现在让我们添加一些要显示的联系人。由于我们专注于 htmx,因此为了简单起见,我们将使用硬编码数组。这将使事情变得精简,使我们能够专注于 htmx 的动态功能,而无需复杂的数据库集成。

对于那些有兴趣稍后添加数据库的人来说,SQLite 和 Sequelize 是不错的选择,它们提供了不需要单独数据库服务器的基于文件的系统。

话虽如此,请将以下内容添加到第一个路由之前的 index.js 中:

const contacts = [{ id: 1, name: 'John Doe', email: 'john.doe@example.com' },{ id: 2, name: 'Jane Smith', email: 'jane.smith@example.com' },{ id: 3, name: 'Emily Johnson', email: 'emily.johnson@example.com' },{ id: 4, name: 'Aarav Patel', email: 'aarav.patel@example.com' },{ id: 5, name: 'Liu Wei', email: 'liu.wei@example.com' },{ id: 6, name: 'Fatima Zahra', email: 'fatima.zahra@example.com' },{ id: 7, name: 'Carlos Hernández', email: 'carlos.hernandez@example.com' },{ id: 8, name: 'Olivia Kim', email: 'olivia.kim@example.com' },{ id: 9, name: 'Kwame Nkrumah', email: 'kwame.nkrumah@example.com' },{ id: 10, name: 'Chen Yu', email: 'chen.yu@example.com' },
];

现在,我们需要为路由创建一个显示模板。创建一个包含 index.pug 文件的 views 文件夹:

mkdir views
touch views/index.pug

并添加以下内容:

doctype html
htmlheadmeta(charset='UTF-8')title Contact Managerlink(rel='preconnect', href='https://fonts.googleapis.com')link(rel='preconnect', href='https://fonts.gstatic.com', crossorigin)link(href='https://fonts.googleapis.com/css2?family=Roboto:wght@300;400&display=swap', rel='stylesheet')link(rel='stylesheet', href='/styles.css')bodyheadera(href='/contacts')h1 Contact Managersection#sidebarul.contact-listeach contact in contactsli #{contact.name}div.actionsa(href='/contacts/new') New Contactmain#contentp Select a contactscript(src='https://unpkg.com/htmx.org@1.9.10')

在此模板中,我们为应用程序布置 HTML 结构。在 head 部分,我们包含了来自 Google Fonts 的 Roboto 字体和自定义样式的样式表。

正文分为标题、用于列出联系人的侧边栏以及用于存放所有联系信息的主要内容区域。内容区域当前包含一个占位符。在正文的末尾,我们还包含来自 CDN 的最新版本的 htmx 库。

该模板期望接收一个联系人数组(在 contacts 变量中),我们在侧边栏中对其进行迭代,并使用 Pug 的插值语法在无序列表中输出每个联系人姓名。

接下来,让我们创建自定义样式表:

mkdir public
touch public/styles.css

我不想在这里列出样式。请从随附的 GitHub 存储库中的 CSS 文件中复制它们,或者随意添加一些您自己的 CSS 文件。 🙂

回到 index.js,更新路由以使用模板:

// GET /contacts
router.get('/contacts', (req, res) => {res.render('index', { contacts });
});

现在,当您刷新页面时,您应该会看到类似这样的内容。

显示单个联系人

到目前为止,我们所做的只是建立了一个基本的 Express 应用程序。让我们改变一下,最后添加 htmx。下一步要做的是,当用户点击侧边栏中的联系人时,该联系人的信息就会显示在主内容区域–自然不需要重新载入整个页面。

首先,让我们将侧边栏移至其自己的模板中:

touch views/sidebar.pug

将以下内容添加到这个新文件中:

ul.contact-listeach contact in contactslia(href=`/contacts/${contact.id}`,hx-get=`/contacts/${contact.id}`,hx-target='#content',hx-push-url='true')= contact.namediv.actionsa(href='/contacts/new') New Contact

这里我们为每个联系人创建了一个指向 /contacts/${contact.id} 的链接,并添加了三个 htmx 属性:

  • hx-get:当用户单击链接时,htmx 将拦截单击并通过 Ajax 向 /contacts/${contact.id} 端点发出 GET 请求。
  • hx-target:当请求完成时,响应将被插入到 ID 为 content 的 div 中。我们在这里没有指定任何类型的交换策略,因此 div 的内容将被 Ajax 请求返回的内容替换。这是默认行为。
  • hx-push-url:这将确保 htx-get 中指定的值被推送到浏览器的历史堆栈中,从而更改 URL。

更新 index.pug 以使用我们的模板:

section#sidebarinclude sidebar.pug

请记住:Pug 对空格敏感,因此请务必使用正确的缩进。

现在让我们在 index.js 中创建一个新端点以返回 htmx 期望的 HTML 响应:

// GET /contacts/1
router.get('/contacts/:id', (req, res) => {const { id } = req.params;const contact = contacts.find((c) => c.id === Number(id));res.send(`<h2>${contact.name}</h2><p><strong>Name:</strong> ${contact.name}</p><p><strong>Email:</strong> ${contact.email}</p>`);
});

如果保存并刷新浏览器,您现在应该能够查看每个联系人的详细信息。

网络上的 HTML

让我们花点时间了解一下这里发生了什么。正如文章开头提到的,htmx 通过网络传输 HTML,而不是传统前端框架的 JSON 有效负载。

如果我们打开浏览器的开发人员工具,切换到“Network”选项卡并单击其中一个联系人,我们就可以看到这一点。收到来自前端的请求后,我们的 Express 应用程序会生成显示该联系人所需的 HTML,并将其发送到浏览器,其中 htmx 将其交换到 UI 中的正确位置。

处理全页刷新

所以事情进展得很顺利,是吧?感谢 htmx,我们通过在锚标记上指定几个属性来使页面动态化。不幸的是,有一个问题……

如果您显示联系人,然后刷新页面,我们可爱的用户界面就会消失,您看到的只是裸露的联系方式详细信息。如果您直接在浏览器中加载 URL,也会发生同样的情况。

如果你仔细想想,其原因是显而易见的。当您访问 http://localhost:3000/contacts/1 之类的 URL 时, '/contacts/:id' 的 Express 路由将启动并返回联系人的 HTML,正如我们告诉它的那样。它对我们用户界面的其余部分一无所知。

为了解决这个问题,我们需要做一些改动。在服务器上,我们需要检查是否存在 HX-Request 标头,它表明请求来自 htmx。如果存在,我们就可以发送部分内容。否则,我们需要发送整个页面。

像这样更改路由处理程序:

// GET /contacts/1
router.get('/contacts/:id', (req, res) => {const { id } = req.params;const contact = contacts.find((c) => c.id === Number(id));if (req.headers['hx-request']) {res.send(`<h2>${contact.name}</h2><p><strong>Name:</strong> ${contact.name}</p><p><strong>Email:</strong> ${contact.email}</p>`);} else {res.render('index', { contacts });}
});

现在,当您重新加载页面时,用户界面不会消失。但是,它确实会从您正在查看的任何联系人恢复为消息“选择联系人”,这并不理想。

为了解决这个问题,我们可以在 index.pug 模板中引入 case 语句:

main#contentcase actionwhen 'show'h2 #{contact.name}p #[strong Name:] #{contact.name}p #[strong Email:] #{contact.email}when 'new'// Coming soonwhen 'edit'// Coming soondefaultp Select a contact

最后更新路由处理程序:

if (req.headers['hx-request']) {// As before
} else {res.render('index', { action: 'show', contacts, contact });
}

请注意,我们现在传入一个 contact 变量,该变量将在整个页面重新加载时使用。

这样,我们的应用程序应该能够承受刷新或直接加载联系人。

快速重构

虽然这样做可行,但您可能会注意到,我们的路由处理程序和主 pug 模板中都有一些重复的内容。这种情况并不理想,只要联系人的属性不超过几个,或者我们需要使用一些逻辑来决定显示哪些属性,事情就会开始变得臃肿。

为了解决这个问题,让我们将联系人移动到自己的模板中:

touch views/contact.pug

在新创建的模板中,添加以下内容:

h2 #{contact.name}p #[strong Name:] #{contact.name}
p #[strong Email:] #{contact.email}

在主模板( index.pug )中:

main#contentcase actionwhen 'show'include contact.pug

还有我们的路由处理程序:

if (req.headers['hx-request']) {res.render('contact', { contact });
} else {res.render('index', { action: 'show', contacts, contact });
}

事情应该仍然像以前一样工作,但现在我们已经删除了重复的代码。

新的联系表

我们要关注的下一个任务是创建新联系人。本教程的这一部分将指导您设置表单和后端逻辑,使用 htmx 动态处理提交。

让我们从更新侧边栏模板开始。更改:

div.actionsa(href='/contacts/new') New Contact

… 到:

div.actionsa(href='/contacts/new',hx-get='/contacts/new',hx-target='#content',hx-push-url='true') New Contact

这将使用与链接相同的 htmx 属性来显示联系人:hx-get 将通过 Ajax 向 /contacts/new 端点发出 GET 请求,hx-target 将指定插入响应的位置,hx-push-url 将确保更改 URL。

现在让我们为表单创建一个新模板:

touch views/form.pug

并添加以下代码:

h2 New Contactform(action='/contacts',method='POST',hx-post='/contacts',hx-target='#sidebar',hx-on::after-request='if(event.detail.successful) this.reset()'
)label(for='name') Name:input#name(type='text', name='name', required)label(for='email') Email:input#email(type='email', name='email', required)div.actionsbutton(type='submit') Submit

在这里,我们使用 hx-post 属性告诉 htmx 拦截表单提交,并向 /contacts 端点发出带有表单数据的 POST 请求。结果(更新的联系人列表)将被插入到侧边栏中。在这种情况下,我们不想更改 URL,因为用户可能想要输入多个新联系人。但是,我们确实希望在成功提交后清空表单,这就是 hx-on::after-request 的作用。 hx-on* 属性允许您内联嵌入脚本以直接响应元素上的事件。你可以在这里读更多关于它的内容。

接下来,我们在 index.js 中添加表单的路由:

// GET /contacts
...// GET /contacts/new
router.get('/contacts/new', (req, res) => {if (req.headers['hx-request']) {res.render('form');} else {res.render('index', { action: 'new', contacts, contact: {} });}
});// GET /contacts/1
...

路由顺序在这里很重要。如果您先有 '/contacts/:id' 路由,那么 Express 将尝试查找 ID 为 new 的联系人。

最后,更新我们的 index.pug 模板以使用以下形式:

when 'new'include form.pug

刷新页面,此时您应该能够通过单击侧栏中的“New Contact”链接来呈现新的联系人表单。

创建联系人

现在我们需要创建一个路由来处理表单提交。

首先更新 app.js 以使我们能够访问路由处理程序中的表单数据。

const express = require('express');
const path = require('path');
const routes = require('./routes/index');const app = express();app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');+ app.use(express.urlencoded({ extended: true }));
app.use(express.static('public'));
app.use('/', routes);const server = app.listen(3000, () => {console.log(`Express is running on port ${server.address().port}`);
});

以前,我们会使用 body-parser 包,但我最近了解到这不再是必要的。

然后将以下内容添加到 index.js

// POST /contacts
router.post('/contacts', (req, res) => {const newContact = {id: contacts.length + 1,name: req.body.name,email: req.body.email,};contacts.push(newContact);if (req.headers['hx-request']) {res.render('sidebar', { contacts });} else {res.render('index', { action: 'new', contacts, contact: {} });}
});

在这里,我们使用从客户端收到的数据创建一个新联系人,并将其添加到 contacts 数组中。然后,我们重新渲染侧边栏,并向其传递更新的联系人列表。

请注意,如果您正在制作任何类型的有用户的应用程序,则由您负责验证从客户端接收的数据。在我们的示例中,我添加了一些基本的客户端验证,但这很容易被绕过。

我上面链接的 Node 教程中有一个示例,说明如何使用 express-validator 包来验证服务器上的输入。

现在,如果您刷新浏览器并尝试添加联系人,它应该按预期工作:新联系人应添加到侧边栏,并且应重置表单。

添加 toast 消息提示

这很好,但现在我们需要一种方法来通知用户联系人已添加。在典型的应用程序中,我们会使用 toast 消息——一种临时通知,提醒用户操作的结果。

我们使用 htmx 遇到的问题是,我们在成功创建新联系人后更新侧边栏,但这不是我们希望显示 toast 消息的位置。更好的位置将位于新联系表格上方。

为了解决这个问题,我们可以使用 hx-swap-oob 属性。这允许您指定响应中的某些内容应交换到目标以外的 DOM 中,即“Out of Band”。

更新路由处理程序如下:

if (req.headers['hx-request']) {res.render('sidebar', { contacts }, (err, sidebarHtml) => {const html = `<main id="content" hx-swap-oob="afterbegin"><p class="flash">Contact was successfully added!</p></main>${sidebarHtml}`;res.send(html);});
} else {res.render('index', { action: 'new', contacts, contact: {} });
}

在这里,我们像以前一样渲染侧边栏,但向 render 方法传递一个匿名函数作为第三个参数。该函数接收通过调用 res.render('sidebar', { contacts }) 生成的 HTML,然后我们可以使用它来组装最终响应。

通过指定交换策略 "afterbegin" ,将 toast 消息插入到容器的顶部。

现在,当我们添加联系人时,我们应该会收到一条不错的消息,告诉我们发生了什么。

编辑联系人

为了更新联系人,我们将重用上一节中创建的表单。

让我们首先更新 contact.pug 模板以添加以下内容:

div.actionsa(href=`/contacts/${contact.id}/edit`,hx-get=`/contacts/${contact.id}/edit`,hx-target='#content',hx-push-url='true') Edit Contact

这将在联系人详细信息下方添加一个编辑联系人按钮。正如我们之前所见,当单击链接时, hx-get 将通过 Ajax 向 /${contact.id}/edit 端点发出 GET 请求, hx-target 将指定插入位置响应, hx-push-url 将确保 URL 发生更改。

现在让我们更改 index.pug 模板以使用以下形式:

when 'edit'include form.pug

还添加一个路由处理程序来显示表单:

// GET /contacts/1/edit
router.get('/contacts/:id/edit', (req, res) => {const { id } = req.params;const contact = contacts.find((c) => c.id === Number(id));if (req.headers['hx-request']) {res.render('form', { contact });} else {res.render('index', { action: 'edit', contacts, contact });}
});

请注意,我们使用请求中的 ID 检索联系人,然后将该联系人传递到表单。

我们还需要更新新的联系人处理程序以执行相同的操作,但此处传递一个空对象:

// GET /contacts/new
router.get('/contacts/new', (req, res) => {if (req.headers['hx-request']) {
-    res.render('form');
+    res.render('form', { contact: {} });} else {res.render('index', { action: 'new', contacts, contact: {} });}
});

然后我们需要更新表单本身:

- isEditing = () => !(Object.keys(contact).length === 0);h2=isEditing() ? "Edit Contact" : "New Contact"form(action=isEditing() ? `/update/${contact.id}?_method=PUT` : '/contacts',method='POST',hx-post=isEditing() ? false : '/contacts',hx-put=isEditing() ? `/update/${contact.id}` : false,hx-target='#sidebar',hx-push-url=isEditing() ? `/contacts/${contact.id}` : falsehx-on::after-request='if(event.detail.successful) this.reset()',
)label(for='name') Name:input#name(type='text', name='name', required, value=contact.name)label(for='email') Email:input#email(type='email', name='email', required, value=contact.email)div.actionsbutton(type='submit') Submit

当我们向此表单传递联系人或空对象时,我们现在有一种简单的方法来确定我们是否处于“编辑”或“创建”模式。我们可以通过检查 Object.keys(contact).length 来做到这一点。我们还可以使用 Pug 的无缓冲代码语法将此检查提取到文件顶部的一个小辅助函数中。

一旦我们知道自己所处的模式,我们就可以有条件地更改页面标题,然后决定向表单标记添加哪些属性。对于编辑表单,我们需要添加 hx-put 属性并将其设置为 /update/${contact.id} 。保存联系人详细信息后,我们还需要更新 URL。

为了做到这一切,我们可以利用这样一个事实:如果条件返回 false ,Pug 将从标签中省略该属性。

这意味着:

form(action=isEditing() ? `/update/${contact.id}?_method=PUT` : '/contacts',method='POST',hx-post=isEditing() ? false : '/contacts',hx-put=isEditing() ? `/update/${contact.id}` : false,hx-target='#sidebar',hx-on::after-request='if(event.detail.successful) this.reset()',hx-push-url=isEditing() ? `/contacts/${contact.id}` : false
)

isEditing() 返回 false 时将编译为以下内容:

<form action="/contacts" method="POST" hx-post="/contacts" hx-target="#sidebar" hx-on::after-request="if(event.detail.successful) this.reset()"
>...
</form>

但是当 isEditing() 返回 true 时,它将编译为:

<form action="/update/1?_method=PUT" method="POST" hx-put="/update/1" hx-target="#sidebar" hx-on::after-request="if(event.detail.successful) this.reset()" hx-push-url="/contacts/1"
>...
</form>

在其更新状态下,请注意表单操作是 "/update/1?_method=PUT" 。添加此查询字符串参数是因为我们正在使用方法覆盖包,它将使我们的路由器响应 PUT 请求。

开箱即用的 htmx 可以发送 PUT 和 DELETE 请求,但浏览器却不行。这意味着,如果我们要处理 JavaScript 被禁用的情况,就需要复制我们的路由处理程序,让它同时响应 PUT(htmx)和 POST(浏览器)。使用这种中间件将使我们的代码保持 DRY。

让我们继续将其添加到 app.js

const express = require('express');
const path = require('path');
+ const methodOverride = require('method-override');
const routes = require('./routes/index');const app = express();app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');+ app.use(methodOverride('_method'));
app.use(express.urlencoded({ extended: true }));
app.use(express.static('public'));
app.use('/', routes);const server = app.listen(3000, () => {console.log(`Express is running on port ${server.address().port}`);
});

最后,让我们用新的路由处理程序更新 index.js

// PUT /contacts/1
router.put('/update/:id', (req, res) => {const { id } = req.params;const newContact = {id: Number(id),name: req.body.name,email: req.body.email,};const index = contacts.findIndex((c) => c.id === Number(id));if (index !== -1) contacts[index] = newContact;if (req.headers['hx-request']) {res.render('sidebar', { contacts }, (err, sidebarHtml) => {res.render('contact', { contact: contacts[index] }, (err, contactHTML) => {const html = `${sidebarHtml}<main id="content" hx-swap-oob="true"><p class="flash">Contact was successfully updated!</p>${contactHTML}</main>`;res.send(html);});});} else {res.redirect(`/contacts/${index + 1}`);}
});

希望现在没有什么太神秘的事情了。在处理程序的开头,我们从请求参数中获取联系人 ID。然后,我们找到想要更新的联系人,并将其替换为根据我们收到的表单数据创建的新联系人。

在处理 htmx 请求时,我们首先用更新的联系人列表呈现侧边栏模板。然后,我们用更新的联系人渲染联系人模板,并使用这两次调用的结果来组合我们的响应。与之前一样,我们使用 “Out of Band” 更新创建一条 toast 消息,告知用户联系人已更新。

此时,您应该能够更新联系人。

删除联系人

最后一个难题是删除联系人的能力。让我们在联系人模板中添加一个按钮来执行此操作:

div.actionsform(method='POST', action=`/delete/${contact.id}?_method=DELETE`)button(type='submit',hx-delete=`/delete/${contact.id}`,hx-target='#sidebar',hx-push-url='/contacts'class='link') Delete Contacta( // as before )

请注意,最好使用表单和按钮来发出 DELETE 请求。表单是为导致更改(例如删除)的操作而设计的,这确保了语义的正确性。此外,使用链接进行删除操作可能存在风险,因为搜索引擎可能会无意中跟踪链接,从而可能导致不必要的删除。

话虽这么说,我添加了一些 CSS 来将按钮设置为链接样式,因为按钮很难看。如果您之前从存储库复制了样式,那么您的代码中已经包含了该样式。

最后,我们的路由处理程序在 index.js 中:

// DELETE /contacts/1
router.delete('/delete/:id', (req, res) => {const { id } = req.params;const index = contacts.findIndex((c) => c.id === Number(id));if (index !== -1) contacts.splice(index, 1);if (req.headers['hx-request']) {res.render('sidebar', { contacts }, (err, sidebarHtml) => {const html = `<main id="content" hx-swap-oob="true"><p class="flash">Contact was successfully deleted!</p></main>${sidebarHtml}`;res.send(html);});} else {res.redirect('/contacts');}
});

删除联系人后,我们将更新侧边栏并向用户显示一条提示消息。

更进一步

就这样吧。

在本文中,我们使用 Node 和 Express 作为后端,使用 htmx 作为前端,制作了一个全栈 CRUD 应用程序。在此过程中,我演示了 htmx 如何简化向 Web 应用程序添加动态行为,减少对复杂 JavaScript 和整页重新加载的需求,从而使用户体验更流畅、更具交互性。

作为额外的好处,该应用程序无需 JavaScript 也能正常运行。

然而,虽然我们的应用程序功能齐全,但不可否认它还有些简陋。如果您希望继续探索 htmx,您可能希望考虑在应用程序状态之间实现视图转换,或者向表单添加一些进一步的验证 - 例如,验证电子邮件地址是否来自特定域。

我在 htmx 简介中提供了这两件事(以及更多)的示例。


原文:https://www.sitepoint.com/node-js-htmx-build-full-stack-app

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/770210.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

多叉树题目:N 叉树的前序遍历

文章目录 题目标题和出处难度题目描述要求示例数据范围进阶 解法一思路和算法代码复杂度分析 解法二思路和算法代码复杂度分析 解法三思路和算法代码复杂度分析 题目 标题和出处 标题&#xff1a;N 叉树的前序遍历 出处&#xff1a;589. N 叉树的前序遍历 难度 3 级 题目…

一文速通自监督学习(Self-supervised Learning):教机器自我探索的艺术

一文速通自监督学习&#xff08;Self-supervised Learning&#xff09;&#xff1a;教机器自我探索的艺术 前言自监督学习是什么&#xff1f;自监督学习的魔力常见的自监督学习方法1. 对比学习2. 预测缺失部分3. 旋转识别4. 时间顺序预测 结语 &#x1f308;你好呀&#xff01;…

Docker新手攻略:编辑Dockerfile、构建镜像、启动容器全攻略

万能dockerfile编写模板文件 FROM openjdk:11.0 as builder WORKDIR application ARG JAR_FILEtarget/*.jar COPY ${JAR_FILE} application.jar RUN java -Djarmodelayertools -jar application.jar extractFROM openjdk:11.0 WORKDIR application COPY --frombuilder applica…

Springboot项目结构

1. 一个正常的企业项目里一种通用的项目结构和代码层级划分的指导意见&#xff1a; 一般分为如下几层&#xff1a; 开放接口层 终端显示层 Web 层 Service 层 Manager 层 DAO 层 外部接口或第三方平台 2. 以当下非常火热的Spring Boot典型项目结构为例&#xff0c;创建出…

代码随想录算法训练营第三十四天 |1005. K 次取反后最大化的数组和 、134. 加油站、135. 分发糖果

代码随想录算法训练营第三十四天 |1005. K 次取反后最大化的数组和 、134. 加油站、135. 分发糖果 1005. K 次取反后最大化的数组和题目解法 134. 加油站题目解法 135. 分发糖果题目解法 感悟 1005. K 次取反后最大化的数组和 题目 解法 考虑绝对值 class Solution { public…

真机笔记(3) 真机需求讲解

目录 拓扑分析&#xff1a; 设计理念&#xff1a; 1. 生产区交换需求&#xff1a; 2. 生产区交换需求&#xff1a; 3. 服务器区交换需求&#xff1a; 4. 路由设计 5. 地址规划&#xff1a; 拓扑分析&#xff1a; 蓝色&#xff1a;网线 红色&#xff1a;多模光纤 黄色&am…

【SpringBoot框架篇】37.使用gRPC实现远程服务调用

文章目录 RPC简介gPRC简介protobuf1.文件编写规范2.字段类型3.定义服务(Services) 在Spring Boot中使用grpc1.父工程pom配置2.grpc-api模块2.1.pom配置2.2.proto文件编写2.3.把proto文件编译成class文件 3.grpc-server模块3.1.pom文件和application.yaml3.2.实现grpc-api模块的…

2-Flume之Sink与Channel

Flume Sink HDFS Sink 将数据写到HDFS上。数据以文件形式落地到HDFS上&#xff0c;文件名默认是以FlumeData开头&#xff0c;可以通过hdfs.filePrefix来修改 HDFS Sink默认每隔30s会滚动一次生成一个文件&#xff0c;因此会导致在HDFS上生成大量的小文件&#xff0c;实际过程…

使用git下载github/gitee仓库部分或单个文件的方法

前言 有些时候在github或者gitee仓库中我们只需要下载整个项目中的我门需要的那一部分文件夹或文件就行了&#xff0c;不需要下载所有的项目。这样可以节省很多流量和时间 步骤 1.建立一个新的 git 本地仓库 这里我在D:\test中初始化 命令&#xff1a; git init2.在本地仓…

Istio 部署 Spring Coud 微服务应用

Istio 服务部署 这篇文章讲述如何将 Java Spring Cloud 微服务应用部署到 Istio mesh 中。 准备基础环境 使用 Kind 模拟 kubernetes 环境。文章参考&#xff1a;https://blog.csdn.net/qq_52397471/article/details/135715485 在 kubernetes cluster 中安装 Istio 创建一…

Excel双击单元格后弹窗输入日期

Step1. 在VBE界面新建一个窗体(Userform1),在窗体的工具箱的空白处右键,选中添加附件,勾选Calendar control 8.0,即可完成日历的添加。 PS:遗憾的是, Office 64 位没有官方的日期选择器控件。唯一的解决方案是使用Excel 的第三方日历。 参考链接:How to insert calen…

电路笔记 :灯光画 JLC 嘉立创EDA+PCB打样(笔记)

项目介绍 PCB板灯光画(可行性验证)&#xff0c;PCB灯光画无论是电路原理还是焊接都非常简单&#xff0c;所有元器件均采用0805及体积更大的封装&#xff0c;只需要电烙铁和焊锡丝等基础的工具就能制作&#xff0c;新手小白也能轻松复刻&#xff0c;里边还有PCB板实现灯光画效果…

数据交换技术

目录 <线路交换> <报文交换> <分组交换> 1.数据报分组交换 2.虚电路分组交换 计算机网络是以数据交换为目的的技术&#xff0c;从交换技术的发展过程来看&#xff0c;主要经历了线 路交换、报文交换、分组交换的过程。 <线路交换> 线路交换又称为…

vue3+threejs新手从零开发卡牌游戏(十):创建己方战域

首先在game目录下创建site文件夹&#xff0c;用来存放战域相关代码&#xff1a; 这里思考下如何创建战域&#xff0c;我的想法是添加一个平面&#xff0c;将己方战域和对方战域都添加进这个平面中&#xff0c;所以首先我们先添加一个战域plane&#xff0c;site/index.vue代码如…

[蓝桥杯 2020 省 AB1] 网络分析

一开始写的暴力合并 卡n^2过的不是正解 看正解是类似 虚拟点树形DP的思路 很巧妙 记录一下 #include<bits/stdc.h> using namespace std; using ll long long; using pii pair<int,int>; #define int long long const int N 3e510; const int inf 0x3f3f3f3f; …

【RPG Maker MV 仿新仙剑 战斗场景UI (八)】

RPG Maker MV 仿新仙剑 战斗场景UI 八 状态及装备场景代码效果 状态及装备场景 本计划在战斗场景中直接制作的&#xff0c;但考虑到在战斗场景中加入太多的窗口这不太合适&#xff0c;操作也繁琐&#xff0c;因此直接使用其他场景。 代码 Pal_Window_EquipStatus.prototype.…

Word邮件合并

Word邮件合并功能可以解决在Word中批量填写内容的需求&#xff0c;当需要大量格式相同&#xff0c;只修改少数相关内容时&#xff0c;例如利用Word制作工资条&#xff0c;通知函&#xff0c;奖状等等&#xff0c;同时操作也非常简单灵活。下面通过例子来说明邮件合并的使用方法…

突破距离限制 远程级联测径仪 让您使用更安心!

关键词&#xff1a;在线测径仪,测径仪,远程级联 在现代工业领域&#xff0c;测量的准确性和高效性至关重要。在线测径仪不仅具备了这两项特质&#xff0c;更能进行远程级联&#xff0c;能更快速的为您解决软件系统在使用中遇到的问题。 在线测径仪能做到以下几点 精准测量&am…

过了30岁,要具备翻篇的能力

哈喽&#xff0c;你好啊&#xff0c;我是雷工&#xff01; 杨绛先生曾经说过&#xff1a; “人生没有彩排&#xff0c;每一天都是现场直播。只有翻篇&#xff0c;才能重新开始。” 一个人如果整天回顾以前&#xff0c;想着自己做的不好的事情&#xff0c;想着曾经遇到的挫折&am…