当您使用可重用组件的库时,API问题尤其严重。 一方面,您需要做出可靠,准确的决定,另一方面,要满足很多特殊情况。 这适用于处理数据以及各种用例的外部功能。 此外,所有内容都应易于更新并在项目中推广。
这些组件需要前所未有的灵活性。 同时,设置不能太复杂,因为老年人和六月将使用它们。 减少代码重复是组件库的任务之一。 因此,无法将配置转换为复制代码。

不可知数据组件
假设我们制作了一个带有下拉菜单的按钮。 它的API是什么? 当然,他需要一些项目作为输入-一系列菜单项。 接口的第一个版本很可能是这样的:
interface MenuItem { readonly text: string; readonly onClick(): void; }
很快,被disabled: boolean
将添加到此。 然后设计师将来绘制带有图标的菜单。 而来自邻近项目的人,他们将另一方面绘制图标。 现在界面越来越大,有必要涵盖越来越多的特殊情况,并且从大量的标志中,该组件开始类似于联合国大会。

泛型抢救。 如果组织该组件以使其不关心数据模型,那么所有这些问题都将消失。 菜单将仅发出被单击的项目,而不是单击item.onClick
。 之后如何处理是图书馆用户的任务。 即使他们调用相同的item.onClick
。
例如,在disabled
状态下,可以使用特殊处理程序解决问题。 disabledItemHandler: (item: T) => boolean
方法传递给组件disabledItemHandler: (item: T) => boolean
,通过它运行每个项目。 结果表明此元素是否被锁定。

如果您正在执行ComboBox ,则可能会想到一个接口,该接口存储用于显示的字符串和代码中使用的实际任意值。 这个想法很明确。 毕竟,当用户键入文本时, 组合框应根据输入的行过滤选项。
interface ComboBoxItem { readonly text: string; readonly value: any; }
但是,在这种情况下,这种方法的局限性也会出现-一旦设计出现线数不够的情况。 此外,该表单将包含一个包装而不是实际值,搜索并不总是通过字符串表示形式来进行的(例如,我们可以输入电话号码,但是应该显示人的姓名)。 即使其他组件的数据模型相同,接口的数量也会随着其他组件的出现而增加。
泛型和处理程序也将在这里提供帮助。 让我们将函数(item: T) => string
stringify
组件。 默认值为item => String(item)
。 因此,您甚至可以通过在类中定义toString()
方法来将类用作选项。 如上所述,不仅要过滤字符串表示形式,还必须过滤选项。 这也是使用处理程序的好例子。 您可以为组件提供一个函数,该函数接收搜索字符串和一个元素作为输入。 它将返回boolean
-这将告诉该项目是否适合该请求。
使用接口的另一个常见示例是唯一的ID,该ID与JavaScript对象的副本匹配。 当我们立即收到表单值,并且选择选项来自服务器的单独请求时-它们将仅具有当前元素的副本。 此任务由处理程序处理,该处理程序将两个元素作为输入并返回它们的相等性。 默认比较是normal ===
。
实际上,选项卡显示组件不需要知道将选项卡传递给它的形式:至少使用文本,至少使用带有附加字段的对象,甚至如何。 格式的知识对于实现不是必需的,但是许多知识都可以链接到特定格式。 没有关系意味着组件在精炼过程中不会造成重大变化,不会迫使用户为其修改数据,并且可以像乐高积木一样将原子组件相互组合。
相同的项目选择适用于上下文菜单和组合框,选择,多选,简单的组件很容易包含在更复杂的设计中。 但是,您需要能够以某种方式显示任意数据。

列表可以包含头像,不同的颜色,图标,未读消息的数量等等。
为此,组件必须以类似于泛型的方式在外观上起作用。
与设计无关的组件
Angular提供了用于定义外观的强大工具。
例如,考虑一下ComboBox ,因为它看起来非常多样化。 当然,在组件中将设置一定程度的限制,因为它必须服从应用程序的总体设计。 它的大小,默认颜色,填充-所有这些都应该可以单独使用。 我们不想强迫用户考虑外观方面的所有问题。

任意数据就像水:它们没有形式,它们本身不携带任何特定内容。 我们的任务是提供一个为他们设置“船只”的机会。 从这个意义上说,从外观抽象的组件的开发如下:

该组件是一种需要大小的架子,并且使用自定义模板来显示内容,即“放置”在其上。 最初将标准方法(例如输出字符串表示形式)放置在组件中,并且用户可以从外部传递更复杂的选项。 让我们看一下Angular在此方面的可能性。
更改外观的最简单方法是线插值。 但不变的行不适合显示菜单项,因为它对每个项目一无所知-它们看起来都一样。 静态字符串被剥夺上下文 。 但是,如果选项列表为空,则非常适合设置文本“ Nothing Found”。
<div>{{content}}</div>
我们已经讨论过任意数据的字符串表示形式。 结果也是一个字符串,但由输入值确定。 在这种情况下,上下文将是一个列表项。 这是一个更灵活的选项,尽管它不允许对结果进行样式设置-字符串不在HTML中插值-甚至更多,因此它不允许使用指令或组件。
<div>{{content(context)}}</div>
Angular提供了ng-template
和*ngTemplateOutlet
结构指令来*ngTemplateOutlet
。 在他们的帮助下,我们可以定义一段HTML,该HTML希望输入一些数据并将其传递给组件。 在那里将以特定的上下文实例化他。 我们将把元素传递给它,而不必担心模型。 为您的对象制定正确的模板是我们组件的消费者-开发人员的任务。
<ng-container *ngTemplateOutlet="content; context: context"></ng-container>
模板是一个非常强大的工具,但是需要在某些现有组件中进行定义。 这极大地使其重用变得复杂。 有时,在应用程序的不同部分甚至在不同的应用程序中都需要相同的外观。 在我的实践中,例如,这是带有名称,货币和余额显示的帐户选择的外观。

自定义解决此问题的外观的最复杂方法是动态组件。 在Angular中,早已存在*ngComponentOutlet
指令来以声明方式创建它们。 它不允许上下文的传递,但是这个问题可以通过实现依赖来解决。 我们可以为上下文创建令牌,并将其添加到用于创建组件的Injector
。
<ng-container *ngComponentOutlet="content; injector: injector"></ng-container>
值得注意的是,上下文不仅可以是我们要显示的元素,而且可以是它所处的环境:
<ng-template let-item let-focused="focused"> </ng-template>
例如,在提款的情况下,项目的焦点状态会在外观上反映出来-图标的背景从灰色变为白色。 一般而言,将可能影响模板显示的条件转移到上下文是有意义的。 这一点可能是此方法的唯一限制界面。

通用插座
第五版的Angular中提供了上述工具。 但是我们想轻松地从一个选项切换到另一个。 为此,我们将组装一个接受内容和上下文作为输入并实现自动插入此内容的适当方法的组件。 通常,我们足以学习区分类型string
, number
, (context: T) => string | number
(context: T) => string | number
, TemplateRef<T>
和Type<any>
(但这里有一些细微差别,我们将在下面讨论)。
组件模板将如下所示:
<ng-container [ngSwitch]="type"> <ng-container *ngSwitchCase="'primitive'">{{content}}</ng-container> <ng-container *ngSwitchCase="'function'">{{content(context)}}</ng-container> <ng-container *ngSwitchCase="'template'"> <ng-container *ngTemplateOutlet="content; context: context"></ng-container> </ng-container> <ng-container *ngSwitchCase="'component'"> <ng-container *ngComponentOutlet="content; injector: injector"></ng-container> </ng-container> </ng-container>
该代码将获取类型获取器以选择适当的方法。 应该注意的是,通常我们无法将组件与功能区分开。 当使用惰性模块时,我们需要一个知道组件存在的Injector
。 为此,我们将创建一个包装器类。 这也使得可以通过instanceof
确定它:
export class ComponentContent<T> { constructor( readonly component: Type<T>, private readonly injector: Injector | null = null, ) {} }
添加一个方法来使用传递的上下文创建注入器:
createInjectorWithContext(injector: Injector, context: C): Injector { return Injector.create({ parent: this.injector || injector, providers: [ { provide: CONTEXT, useValue: context, }, ], }); }
至于模板,在大多数情况下,您可以按原样使用它们。 但是我们必须牢记,模板必须在其定义位置进行更改验证。 如果将其传输到与定义位置在树中平行或更高的View ,则可能在其中触发的更改不会在原始View中获取 。
为了纠正这种情况,我们将不仅使用模板,还使用一条指令,该指令还将具有ChangeDetectorRef
及其定义位置。 这样,我们可以在必要时开始检查更改。
多态模式
在实践中,根据模板内容的类型来控制模板的行为可能很有用。
例如,我们希望有机会将模板转移到特殊组件上。 同时,在大多数情况下,您只需要一个图标。 在这种情况下,您可以配置默认行为,并在基元或函数输入输入时使用它。 有时,甚至原始类型的类型也很重要:例如,如果您有一个徽章组件来在选项卡上显示未读消息的数量,但是您想用一个特殊的图标突出显示需要注意的页面。

为此,您需要添加另一件事-传递模板以显示基元。 将@ContentChild
添加到该组件,该组件将从内容中获取TemplateRef
。 如果找到一个并将函数,字符串或数字传递给内容,我们可以使用原语作为上下文实例化它:
<ng-container *ngSwitchCase="'interpolation'"> <ng-container *ngIf="!template; else child">{{primitive}}</ng-container> <ng-template #child> <ng-container *ngTemplateOutlet="template; context: { $implicit: primitive }" ></ng-container> </ng-template> </ng-container>
现在我们可以对插值进行样式设置,甚至可以将结果传递给某个组件以进行显示:
<content-outlet [content]="content" [context]="context"> <ng-template let-primitive> <div class="primitive">{{primitive}}</div> </ng-template> </content-outlet>
现在是将我们的代码付诸实践的时候了。
使用方法
例如,我们概述了两个组件:tabs和ComboBox 。 选项卡模板将仅包含每个选项卡的内容出口,其中用户传递的对象将是上下文:
<content-outlet *ngFor="let tab of tabs" [class.disabled]="disabledItemHandler(tab)" [content]="content" [context]="getContext(tab)" (click)="onClick(tab)" ></content-outlet>
您需要设置默认样式:例如,字体大小,当前选项卡下的下划线,颜色。 但是我们将保留具体的外观。 组件代码将如下所示:
export class TabsComponent<T> { @Input() tabs: ReadonlyArray<T> = []; @Input() content: Content = ({$implicit}) => String($implicit); @Input() disabledItemHandler: (tab: T) => boolean = () => false; @Input() activeTab: T | null = null; @Output() activeTabChange = new EventEmitter<T>(); getContext($implicit: T): IContextWithActive<T> { return { $implicit, active: $implicit === this.activeTab, }; } onClick(tab: T) { this.activeTab = tab; this.activeTabChange.emit(tab); } }
我们得到了一个可以与任意数组一起使用的组件,将其显示为选项卡。 您可以在那里简单地传递字符串并获得基本外观:

您可以传输对象和模板以显示它们,并自定义外观以适合您的需求,添加HTML,图标,指示符:

对于ComboBox,我们将首先使其包含两个基本组件:带有图标和菜单的输入字段。 后者没有必要进行详细绘画-它与制表符非常相似,仅在垂直方向上具有其他基本样式。 输入字段可以实现如下:
<input #input [(ngModel)]="value"/> <content-outlet [content]="content" (mousedown)="onMouseDown($event, input)" > <ng-template let-icon> <div [innerHTML]="icon"></div> </ng-template> </content-outlet>
如果您将输入绝对定位,它将阻塞出口,并且所有单击都会在其上。 这对于带有装饰图标(例如放大镜图标)的简单输入字段很方便。 在上面的示例中,应用了多态模板方法-传输的字符串将用作innerHTML
来插入SVG图标。 例如,如果需要显示输入用户的头像,则可以在此处转移模板。
ComboBox也需要一个图标,但它必须是交互式的。 为防止它破坏焦点,请将onMouseDown
处理函数添加到插座:
onMouseDown(event: MouseEvent, input: HTMLInputElement) { event.preventDefault(); input.focus(); }
通过将模板作为内容传递,将使我们能够通过简单地通过位置:相对图标来将其提高到CSS。 然后,您可以在ComboBox本身中订阅对它的单击:
<app-input [content]="icon"></app-input> <ng-template #icon> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="icon" [class.icon_opened]="opened" (click)="onClick()" > <polyline points="7,10 12,15 17,10" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" /> </svg> </ng-template>
由于有了这样的组织,我们得到了预期的行为:

与制表符一样,组件代码无需了解数据模型。 看起来像这样:
export class ComboBoxComponent<T> { @Input() items: ReadonlyArray<T> = []; @Input() content: Content = ({$implicit}) => String($implicit); @Input() stringify = (item: T) => String(item); @Input() value: T | null = null; @Output() valueChange = new EventEmitter<T | null>(); stringValue = '';
这种简单的代码使您可以使用ComboBox中的任何对象,并非常灵活地自定义它们的显示。 在进行了一些与所描述概念无关的改进之后,就可以使用它了。 外观可以针对每种口味进行定制:

结论
不可知组件的创建消除了考虑每个特定案例的需要。 同时,用户可以获得用于针对特定情况配置组件的简单工具。 这些解决方案易于重用。 独立于数据模型使代码通用,可靠且可扩展。 同时,我们编写的行并不多,主要使用内置的Angular工具。
使用上述方法,您将很快注意到根据内容而不是特定的线条或图案进行思考的便利性。 显示验证错误消息,工具提示,模式窗口-这种方法不仅可用于自定义外观,而且可用于整体传输内容。 绘制布局和测试逻辑很容易! 例如,要显示弹出窗口,用户无需创建组件甚至模板,只需传递存根字符串并在以后返回即可。
长期以来,我们Tinkoff.ru一直成功地应用了所描述的方法,并将其移至一个名为ng-polymorpheus的微型开放源代码库(1 KB gzip)。
源代码
npm包
互动演示和沙箱
您是否还想将某些东西放到开源中,但是您是否对相关的杂务感到恐惧? 试试为我们的项目制作的Angular开源库启动器 。 它已经配置了CI,检查提交,短绒,CHANGELOG生成,测试覆盖率以及所有这些内容。