Angular由一个bug说起之十九:Angular 实现可拓展 Dropdown 组件
目录:
- 项目中的 Dropdown
- 简要分析需求
- 设计方案
- Demo 展示
- 验证
项目中的 Dropdown
我公司的项目中实现过不止一个 Dropdown 组件,但是普遍存在一些难以解决的痛点。又或者在实现的时候能满足需求,但是一旦功能修改了就很难拓展。这也是写这篇文章的原因,主要是想找出一个比较好的解决方案。
以下我列举一些项目中典型的问题以及它们出现的原因:
- 多份 options 备份
- 多组件复合
- 复杂的组件嵌套
多份 options 备份
这个问题最先出现的原因是我们引入了检索功能。被检索的 options 是不完整的列表,当我们需要从列表中筛选出被选中的选项来改变它们的状态时需要一个完整的 options。所以此时就多了一个 filteredOptions。
而后随着功能越来越复杂,这类的 options 备份也越来越多,甚至曾经一度达到了 4-5 个。
多组件复合
一开始我们只需要实现一个单选下拉框和一个复选下拉框。而后功能增加例如: lazyload options,配合其它功能的Switch开关,Search 输入框 等。
这就造成了组件的布局问题,以及各个功能之间的通信复杂度也上升了。慢慢的 Dropdown 的体量变的越来越大,它的维护难度也直线上升。
复杂的组件嵌套
我们一开始实现的单选和多选 dropdown 还是有很多可以复用的组件和样式的。后期二者的区别越来越大,共用的组件慢慢变的不再共用。所以项目中出现了很多的 if else 来区分这两个组件的应用范围。
同时这两个组件当时是嵌套在同一个组件中,因为它们有类似的数据流,可以共享通信过程。但是这也为后期的功能拓展埋下了隐患。
综上所述,正因为存在这样的问题所以我们需要一个更好的 dropdown。为下次项目重构提前做出准备。
简要分析需求
Dropdown 组件可涵盖的业务非常广泛,要具有 和业务解耦,扩展性强,好维护 等特性。我们列出不同视角下,组件的特性和规律,方便我们制定方案。
组件有什么?
以 Angular component 为例,一个组件至少需要以下 4 点:
- input
- output
- UI (html, css)
- functions (event, method …)
这 4 点可以归纳为:
- input AND output --> Model
- UI --> View
- functions --> Controller
如何解耦
由此可见,实现一个扩展性强的组件我们需要做的是:协调好 mvc 的逻辑,明确 mvc 的代码应该放在哪些文件里,用什么形式实例化组件。
那么,为了实现解耦,为了能以 API 热插拔的形式实现组件的拓展,就有一个核心的要求:Dropdown 中的每一个小功能要以一个 独立的,完整的 包含全套 mvc 的组件形式存在。
用代码表示类似于:
<!-- Dropdown UI -->
<div class="dropdown"><SearchInput></SearchInput><DropdownMenu></DropdownMenu><DropdownBtns></DropdownBtns>
</div>
只有功能完整且独立才能不受到其它功能的影响,这是目前我能想到的唯一的解耦方式。
设计方案
上一步已经分析出了实现组件要遵循的核心原则,方案设计要敲定实现的方式。
我实现了两个方案,第一个方案在实现后有缺陷所以废弃了,最后采纳了第二个方案。
第一次方案的实现
实现方式要考虑到以下几个问题:
- 组件布局
- 各个功能之间的交互
- 热插拔怎么实现
有两个方案能满足上述要求:
- 使用
- 动态创建组件
它们各自的优缺点:
-
ng-content
● 优点:直观,实现简单
● 缺点:限制太多,性能消耗 -
动态组件
● 优点:更加灵活,复用性强,拓展性好
● 缺点:动态组件的创建和管理增加代码复杂度
const components = [{name: 'menu',input: {props: {...}},output: {changes: () => {...}},component: MyComponent}
]
ng-content 直观而且可以减少组件的嵌套,但是 ng-content 的限制太多,而且在多组件嵌套情况下性能也不好。
不应该有条件地包含带有@if, @for或@switch的。即使该占位符被隐藏,Angular总是实例化并为渲染到占位符的内容创建DOM节点。有关组件内容的条件渲染,请参阅模板片段。
第一次方案决定采用动态组件渲染,即:将组件和配置信息以 Input 形式传入 dropdown 组件,在 dropdown 组件中使用 ViewContainerRef.createComponent 来创建组件。
虽然能实现,但是除了上面提到的代码复杂度以外,还存在几个问题,:
- 渲染的时机:要先渲染 dropdown 中的占位元素,然后才能 createComponent,增加了渲染流程的复杂度
- 更新数据:因为传入的数据比较复杂,在改变其中数据的时候可能无法触发 Angular 的 OnChange
以上问题导致虽然实现了 dropdown 组件,但是维护难度太大,所以放弃这个方案。
第二次方案
核心要求不变,使用 ngTemplateOutlet 来实现 dropdown 组件。
相比于上面的两个选择,ngTemplateOutlet 实现的代码简洁而且直观。同时它比 ng-content 指向性强,安全性好。
Demo 展示
使用 Angular 19 进行 Demo 的展示。
利用 angular-cli 生成了标准的 angular 项目,插件增加了:@angular/material 和 lodash,其它未作改动
dropdown.html
<div class="my-dropdown-container"><!-- Dropdown Trigger --><div class="my-dropdown-trigger"><ng-container *ngTemplateOutlet="trigger"></ng-container></div><!-- Dropdown Menu --><div class="my-dropdown-menu"><ng-container *ngTemplateOutlet="menu"></ng-container></div>
</div>
dropdown.ts
import { Component, Input, TemplateRef } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';@Component({selector: 'my-dropdown',templateUrl: './myDropdown.component.html',styleUrls: ['./myDropdown.component.less'],imports: [NgTemplateOutlet]
})
export class MyDropdownComponent {@Input({ required: true }) trigger!: TemplateRef<any>;@Input({ required: true }) menu!: TemplateRef<any>;
}
分别实现了三个组件 search-input,dropdown-menu,dropdown-btns。把这些组件封装成了一个标准组件 standard-dropdown
standardDropdown.html
<my-dropdown [trigger]="triggerTmp" [menu]="menuTmp"><ng-template #triggerTmp><dropdown-trigger></dropdown-trigger></ng-template><ng-template #menuTmp><search-input [placeholder]="'Search...'" (valueChange)="handleSearchChange($event)"></search-input><dropdown-menu [options]="options | filterOptions : args" (optionSelected)="handleValuesChange($event)"></dropdown-menu><dropdown-btns (eventClick)="handleEventClick($event)"></dropdown-btns></ng-template>
</my-dropdown>
效果如下:
dropdown 只负责渲染,在 standard-dropdown 根据需求定制不同的内部组件。search-input,dropdown-menu,dropdown-btns 都是独立的组件,与外部解耦。
我们可以根据不同的需要来创建不同的 dropdown,为它分配不同的功能。如:多选、单选、条件选择、懒加载 等。
验证
这部分比对一开始表述的项目中的问题来看看我们实现的组件是不是能解决问题。
1. 多份 options 备份
我们的 standardDropdown 只维护一个 options,检索的结果直接以 pipe 形式过滤出合法值传入 dropdown-menu。
如果有其它需要按条件检索 options 的情况,将方法封装入 service 中,在使用的时候调用方法过滤 options,而不是提前存储一个处理过的 filteredOptions
2. 多组件复合
ngTemplateOutlet 的方式本身拓展性就很好。每个组件只需要考虑它自己的功能,而与业务有关的代码则放入外层的 standardDropdown 中。那么当我需要增加或者修改组件的时候只要替换它就行了。
3. 复杂的组件嵌套
按这种方式实现的dropdown就像积木一样,我只需要考虑如何拼接它。
不再需要复杂的嵌套,只需要一个外层组件来组合并且处理业务逻辑。而这个外层组件直接控制它内部的小组件,需要什么功能就添加什么组件。
目前来看它比较好的解决了我们一开始提出的问题。