利用声明式编程来创建可维护的Web应用程序
在这篇文章中,我展示了如何明智地采用声明式编程技术,使团队能够创建更容易扩展和维护的网络应用。
"......声明式编程是一种编程范式,它表达了计算的逻辑,而没有描述其控制流。"-Remo H. Jansen,Hands-on Functional Programming with TypeScript
像软件中的大多数问题一样,决定在你的应用程序中使用声明式编程技术需要仔细评估其中的利弊。请看我们之前的一篇文章,对这些问题进行了深入的讨论。
这里的重点是如何将声明式编程模式逐步应用于用JavaScript编写的新的和现有的应用程序,这是一种支持多种范式的语言。
首先,我们讨论了如何在后端和前端使用TypeScript,以使你的代码更具表现力和对变化的弹性。然后,我们探讨有限状态机(FSM),以简化前端开发,增加利益相关者在开发过程中的参与。
FSM不是一项新技术。它们在近50年前就被发现了,并且在信号处理、航空和金融等行业很受欢迎,在这些行业中,软件的正确性可能是至关重要的。它们也非常适用于对现代网络开发中经常出现的问题进行建模,例如协调复杂的异步状态更新和动画。
这种好处是由于对状态管理方式的限制而产生的。一个状态机只能同时处于一个状态,并且在响应外部事件(如鼠标点击或获取响应)时可以过渡到有限的邻近状态。其结果通常是显著降低缺陷率。然而,FSM方法可能很难扩展到大型应用中去。最近被称为状态图的FSM扩展允许复杂的FSM被可视化并扩展到更大的应用中,这就是本文关注的有限状态机的味道。在我们的演示中,我们将使用XState库,它是JavaScript中FSMs和状态图的最佳解决方案之一。
用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(第一次尝试)
让我们考虑一下验证函数的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);
}
这样做比较好,但也很麻烦。所有对ValidEmail 的使用都通过email.value 来访问实际地址。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@address.com"); // Error: expected type ValidEmail.
lookupUser(validatePassword("MyPassword"); // Error: expected type ValidEmail.
lookupUser(validateEmail("email@address.com")); // 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代码或共享的后端/前端验证层如何用类型来扩展,以提高代码的可读性,并允许更安全的重构--这对团队来说是很重要的要求。
声明式用户界面
使用声明式编程技术开发的用户界面将精力集中在描述 "什么 "而不是 "如何 "上。网络的三大基本要素中的两个,即CSS和HTML,是经得起时间和超过10亿个网站考验的声明式编程语言。
推动网络发展的主要语言。
React在2013年由Facebook开源,它大大改变了前端开发的进程。当我第一次使用它时,我很喜欢我可以把GUI声明为应用程序状态的一个函数。我现在能够从较小的构建块中组成大型复杂的UI,而不需要处理DOM操作的混乱细节,也不需要跟踪应用程序的哪些部分需要更新以响应用户的操作。在定义用户界面时,我可以在很大程度上忽略时间方面的问题,而专注于确保我的应用程序从一个状态正确过渡到下一个状态。
前端JavaScript的演变,从如何到什么。
为了实现更简单的UI开发方式,React在开发者和机器/浏览器之间插入了一个抽象层。 虚拟DOM.
其他现代Web UI框架也弥补了这个差距,尽管方式不同。例如,Vue通过JavaScript getters/setters(Vue 2)或proxies(Vue 3)采用了功能反应性。Svelte通过一个额外的源代码编译步骤(Svelte)带来了反应性。
这些例子似乎证明了我们这个行业的一个巨大愿望,即为开发者提供更好、更简单的工具,通过声明式方法来表达应用行为。
声明性的应用程序状态和逻辑
虽然表现层仍然围绕着某种形式的HTML(例如React中的JSX,Vue、Angular和Svelte中的基于HTML的模板),但我推测,如何以一种容易被其他开发者理解的方式对应用程序的状态进行建模,并在应用程序成长过程中进行维护,这个问题仍然没有解决。我们可以通过持续到今天的状态管理库和方法的激增看到这个问题的证据。
由于现代Web应用的期望值越来越高,情况变得更加复杂。现代状态管理方法必须支持的一些新兴挑战:
- 使用先进的订阅和缓存技术的离线第一的应用程序
- 简洁的代码和代码重用,以满足不断缩小的捆绑尺寸要求
- 通过高保真动画和实时更新,对日益复杂的用户体验的需求
(有限状态机和状态图的(重新)出现
在某些应用稳健性至关重要的行业,如航空和金融业,有限状态机已被广泛用于软件开发。通过优秀的XState库,它在网络应用的前端开发中也逐渐流行起来,例如。
维基百科对有限状态机的定义是。
一种抽象的机器,在任何时候都可以准确地处于有限数量的状态中的一种。有限状态机可以根据一些外部输入从一个状态改变到另一个状态;从一个状态到另一个状态的改变被称为转换。一个FSM是由其状态列表、初始状态和每个转换的条件来定义的。
再进一步说。
状态是对正在等待执行转换的系统状态的描述。
由于状态爆炸的问题,FSM的基本形式不能很好地扩展到大型系统。最近,UML状态图被创造出来,用层次结构和并发性来扩展FSM,这是FSM在商业应用中广泛使用的推动力。
声明你的应用逻辑
首先,FSM的代码是什么样子的?有几种方法可以在JavaScript中实现有限状态机。
- 作为开关语句的有限状态机
下面是一个描述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组件的层次结构来帮助指导这个过程。参见下一节关于如何将状态机映射到用户界面组件。
使用状态机的一个主要好处是在你的应用程序中明确地模拟所有的状态和状态之间的转换,这样所产生的行为就会被清楚地理解,使逻辑错误或差距很容易被发现。
为了使其顺利运行,机器需要保持小而简洁。幸运的是,分层组成状态机很容易。在交通灯系统的典型状态图例子中,"红色 "状态本身成为一个子状态机。父 "灯 "机不知道 "红 "的内部状态,但决定何时进入 "红",以及退出时的预期行为是什么。
使用状态图的交通灯例子。
1-1 状态机与有状态UI组件的映射
以一个非常简化的、虚构的电子商务网站为例,它有以下React视图:
<App>
<SigninForm />
<RegistrationForm />
<Products />
<Cart />
<Admin>
<Users />
<Products />
</Admin>
</App>
对于那些使用过Redux状态管理库的人来说,生成对应于上述视图的状态机的过程可能很熟悉:
- 该组件是否有需要被建模的状态?例如,Admin/Products可能没有;分页获取到服务器加上一个缓存解决方案(如SWR)可能就足够了。另一方面,像SignInForm或Cart这样的组件通常包含需要管理的状态,如输入到字段的数据或当前的购物车内容。
- 本地状态技术(如React的
setState() / useState())是否足以捕捉到这个问题?追踪购物车弹出模式当前是否打开几乎不需要使用有限状态机。 - 产生的状态机是否可能太复杂?如果是这样,就把机器拆成几个小的,找出机会来创建可以在其他地方重用的子机器。例如,SignInForm和RegistrationForm机器可以调用子机textFieldMachine的实例,为用户电子邮件、姓名和密码字段的验证和状态建模。
何时使用有限状态机模型
虽然状态图和FSMs可以优雅地解决一些具有挑战性的问题,但决定在一个特定的应用中使用最好的工具和方法通常取决于几个因素。
在一些情况下,使用有限状态机会大放异彩:
- 你的应用程序包括一个相当大的数据输入组件,其中字段的可访问性或可见性由复杂的规则控制:例如,保险索赔应用程序中的表格输入。在这里,有限状态机有助于确保业务规则被稳健地实施。此外,状态图的可视化功能可以用来帮助增加与非技术利益相关者的合作,并在开发初期确定详细的业务需求。
- 为了在较慢的连接上更好地工作,并为用户提供更高的保真度体验,网络应用必须管理越来越复杂的异步数据流。FSM明确地模拟了应用程序可能处于的所有状态,并且状态图可以被可视化,以帮助诊断和解决异步数据问题。
- **需要大量复杂的、基于状态的动画的应用。**对于复杂的动画,用RxJS将动画建模为通过时间的事件流的技术很受欢迎。对于许多场景来说,这很有效,然而,当丰富的动画与一系列复杂的已知状态相结合时,FSM提供了定义明确的 "休息点",动画在其间流动。FSM与RxJS的结合似乎是帮助提供下一波高保真、富有表现力的用户体验的完美组合。
- 丰富的客户端应用程序,如照片或视频编辑、图表创建工具或游戏,其中大部分的业务逻辑都在客户端。有限状态机本质上是与UI框架或库解耦的,并且很容易编写测试,使高质量的应用程序能够快速迭代,并充满信心地交付。
有限状态机的注意事项
- 对于大多数前端开发者来说,状态图库(如XState)的一般方法、最佳实践和API都是新的,他们需要投入时间和资源来提高工作效率,特别是对于经验不足的团队。
- 与前面的注意事项类似,虽然XState的受欢迎程度持续增长,并且有很好的文档,但现有的状态管理库,如Redux、MobX或React Context有巨大的追随者,提供了大量XState尚不匹配的在线信息。
- 对于遵循更简单的CRUD模型的应用,现有的状态管理技术与一个好的资源缓存库(如SWR或React Query)相结合就足够了。在这里,FSM提供的额外约束,虽然对复杂的应用程序有难以置信的帮助,但可能会减缓开发速度。
- 与其他状态管理库相比,该工具的成熟度较低,改进TypeScript支持和浏览器devtools扩展的工作仍在进行中。
总结
声明式编程在Web开发社区的受欢迎程度和采用程度继续上升。
在现代Web开发继续变得更加复杂的同时,采用声明式编程方法的库和框架也越来越频繁地出现。原因似乎很清楚--需要创建更简单、更具描述性的方法来编写软件。
使用强类型语言(如TypeScript)可以简洁明了地对应用领域的实体进行建模,这就减少了出错的机会和需要操作的易错检查代码的数量。在前端采用有限状态机和状态图,允许开发人员通过状态转换来声明应用程序的业务逻辑,从而实现丰富的可视化工具的开发,并增加与非开发人员密切协作的机会。
当我们这样做的时候,我们将注意力从应用程序如何工作的螺母和螺栓转移到一个更高层次的视图,使我们能够更加关注客户的需求并创造持久的价值。