Angular 架构模式和最佳实践(有助于扩展)
古图布
=== 要接收更多文章 👉 加入博客上的新闻通讯===
构建可扩展的软件是一项极具挑战性的任务。当我们思考前端应用程序的可扩展性时,我们会想到日益增长的复杂性、越来越多的业务规则、加载到应用程序中的数据量以及通常分布在世界各地的大型团队。为了应对上述因素,保持高质量的交付并避免技术债务,一个健壮且扎实的架构至关重要。Angular 本身是一个相当固执己见的框架,它迫使开发人员以正确的方式做事,但其中也有很多地方可能出错。在本文中,我将基于最佳实践和久经考验的模式,提出一些精心设计的 Angular 应用程序架构的高级建议。本文的最终目标是学习如何设计 Angular 应用程序,以便长期保持可持续的开发速度并轻松添加新功能。为了实现这些目标,我们将应用:
- 应用层之间的适当抽象,
- 单向数据流,
- 反应状态管理,
- 模块化设计,
- 智能和愚蠢的组件模式。
前端可扩展性问题
让我们思考一下在开发现代前端应用程序时可能面临的可扩展性问题。如今,前端应用程序不仅仅是“显示”数据和接受用户输入。单页应用程序 (SPA) 为用户提供丰富的交互,并主要将后端用作数据持久层。这意味着,更多的责任被转移到了软件系统的前端部分。这导致我们需要处理的前端逻辑变得越来越复杂。不仅需求数量会随着时间的推移而增长,而且我们加载到应用程序中的数据量也在增加。最重要的是,我们需要维护应用程序的性能,而这很容易受到影响。最后,我们的开发团队正在壮大(或者至少是轮换——人员来来去去),新人尽快上手至关重要。
解决上述问题的方法之一是构建稳固的系统架构。但这需要付出代价,从一开始就投入大量精力构建架构。对于我们这些开发人员来说,在系统规模还很小的时候,快速交付新功能可能非常诱人。在这个阶段,一切都简单易懂,开发速度很快。但是,除非我们重视架构,否则在经历了几次开发人员轮换、一些棘手的功能、重构以及一些新模块之后,开发速度就会急剧下降。下图展示了我开发生涯中通常的架构情况。这并非任何科学研究,只是我的看法。
软件架构
要讨论架构的最佳实践和模式,我们首先需要回答一个问题:什么是软件架构。Martin Fowler将架构定义为“将系统分解成各个部分的最高层次”。除此之外,我认为软件架构描述了软件如何由其各个部分组成,以及这些部分之间通信的规则和约束是什么。通常,我们在系统开发过程中做出的架构决策很难随着系统的发展而改变。因此,从项目一开始就关注这些决策至关重要,尤其是当我们构建的软件需要在生产环境中运行多年时。Robert C. Martin曾经说过:软件的真正成本在于维护。拥有完善的架构有助于降低系统维护的成本。
软件架构是软件由其各个部分组成的方式以及这些部分之间通信的规则和约束
高级抽象层
我们将分解系统的第一个方法是通过抽象层。下图描述了这种分解的总体概念。其理念是将适当的职责分配到系统的相应层级:核心层、抽象层或表示层。我们将分别考察每一层并分析其职责。这种系统划分也规定了通信规则。例如,表示层只能通过抽象层与核心层通信。稍后,我们将了解这种约束有哪些好处。
表示层
让我们从表示层开始分析系统。所有 Angular 组件都驻留在表示层。该层的唯一职责是呈现和委托。换句话说,它呈现 UI,并通过抽象层将用户的操作委托给核心层。它知道要显示什么、要做什么,但不知道应该如何处理用户的交互。
下面的代码片段包含CategoriesComponent
使用SettingsFacade
抽象层的实例来委托用户的交互(通过addCategory()
和updateCategory()
)并在其模板中呈现一些状态(通过isUpdating$
)。
@Component({
selector: 'categories',
templateUrl: './categories.component.html',
styleUrls: ['./categories.component.scss']
})
export class CategoriesComponent implements OnInit {
@Input() cashflowCategories$: CashflowCategory[];
newCategory: CashflowCategory = new CashflowCategory();
isUpdating$: Observable<boolean>;
constructor(private settingsFacade: SettingsFacade) {
this.isUpdating$ = settingsFacade.isUpdating$();
}
ngOnInit() {
this.settingsFacade.loadCashflowCategories();
}
addCategory(category: CashflowCategory) {
this.settingsFacade.addCashflowCategory(category);
}
updateCategory(category: CashflowCategory) {
this.settingsFacade.updateCashflowCategory(category);
}
}
抽象层
抽象层将表示层与核心层解耦,并拥有其自身定义的职责。该层为表示层中的组件公开状态流和接口,充当外观(Facade)的角色。这种外观会将组件在系统中可见的内容和操作限制在沙盒中。我们可以简单地使用 Angular 类提供程序来实现外观。这些类可以以Facade后缀命名,例如SettingsFacade
。下面是一个此类外观的示例。
@Injectable()
export class SettingsFacade {
constructor(private cashflowCategoryApi: CashflowCategoryApi, private settingsState: SettingsState) { }
isUpdating$(): Observable<boolean> {
return this.settingsState.isUpdating$();
}
getCashflowCategories$(): Observable<CashflowCategory[]> {
// here we just pass the state without any projections
// it may happen that it is necessary to combine two or more streams and expose to the components
return this.settingsState.getCashflowCategories$();
}
loadCashflowCategories() {
return this.cashflowCategoryApi.getCashflowCategories()
.pipe(tap(categories => this.settingsState.setCashflowCategories(categories)));
}
// optimistic update
// 1. update UI state
// 2. call API
addCashflowCategory(category: CashflowCategory) {
this.settingsState.addCashflowCategory(category);
this.cashflowCategoryApi.createCashflowCategory(category)
.subscribe(
(addedCategoryWithId: CashflowCategory) => {
// success callback - we have id generated by the server, let's update the state
this.settingsState.updateCashflowCategoryId(category, addedCategoryWithId)
},
(error: any) => {
// error callback - we need to rollback the state change
this.settingsState.removeCashflowCategory(category);
console.log(error);
}
);
}
// pessimistic update
// 1. call API
// 2. update UI state
updateCashflowCategory(category: CashflowCategory) {
this.settingsState.setUpdating(true);
this.cashflowCategoryApi.updateCashflowCategory(category)
.subscribe(
() => this.settingsState.updateCashflowCategory(category),
(error) => console.log(error),
() => this.settingsState.setUpdating(false)
);
}
}
抽象接口
我们已经知道这一层的主要职责:为组件公开状态流和接口。让我们从接口开始。公共方法loadCashflowCategories()
,addCashflowCategory()
并updateCashflowCategory()
从组件中抽象出状态管理的细节和外部 API 调用。我们不会CashflowCategoryApi
直接在组件中使用 API 提供程序(例如),因为它们位于核心层。此外,状态如何变化不是组件关心的。表示层不应该关心事情是如何完成的,组件应该只在必要时调用抽象层的方法(委托)。查看抽象层中的公共方法应该能让我们快速了解系统这一部分的高级用例。
但我们应该记住,抽象层不是实现业务逻辑的地方。在这里,我们只是想将表示层连接到我们的业务逻辑,并抽象出它们的连接方式。
状态
就状态而言,抽象层使我们的组件独立于状态管理解决方案。组件被赋予 Observables 数据,用于在模板上显示(通常通过async
管道),而组件无需关心这些数据的来源和方式。为了管理状态,我们可以选择任何支持 RxJS 的状态管理库(例如NgRx),或者简单地使用 BehaviorSubjects 来建模状态。在上面的示例中,我们使用了状态对象,该对象内部使用了 BehaviorSubjects(状态对象是我们核心层的一部分)。对于 NgRx 的情况,我们将为 store 调度操作。
这种抽象赋予了我们极大的灵活性,让我们无需触及表现层就能改变状态管理方式。甚至可以无缝迁移到像 Firebase 这样的实时后端,让我们的应用真正实现实时化。我个人喜欢从 BehaviorSubjects 开始管理状态。如果之后在系统开发的某个阶段需要使用其他方法,这种架构很容易重构。

同步策略
现在,让我们仔细看看抽象层的另一个重要方面。无论我们选择哪种状态管理解决方案,我们都可以以乐观或悲观的方式实现 UI 更新。假设我们想要在某些实体的集合中创建一条新记录。此集合是从后端获取的,并显示在 DOM 中。在悲观方法中,我们首先尝试在后端更新状态(例如使用 HTTP 请求),如果成功,则在前端应用程序中更新状态。另一方面,在乐观方法中,我们以不同的顺序执行。首先,我们假设后端更新将成功并立即更新前端状态。然后我们发送请求以更新服务器状态。如果成功,我们不需要执行任何操作,但如果失败,我们需要在前端应用程序中回滚更改并将此情况告知用户。
乐观更新会先更改 UI 状态,然后再尝试更新后端状态。这能为用户提供更好的体验,因为用户不会因为网络延迟而感到任何延迟。如果后端更新失败,则必须回滚 UI 更改。
悲观更新会先更改后端状态,只有在成功的情况下才会更新 UI 状态。通常,由于网络延迟,需要在执行后端请求期间显示某种旋转图标或加载栏。
缓存
有时,我们可能会决定从后端获取的数据不作为应用程序状态的一部分。这对于我们根本不想操作的只读shareReplay()
数据(通过抽象层)传递给组件来说可能很有用。在这种情况下,我们可以在外观层中应用数据缓存。最简单的实现方法是使用RxJS 运算符,该运算符将为每个新订阅者重放流中的最后一个值。查看下面的代码片段,了解如何RecordsFacade
使用它们RecordsApi
来获取、缓存和过滤组件的数据。
@Injectable()
export class RecordsFacade {
private records$: Observable<Record[]>;
constructor(private recordApi: RecordApi) {
this.records$ = this.recordApi
.getRecords()
.pipe(shareReplay(1)); // cache the data
}
getRecords() {
return this.records$;
}
// project the cached data for the component
getRecordsFromPeriod(period?: Period): Observable<Record[]> {
return this.records$
.pipe(map(records => records.filter(record => record.inPeriod(period))));
}
searchRecords(search: string): Observable<Record[]> {
return this.recordApi.searchRecords(search);
}
}
总结一下,我们在抽象层可以做的事情有:
- 公开组件的方法,其中我们:
- 将逻辑执行委托给核心层,
- 决定数据同步策略(乐观与悲观),
- 公开组件的状态流:
- 选择一个或多个 UI 状态流(如有必要,可以将它们组合起来),
- 来自外部 API 的缓存数据。
正如我们所见,抽象层在我们的分层架构中扮演着重要的角色。它明确定义了职责,有助于更好地理解和推理系统。根据您的具体情况,您可以为每个 Angular 模块创建一个外观,或者为每个实体创建一个外观。例如,如果不太臃肿,SettingsModule
可以只有一个外观SettingsFacade
。但有时,为每个实体单独创建更细粒度的抽象外观可能更好,例如UserFacade
为User
实体创建一个外观。
核心层
最后一层是核心层。核心应用逻辑在这里实现。所有数据操作和外部通信都在这里进行。如果我们使用 NgRx 之类的解决方案进行状态管理,那么这里可以放置状态定义、Action 和 Reducer。由于我们的示例中使用 BehaviorSubjects 来建模状态,因此我们可以将其封装在一个方便的状态类中。下面是SettingsState
核心层的示例。
@Injectable()
export class SettingsState {
private updating$ = new BehaviorSubject<boolean>(false);
private cashflowCategories$ = new BehaviorSubject<CashflowCategory[]>(null);
isUpdating$() {
return this.updating$.asObservable();
}
setUpdating(isUpdating: boolean) {
this.updating$.next(isUpdating);
}
getCashflowCategories$() {
return this.cashflowCategories$.asObservable();
}
setCashflowCategories(categories: CashflowCategory[]) {
this.cashflowCategories$.next(categories);
}
addCashflowCategory(category: CashflowCategory) {
const currentValue = this.cashflowCategories$.getValue();
this.cashflowCategories$.next([...currentValue, category]);
}
updateCashflowCategory(updatedCategory: CashflowCategory) {
const categories = this.cashflowCategories$.getValue();
const indexOfUpdated = categories.findIndex(category => category.id === updatedCategory.id);
categories[indexOfUpdated] = updatedCategory;
this.cashflowCategories$.next([...categories]);
}
updateCashflowCategoryId(categoryToReplace: CashflowCategory, addedCategoryWithId: CashflowCategory) {
const categories = this.cashflowCategories$.getValue();
const updatedCategoryIndex = categories.findIndex(category => category === categoryToReplace);
categories[updatedCategoryIndex] = addedCategoryWithId;
this.cashflowCategories$.next([...categories]);
}
removeCashflowCategory(categoryRemove: CashflowCategory) {
const currentValue = this.cashflowCategories$.getValue();
this.cashflowCategories$.next(currentValue.filter(category => category !== categoryRemove));
}
}
在核心层,我们还以类提供程序的形式实现 HTTP 查询。此类类可以带有Api
或Service
名称后缀。API 服务只有一个职责——它只负责与 API 端点通信,不做任何其他事情。我们应避免在此进行任何缓存、逻辑或数据操作。下面是一个简单的 API 服务示例。
@Injectable()
export class CashflowCategoryApi {
readonly API = '/api/cashflowCategories';
constructor(private http: HttpClient) {}
getCashflowCategories(): Observable<CashflowCategory[]> {
return this.http.get<CashflowCategory[]>(this.API);
}
createCashflowCategory(category: CashflowCategory): Observable<any> {
return this.http.post(this.API, category);
}
updateCashflowCategory(category: CashflowCategory): Observable<any> {
return this.http.put(`${this.API}/${category.id}`, category);
}
}
在此层中,我们还可以放置任何验证器、映射器或需要操作 UI 状态的多个片段的更高级的用例。
我们已经讨论了前端应用程序中抽象层的主题。每一层都有其明确定义的边界和职责。我们还定义了层间通信的严格规则。所有这些都有助于随着系统变得越来越复杂,更好地理解和推理系统。
🚀 要接收更多文章 👉 请加入博客上的新闻通讯🚀
单向数据流和反应状态管理
我们想要在系统中引入的下一个原则是关于数据流和变化传播的。Angular 本身在表示层(通过输入绑定)使用单向数据流,但我们将在应用层施加类似的限制。结合基于流的响应式状态管理,它将赋予我们系统一个非常重要的属性——数据一致性。下图展示了单向数据流的总体概念。

每当我们应用中的模型值发生变化时,Angular变更检测系统都会负责该变化的传播。它通过输入属性绑定在整个组件树中从上到下进行传递。这意味着子组件只能依赖于其父组件,而反之则不能。这就是我们称之为单向数据流的原因。这使得 Angular只需遍历组件树一次(因为树结构中没有循环)即可达到稳定状态,这意味着绑定中的每个值都会被传播。
从前面的章节中我们了解到,在表示层之上还有核心层,也就是我们应用逻辑的实现层。那里有操作数据的服务和提供者。如果我们在这个层面上应用同样的数据操作原则会怎么样?我们可以将应用数据(状态)放在组件“上方”的位置,并通过可观察流(Redux 和 NgRx 将此位置称为 store)将值向下传播到组件。状态可以传播到多个组件并显示在多个位置,但永远不会在本地进行修改。更改可能仅来自“上方”,而下方的组件仅“反映”系统的当前状态。这赋予了我们之前提到的重要系统属性——数据一致性——并且状态对象成为唯一的事实来源。实际上,我们可以在多个位置显示相同的数据,而不必担心值会有所不同。
我们的状态对象向核心层中的服务公开了操作状态的方法。每当需要更改状态时,只需调用状态对象上的方法(或者在使用 NgRx 的情况下调度操作)即可。然后,更改会通过流向下传播到表示层(或任何其他服务)。这样,我们的状态管理就变得响应式了。此外,由于对操作和共享应用程序状态有严格的规则,这种方法还提高了系统的可预测性。您可以在下面找到使用 BehaviorSubjects 建模状态的代码片段。
@Injectable()
export class SettingsState {
private updating$ = new BehaviorSubject<boolean>(false);
private cashflowCategories$ = new BehaviorSubject<CashflowCategory[]>(null);
isUpdating$() {
return this.updating$.asObservable();
}
setUpdating(isUpdating: boolean) {
this.updating$.next(isUpdating);
}
getCashflowCategories$() {
return this.cashflowCategories$.asObservable();
}
setCashflowCategories(categories: CashflowCategory[]) {
this.cashflowCategories$.next(categories);
}
addCashflowCategory(category: CashflowCategory) {
const currentValue = this.cashflowCategories$.getValue();
this.cashflowCategories$.next([...currentValue, category]);
}
updateCashflowCategory(updatedCategory: CashflowCategory) {
const categories = this.cashflowCategories$.getValue();
const indexOfUpdated = categories.findIndex(category => category.id === updatedCategory.id);
categories[indexOfUpdated] = updatedCategory;
this.cashflowCategories$.next([...categories]);
}
updateCashflowCategoryId(categoryToReplace: CashflowCategory, addedCategoryWithId: CashflowCategory) {
const categories = this.cashflowCategories$.getValue();
const updatedCategoryIndex = categories.findIndex(category => category === categoryToReplace);
categories[updatedCategoryIndex] = addedCategoryWithId;
this.cashflowCategories$.next([...categories]);
}
removeCashflowCategory(categoryRemove: CashflowCategory) {
const currentValue = this.cashflowCategories$.getValue();
this.cashflowCategories$.next(currentValue.filter(category => category !== categoryRemove));
}
}
让我们回顾一下处理用户交互的步骤,并牢记所有已介绍的原则。首先,假设在表示层发生了某个事件(例如按钮点击)。组件将执行委托给抽象层,调用外观层上的方法settingsFacade.addCategory()
。然后,外观层调用核心层categoryApi.create()
和服务上的方法settingsState.addCategory()
。这两个方法的调用顺序取决于我们选择的同步策略(悲观或乐观)。最后,应用程序状态通过可观察流向下传播到表示层。这个过程是定义明确的。

模块化设计
我们已经介绍了系统中的水平划分及其通信模式。现在,我们将引入功能模块的垂直分离。其理念是将应用程序切分成代表不同业务功能的功能模块。这又一步将系统解构为更小的部分,以提高可维护性。每个功能模块都共享相同的核心层、抽象层和表示层的水平分离。需要注意的是,这些模块可能会被延迟加载(或预加载)到浏览器中,从而增加应用程序的初始加载时间。下图展示了功能模块的分离。
由于技术原因,我们的应用程序还包含两个附加模块。一个模块CoreModule
定义了单例服务、单实例组件、配置,并导出了所需的任何第三方模块。该模块在 中仅AppModule
导入一次。第二个模块是 ,它包含常用的组件/管道/指令,并导出常用的 Angular 模块(例如)。可以被任何功能模块导入。下图展示了导入结构。AppModule
SharedModule
CommonModule
SharedModule
模块目录结构
下图展示了如何将所有部分放入SettingsModule
目录中。我们可以将文件放入文件夹中,并用一个代表其功能的名称命名。
智能组件和愚蠢组件
本文介绍的最后一种架构模式是关于组件本身的。我们根据组件的职责,将其分为两类。第一类是智能组件(又称容器)。这类组件通常:
- 注入外观和其他服务,
- 与核心层通信,
- 将数据传递给哑组件,
- 对来自哑组件的事件做出反应,
- 是顶级可路由组件(但并非总是如此!)。
前面介绍的CategoriesComponent
是smart。它已SettingsFacade
注入并使用它来与我们应用程序的核心层进行通信。
第二类是哑组件(又称展示组件)。它们唯一的职责是呈现 UI 元素,并通过事件将用户交互“委托”给智能组件。想象一下像这样的原生 HTML 元素<button>Click me</button>
。该元素没有实现任何特定的逻辑。我们可以将文本“Click me”视为该组件的输入。它也有一些可以订阅的事件,例如 click 事件。下面是一个简单的展示组件的代码片段,它只有一个输入事件,没有输出事件。
@Component({
selector: 'budget-progress',
templateUrl: './budget-progress.component.html',
styleUrls: ['./budget-progress.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BudgetProgressComponent {
@Input()
budget: Budget;
today: string;
}
概括
我们介绍了一些关于如何设计 Angular 应用架构的想法。如果能够合理运用这些原则,将有助于保持持续的开发速度,并轻松交付新功能。请不要将它们视为严格的规则,而应将其视为在合理的情况下可以采用的建议。
我们仔细研究了抽象层、单向数据流、响应式状态管理、模块化设计以及智能/哑组件模式。希望这些概念能够对您的项目有所帮助。如果您有任何疑问,我很乐意与您交流。
在此,我要特别感谢Brecht Billiet ,他撰写了这篇博文,让我了解了抽象层和外观的概念。谢谢你,Brecht!同时,我也要特别感谢Tomek Sułkowski,他回顾了我对分层架构的看法。
文章来源:https://dev.to/dev-academy/angular-architecture-patterns-and-best-practices-that-help-to-scale-507m