初次相遇 我所在的公司专注于低代码业务流程自动化。当时我刚刚被提拔为前端产品开发主管,负责创建一个能够与现有产品生态系统无缝集成的可视化 UI 构建器。最初,我们认为为动态 UI 构建一个自定义响应式系统是没有意义的——显然,我们应该在成熟稳定的系统基础上进行构建,而不是重新发明轮子,对吧?
当时,Knockout 看起来是个不错的选择,而且一开始也运行良好。但随着时间的推移,我们在其上构建的越多,就越需要绕过框架的默认行为,从而遇到了性能、稳定性和可维护性方面的问题。后来,我意识到,与其继续添加更多变通方案,不如用一个真正符合我们需求的自定义基础框架来替换它。最终,我们开发了一个简单的专用核心 API,之后它确实运行快速可靠,维护成本极低。
历史重演 在我的职业生涯后期,我参与了基于Angular 和 Vue构建其他复杂产品的开发。两者的使用体验并无太大差异。它们对于标准的 Web 项目来说运行良好,但当你在其上构建高度可配置的产品时,就会变得非常痛苦。Vue 3 中的新 API 和 Angular 中的 Signals 终于支持了更高的灵活性和控制力,但在我看来,使用它们仍然不太方便。
我高中时的一位 IT 老师曾经说过:优秀的开发人员需要懒惰。我把这句话牢记在心。:) 我太懒了,不愿意一直传递额外的元上下文,因为数据模型对自己一无所知。我太懒了,不愿意在访问之前总是显式初始化嵌套对象。我太懒了,不愿意将数据映射到不同的数据结构以有效地执行基本操作。我也不想分别管理验证和加载的状态跟踪。而且我不想因为某些东西没有在正确的时间或在正确的上下文中创建或跟踪而导致响应性 中断,这将是一场调试的噩梦。 但如何避免这些问题呢?我的首选解决方案是自定义数据包装器。
应用课程 在反复构建围绕响应式数据的包装器和代理时,我注意到了一些反复出现的模式。在接下来的小章节中,我们将探讨一些最有用的概念。代码示例使用了可跨框架适配的纯值实现(只需将纯值字段替换为响应式引用,例如 Vue Refs、Angular Signals 等)。后续实现扩展了之前的实现,并重点关注相关的新方面,以保持代码的简洁。
隐式初始化 在嵌套对象结构中,总是确保在访问属性之前初始化对象可能非常繁琐且容易出错。现代 JS 特性和 TS 对此有所帮助,但仍然不够方便。一个可以动态访问属性的自定义包装器可以简化这一过程。
简单的可选初始化:
data.users ??= {}; data.users[userId] ??= {};
const user = data.users[userId];
user.contact ??= {}; user.contact.email = email; user.location ??= {}; user.location.address = address; 具有隐式初始化的包装器:
const user = model.prop('users').prop(userId);
user.prop('contact').prop('email').set(email); user.prop('location').prop('address').set(address); 使用 TS,我们可以确保动态键仅支持有效属性,并且子模型继承正确的类型。以下是该prop()方法的基本版本。
prop(key: K): Model<T[K]> { let prop = this.props.get(key) as Model<T[K]>; if (!prop) { prop = new Model(); this.props.set(key, prop); } return prop; } 简化初始化固然很好,但这种模型方法的真正价值在情况变得更加复杂时才会显现出来。所以,让我们继续讨论更高级的概念。
情境感知 您是否遇到过这样的问题:您将数据传递给函数或子组件,之后需要该数据的键或索引?或者您想删除数据,但无法访问其父级。当然,您可以向下传递键、索引、父级或其他任何您需要的信息。如果只是一层,这没什么大不了的,但在复杂的应用程序中,这可能会变得非常混乱。如果数据模型只包含这些上下文信息,那不是很好吗?
示例如下:
function performAction(user: Model, action: UserAction): boolean { console.log('Perform action for user ', user.key, ': ', action);
switch (action) {
case 'duplicate':
return duplicateProp(user, createId());
case 'remove':
return user.remove();
default:
throw new Error(Unknown action: ${action});
}
}
duplicateProp 函数
这是实施的基本概要(请参阅下面折叠部分中的完整类)。 class Model {
readonly key?: string; readonly parent?: Model;
// ...
remove(): boolean { const props = this.parent?.props; if (props && this.key) { return props.delete(this.key); } return false; } } 完整模型类 一致的访问和迭代 另一个反复出现的挑战是决定将集合存储为数组还是映射。乍一看似乎很简单——列表用数组,基于键的查找用映射。但在很多实际情况下,两者都需要,这时情况就会变得复杂。一个内部透明地支持两者并保持同步的模型对于这些情况非常有用。
const model = new Model({a: true, b: false, c: null});
log(model.prop('b').index); // → 1 log(model.item(1).key); // → b
log(model.child('b') === model.child(1)); // → true
model.forEach(child => log(child.index, child.key, child.get())); // → 0 a true / 1 b false / 2 c null 如果基础结构是列表,模型可以自动生成键,以允许相同类型的双重访问。让我们深入探讨一下!
稳定(深层)引用 使用索引引用列表项很容易在列表更改时出现不一致的情况。而且,使用 ID 来标识列表项会导致列表搜索效率低下。借助我们的组合模型方法和生成的键,可以相当轻松地实现稳定高效的查找。
class ListSelection {
protected key?: string; // replace with reactive reference
constructor(readonly list: Model<Item[]>) { }
get selected(): Model | undefined { const {key} = this; return key ? this.list.child(key) : undefined; }
select(index: number): void { this.key = this.list.item(index).key; } } 尽管它应该比下面的简约示例具有更好的键/索引验证,但实现也相当简单。
列表访问实现。
现在,我们已经有了对平面列表项的稳定引用,但是如果我们有深层嵌套的数据结构,并且想要引用其中的任何条目,该怎么办?通过一些小扩展,我们也可以解决这个问题。 const node = tree.prop('nodes').item(3).prop('nodes').item(2); const ref = node.ref; // stable deep reference
log(tree.resolve(ref) === node); // → true log(node.resolve(ref) === node); // → true log(node.resolve(SpecialKeys.root) === tree); // → true log(node.resolve(SpecialKeys.parent) === node.parent); // → true 这为我们提供了一种强大的方法,可以通过相对或绝对(基于根)引用来引用同一模型层次结构中的任何其他条目。而且实现起来也并不复杂。
深度引用实现。 内部验证和加载状态 在大多数应用程序中,我们需要能够检查数据是否正在加载以及更改的数据是否有效。从逻辑上讲,这两种状态信息都与数据紧密相关——将它们直接包含在数据模型中可以避免不一致,并有助于保持代码简洁。
submitTask(task: Model): void { if (task.isValid && !task.isLoading) { // ... } } 下面的代码片段展示了加载状态集成的一个简单示例。
简单加载状态
根据具体情况,您可能需要使用外部库或跟踪其他元信息,尤其是在进行验证时。在下一章中,我们将探索更高级的模式,以简单可靠地跟踪不同类型的异步状态。 通用异步状态管理 对于简单情况,布尔标志、状态类型或计数器足以跟踪异步操作的状态。但我们通常需要更详细的状态信息,例如错误消息。此外,我们还希望能够取消待处理的操作并避免竞争条件。以下实现使用轻量级、统一的包装类解决了这些问题。
加载中 验证 动态 超越包装 正如我们所见,许多有用的数据模型功能只需包装常用框架的响应式值即可实现。这种方法虽然方便易用,并有助于维护更简洁的代码,但它无法克服更深层次的限制。高效可靠地处理边缘情况也变得棘手。让我们来看看我遇到的两个更根本的问题以及解决这些问题的方法。
显式依赖关系跟踪 有时,代码中会存在由响应式绑定执行的副作用逻辑。如果该逻辑访问响应式数据,即使它并非依赖项,该数据的更改也会触发更新。例如,Angularuntracked在 Signals 中引入了一个函数,以避免不必要的跟踪。显式跟踪则颠覆了这种方法,实现了更灵活的控制,并使应用程序的哪些部分依赖于响应式值变得更加透明。
更重要的是,它可以与异步逻辑无缝协作。现代前端高度依赖异步逻辑,但响应式状态跟踪通常需要同步执行依赖项。这意味着,所有响应式值都需要在异步代码之外解析,然后通过异步调用层次结构向下传递,这限制了架构选项,并且很容易导致 FE 结构变得更加复杂。显式依赖项跟踪提供了更大的架构自由度,例如,使我们能够以 OOP 风格组织主要结构。但它需要一种完全不同的响应式方法,而不能仅仅通过为同步响应式系统添加一个包装器来实现。语言无关的全栈模型 全栈应用程序的一个大麻烦在于管理FE 和 BE 之间的一致数据模型。一种解决方案是使用 JS 实现 BE,但在很多情况下这并不是一个好选择,原因很充分——虽然已经有了很多改进,但它仍然不太适合构建可靠且可扩展的业务逻辑。Google Web Toolkit 尝试了另一种有趣的方法来使用通用语言,但使用 Java 实现 FE 太复杂了。总的来说,我认为局限于一种特定的语言并不是一个好主意。对我来说,这个问题的最佳解决方案似乎是定义数据结构和高级验证逻辑的声明式抽象层。OpenAPI 和 GraphQL 等标准支持共享模式,但它们不支持计算数据和高级验证的共享逻辑。我希望有一个统一的全栈解决方案,以一致的方式定义数据结构及其相关逻辑。当这些结构基于像 JSON 这样的通用格式时,用大多数主流语言构建解释器就相对容易了,这可以提供很大的技术灵活性。这就是我下一步旅程的目标。
新的基础 在我的职业生涯中,我反复遇到上述所有问题,并为其中大多数问题开发了多种解决方案。最终,我厌倦了一遍又一遍地解决同样的问题,所以我开始创建一个框架来解决所有问题。:)作者www.lglngy.com