内容投影和ng-content
是可以让我们最大程度构建可重用组件的Angular功能之一。详细的了解下。
我们来构造一个小组件,一个Font Awesomne
输入框。我们设计这个组件的目标是为了构造一个带有图标的文本框。
最终的样子如图所示:
不使用ng-content的话会遇到什么问题?
先来尝试下不用内容投影的话,我们的组件会遇到什么问题。
首先看模板:
1 | <i class="fa" [ngClass]="classes"></i> |
用classes
对象来控制展示的图标,然后用inputFocus
获得焦点进入input,通过组件的HostBinding
来给组件应用外边框。
1 | import {Component, EventEmitter, HostBinding, Input, OnInit, Output} from '@angular/core'; |
看下css:
1 | :host{ |
看样式文件,我们知道:
- 组件内部的
input
元素被移除了自带的样式。 - 但我们给宿主元素加上了边框,让组件看起来像原生的html input元素。
- 当input获取到焦点的时候,通过将
.focus
类添加到宿主元素来模拟输入框获得焦点。
然后看看如何使用这个组件:
1 | <div> |
使用的时候,我们只需要向组件传递一个图标的名称和接收input输入值的函数即可。
让我们回顾下我们是如何设计这个组件的:
- 作为组件公共api的一部分,我们有一个图标的属性,该属性定义了需要显示的图标。
- 组件有个名为
value
的自定义输出事件,该事件在input元素输入值发生变化时发出新的值。 - 为了实现焦点功能,我们在组件内部的input元素上绑定了
blur
和focus
事件,通过@HostBinding
在宿主元素上增加或删除focus
css 类。
这个组件可以满足我们的需求。但是假设我们的需求发生了变更,我们马上会陷入到新的麻烦中。
问题1:如何支持所有的input属性?
我们的组件目前只是预定义了blur
和focus
属性,那我们需要增加其他属性,比如type
,autocomplete
、placeholder
等,咋办?
那我们只能被迫去修改组件,使其可以支持这样调用:
1 | <app-fa-input icon="envelope" type="text" placeholder="email" autocomplete="off" (value)="onNewValue($event)"></app-fa-input> |
在组件类中需要接收这些属性:
1 | export class FaInputComponent implements OnInit { |
在模板中应用:
1 | <i class="fa" [ngClass]="classes"></i> |
总而言之,我们要将需要处理的属性,从消费处一直传递到组件内部,然后在组件内部从组件类到组件的模板。虽然是很麻烦,但这样是可行的。
但是,还有其他更棘手的问题。
问题2:如何和Angular Form 集成?
我们的组件是个带图标的输入框,那么它的作用不仅仅是展示,它的重点功能是表单的一个输入元素,那么我们很可能需要和Angular Form集成,那么我们咋办?
还是如上面一样,我们需要将表单的所有属性,比如formControlName
全部转发到组件内部。
问题3:检测普通浏览器事件
我们想在组件上检测到标准浏览器的dom事件怎么办?比如keydown
事件?
也还是和上面一样,我们需要通过组件内部检测然后在消费的地方去提供处理方法。
也是可行的,但是好像我们的这个设计变得很不好,这样慢慢的会很臃肿。这种设计不是个很好的解决方法。
问题4:自定义属性
在构建表单时,第三方系统可能希望填写某些自定义的html数据属性,比如类似于:data-
之类的属性。以用于其他作用。
这会变得非常难办,因为我们无法预知这些属性的名字。
那么,到目前为止,我们这种设计的关键问题是什么?
关键问题是,我们将input元素隐藏到了组件模板中。
在需要调用这个组件的地方和组件内部形成了一个屏障。
我们可以用内容投影来重构组件,以解决上面的问题。
使用ng-content内容投影来重构组件
让我们重新设计组件Api,与其将输入元素隐藏在组件内部,不如将其提供为组件本身的内容元素(content element)。
那么我们在调用的地方应该是这样的:
1 | <app-fa-input icon="envelope"> |
需要注意的是,我们这里的input元素不是存在于组件内部,而是作为组件的html标签的一部分“内容”。
实际上,这种api在html标准元素中非常常见,比如选择框:
1 | <select> |
Angular Core 确实允许我们做同样的事情。
我们可以使用@ContentChild
和@ContentChildren
装饰器来查询组件HTML内容的所标记的内容。并将其在内部模板用作配置API。
如果有必要,我们还可以将区域中的内容直接用作组件的内容。
我们需要改造fa-input
组件:
1 | <mat-icon>{{icon}}</mat-icon> |
为省事我这里使用了Angular Material
的图标。
1 | import {Component, Input, OnInit} from '@angular/core'; |
然后在其他组件中使用这个组件:
1 | <app-fa-input icon="mail_outline"> |
页面需要的元素都是OK的,我们这里的input也作为投影的内容显示在了组件的内部。
但是好像css没有应用上啊,那投影的元素的样式如何处理?
给投影的元素应用css样式
目前的样式是定义在组件的样式fa-input.component.scss
之中:
1 | input{ |
为啥不起作用?因为这些样式位于链接到组件的样式文件内,所以它们会被赋予一个运行时的属性,这个属性是该组件模板中所有html元素独有的属性。
目前元素没应用上,我们可以给mat-icon
写个样式来观察下:
1 | .mat-icon{ |
然后查看运行后的页面,我们查看控制板板里面的css有:
1 | .mat-icon[_ngcontent-hhd-c122] { |
对应的html有:
1 | <app-fa-input _ngcontent-hhd-c144 icon="mail_outline" _nghost-hhd-c122 ng-reflect-icon="mail_outline"> |
我们可以看到,组件内部的元素是拥有一个特定的属性_ngcontent-hhd-c122
,组件内部链接的样式也是有一个属性_ngcontent-hhd-c122
,这可以让组件内部的样式不去干涉外部的元素。这是非常有用的。
而input
元素是外部投影进来的,所以它的属性是_ngcontent-hhd-144
,组件内部的样式是应用不上去的,这就是为啥我们样式不起作用的原因。
我们需要加上::ng-deep
来使样式穿透。
1 | ::ng-deep input{ |
这样看起来是好的,但是有个隐患,我们在外层使用组件的地方加上一个input:
1 | <app-fa-input icon="mail_outline"> |
好嘛,两个input都被应用上了样式。
甚至于我们去别的组件,不是父子组件,只是在这个页面组件树种的其他组件中加上input,发现都应用上了,看来这个样式使用::ng-deep
之后就变成了全局的css了。这样会造成一些不可控的问题。
如何解决呢?我们只需要在样式前面加上:host
来限定下即可:
1 | :host ::ng-deep input{ |
这样,发现只在投影到组件内部的元素才会应用这个样式。
所以,我们的需求是样式既要应用在当前组件,也需要应用到投影进来的元素。我们使用:host ::ng-deep
就可以完美解决。
再在控制台下查看下样式:
1 | [_nghost-unf-c122] input { |
正如我们所看到的,这个样式的作用域依旧是当前的组件内容,但是他们也会穿透到投影到当前组件的元素。
如何与投影内容交互?
前面我们尝试了将组件内的样式应用到投影的元素中,现在我们尝试下和投影的内容进行交互。
我们无法在ng-content
标签上创建交互,也没法在其上绑定事件监听。
相对的,与投影内容做交互做好的方法是以单独的指令去操作。
这里为了示例,我就不创建新的指令了,而是使用Angular Material
的matInput
指令。
首先在将matInput
挂到input元素上:
1 | <app-fa-input icon="mail_outline"> |
然后在指令中通过@ContentChild
修饰符获取到投影进来的input元素:
1 | export class FaInputComponent implements OnInit { |
然后通过这个指令去模拟input获取到焦点的过程:
1 | @HostBinding('class.focus') |
相对应的css样式:
1 | .fa-input{ |
最后的效果:
多插槽(Multi-Slot)内容投影
到目前为止,我们基本是一个ng-content
将内容投影进来,但是,加入我们想投影一部分或者几个部分呢?
我们前面是在fa-input
组件内部定义了icon,然后将input
从外部投影到了组件内部。那么我希望可以将两个都投影进来。fa-input
组件只是提供一个空壳子。
我们可以通过ng-content
的select
属性来获取到组件tag标记中的内容进行部分投影。
我们可以修改fa-input
组件的模板内容:
1 | <div class="fa-input"> |
然后在使用的地方:
1 | <app-fa-input icon="mail_outline"> |
最后可以看到,我们包括在组件tag中的内容会被分配到我们希望他们出现的地方。
来解读下上面的“插槽”。上面两个ng-content
的select
属性查找组件tag标记中的内容的特定元素,匹配后就投影进来,不带select
的ng-content
会将没有匹配的内容投影到组件中去。
我们也可以查找具有特定类的元素,以结合多个选择器。
例如,根据类选择某个input:
1 | <div class="fa-input"> |
对应的使用的地方:
1 | <app-fa-input> |
可以看到投影到了具体的input。
对ng-content
使用的加深了解。
当然,这篇文章是我根据angular blog angular-ng-content这篇文章的翻译和解读,觉得啰嗦或者说不清楚的话,可以去看原文。