利用声明式编程创建可维护的 Web 应用程序
已发表: 2022-03-11在本文中,我将展示如何明智地采用声明式编程技术,使团队能够创建更易于扩展和维护的 Web 应用程序。
“……声明式编程是一种编程范式,它表达了计算的逻辑而不描述其控制流。” —Remo H. Jansen,使用 TypeScript 进行动手函数式编程
与软件中的大多数问题一样,决定在应用程序中使用声明式编程技术需要仔细评估权衡。 查看我们之前的一篇文章,深入讨论这些内容。
在这里,重点是声明式编程模式如何逐渐被用 JavaScript 编写的新应用程序和现有应用程序采用,JavaScript 是一种支持多种范式的语言。
首先,我们讨论如何在后端和前端使用 TypeScript,以使您的代码更具表现力和适应变化的能力。 然后,我们探索有限状态机 (FSM) 以简化前端开发并增加利益相关者对开发过程的参与。
FSM 并不是一项新技术。 它们是在近 50 年前被发现的,在信号处理、航空和金融等行业很受欢迎,在这些行业中,软件的正确性至关重要。 它们也非常适合现代 Web 开发中经常出现的建模问题,例如协调复杂的异步状态更新和动画。
这种好处是由于对状态管理方式的限制而产生的。 状态机只能同时处于一种状态,并且它可以转换到的相邻状态有限,以响应外部事件(例如鼠标单击或获取响应)。 结果通常是显着降低的缺陷率。 但是,FSM 方法可能难以扩展以在大型应用程序中正常工作。 最近对 FSM 的扩展称为状态图允许将复杂的 FSM 可视化并扩展到更大的应用程序,这是本文重点介绍的有限状态机的特点。 对于我们的演示,我们将使用 XState 库,它是 JavaScript 中 FSM 和状态图的最佳解决方案之一。
使用 Node.js 在后端声明式
使用声明性方法对 Web 服务器后端进行编程是一个很大的话题,通常可能从评估合适的服务器端函数式编程语言开始。 相反,让我们假设您正在阅读本文时,您已经选择(或正在考虑)Node.js 作为后端。
本节详细介绍了一种在后端建模实体的方法,该方法具有以下优点:
- 提高代码可读性
- 更安全的重构
- 由于类型建模提供的保证,提高性能的潜力
通过类型建模的行为保证
JavaScript
考虑在 JavaScript 中通过电子邮件地址查找给定用户的任务:
function validateEmail(email) { if (typeof email !== "string") return false; return isWellFormedEmailAddress(email); } function lookupUser(validatedEmail) { // Assume a valid email is passed in. // Safe to pass this down to the database for a user lookup.. }
此函数接受电子邮件地址作为字符串,并在匹配时从数据库中返回相应的用户。
假设只有在执行基本验证后才会调用lookupUser()
。 这是一个关键假设。 如果几周后,进行了一些重构并且这个假设不再成立怎么办? 手指交叉单元测试捕捉到错误,或者我们可能会将未经过滤的文本发送到数据库!
打字稿(第一次尝试)
让我们考虑一个等效于验证函数的 TypeScript:
function validateEmail(email: string) { // No longer needed the type check (typeof email === "string"). return isWellFormedEmailAddress(email); }
这是一个轻微的改进,TypeScript 编译器使我们免于添加额外的运行时验证步骤。
强类型可以带来的安全保证还没有真正被利用。 让我们研究一下。
TypeScript(第二次尝试)
让我们提高类型安全性并禁止将未处理的字符串作为输入传递给looukupUser
:
type ValidEmail = { value: string }; function validateEmail(input: string): Email | null { if (!isWellFormedEmailAddress(input)) return null; return { value: email }; } function lookupUser(email: ValidEmail): User { // No need to perform validation. Compiler has already ensured only valid emails have been passed in. return lookupUserInDatabase(email.value); }
这更好,但它很麻烦。 email.value
ValidEmail
实际地址。 TypeScript 使用结构类型,而不是 Java 和 C# 等语言使用的名义类型。
虽然功能强大,但这意味着任何其他符合此签名的类型都被视为等效。 例如,可以将以下密码类型传递给lookupUser()
而不会引起编译器的投诉:
type ValidPassword = { value: string }; const password = { value: "password" }; lookupUser(password); // No error.
TypeScript(第三次尝试)
我们可以使用交集在 TypeScript 中实现名义输入:
type ValidEmail = string & { _: "ValidEmail" }; function validateEmail(input: string): ValidEmail { // Perform email validation checks.. return input as ValidEmail; } type ValidPassword = string & { _: "ValidPassword" }; function validatePassword(input: string): ValidPassword { ... } lookupUser("[email protected]"); // Error: expected type ValidEmail. lookupUser(validatePassword("MyPassword"); // Error: expected type ValidEmail. lookupUser(validateEmail("[email protected]")); // Ok.
我们现在已经实现了只有经过验证的电子邮件字符串才能传递给lookupUser()
的目标。
专业提示:使用以下帮助类型轻松应用此模式:
type Opaque<K, T> = T & { __TYPE__: K }; type Email = Opaque<"Email", string>; type Password = Opaque<"Password", string>; type UserId = Opaque<"UserId", number>;
优点
通过在您的域中强输入实体,我们可以:
- 减少需要在运行时执行的检查次数,这些检查会消耗宝贵的服务器 CPU 周期(虽然数量很少,但在每分钟处理数千个请求时确实会加起来)。
- 由于 TypeScript 编译器提供的保证,维护更少的基本测试。
- 利用编辑器和编译器辅助的重构。
- 通过提高信噪比提高代码可读性。
缺点
类型建模需要考虑一些权衡:
- 引入 TypeScript 通常会使工具链复杂化,从而导致构建和测试套件执行时间更长。
- 如果您的目标是对功能进行原型设计并尽快将其交到用户手中,那么显式建模类型并通过代码库传播它们所需的额外工作可能不值得。
我们已经展示了如何使用类型扩展服务器上现有的 JavaScript 代码或共享的后端/前端验证层,以提高代码的可读性并允许更安全的重构——这是团队的重要要求。
声明式用户界面
使用声明式编程技术开发的用户界面集中精力描述“什么”而不是“如何”。 Web 的三个主要基本成分中的两个,CSS 和 HTML,是声明性编程语言,它们经受住了时间和超过 10 亿个网站的考验。
React 于 2013 年由 Facebook 开源,它极大地改变了前端开发的进程。 当我第一次使用它时,我喜欢如何将 GUI声明为应用程序状态的函数。 我现在能够从较小的构建块组成大型而复杂的 UI,而无需处理 DOM 操作的混乱细节和跟踪应用程序的哪些部分需要更新以响应用户操作。 在定义 UI 时,我可以在很大程度上忽略时间方面,并专注于确保我的应用程序正确地从一种状态转换到另一种状态。
为了实现更简单的 UI 开发方式,React 在开发人员和机器/浏览器之间插入了一个抽象层:虚拟 DOM 。
其他现代 Web UI 框架也弥补了这一差距,尽管方式不同。 例如,Vue 通过 JavaScript getter/setter (Vue 2) 或代理 (Vue 3) 使用功能响应性。 Svelte 通过额外的源代码编译步骤 (Svelte) 带来反应性。
这些示例似乎表明了我们行业的强烈愿望,即为开发人员提供更好、更简单的工具,以通过声明性方法表达应用程序行为。
声明式应用程序状态和逻辑
虽然表示层继续围绕某种形式的 HTML(例如,React 中的 JSX,Vue、Angular 和 Svelte 中的基于 HTML 的模板),但我假设如何以一种方式对应用程序的状态建模的问题是其他开发人员易于理解并且随着应用程序的增长而可维护的问题仍未解决。 我们通过持续到今天的状态管理库和方法的扩散看到了这一点。
对现代 Web 应用程序的期望越来越高,情况变得更加复杂。 现代状态管理方法必须支持的一些新出现的挑战:
- 使用高级订阅和缓存技术的离线优先应用程序
- 简洁的代码和代码重用可满足不断缩小的捆绑包大小要求
- 通过高保真动画和实时更新对日益复杂的用户体验的需求
(重新)有限状态机和状态图的出现
有限状态机已广泛用于某些行业的软件开发,这些行业的应用程序稳健性至关重要,例如航空和金融。 通过例如优秀的 XState 库,它在 Web 应用程序的前端开发中也越来越受欢迎。
维基百科将有限状态机定义为:
在任何给定时间都可以恰好处于有限数量的状态之一的抽象机器。 FSM 可以响应一些外部输入从一种状态变为另一种状态; 从一种状态到另一种状态的变化称为过渡。 FSM 由其状态列表、初始状态和每个转换的条件定义。
并进一步:
状态是对等待执行转换的系统状态的描述。
由于状态爆炸问题,基本形式的 FSM 不能很好地扩展到大型系统。 最近,创建了 UML 状态图来扩展具有层次结构和并发性的 FSM,这是在商业应用中广泛使用 FSM 的推动力。
声明你的应用程序逻辑
首先,作为代码的 FSM 是什么样的? 有几种方法可以在 JavaScript 中实现有限状态机。
- 有限状态机作为 switch 语句
这是一台描述 JavaScript 可能处于的状态的机器,使用 switch 语句实现:

const initialState = { type: 'idle', error: undefined, result: undefined }; function transition(state = initialState, action) { switch (action) { case 'invoke': return { type: 'pending' }; case 'resolve': return { type: 'completed', result: action.value }; case 'error': return { type: 'completed', error: action.error ; default: return state; } }
使用过流行的 Redux 状态管理库的开发人员会熟悉这种代码风格。
- 作为 JavaScript 对象的有限状态机
这是使用 JavaScript XState 库实现为 JavaScript 对象的同一台机器:
const promiseMachine = Machine({ id: "promise", initial: "idle", context: { result: undefined, error: undefined, }, states: { idle: { on: { INVOKE: "pending", }, }, pending: { on: { RESOLVE: "success", REJECT: "failure", }, }, success: { type: "final", actions: assign({ result: (context, event) => event.data, }), }, failure: { type: "final", actions: assign({ error: (context, event) => event.data, }), }, }, });
虽然 XState 版本不太紧凑,但对象表示有几个优点:
- 状态机本身是简单的 JSON,可以很容易地持久化。
- 因为它是声明性的,所以机器可以被可视化。
- 如果使用 TypeScript,编译器会检查是否只执行了有效的状态转换。
XState 支持状态图并实现了 SCXML 规范,这使得它适用于非常大的应用程序。
承诺的状态图可视化:
XState 最佳实践
以下是使用 XState 帮助保持项目可维护性时应用的一些最佳实践。
从逻辑中分离副作用
XState 允许从状态机的逻辑中独立指定副作用(包括日志记录或 API 请求等活动)。
这有以下好处:
- 通过保持状态机代码尽可能干净和简单,帮助检测逻辑错误。
- 无需先删除额外的样板即可轻松可视化状态机。
- 通过注入模拟服务更容易测试状态机。
const fetchUsersMachine = Machine({ id: "fetchUsers", initial: "idle", context: { users: undefined, error: undefined, nextPage: 0, }, states: { idle: { on: { FETCH: "fetching", }, }, fetching: { invoke: { src: (context) => fetch(`url/to/users?page=${context.nextPage}`).then((response) => response.json() ), onDone: { target: "success", actions: assign({ users: (context, event) => [...context.users, ...event.data], // Data holds the newly fetched users nextPage: (context) => context.nextPage + 1, }), }, onError: { target: "failure", error: (_, event) => event.data, // Data holds the error }, }, }, // success state.. // failure state.. }, });
虽然在您仍在工作时以这种方式编写状态机很诱人,但通过将副作用作为选项传递可以实现更好的关注点分离:
const services = { getUsers: (context) => fetch( `url/to/users?page=${context.nextPage}` ).then((response) => response.json()) } const fetchUsersMachine = Machine({ ... states: { ... fetching: { invoke: { // Invoke the side effect at key: 'getUsers' in the supplied services object. src: 'getUsers', } on: { RESOLVE: "success", REJECT: "failure", }, }, ... }, // Supply the side effects to be executed on state transitions. { services } });
这也允许对状态机进行简单的单元测试,允许显式模拟用户获取:
async function testFetchUsers() { return [{ name: "Peter", location: "New Zealand" }]; } const machine = fetchUsersMachine.withConfig({ services: { getUsers: (context) => testFetchUsers(), }, });
拆分大型机器
在开始时,如何最好地将问题域构造成一个好的有限状态机层次结构并不总是很明显。
提示:使用 UI 组件的层次结构来帮助指导此过程。 请参阅下一节,了解如何将状态机映射到 UI 组件。
使用状态机的一个主要好处是显式地对应用程序中的所有状态和状态之间的转换进行建模,以便清楚地理解所产生的行为,从而轻松发现逻辑错误或差距。
为了使其正常工作,机器需要保持小而简洁。 幸运的是,分层组合状态机很容易。 在交通灯系统的规范状态图示例中,“红色”状态本身成为子状态机。 父“light”机器不知道“red”的内部状态,但决定何时进入“red”以及退出时的预期行为:
1-1 状态机到有状态 UI 组件的映射
举个例子,一个非常简化的虚构电子商务网站,它具有以下 React 视图:
<App> <SigninForm /> <RegistrationForm /> <Products /> <Cart /> <Admin> <Users /> <Products /> </Admin> </App>
上述视图对应的状态机生成过程对于使用过 Redux 状态管理库的人来说可能很熟悉:
- 组件是否具有需要建模的状态? 例如,管理员/产品可能不会; 分页获取到服务器加上缓存解决方案(例如 SWR)可能就足够了。 另一方面,诸如 SignInForm 或 Cart 之类的组件通常包含需要管理的状态,例如输入到字段中的数据或当前的购物车内容。
- 本地状态技术(例如,React 的
setState() / useState()
)是否足以捕获问题? 跟踪购物车弹出模式当前是否打开几乎不需要使用有限状态机。 - 生成的状态机是否可能过于复杂? 如果是这样,请将机器分成几个较小的机器,寻找机会创建可以在其他地方重复使用的子机器。 例如,SignInForm 和 RegistrationForm 机器可以调用子 textFieldMachine 的实例来对用户电子邮件、姓名和密码字段的验证和状态进行建模。
何时使用有限状态机模型
虽然状态图和 FSM 可以优雅地解决一些具有挑战性的问题,但确定用于特定应用程序的最佳工具和方法通常取决于几个因素。
使用有限状态机的一些情况会大放异彩:
- 您的应用程序包含大量数据输入组件,其中字段可访问性或可见性受复杂规则控制:例如,保险索赔应用程序中的表单输入。 在这里,FSM 有助于确保稳健地实施业务规则。 此外,状态图的可视化功能可用于帮助加强与非技术利益相关者的协作,并在开发早期识别详细的业务需求。
- 为了更好地处理较慢的连接并为用户提供更高保真度的体验,Web 应用程序必须管理日益复杂的异步数据流。 FSM 显式地对应用程序可能处于的所有状态进行建模,并且状态图可以可视化以帮助诊断和解决异步数据问题。
- 需要大量复杂的、基于状态的动画的应用程序。 对于复杂的动画,使用 RxJS 将动画建模为事件流的技术很流行。 对于许多场景,这很有效,但是,当丰富的动画与一系列复杂的已知状态相结合时,FSM 提供了动画在其间流动的明确定义的“休息点”。 FSM 与 RxJS 相结合似乎是帮助交付下一波高保真、富有表现力的用户体验的完美组合。
- 富客户端应用程序,例如照片或视频编辑、图表创建工具或大部分业务逻辑驻留在客户端的游戏。 FSM 本质上与 UI 框架或库分离,并且易于编写测试,以允许快速迭代高质量的应用程序并充满信心地交付。
有限状态机注意事项
- XState 等状态图库的通用方法、最佳实践和 API 对于大多数前端开发人员来说都是新颖的,他们需要投入时间和资源才能提高生产力,尤其是对于经验不足的团队。
- 与前面的警告类似,虽然 XState 的受欢迎程度继续增长并且有据可查,但现有的状态管理库(如 Redux、MobX 或 React Context)拥有大量的追随者,提供了 XState 尚未匹配的大量在线信息。
- 对于遵循更简单的 CRUD 模型的应用程序,现有的状态管理技术与良好的资源缓存库(如 SWR 或 React Query)相结合就足够了。 在这里,FSM 提供的额外约束虽然对复杂的应用程序非常有用,但可能会减慢开发速度。
- 该工具不如其他状态管理库成熟,改进 TypeScript 支持和浏览器开发工具扩展的工作仍在进行中。
包起来
声明式编程在 Web 开发社区中的流行度和采用率继续上升。
尽管现代 Web 开发继续变得更加复杂,但采用声明式编程方法的库和框架越来越频繁地出现。 原因似乎很清楚——需要创建更简单、更具描述性的软件编写方法。
使用诸如 TypeScript 之类的强类型语言允许对应用程序域中的实体进行简洁而明确的建模,从而减少出错的机会和需要操作的易出错检查代码的数量。 在前端采用有限状态机和状态图允许开发人员通过状态转换来声明应用程序的业务逻辑,从而能够开发丰富的可视化工具并增加与非开发人员密切协作的机会。
当我们这样做时,我们将关注点从应用程序如何工作的具体细节转移到更高级别的视图,使我们能够更多地关注客户的需求并创造持久的价值。