将近四十年前,软件工程师、《人月神话》的作者弗雷德·布鲁克斯写道:
没有固有的银弹。
他是在写关于构建软件时的复杂性,以及没有可行的方法可以显著加快这一过程。他还区分了在构建软件时遇到的两种复杂性:本质复杂性,这是软件试图解决的问题固有的;以及偶然复杂性,这是我们在开发过程中所做的选择引入的复杂性。偶然复杂性的一个例子是可重用性;你不想两次编写同一段代码,因为当这段代码需要更改时,你不想在两个(或更多)地方进行更改。
‘银弹’一词也用作贬义词,指的是没有单一的技术或软件在所有情况下都普遍适用,甚至在少数情况下也是如此。‘银弹’技术是开发者心中固执存在的一个梦想;谁不想偶然发现那种神奇地解决你所有问题的单一技术呢?然而,正如弗雷德·布鲁克斯所巧妙地指出的,没有这样的东西。行业似乎在努力学习这一点。
‘银弹’的候选者通常关注偶然复杂性,试图让开发者的生活更轻松,但正如另一句永恒的格言所说,《天下没有免费的午餐》,这意味着一切都有成本或权衡。在我看来,现在通常付出的代价是简单性;虽然某个特定的解决方案使代码重用变得更容易,但它也可能使事情变得更加复杂。
前端复杂性
前端开发充满了这样的例子。许多前端开发者之间的工程文化似乎是一种将简单性视为一个肮脏词汇的文化。有大量的抽象层次,滑稽复杂和漫长的构建过程,糟糕的性能,可怕的工具,以及充满bug的应用程序,而每个人都似乎对此感到满意。
让我们来看一个例子。我们想在我们的网站上添加搜索功能。使用HTML 5,我们可以创建一个带有文本字段的表单。
<form method="GET" action="/search">
<input type="search" name="query" placeholder="搜索" />
<input type="submit" value="搜索" />
</form>
这是非常基础的,但它完成了工作。当你在字段中输入一些内容并按回车键或点击搜索按钮时,你的查询将被发送到服务器,响应(希望包含一些搜索结果)将替换整个页面。它替换整个页面可能会让人感到突兀。当你有慢或糟糕的连接时(任何使用过酒店或火车WiFi的人都可以证实这仍然是一个问题),页面在加载资源时会暂时停止交互。当它完成加载后,你将失去页面上的所有状态,例如滚动位置,输入表单字段的数据,展开或收缩的元素等。
只改变页面的一部分以显示搜索结果将带来更好的用户体验。不幸的是,这不是HTML的一部分。JavaScript可以在这一点上帮助我们。
纯JavaScript
让我们只添加一个带有一些‘纯’JavaScript代码的<script>
标签,使过程异步化。自然,我们将从‘REST端点’请求一些JSON。不是GraphQL。那将是过度工程。
<input type="search" id="searchInput" placeholder="搜索...">
<button id="searchButton">搜索</button>
<ul id="resultsList"></ul>
<script>
const resultsList = document.getElementById('resultsList');
const searchInput = document.getElementById('searchInput');
const searchButton = document.getElementById('searchButton');
searchButton.addEventListener('click', handleSearch);
searchInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
handleSearch();
}
});
async function handleSearch() {
const searchTerm = searchInput.value.trim();
const encodedSearchTerm = encodeURIComponent(searchTerm);
const apiUrl = `/search?query=${encodedSearchTerm}`;
resultsList.innerHTML = ''; // 清除之前的结果
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error('网络响应不正确');
}
const data = await response.json();
if (data.length === 0) {
resultsList.innerHTML = '没有找到结果。';
} else {
data.forEach(result => {
const listItem = document.createElement('li');
listItem.textContent = result.title;
resultsList.appendChild(listItem);
});
}
}
</script>
天哪。这里比HTML版本多了很多。许多代码集中在转换上。在原始搜索查询和URL之间的转换。在(假定的)JSON响应和DOM元素之间的转换。看看我们需要做多少工作;获取和创建DOM元素,添加事件处理器,处理按键…
这是一个非常有限的例子,存在许多问题:
- 搜索结果的表示非常基础,甚至连链接都不是;
- 当您拥有极好的网络条件时(比如说,您的浏览器和本地主机之间),它运行得相当好,但当您使用酒店WiFi时,如果有一种加载指示器就好了;
- 如果您在另一个请求仍在进行中时启动了新请求,如果旧的请求被取消或至少被丢弃就好了。
请注意,这段代码在浏览器中运行和服务器上的代码之间存在隐含的合同:响应的形状。代码假设响应是包含一个对象数组的JSON,其中每个对象都有一个名为title
的属性。如果情况并非如此,它对用户来说就好像什么都没有发生(因为没有错误处理)。
处理加载指示器和中止正在进行的请求需要一些额外的代码,但对于结果布局的可读代码来说,这是一种延伸。让我们全力以赴,使用React!
React
import React, { useState, useEffect } from 'react';
function Search() {
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [abortController, setAbortController] = useState(null);
const handleSearch = async () => {
if (abortController) {
// 如果有一个,中止之前的请求
abortController.abort();
}
if (searchTerm.trim() === '') {
setResults([]);
return;
}
const encodedSearchTerm = encodeURIComponent(searchTerm.trim());
const apiUrl = `/search?q=${encodedSearchTerm}`;
setResults([]);
setLoading(true);
const controller = new AbortController();
setAbortController(controller);
try {
const response = await fetch(apiUrl, { signal: controller.signal });
if (controller.signal.aborted) {
// 如果请求被中止,则不需要进一步的操作
return;
}
if (!response.ok) {
throw new Error('网络响应不正确');
}
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== 'AbortError') {
// 仅当它不是中止时才显示错误消息
console.error('获取错误:', error);
}
} finally {
setLoading(false);
}
};
const handleKeyPress = (event) => {
if (event.key === 'Enter') {
handleSearch();
}
};
useEffect(() => {
return () => {
// 如果组件卸载,则中止请求的清理函数
if (abortController) {
abortController.abort();
}
};
}, [abortController]);
return (
<input
type="search"
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={handleKeyPress}
/>
<button onClick={handleSearch}>搜索</button>
{loading && <div>加载中...</div>}
<ul>
{results.map((result, index) => (
<li key={`result-${index}`}><a href={result.href}>{result.title}</a></li>
))}
</ul>
);
}
export default Search;
看看这变得多么庞大。公平地说,这甚至没有比纯JavaScript版本大多少。有很多状态声明和处理,一些错误处理,还有一些围绕请求中止逻辑的额外逻辑。尽管如此,我想指出两件事。
首先,我们现在有更多的状态要管理,包括一些需要在组件从DOM中移除时清理的状态(或者用React的话说,是‘卸载’);这是接近末尾的useEffect
调用。
其次,与前两个解决方案不同,你不能只将这个放入一个文件中,使用<script>
标签访问它,并期望它工作。本质上,你需要将这种种类的 JavaScript文件转译成实际的 JavaScript文件。为了能够做到这一点,你需要设置一个React应用程序。这意味着运行一个NPM脚本来设置一个复杂的目录结构,该结构配置得可以运行一个命令行应用程序来启动一个web服务器,然后可以提供你的React应用程序。即使你做了一个所谓的生产构建,你最终会得到一个带有占位符和脚本标签的HTML文件,以及一大堆叫做块的JavaScript文件,这些文件包含你的应用程序和它需要的第三方库的微小部分。所有这些部分只有在运行时才有意义,它们希望在那里结合并正常工作。
尽管它的复杂性,这个版本的搜索框仍然有限制。它不支持增量搜索,即在您键入时连续执行搜索,而不会失去焦点或必须点击按钮。在web应用程序中,这通常不是在每次按键时完成的,而是在用户停止键入一秒钟后。另一个问题是,搜索后页面的URL没有更新。这意味着您无法共享您的搜索查询,将其添加到书签,或在新的浏览器标签页中打开它。这个缺陷是纯JavaScript版本所共有的,但不是简单的HTML版本,因为那只是导航到一个不同的URL。
虽然在纯JavaScript或React版本的代码中肯定有可能克服所有这些问题,我们都明白代码将变得更加复杂。相反,让我们看看一种方法,它专注于HTML方面,而不是不断地增加更多的JavaScript。
Htmx
Htmx 是一个微小的JavaScript库,它允许您仅使用HTML属性就为HTML元素添加Fetch功能。您可以让一个标准的 <button>
元素在点击时发出HTTP请求,并通过几个属性处理响应。
<button hx-post="/transfer/all?to=randomCharity"
hx-target="#thanks">
清空我的银行账户
</button>
<div id="thanks"></div>
这里的 hx-post
属性定义了当按钮被点击时,将向该URL发出 POST
请求。hx-target
属性定义了在哪里显示响应,作为一个CSS选择器;在这种情况下,是一个具有 id
属性等于 thanks
的元素。那么htmx是如何知道从响应中创建哪些HTML元素的呢?一个好问题,有一个简单的答案:响应 是HTML。这样,您的浏览器就不需要运行代码来将从后端系统接收到的数据转换为DOM元素——它直接接收HTML,它已经知道如何处理。
超媒体
与大多数单页应用程序框架不同,htmx不是试图在浏览器中运行整个应用程序,而是试图保持浏览器的“愚蠢”。它通过使用一个几十年前的概念——超媒体来实现这一点。什么是超媒体?根据htmx的作者所著的《超媒体系统》Hypermedia Systems:
超媒体是一种媒体,例如文本,它包含从媒体的一个位置到另一个位置的非线性分支,通过例如嵌入在媒体中的超链接。
这里的关键是 嵌入在媒体中:超文本(一种超媒体文本,如HTML)包含了所有关于可能交互的信息编码在文本中。想想几年前的谷歌搜索结果页面;你知道还有更多页面可用,因为有一个“下一页”链接。你或你的浏览器不需要知道下一页的URL是什么:这是嵌入在你的浏览器从谷歌接收到的HTML中的。如果你更改了URL结构,你只需要在一个应用程序中更改它。如果你添加了一个新功能,比如说不是跳到下一页而是跳到五页之后,它就像添加另一个超链接一样简单。
糟糕的REST
对于那些开发Web API有一段时间的人来说,这可能会引起一些警钟。在谈论Web API时仍然经常听到的一个概念是HATEOAS:超文本作为应用程序状态的引擎。这是从Roy Fielding关于表述性状态转移的论文中派生出的RESTful API的几个不同概念的约束。这些概念是 无状态通信 和 自描述消息,以及客户端应该能够仅使用服务器提供的信息来导航和与应用程序交互,而不需要硬编码知识或带外信息。这正是超媒体解决的问题。
REST,或表述性状态转移,最初是作为分布式应用程序的架构风格构想的。现在“REST”通常指的是有一个关于如何构建URL的观点的JSON API,并且使用比 GET
和 POST
更多的动词。问题是:JSON本身并不促进 超媒体控件(允许这种非线性分支的元素),所以大多数JSON API实际上并不是RESTful的。
人们已经尝试通过向文档添加链接来使JSON API更具RESTfulness。其中最熟悉的一个是HAL,或超文本应用程序语言。它向所有JSON文档甚至文档的元素内添加了一个众所周知的属性。想象一下我们搜索操作的响应看起来像这样:
{
"_links": {
"self": "https://example.org/api/search?q=complexity&p=4",
"first": "https://example.org/api/search?q=complexity",
"prev": "https://example.org/api/search?q=complexity&p=3",
"next": "https://example.org/api/search?q=complexity&p=5",
"last": "https://example.org/api/search?q=complexity&p=42"
},
"_embedded": {
"results": [
{
"_links": {
"self": "http://www.cs.unc.edu/techreports/86-020.pdf"
},
"title": "没有银弹:软件工程的本质和偶然"
},
// ...
]
}
}
这描述了如何获取第一页、下一页,以及如何导航到每个搜索结果。它没有,例如,描述你在哪一页,总共有多少页,或者如何跳转到第十七页。是的,你可以为所有页面添加URL,但现在你需要 硬编码知识 在客户端来处理这些链接。至少 first
、prev
、next
和 last
是链接的某种标准化名称,但要提供到任意页面的链接,你需要创建一个自定义方案,比如 page:17
。这需要更多的硬编码知识才能使用。
另一个解决方案是添加一个链接“模板”,描述特定页面的URL是如何构建的。像这样:
{
"_links": {
"template:page": "https://example.org/api/search?q=complexity&p=%d"
}
}
现在你需要 硬编码知识 在客户端告诉它寻找这个链接,并将占位符 %d
替换为它想要的页码。
限制还在继续:想象一下如何用这种方式描述像搜索表单这样的 琐事。你需要各种占位符和神奇值,这些需要以某种方式对客户端应用程序有意义。真正RESTful的JSON API有时是可能的,但它们非常有限且脆弱。
正确的REST
另一方面,HTML是真正的超媒体,所以使用仅HTML创建RESTful应用程序几乎是微不足道的。HTML有它的限制,正如我们所讨论的,我们可以使用像htmx这样的库来克服这些限制。很容易想象我们的搜索结果在HTML中是什么样子。
<ul>
<li><a href="http://www.cs.unc.edu/techreports/86-020.pdf">没有银弹:软件工程的本质和偶然</a></li>
</ul>
<nav>
<ul>
<li><a href="/search?q=complexity">« 首页</a></li>
<li><a href="/search?q=complexity&p=3">3</a></li>
<li>4</li>
<li><a href="/search?q=complexity&p=5">5</a></li>
<li><a href="/search?q=complexity&p=42">尾页 »</a></li>
</ul>
</nav>
当这被浏览器渲染时,即使没有任何样式,它也是完全功能的。超链接只是工作,因为它们是自描述的。如果我们想添加一个搜索框,以便能够手动输入页码,我们可以这样做,甚至不需要花哨的东西。我们只需将以下片段添加到 <nav>
元素中:
<form method="get" action="/search">
<input type="hidden" name="q" value="complexity" />
<input type="number" name="p" min="1" max="42" />
</form>
多么简单!由于HTML的力量,这个 就是工作。你输入一个数字,按回车,它就去了。隐藏字段用于传输应用程序的所有状态,由浏览器来渲染正确的用户界面元素并使它们适当地行为。身份验证与查看任何页面的方式相同,因为它 是页面。假设你已经正确设置了响应头,缓存就可以立即工作。
剩下的问题是这仍然刷新了整个页面,这会导致状态的丢失。让我们使用htmx来解决这个问题。
属性的力量
正如我们之前提到的,htmx通过定义新属性来 增强 HTML,使每个元素都有能力触发Fetch请求,结果是元素被交换。这些属性让你定义,除其他外:
如何以及在哪里发出请求;这是通过像
hx-get
、hx-post
、hx-put
、hx-delete
等属性完成的。要 交换 什么到页面中;主要是由
hx-target
指导的。hx-swap
让你定义 如何 交换内容;目标的内HTML应该被替换,还是 外 HTML(即替换整个元素),或者应该将其添加到目标内部或之后?何时发出请求。默认触发器取决于元素。例如,文本框和下拉列表由它们的值改变触发,表单由表单提交触发,其他所有元素由点击触发。你可以使用
`hx-trigger
来指定不同的事件来触发,甚至多个事件,并添加修饰符,如延迟或仅在值改变时触发。使用
hx-select
来指定响应中要使用的哪一部分。默认情况下,使用所有内容,但如果你想只使用响应的特定部分,可以使用hx-select
。
还有更多的属性(虽然不是很多),但这些是解决大部分问题的核心属性。那么我们如何将这应用于例如分页链接呢?
让我们假设我们对应用程序的页面渲染方式没有做任何改变。这意味着 /search?q=complexity
将返回一个带有第一页结果的完整HTML页面,而 /search?q=complexity&p=2
将返回一个带有第二页结果的完整HTML页面。我们将把搜索结果放入一个 <section id="results">
元素中,因为这为我们提供了一个简单的目标。下面是单个页面链接的样子:
<li>
<a hx-get="/search?q=complexity&p=3"
hx-select="section#results"
hx-target="section#results"
hx-swap="outerHTML"
hx-push-url="true">3</a>
</li>
这五个属性定义了以下行为:
hx-get
:当此链接被点击(或以其他方式激活)时,向/search?q=complexity&p=3
发出GET
请求。hx-select
:从该请求的响应中,选择一个ID为results
的<section>
元素。hx-target
:将该元素交换到当前页面上ID为results
的<section>
元素中。hx-swap
:替换目标的外部HTML。换句话说,替换整个元素。hx-push-url
:将URL/search?q=complexity&p=3
推入浏览器历史记录和地址栏。
就是这样!现在分页链接只替换它们影响的页面部分。我们写了正好零行JavaScript,我们的应用程序仍然是RESTful的。
但等等,它变得更好了。Htmx支持渐进增强。这意味着你可以从基本的、仅HTML功能开始,只有在浏览器允许JavaScript并且已加载htmx库时,它的全部功能才会被启用。这也使搜索引擎更容易理解你的页面结构。它看起来像这样:
<li>
<a href="/search?q=complexity&p=3"
hx-boost="true"
hx-select="section#results"
hx-target="section#results"
hx-swap="outerHTML">3</a>
</li>
我们没有使用 hx-get
,而是有一个正常的 href
属性,因为我们在锚标签上定义了 hx-boost=true
,htmx知道向 href
属性中的URL发出 GET
请求,并将URL推入浏览器历史记录。
在htmx中搜索
如果你还在这里,祝贺你。现在让我们回到我们之前一直在看的搜索示例,并看看使用htmx会是什么样子。
<input type="search"
name="q"
placeholder="搜索"
hx-get="/search"
hx-target="section#searchResults"
hx-select="section#searchResults"
hx-trigger="search, click from:#searchButton"
hx-indicator="#searchLoading"
hx-sync="this:replace" />
<button id="searchButton">搜索</button>
<div id="searchLoading" class="htmx-indicator">加载中...</div>
<section id="searchResults">
<!-- 在这里放置搜索结果 -->
</section>
希望您明白 hx-get
、hx-target
和 hx-select
元素在这里的作用。
hx-trigger
定义了何时触发请求。在这种情况下,是在 search
事件,或者是当具有ID searchButton
的元素发出 click
事件时。接下来,hx-indicator
设置了一个元素,当此元素有请求正在进行时应该显示。最后,hx-sync
定义了如果在有请求正在进行时触发了新请求,将用新请求替换正在进行中的请求。
这在功能上与React版本相同。然而,使用htmx,我们也可以摆脱我之前提到的那些限制:增量搜索和更新地址栏中的URL。
<input type="search"
name="q"
placeholder="搜索"
hx-get="/search"
hx-target="section#results"
hx-select="section#results"
hx-trigger="search, click from:#searchButton, keyup changed delay:300ms"
hx-indicator="#searchLoading"
hx-sync="this:replace"
hx-push-url="true" />
唯一新添加的是额外的触发器和 hx-push-url
属性。新的触发器 keyup changed delay:300ms
意味着请求应在“键起”事件上触发,但只有在字段的值改变时,并且只有在300毫秒内没有收到新的键起请求时。这是增量搜索,但不会向应用程序发送大量请求,这将发生在如果每次按键都发送请求的情况下。最后,因为 hx-push-url
设置为 true
,请求的最终URL被设置为地址栏中的新位置,并将之前的地址添加到浏览器历史记录中。如果你曾经需要实现推送和弹出浏览器历史记录,你会认识到htmx的解决方案有多简单。
HOWL
如果你的前端主要用JavaScript实现,那么以下论点可能会浮现出来:
让我们在后端也使用JavaScript,这样我们可以重用代码,前端开发者也可以在后端工作。
我讨厌这个论点,有几个原因。
首先,JavaScript。JavaScript除了在浏览器中之外,并不是一个适合任何事情的伟大语言。它从来都不是,很可能永远不会是,除非它获得了一个体面的标准库,并放弃了它的类型强制和其他许多破碎的语言特性。不管开发者的纪律和linting如何,都无法 真正 缓解这一点。不幸的是,我们基本上被困在浏览器中的JavaScript,但如果可能的话,让我们不要在其他地方使用它。
其次,前后端代码重用。这听起来是个好主意,但响应请求的Web服务器使用的应用程序模型与在浏览器中响应用户交互的代码非常不同。重用代码几乎肯定意味着迫使两者中的一个进入没有太多意义的抽象中。除此之外,由于不同的应用程序模型和许多其他差异,前后端开发之间有一个相当大的学习曲线。
你在前端投入的JavaScript越多,这种论点的压力就会越大。
幸运的是,当使用htmx时,你就不需要参与这个论点,因为你几乎不会在前端写任何JavaScript。你可以使用任何你喜欢的服务器端语言和框架。如果没有 压力 使用JavaScript,那就不要使用。你的现有代码库大部分是Python吗?Django或Flask都与htmx很好地集成。你喜欢Java或.NET吗?也许是Ruby或Rust?所有这些以及更多的都有极好的选项来创建基于HTML的应用程序,而且向它们添加htmx是微不足道的。这在htmx社区被称为HOWL:Hypermedia On Whatever you Like。
银弹?
我希望你同意htmx提供了一个比SPA框架更简单的解决方案,这值得 很多。Carson Gross(htmx背后的开发者)谈到了复杂性预算,换句话说:在你的应用程序中,你允许复杂性存在的地方。对于大多数应用程序来说,前端应该只是前端。通常使你的应用程序与其他应用程序区别开来的是后端功能。将复杂性预算节省在后端,而不是在前端花费,这是很有意义的。
然而,正如本文开头所述,没有银弹这回事。htmx并不适用于所有情况。当你的应用程序大部分是图形的(比如地图或绘图应用程序)或者大部分UI需要根据用户输入重新绘制(比如电子表格或文字处理器)时,你需要一个基于JavaScript的解决方案。当你的应用程序需要在用户离线时也可用时,你需要一个基于JavaScript的解决方案。幸运的是,绝大多数应用程序只是文本和图像,不受这些限制的影响。对于这绝大多数,htmx可能是一个好的解决方案,可能比任何当前流行的SPA框架都更好。