友好超媒体的脚本编写

Carson Gross

The final addition to our constraint set for REST comes from the code-on-demand style of Section 3.5.3 (Figure 5-8). REST allows client functionality to be extended by downloading and executing code in the form of applets or scripts. This simplifies clients by reducing the number of features required to be pre-implemented. Allowing features to be downloaded after deployment improves system extensibility. However, it also reduces visibility, and thus is only an optional constraint within REST.

--Roy Fielding - Representational State Transfer (REST)

脚本编写与 Web

Hypermedia-Driven Applications 中,我们讨论了如何构建 Web 应用程序,使其以 hypermedia(超媒体)驱动的方式运行,这与流行的 SPA 方法形成对比,后者以 JavaScript 驱动,并在网络层面上 RPC-driven

在 HDA 文章中,我们简要提到了脚本编写:

在 HDA 中,超媒体(HTML)是构建应用程序的主要媒介,这意味着:

脚本编写增强了现有的超媒体(HTML),但不会取代它或破坏 HDA 的基本 REST-ful 架构。

在本文中,我们希望扩展最后一条评论,并描述不“取代”或 “破坏” REST-ful、Hypermedia-Driven Application 的脚本编写是什么样子。这些经验法则适用于直接编写以支持 Web 应用程序的脚本,以及通用 JavaScript 库。

友好超媒体的脚本编写的基本规则是:

下面将详细阐述这些规则。

首要指令

HDA 的首要指令是使用 Hypermedia As The Engine of Application State。 友好超媒体的脚本编写方法将遵循这一指令。

实际上,这意味着脚本编写应避免通过网络与服务器进行非超媒体交换。

因此,一般来说,友好超媒体的脚本编写应避免使用 fetch()XMLHttpRequest 除非 服务器的响应使用某种超媒体(例如 HTML),而不是数据 API 格式(例如纯 JSON)。

尊重 HATEOAS 还意味着,一般来说,应避免在 JavaScript 中存储复杂的状态(而非在 DOM 中)。

然而,最后这条陈述需要限定:可以在 JavaScript 中存储客户端状态,只要它直接支持比纯 HTML 允许的更复杂的用户界面体验(例如小部件)。

重申 Fielding 关于 REST 中脚本编写目的的说法:

Allowing features to be downloaded after deployment improves system extensibility.

因此,脚本编写是 REST-ful 系统的一个合法部分,以便允许创建底层超媒体中未直接实现的附加功能,从而使超媒体(例如 HTML)更具可扩展性。

这种功能的一个好例子是富文本编辑器:它可能有一个极其复杂的 JavaScript 模型来表示编辑器的文档,包括选区信息、高亮信息、代码补全等。 然而,这个模型应与 DOM 的其余部分隔离,并且富文本编辑器应使用标准超媒体功能向 DOM 暴露其信息。例如,它应使用隐藏输入来将编辑器的内容通信到周围的 DOM,而不是要求 JavaScript API 调用来获取内容。

这个想法是使用脚本编写来改进超媒体体验,提供标准超媒体(HTML)工具集中不包含的功能和特性,但要以与 HTML 良好协作的方式进行,而不是像许多 SPA 框架那样将 HTML 降级为更大的 JavaScript 应用程序中的单纯 UI 描述语言。

状态

请注意,使用 Hypermedia As The Engine Of Application State 并不意味着你不能有 任何 客户端状态。 显然,上面引用的富文本编辑器示例可能有大量的客户端状态。但 有一些更简单的情况,其中客户端状态是合理的,并且与 Hypermedia-Driven Application 完全一致。

考虑一个简单的可见性切换,其中点击按钮或锚点会向另一个元素添加类,使其可见。

这种短暂的客户端状态在 Hypermedia-Driven Application 中是可以接受的,因为状态纯粹是前端的。这种脚本编写不会更新系统状态。如果系统状态被改变(也就是说,如果显示或隐藏元素会影响存储在服务器上的数据),则需要使用超媒体交换。

需要考虑的关键方面是,客户端更新的任何状态是否需要与服务器同步。 如果是,则应使用超媒体交换。如果不是,则仅保持客户端状态是可以的。

事件

JavaScript 库实现友好超媒体脚本编写的一个优秀方法是拥有 丰富的自定义事件模型

基于 JavaScript 的组件触发事件,可以使面向超媒体的 JavaScript 库(如 htmx) 监听这些事件并触发超媒体交换。这反过来使任何 JavaScript 库成为潜在的 超媒体控件,能够通过用户选择的操作驱动 Hypermedia-Driven Application。

一个很好的例子是 Sortable.js 示例,其中 htmx 监听 Sortable.js 触发的 end 事件:

<form class="sortable" hx-post="/items" hx-trigger="end">
  <div class="htmx-indicator">Updating...</div>
  <div><input type='hidden' name='item' value='1'/>Item 1</div>
  <div><input type='hidden' name='item' value='2'/>Item 2</div>
  <div><input type='hidden' name='item' value='3'/>Item 3</div>
  <div><input type='hidden' name='item' value='4'/>Item 4</div>
  <div><input type='hidden' name='item' value='5'/>Item 5</div>
</form>

end 事件由 Sortable.js 在拖放完成时触发。htmx 通过 hx-trigger 属性监听此事件,然后发出 HTTP 请求,与 服务器交换超媒体。这将 Sortable.js 拖放驱动的小部件转变为一个新的、强大的超媒体控件。

Islands

Web 开发中的一个最近趋势是 “islands” 的概念:

The islands architecture encourages small, focused chunks of interactivity within server-rendered web pages.

在需要更复杂的脚本编写方法并且需要与服务器进行超出正常超媒体交换机制之外通信的情况下,最友好超媒体的方法是使用 islands 架构。这将非超媒体组件与 Hypermedia-Driven Application 的其余部分隔离。

事件是一种干净的方式,将你的非超媒体驱动 islands 集成到更广泛的 Hypermedia-Driven Application 中, 允许你将“内部” islands 转换为“外部”超媒体控件,就像上面的 Sortable.js 示例一样。

Deniz Akşimşek 观察到,通常更容易将非超媒体 islands 嵌入到更大的 Hypermedia-Driven Application 中,而不是反之。

内联脚本

友好超媒体脚本编写的最后一条规则是内联脚本编写:直接在超媒体中编写脚本, 而不是将脚本放在外部文件中。与这里列出的其他规则相比,这是一个有争议的概念,我们认为它是友好超媒体脚本编写的“可选”规则:值得考虑但不是必需的。

这种脚本编写方法虽然特立独行,但已被某些 HTML 脚本库采用,特别是 Alpine.jshyperscript

这里是一个 hyperscript 示例,显示内联脚本:

<button _="on click toggle .visible on the next <section/>">
    Show Next Section
</button>
<section>
    ....
</section>

这个按钮,如其所述,在被点击时在 section 元素上切换 .visible 类。

这种内联超媒体脚本编写的主要优势是,从概念上强调超媒体本身, 而不是超媒体的脚本编写。

将此代码与 JSX Components 对比,其中 脚本语言(JavaScript)是核心概念,超媒体/HTML 嵌入其中:

class Button extends React.Component {
    constructor(props) {
        // ...
    }
    toggleVisibilityOnNextSection() {
        // ...
    }
    render() {
        return <button onClick={this.toggleVisibilityOnNextSection}>{this.props.text}</button>;
    }
}

在这里,你可以看到 JavaScript 是主要使用的技术,超媒体/HTML 被用作 UI 描述机制。在这种情况下,HTML 作为超媒体的事实几乎无关紧要。

话虽如此,内联脚本编写和 JSX 方法有一个共同的优势:两者都满足 Locality of Behavior(LoB), 设计原则。它们都将行为 本地化 到相关元素或组件,这使得更容易看到 这些元素和组件的作用。

当然,对于内联脚本,应设置脚本编写量的软限制,直接在 超媒体中进行。你不想让脚本编写压倒你的超媒体,以至于难以理解超媒体文档的“形状”。

使用诸如调用库函数或使用 hyperscript behaviors 等技术 允许你在使用内联脚本编写的同时,将实现拉出到单独的文件或位置。

内联脚本编写不是友好超媒体脚本编写的必需,但值得考虑作为更传统的脚本编写/超媒体分离的替代方案。

实用主义

当然,在现实世界中,有许多有用的 JavaScript 库违反 HATEOAS 并且不触发 事件。这通常使它们难以适应 Hypermedia-Driven Application。尽管如此,这些库可能 提供其他地方难以找到的关键功能。

在这种情况下,我们主张实用主义:如果足够容易修改库使其友好超媒体或以 友好超媒体的方式包装它,那可能是一个好选择。你永远不知道,上游作者可能 考虑一个拉取请求 来帮助改进他们的库。

但是,如果不能,并且没有好的替代方案,那么就按设计使用 JavaScript 库。

尽量将不友好超媒体的库与应用程序的其余部分隔离,但一般来说,不要 花费太多你的 complexity budget 来维护概念纯度: sufficient unto the day is the evil thereof.

</>