热门推荐
创建可重用组件需要在灵活性和技术要求之间找到平衡,特别是当用户需要自定义内容时。在开发Angular的UI库时,我经常遇到这个挑战,并尝试了各种解决办法。
在本文中,我们将从UI库开发者的角度来讨论这个话题,强调为用户提供可灵活定制的自定义API的重要性。
让我们直接来看一个实际的例子。假设我们正在开发一个 Field 组件,它会渲染一个输入字段。通常,单独使用输入元素的情况很少。在某些情形下,我们需要包含一个自动绑定 [for]
属性的 标签文本 ,显示诸如字段无效的 错误信息 这样的反馈,或提供一个 提示文字。有时候,我们可能还想在输入框的一侧添加一个 图标 或 互动元素。
字段部分
根据_Field__的设计,我们可以识别出一些关键的内容区域,这些区域应该被开发者用于定制。
现在,咱们需要决定一个方法来填充这些区域的内容。初学者开发者有时候会使用inputs来传递文本内容,例如标签或错误/提示文本。然而,这并不是处理内容最灵活的处理方式,因为它限制了我们的发挥。即使是看似很小的要求,比如添加一个图标(如示例中提示包含的>
键图标),也可能让我们感到意外。
这种自然且灵活的方式是内容投影。它允许我们将一段模板插入到预期的区域。Angular 与其他现代框架一样,采纳了 Web 组件的插槽概念,并允许我们使用带有 [select]
属性的 ng-content
标签定义每个内容区域的占位符(类似于命名槽)。
采用这种方法的模板伪代码如下所示(为了简洁起见,本例中使用了 [属性]
选择器)。
<label [attr.for]="id" class="label">
<ng-content select="[label]" />
</label>
<div class="field">
<div class="prefix">
<ng-content select="[prefix]" />
</div>
<div class="infix">
<!-- 这里允许开发者直接绑定可访问性属性,并应用表单指令等。 -->
<ng-content select="input, textarea" />
</div>
<div class="suffix">
<ng-content select="[suffix]" />
</div>
</div>
<div class="备注">
<div class="error">
<ng-content select="[error]" />
</div>
<div class="hint">
<ng-content select="[hint]" />
</div>
</div>
👤 从组件用户的视角来看,可能看起来是这样的:
<app-field>
<!-- 文本节点的投影 -->
<ng-container label>标签文本</ng-container>
<input>
<!-- 元素节点的投影 -->
<b 提示>提示</b>
</app-field>
但是,使用组件的人可能不需要为所有现有的槽填写内容。这带来了以下问题。
- 如果一个插槽未被定义,它不应创建多余的节点,或者至少不应影响布局,(例如,
[prefix]
/[suffix]
插槽的容器可能会为输入元素添加额外的内边距) - 与未定义的插槽相关的节点也不应影响屏幕阅读器或其他辅助技术的运行,如果容器元素仍保留在树中
🚧 这里,我们遇到了一个问题:无法在运行时确定特定的这个 ng-content
是否已被该组件定义。
可以使用CSS来判断相应容器元素内是否有内容。通过使用:empty
伪类和display: none
,若容器内无内容,可以将其从文档流中移除(此外,它也将从辅助技术树中被移除)。
/* 空元素隐藏 */
.suffix:empty {
display: none;
}
:empty
伪类不仅适用于元素节点,也适用于文本中的空节点,这正是我们所需要的。例如,Angular Material就是使用这种方法来隐藏<mat-checkbox>
中多余的内边距,具体可以在这个链接中看到:Angular Material使用这种方法隐藏空标签。
相对较新的 CSS 伪类 :has
(从大部分浏览器已支持的角度来看)允许我们通过父元素来调整样式。如果开发者在 [suffix]
插槽中添加内容,我们可能需要调整输入框的内边距,以确保正确的对齐和显示效果。
:host 元素包含一个非空的 .suffix 元素时,.infix 元素的内边距会在结束方向增加 2rem。
⚠️ 不要过度使用这类选择器,因为它们的特定性很高。 例如,如上所述的选择器具有 0.4.0 的特定性,这使得它难以被覆盖。查看它 (check it)。
使用 CSS 的 workaround 是一種比較情境性的技巧,並不能總是有效管理组件槽。這包括在複雜情境下可能出現的“重量級”选择器,僅僅依靠 CSS 可能並不足夠。理想情況下,我們還應在 TS 层级控制内容的存在。
Angular模板模板片段(例如:https://angular.dev/guide/templates/ng-template#)提供了另一种内容投影的选项,使我们能够在运行时进行操作。这种方法让我们可以避免将未定义插槽相关的节点插入到树中。比如,我们可以用模板片段来动态插入内容。
readonly suffix = contentChild('suffix', { read: TemplateRef });
在模板里:
@if (suffix(); as template) {
<div class="suffix">
<ng-container *ngTemplateOutlet="template"></ng-container>
</div>
}
此外,它允许我们将上下文传递给槽位,在某些场景中,这成为了一个极其强大的定制选项:
<ng-container *ngTemplateOutlet="template; context: { ... }" />
如我们在 Field 中所见,我们有如下要求:如果控件无效,我们希望按钮变为红色。通常,如果我们可以直接访问 FormControl 实例,这将变得非常简单。然而,如果我们通过名称绑定 FormArray 或 FormGroup 中的深层嵌套控件,这就会变得相当复杂。
使用 ng-template
可以让我们利用上下文环境并将相应的控件实例传递到槽位中。从概念上讲,我们的槽位不仅仅作为一个内容的占位符——它还包含一些我们可以利用的信息。
现在开发人员可以定义一个#suffix
槽,并使用let-*
来访问上下文中的数据。
<app-field>
...
<input formControlName="name">
<ng-template #suffix let-control>
<button [class.bg-red]="control.invalid"
[attr.disabled]="control.disabled || null"
... >
<app-icon icon="搜索" />
</button>
</ng-template>
</app-field>
不仅仅是一个槽,而是一个可以配置的设计
在这里,值得讨论一下 ng-template
的概念模型,它与 ng-content
有些不同。得益于上下文,模板不仅可以用来插入内容到槽中,还可以用来 修改组件如何渲染现有内容。比如,当我们使用日历组件时,可以用来自定义单个日期单元格的渲染。
日历组件
如上所示的第二个日历版本所示,修改一个单元格往往需要做更多的事情,而不仅仅是改变样式或使用CSS创建伪元素。在某些情况下,我们需要插入图标或图钉,有时则需要在悬停时显示提示。这种场景在客户驱动的业务逻辑中很常见,因此,该组件需要有足够的灵活性以供自定义。
在 日历 的情况下,单元格模板可以包含计算出来的元数据,来显示当天的内容。
基本上,插槽(slot)已经包含具体的日期数字,但可以通过自定义开发人员逻辑,使用ng-template
(模板)来改变其显示方式。伪代码使用示例如下:
<app-calendar>
...
<ng-template #cell let-day>
<div [appTooltip]="day.disabled ? '不可' : null">
@if (day.isLastDay) {
🌚
}
<span [class.line-through]="day.isAdjacent">{{ day }}</span>
@if (day.isFirstDay) {
🌝
}
</div>
</ng-template>
</app-calendar>
这种方法为在 Angular 中创建可重用组件奠定了基础,并且被生态系统中的几乎所有现有 UI 框架采用。
让我们一起努力在研究现有内容投影方法的原则后,一个自然的问题便产生了:每种方法应在何时应用?实际上,在开发UI组件时,这两种方法在实践中常常被一起使用,选择哪种方法主要看具体应用场景。
我们再来看看几个实际的例子。
比较装置比较器模块
- Comparator 有两个插槽,这两个插槽将始终按照组件设计进行定义
- 插槽内容不受条件渲染的影响,可以与组件的视图一同立即初始化
- 这两个插槽也不需要任何特定的上下文
这是一个明确使用ng-content
的例子。伪代码如下:
<app-comparator>
<img 左边 class="lazyload" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC" data-original="1.jpg" alt="1" >
<img 右边 class="lazyload" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC" data-original="2.jpg" alt="2" >
</app-comparator>
小手风琴
Accordion 组件(Accordion 组件,常用于技术上下文中)
Accordion 是一个绝佳的方法多样性示例。一个可折叠项目有3个槽位:标题、图标 和 内容。
- 标题通常只是普通的文本,但避免限制我们组件用户的灵活性。与其使用输入框,不如通过
ng-content
提供一个插槽位置,这样可以避免限制用户的灵活性。
<app-accordion-item>
标题(假设为未命名插槽)
</app-accordion-item>
- 图标插槽默认内容为一个 Chevron。虽然 Angular 18 引入了为
ng-content
定义备用内容的能力,这个插槽可以根据上下文进行调整,比如当前项是打开还是关闭的状态。在这种情况下,使用ng-template
进行自定义是一个合理的选择:
<app-accordion-item>
<ng-template #icon let-open>
{{ open ? '🙉' : '🙈' }}
</ng-template>
</app-accordion-item>
- 内容 是那种两种方法都用得上的情况。
ng-content
允许我们立即初始化内容,即使它被父组件隐藏起来(通过@if
/@switch
)。这适用于其生命周期会在被投影到插槽时立即启动的投影子组件。另一方面,ng-template
会延迟加载内容——仅在实际插入模板时才初始化。我们应该让用户根据需要选择这两种方式:
<app-accordion-item>
标题:
<ng-template #icon let-open>
{{ open ? '🙉' : '🙈' }}
</ng-template>
<ng-container content>
... 急切的内容
</ng-container>
<ng-template #content>
... 懒加载的内容
</ng-template>
</app-accordion-item>
统一用法:
在使用UI库中的内容投射时,我的团队在定义命名槽时缺乏统一的方法,导致了一些不便。
回到 Field 组件,我们可以创建类似的指令,例如 [appLabel]
或 [appSuffix]
。这会使我们在使用 ng-content
时,选择器更加安全,因为单一单词的属性可能与 原生 HTML5 属性 发生冲突。另外,若涉及到 ng-template
,该指令可以附加到 <ng-template />
,并通过指令的 定位器 来查询。下面是伪代码的使用示例:
<app-field>
...
<span appLabel>标签文本</span>
<ng-template appSuffix let-ctx>...</ng-template>
</app-field>
最初,我们采用了类似的方法,然而之后考虑在整个组件中使用一个统一的选择符,而不是通用选择器。
⚠️ 在这些示例中,我将使用不带前缀的 slot-*
选择器。但在设计系统时,考虑加上一个前缀会是个不错的选择,以避免与原生 slot 属性冲突。例如,Vue 使用的是 v-slot
命名(或许有一天 Angular 会内置 ng-slot
?😏)。
这意味着当我们使用某个组件时,可以很容易地看出投影的内容。
<app-field>
...
<span slot="label">标签文本</span>
<ng-template slot="suffix" let-ctx>...</ng-template>
</app-field>
此外,在文档化组件时,描述预期内容变得更加简单——只需指定槽位名称及其上下文(如有必要)。
实施阶段:使用 ng-content
,实现起来非常直接:
<ng-content select="[slot='name']" />
当使用模板时,我们可以创建一个轻量级指令元素,提供一个 SLOT
占位符(以抽象具体实现的细节),并接受插槽名称作为参数。
export const SLOT = new InjectionToken<Slot>('SLOT'); // 注入令牌,用于标识Slot
@Directive({
selector: 'ng-template[slot]', // 选择器,用于选择具有[slot]属性的ng-template元素
providers: [{ provide: SLOT, useExisting: Slot }], // 提供者,用于提供Slot注入令牌的实现
})
export class Slot { // 表示一个插槽
readonly template = inject(TemplateRef); // 只读的模板引用
readonly name = input.required<string>({ alias: 'slot' }); // 必要的输入,代表插槽名称
}
现在,在一个带有投影内容的UI组件中,我们可以通过contentChildren
来查询插槽(slot)列表,以确保语法正确并符合中文表达习惯。
readonly slots = contentChildren(SLOT); // 只读slots变量,等于contentChildren(SLOT)的结果
⚠️ contentChildren
方法只能在类成员的初始化器中调用,这使我们不能直接在包装器中使用它来转换结果为记录。我发现直接在模板中创建一个用于转换的辅助管道更为方便,这样就无需另外定义一个类属性。
@Pipe({ name: 'asRecord' })
导出类SlotsAsRecordPipe 实现PipeTransform {
转换(slots: readonly Slot[]): 记录<string, TemplateRef<未知> | 未定义> {
返回对象.fromEntries(slots.map(slot => [slot.名称(), slot.模板]));
}
}
✨ 现在,我们记录了所有用户自定义的模板槽位:
@let 模板定义 = slots() | asRecord;
@if (模板定义.label; as label) {
<label [attr.for]="id" class="label">
<ng-container *ngTemplateOutlet="label; context: { ... }" />
</label>
}
@if (模板定义.suffix; as suffix) {
<div class="suffix">
<ng-container *ngTemplateOutlet="suffix; context: { ... }" />
</div>
}
...
👀 实际上,我们达到了类似 Vue 中的条件插槽的状态,其中这个功能让我们根据是否有特定插槽来正确配置渲染。
最后这篇文章不是深入探讨源代码层面的内容投影,而是更多地反思 Angular 中这一基础机制的工作方式。虽然这些考虑在应用程序代码中不一定总是优先,但在设计系统时却非常重要。
统一的插槽定义方式已经被证明是非常灵活的,我在设计底层组件的API时也自信地使用这种方法。然而,没有一种解决方案是绝对正确的,你在Angular中处理内容投影的方式,最终还是取决于你的系统架构。
🫡 咱们下篇文章见,我们将聊更多优化库API的方法!
共同学习,写下你的评论
评论加载中...
作者其他优质文章