前端的清洁架构

2025-06-08

前端的清洁架构

我想分享一些我认为对大规模前端应用有用的见解。我想讨论一些在实践中被证明可靠的想法。同时,我力求解释得简洁明了。

我还实现了一个简单的待办事项列表应用程序来支持口头解释。该应用程序采用了我在更大规模应用中应用的相同设计原则。我将以此应用程序为例,展示各个组件的示例。也欢迎您查看Github 上的源代码,亲自了解其完整功能。

这些示例使用了Angular及其相关工具。通用原则可应用于任何其他生态系统。

我为本文构建的待办事项列表应用程序的屏幕截图。

最终应用程序的屏幕截图。您可以在这里试用

清洁架构

Bob Martin 的《清晰架构》(Clean Architecture)一书给了我很大的启发。这本书非常值得一读,对软件架构的总体理解非常深刻。它对系统设计过程中的重要事项进行了精彩且结构清晰的概述。我发现清晰架构的理念也适用于前端开发。

显示清洁架构的图表。 我在书中和《The Clean Code Blog》中找到了这张图。

清洁架构 (Clean Architecture) 是一种将应用程序与框架、UI 和数据库隔离开来,并确保各个组件可测试的方法。它利用SOLID 原则,并展示了如何在更大规模上将它们整合在一起。

在本文中,我仅描述 Clean Architecture 的其中一种实现方式。我使用 Angular 作为框架和依赖注入容器。

高级前端架构

当我着手开发一个新功能时,我会思考其底层实体及其所需的操作。此图展示了新功能的高级架构。让我们仔细看看每一层。

新功能的高级架构,不包含数据层实现的细节

实体

应用层具有层级结构。实体位于顶层,UI 位于底层。每个层都不能依赖于任何其他底层。例如,实体不应该了解 UI 的任何信息。虽然听起来很琐碎,但实体可能是清晰架构中最关键的部分。我从这里开始设计全新的功能。我最注重的是避免这部分的变化。虽然实体没有在图中显示出来,但它在所有这些层之间流动。

// https://github.com/greetclock/parts/blob/3eec90/libs/todos-data/src/lib/types.ts
export interface Todo {
uuid: string
title: string
description?: string
status: 'pending' | 'done'
}
view raw Todo Entity.ts hosted with ❤ by GitHub
// https://github.com/greetclock/parts/blob/3eec90/libs/todos-data/src/lib/types.ts
export interface Todo {
uuid: string
title: string
description?: string
status: 'pending' | 'done'
}
view raw Todo Entity.ts hosted with ❤ by GitHub

看起来很简单,对吧?没错,实体可以像 Typescript 接口一样简单。其核心思想是只包含描述新功能应用范围的属性。任何可以从这些属性派生的状态都不属于这里。

一个典型的错误是向实体添加一些有助于渲染的额外信息。每次修改实体时,都必须仔细检查新数据是否属于该领域。无论使用哪种 UI、数据管理框架或 API,这些信息都必须具有相关性。

数据层

这一层的作用是为实体提供工具链。你需要哪些操作?操作完成前后的边界条件是什么?适配器(API)的调用频率是多少?你需要乐观更新吗?排序、过滤和分页呢?或许你还需要搜索?而且,你可能还需要一些专门的操作,比如针对待办事项元素的完成/撤消。

可能性有很多,但务必确保不要过度设计你的应用程序。在将新操作实现到数据层之前,业务必须需要某些功能。否则,应用程序可能会毫无理由地变得过于复杂。换句话说,如果没人需要某个功能,为什么要实现它呢?更少的代码意味着更少的维护,以及更快的实现新需求。

应用程序的其余部分取决于数据层的逻辑。它决定 UI 是从缓存还是远程 API 接收对象。

您可以使用任何适合您应用程序的库或模式来实现数据层。这取决于应用程序的复杂程度(根据业务需求)。以下是一些可能性:

  • 具有内部状态的类。它可能使用 RxJs Subjects/Observables。
  • 任何受 Redux 启发的库。在这种情况下,Facade 将触发操作,而不是直接调用数据层的方法。
  • 任何其他国家管理库。
  • Facade 可以直接调用 Adapter。本质上,如果你不需要任何缓存逻辑,它会忽略数据层。
// https://github.com/greetclock/parts/blob/d97018/libs/todos-data/src/lib/todos-data.service.ts
@Injectable({
providedIn: 'root',
})
export class TodosDataService {
constructor(
private todosAdapter: TodosAdapterService,
private todosRepo: TodosRepository,
) {}
getTodos(): Observable<Todo[]> {
return this.todosAdapter
.getTodos()
.pipe(tap((todos) => this.todosRepo.setTodos(todos)))
}
}
view raw data-service.ts hosted with ❤ by GitHub
// https://github.com/greetclock/parts/blob/d97018/libs/todos-data/src/lib/todos-data.service.ts
@Injectable({
providedIn: 'root',
})
export class TodosDataService {
constructor(
private todosAdapter: TodosAdapterService,
private todosRepo: TodosRepository,
) {}
getTodos(): Observable<Todo[]> {
return this.todosAdapter
.getTodos()
.pipe(tap((todos) => this.todosRepo.setTodos(todos)))
}
}
view raw data-service.ts hosted with ❤ by GitHub

适配器

严格来说,适配器也属于数据层。这是一个强大的概念,可以确保应用程序与 API 及其潜在的变更良好隔离。数据服务依赖于我们完全控制的适配器抽象。这是依赖倒置原则的一种实现:我为适配器创建一个抽象类,然后在数据服务中使用它。我还编写了一个完全隐藏于应用程序其余部分之外的适配器实现。因此,数据层规定了适配器实现的技术要求。即使数据从适配器实现流向数据服务,适配器仍然依赖于数据层,而不是反过来。

// https://github.com/greetclock/parts/blob/3eec90/libs/todos-data/src/lib/todos-adapter.service.ts
import { Observable } from 'rxjs'
import { Todo } from './types'
export type CreateTodoDto = Omit<Todo, 'uuid' | 'status'>
export class TodoNotFoundError extends Error {}
export abstract class TodosAdapterService {
abstract getTodos(): Observable<Todo[]>
abstract getTodoByUuid(uuid: string): Observable<Todo | null>
abstract createTodo(todo: CreateTodoDto): Observable<Todo>
abstract deleteTodo(uuid: string): Observable<void>
/**
* @throws TodoNotFoundError
*/
abstract updateTodo(todo: Todo): Observable<Todo>
/**
* @throws TodoNotFoundError
*/
abstract updateTodoStatus(
uuid: string,
status: Todo['status']
): Observable<Todo>
}
// https://github.com/greetclock/parts/blob/3eec90/libs/todos-data/src/lib/todos-adapter.service.ts
import { Observable } from 'rxjs'
import { Todo } from './types'
export type CreateTodoDto = Omit<Todo, 'uuid' | 'status'>
export class TodoNotFoundError extends Error {}
export abstract class TodosAdapterService {
abstract getTodos(): Observable<Todo[]>
abstract getTodoByUuid(uuid: string): Observable<Todo | null>
abstract createTodo(todo: CreateTodoDto): Observable<Todo>
abstract deleteTodo(uuid: string): Observable<void>
/**
* @throws TodoNotFoundError
*/
abstract updateTodo(todo: Todo): Observable<Todo>
/**
* @throws TodoNotFoundError
*/
abstract updateTodoStatus(
uuid: string,
status: Todo['status']
): Observable<Todo>
}

您可以设计应用程序,使整个 API 交互与应用程序逻辑完全隔离。我最喜欢的几个好处如下:

  • 如果 API 发生变化,那么我要做的就是调整适配器实现
  • 如果 API 不可用,我仍然可以实现我的应用程序。API 可用后,我仍然只需调整适配器的实现

在这个应用中,我采用了基于 localStorage 的持久层实现。之后可以轻松地用 API 调用来替换它。这种模式为我的实践节省了无数时间。

正面

在今天的例子中,Facade 是一个充当 UI 和数据层之间接口的对象。每当 UI 需要加载待办事项或创建新待办事项时,它都会调用其中一个 Facade 方法,并将结果作为可观察对象接收。

另一方面,外观可以是内部的任何东西。

  • 在简单的场景中,如果我不需要任何缓存或数据管理,我会直接调用适配器的方法。
  • 在其他情况下,我可能会触发类似 redux 的动作,例如dispatch(loadTodos()),然后监听后续的loadTodosSuccess动作loadTodosFailure
  • 我还可以将调用从外观层传递到另一个负责协调与适配器交互的服务。它可能是基于 RxJS Subjects 的自写服务,也可能是第三方服务,例如@ngrx/data中的服务(不要与裸 NgRx 混淆)!
// https://github.com/greetclock/parts/blob/3eec90/libs/todos-data/src/lib/todos-facade.service.ts
// that's a simplified gist of the original facade. Follow the link to see the whole thing.
import { todos$ } from './todos.repository'
import { TodosDataService } from './todos-data.service'
export class TodosFacadeService {
todos$: Observable<Todo[]> = todos$
constructor(
private todosData: TodosDataService
) {}
getTodos(): Observable<Todo[]> {
const todos$ = this.todosData.getTodos()
todos$.subscribe()
return todos$
}
}
view raw Todos Facade.ts hosted with ❤ by GitHub
// https://github.com/greetclock/parts/blob/3eec90/libs/todos-data/src/lib/todos-facade.service.ts
// that's a simplified gist of the original facade. Follow the link to see the whole thing.
import { todos$ } from './todos.repository'
import { TodosDataService } from './todos-data.service'
export class TodosFacadeService {
todos$: Observable<Todo[]> = todos$
constructor(
private todosData: TodosDataService
) {}
getTodos(): Observable<Todo[]> {
const todos$ = this.todosData.getTodos()
todos$.subscribe()
return todos$
}
}
view raw Todos Facade.ts hosted with ❤ by GitHub

我将职责分散到不同的类中。数据服务负责从适配器请求数据,将数据保存到存储库,并在需要时协调乐观更新。数据服务定义了如何在每次操作后更改状态。

另一方面,Facade 向 UI 暴露了数据 API。它可以请求待办事项列表,也可以创建一个新的列表,然后从统一的todos$可观察对象接收响应,从而隐藏所有响应的复杂性。同时,您可以注意到,我subscribe()在 Facade 方法内部使用了方法,然后返回一个可观察对象本身。
我这样做是为了方便应用程序逻辑。有时触发操作的组件和接收结果的组件是不同的。它们的生命周期也不同。在这个待办事项应用程序中,有时触发组件在请求一些数据后会立即被销毁,因此我需要确保其他组件能够接收结果并保持至少一个订阅处于活动状态。Facade 通过subscribe()在内部引入强制功能巧妙地弥补了这一缺陷。此外,它还确保底层数据服务没有仅与数据消费者相关的额外逻辑。

用户界面

没错,UI 也有逻辑!不过,逻辑是不同的。UI 只与 Facade 交互。UI 的工作是在合适的时机调用 Facade,例如初始化组件或执行某些特定的用户操作。此外,UI 还负责管理其状态。*并非所有状态都会进入数据层。UI 层必须操作特定于 UI 的状态。*

处理 UI 状态的方法有很多。同样,选择哪种方法取决于业务需求。有时,只需将状态存储在组件中即可。在其他情况下,应该有一种在 UI 组件之间交换数据的方法。我今天不会讨论这个话题,也许以后再讨论吧。

把所有东西放在一起

新功能的完整图表。数据层由数据服务和存储库服务表示。

数据层由数据服务和存储库组成。数据服务负责协调操作和逻辑,而存储库负责内存缓存。我使用@ngneat/elf来实现存储库。当然,它也可以是任何其他库,甚至是完全自定义的代码。

数据服务与抽象适配器交互以获取数据。为了简单起见,我完全放弃了后端,并使用了基于本地存储的实现。请记住,当后端可用时,我们前端应用程序的调整可能非常简单。

下一步是什么?

我特意只在文章中粘贴了部分代码来阐述我的想法。我鼓励您自己浏览源代码,了解所有内容。

您想了解更多关于这个主题的内容吗?或者,还有其他内容?您想联系我吗?欢迎留言或在我的个人页面上找到我的联系方式。

归因

封面图片:硫酸铜晶体。CC 4.0 Wikimedia Commons

鏂囩珷鏉ユ簮锛�https://dev.to/alexey_karpov/clean-architecture-in-frontend-1c77
PREV
十大 Git GUI 客户端
NEXT
如何构建全栈 Next.js 应用(使用 Storybook 和 TailwindCSS)