ngTemplateOutlet
从字面来看,就像 <router-outlet/> 之于 router 一样,ngTemplateOutlet 就是 TemplateRef 的“出口”。除了渲染 TemplateRef 之外,它还支持指定一个对象 context 作为“模版内容”的渲染上下文。
Vue 的 scopedSlots 和 React 的 renderProps 以及后来的 Composition API 和 Hooks 都在践行“逻辑与 UI”的分离;因为 UI 的自由度非常高,形态千变万化,而交互逻辑的自由度就小不少,UI 更多是内在逻辑的延伸。有了 ngTemplateOutlet,也可以在 Angular 中分离逻辑与 UI(虽然 Angular 在这方面的最佳实践是“服务”)。
使用 ngTemplateOutlet 构造 DynamicTabListSlotComponent:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
|
// 组件渲染了一个 ng-container,这就是 TemplateRef 的出口
@Component({
selector: 'dynamic-tab-list-slot',
template: `
<ng-container
*ngTemplateOutlet="tabListTemplate; context: temp"
></ng-container>
`,
/**
* \*ngTemplateOutlet
* 可以拆成
* [ngTemplateOutlet]、[ngTemplateOutletContext]
* 两个属性
*/
template: `
<ng-container
[ngTemplateOutlet]="tabListTemplate"
[ngTemplateOutletContext]="temp"
></ng-container>
`
})
export class DynamicTabListSlotComponent implements OnChanges {
// 传入的请求参数
@Input() orgId?: number = null;
// 传入的函数,用来格式化接口返回的数据
@Input() formatter = v => v;
// 传入的 tabListTemplate 所指向的 TemplateRef
@Input() tabListTemplate: TemplateRef<any>;
// 接口返回新的数据之后,通过自定义事件抛出
@Output() updated = new EventEmitter();
/**
* TemplateRef 渲染的上下文,其中包含了:
* 1. 数据集合
* 2. 请求状态
*/
temp = {
tabList: [],
loading: false
};
// 依赖注入 ApiService
constructor(private apiService: ApiService) {}
// 如果传入的“请求参数”变化,重新请求接口
ngOnChanges(changes: SimpleChanges): void {
if (changes?.orgId) {
this.getTabList();
}
}
// 副作用函数
getTabList() {
const park = [];
if (typeof this.orgId !== 'number') {
this.temp.tabList = park.map(this.formatter);
this.updated.emit({
origin: park,
formatted: this.temp.tabList
});
return;
}
// 可以直接修改“上下文”中的数据
this.temp.loading = true;
this.apiService
.dynamicTabInDailyReconciliation({ orgId: this.orgId })
.subscribe(response => {
if (response.success) {
// 请求成功,更新“上下文”
this.temp.tabList = [...response.data, ...park].map(this.formatter);
// 通过自定义事件抛出数据
this.updated.emit({
origin: [...response.data, ...park],
formatted: this.temp.tabList
});
} else {
// 请求失败
this.temp.tabList = park.map(this.formatter);
this.updated.emit({
origin: park,
formatted: this.temp.tabList
});
}
// 更新“请求状态”
this.temp.loading = false;
});
}
}
|
使用 DynamicTabListSlotComponent:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
<!--
当 currentOrgId 发生变化
会重新发起请求,拉取最新数据
更新“上下文”,触发 updated
-->
<app-dynamic-tab-list-slot
(updated)="handleTabListUpdate($event)"
[tabListTemplate]="tabListTemplate"
[formatter]="tabListFormatter"
[orgId]="currentOrgId"
>
<!--
tabListTemplate 指向的 ng-template 包含了“渲染模版”
使用 let-* 语法,解构“上下文”中的属性值
tabListLoading -> context.loading
tabList -> context.tabList
-->
<ng-template
#tabListTemplate
let-tabList="tabList"
let-tabListLoading="loading"
>
<!--
这里不仅可以是 nz-tabset,也可以是 nz-checkbox、nz-radio、nz-select ...
-->
<nz-tabset
nzSize="small"
nzTabPosition="top"
[nzTabBarGutter]="4"
[nzTabBarExtraContent]="extraTemplate"
[(nzSelectedIndex)]="currentSelectedIndex"
(nzSelectedIndexChange)="handleSelectedIndexChange($event)"
>
<nz-tab nzTitle="代征" *ngIf="tabList.includes(Service.dz)">
<ng-template nz-tab>
<app-reconciliation
[orgId]="currentOrgId"
[platform]="Service.dz"
[selectedIndex]="currentSelectedIndex"
></app-reconciliation>
</ng-template>
</nz-tab>
<nz-tab nzTitle="轻税" *ngIf="tabList.includes(Service.qs)">
<ng-template nz-tab>
<app-reconciliation
[orgId]="currentOrgId"
[platform]="Service.qs"
[selectedIndex]="currentSelectedIndex"
></app-reconciliation>
</ng-template>
</nz-tab>
...
</nz-tabset>
</ng-template>
</app-dynamic-tab-list-slot>
|
createEmbeddedView 可以替代 ngTemplateOutlet 指令;同时,如果“逻辑组件”仅有一个 ngTemplateOutlet(仅渲染一个 TemplateRef),可以使用 ContentChild 获取 TemplateRef:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
|
@Component({
selector: 'app-codes',
template: `
<ng-container #vcf></ng-container>
`
})
export class CodesComponent implements OnInit, AfterViewInit {
// 这里不再使用 @Input,而是直接通过 ContentChild 获取 TemplateRef
@ContentChild(TemplateRef) content: TemplateRef<any>;
// 这里必须使用 { read: ViewContainerRef } 明确 vcf 的类型
@ViewChild('vcf', { read: ViewContainerRef }) vcf;
@Output() update = new EventEmitter<string[]>();
@Input() codes: Array<string>;
@Input() type: string;
list: CodeItem[] = [
{
name: '云票',
code: 'yp',
children: [...adminMenus()]
},
{
name: '账套管理',
code: 'zt',
children: [...adminBatchMenus()]
},
{
name: '记账中心',
code: 'jz',
children: [...adminNoteMenus()]
}
];
constructor(private cdf: ChangeDetectorRef) {}
ngOnInit(): void {
// 使用传来的 codes 对数据进行初始化
let temp = [...this.list];
temp = this.handleInitWithFilter(temp as CodeItem[]);
temp = this.handleInitWithCodes(temp as CodeItem[]);
temp[0].active = true;
this.list = [...temp];
}
ngAfterViewInit() {
/**
* createEmbeddedView 的第二个参数为“渲染上下文”
* 这里将数据 list 绑定到“缺省”属性 $implicit 上
* 同时将“用户操作”封装在 handleChecked 中
*/
this?.vcf?.createEmbeddedView(this.content, {
handleChecked: (v, o) => this.handleChecked(o),
$implicit: this.list
});
this.cdf.detectChanges();
}
// 下面这些繁琐复杂的逻辑被封装在组件内部,对外只暴露了“渲染上下文”中的两个属性
handleInitWithFilter(arr) {
return [...arr].filter(item => {
if (Array.isArray(item?.children) && item?.children?.length > 0) {
item.children = this.handleInitWithFilter(item.children);
}
return !item?.hiddenForSetting;
});
}
handleInitWithCodes(arr) {
return [...arr].map(item => {
let checked = false;
if (Array.isArray(item?.children) && item?.children?.length > 0) {
item.children = this.handleInitWithCodes(item.children);
const { length: l1 } = item.children;
const { length: l2 } = item.children.filter(({ checked }) => checked);
checked = l1 === l2;
} else {
checked = this?.codes?.includes(item.code) ? true : false;
}
return {
...item,
active: false,
checked
};
});
}
handleGenCodes(arr, temp) {
arr.forEach(item => {
if (Array.isArray(item?.children) && item?.children?.length > 0) {
this.handleGenCodes(item.children, temp);
} else {
if (item.checked) {
temp.push(item.code);
}
}
});
}
handleChecked({ code }) {
let temp = [];
this.list = [...this.list].map(item => this.handleItem(code, item));
this.handleGenCodes(this.list, temp);
this.update.emit(temp);
}
handleItem(code, item) {
if (item.code === code) {
item.checked = !item.checked;
if (Array.isArray(item?.children) && item?.children?.length > 0) {
item.children = item.children.map(child =>
this.handleAll(child, item.checked)
);
}
}
if (Array.isArray(item?.children) && item?.children?.length > 0) {
item.children = item.children.map(child => this.handleItem(code, child));
item.checked = item.children.every(child => child.checked);
}
return item;
}
handleAll(item, checked) {
item.checked = checked;
if (Array.isArray(item?.children) && item?.children?.length > 0) {
item.children = item.children.map(child =>
this.handleAll(child, checked)
);
}
return item;
}
}
|
从 context 中取出 list 和 handleChecked 即可完成 UI 的渲染:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
|
<app-codes [type]="type" [codes]="role.codes" (update)="handleUpdate($event)">
<!--
UI 包含在 ng-template 中,背后的逻辑封装在 CodesComponent 中
CodesComponent 通过 ContentChild 来获取 ng-template 的引用
同时,通过 let-* 语法,解构出 context 中的属性
-->
<ng-template let-list let-handleChecked="handleChecked">
<div>
<nz-collapse [nzAccordion]="true" [nzBordered]="false" [nzGhost]="false">
<nz-collapse-panel
*ngFor="let f1 of list"
[nzHeader]="collapsePanelHeader"
[(nzActive)]="f1.active"
[nzShowArrow]="false"
>
<div class="collapse-panel-content">
<div nz-row *ngFor="let f2 of f1.children">
<div nz-col nzSpan="6" class="left-cell label-cell">
<label
nz-checkbox
[ngModel]="f2.checked"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="handleChecked($event, f2)"
>
{{ f2.name }}
</label>
</div>
<div nz-col nzSpan="18" class="right-cell label-cell">
<label
*ngFor="let f3 of f2.children"
nz-checkbox
[ngModel]="f3.checked"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="handleChecked($event, f3)"
>
{{ f3.name }}
</label>
</div>
</div>
</div>
<ng-template #collapsePanelHeader>
<div nz-row class="collapse-panel-header" nzAlign="middle">
<div nz-col [nzSpan]="12">
<label
nz-checkbox
[ngModel]="f1.checked"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="handleChecked($event, f1)"
>
{{ f1.name }}
</label>
</div>
<div nz-col [nzSpan]="12">
<div nz-row nzJustify="end">
<a nz-button nzType="link" *ngIf="f1.active">
收起
<i nz-icon nzType="up" nzTheme="outline"></i>
</a>
<a nz-button nzType="link" *ngIf="!f1.active">
展开
<i nz-icon nzType="down" nzTheme="outline"></i>
</a>
</div>
</div>
</div>
</ng-template>
</nz-collapse-panel>
</nz-collapse>
</div>
</ng-template>
</app-codes>
|
ngComponentOutlet
组件类装饰器中有一个可选的属性 selector,selector 决定了组件的实例化位置;当组件装饰器中缺少 selector 属性(或者 selector 属性值为空)时,Ng 会为其生成一个 <ng-component/> 标签。
没有 selector 属性的组件可以通过以下两种方式渲染到页面中:
- ngComponentOutlet;
- router-outlet。
<router-outlet/> 不用多说了;和 ngTemplateOutlet 一样,ngComponentOutlet 也是通过 ng-container 渲染组件的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
@Component({
selector: 'app-ng-component-outlet-example',
template: `
<ng-container *ngComponentOutlet="component"></ng-container>
<button type="button" (click)="handleComponentSwitch()">switch</button>
`
})
export class NgComponentOutletExampleComponent {
component: any = AComponent;
handleComponentSwitch() {
if (this.component.name === 'A') {
this.component = BComponent;
} else {
this.component = AComponent;
}
}
}
|
在自定义元素上使用 formControlName:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
|
/**
* 首先,需要明确一点
* 无论是“模版驱动表单(ngModel)”
* 还是“响应式表单(formControlName)”
* 内部实现都是 FormControl
* 是的,NgModel 内部继承了 FormControl
*/
type OnChangeType = (value: any) => void;
type OnTouchedType = () => any;
@Component({
selector: 'app-administrative-division',
providers: [
/**
* 这里没有使用 forwardRef 的必要
* forwardRef 的作用是“返回对‘值’的引用”
* 因为,“类修饰符”是在“类被定义”之后才生效的
* 所以,无需使用 forwardRef 将“类的引用”捕获在闭包里
*/
{
// useExisting: forwardRef(() => AdministrativeDivisionComponent),
useExisting: AdministrativeDivisionComponent,
provide: NG_VALUE_ACCESSOR,
multi: true
}
]
})
export class AdministrativeDivisionComponent
implements OnInit, ControlValueAccessor
{
/**
* ControlValueAccessor 在“Form API”和“DOM”之间扮演者桥梁的角色
* 通过将自身注册为 NG_VALUE_ACCESSOR provider
* 和 implements ControlValueAccessor
* 任何自定义组件都能和“Form API”进行沟通
* 原生表单元素能和“Form API”沟通的原因是
* Ng 内部已经帮助实现了一部分“指令”:
* SelectMultipleControlValueAccessor
* CheckboxControlValueAccessor
* SelectControlValueAccessor
* RadioControlValueAccessor
* DefaultValueAccessor
* NumberValueAccessor
* RangeValueAccessor
*/
@Input() nzValue = null;
@Input() nzDisabled = false;
@Input() placeholder = '';
@Output() readonly nzValueChange = new EventEmitter();
@ViewChild('buttonElement') buttonElement!: ElementRef<HTMLButtonElement>;
@ViewChild('selectElement') selectElement!: ElementRef<HTMLSelectElement>;
provinceValue = null;
cityValue = null;
areaValue = null;
currentProvinceList = [];
currentCityList = [];
currentAreaList = [];
loadingProvinceList = false;
loadingCityList = false;
loadingAreaList = false;
isEditMode = false;
onChange: OnChangeType = () => {};
onTouched: OnTouchedType = () => {};
constructor(private apiService: ApiService) {}
ngOnInit() {
this.getCurrentProvinceList();
}
focus(): void {
if (this.isEditMode) {
this.selectElement.nativeElement.focus();
} else {
this.buttonElement.nativeElement.focus();
}
}
blur(): void {
if (this.isEditMode) {
this.selectElement.nativeElement.blur();
} else {
this.buttonElement.nativeElement.blur();
}
}
/**
* 接收 FormControl 传来的 value
* 将 value 更新到 UI 上
*/
writeValue(value: any) {
this.nzValue = value;
}
/**
* registerOnChange 会被 FormControl 用来注册回调 fn
* 当 UI 中的值变化时,fn 会被调用
*/
registerOnChange(fn: OnChangeType) {
this.onChange = fn;
}
/**
* 用来处理用户对组件的交互
* 通常可以不用处理交互
*/
registerOnTouched(fn: OnTouchedType) {
this.onTouched = fn;
}
setDisabledState(disabled) {
this.nzDisabled = disabled;
}
handleSwitch2Edit() {
this.isEditMode = !this.isEditMode;
}
handleProvinceValueChange(value) {
this.provinceValue = value;
this.getCurrentCityList(value);
}
handleCityValueChange(value) {
this.cityValue = value;
this.getCurrentAreaList(value);
}
handleAreaValueChange(value) {
this.areaValue = value;
const { provinceName } = this.provinceValue;
const { cityName } = this.cityValue;
const { areaName } = this.areaValue;
const temp = `${provinceName}${cityName}${areaName}`;
this.nzValue = temp;
this.isEditMode = false;
this.onChange(this.nzValue);
this.nzValueChange.emit(this.nzValue);
}
getCurrentProvinceList() {
// ...
}
getCurrentCityList({ provinceId }) {
// ...
}
getCurrentAreaList({ cityId }) {
// ...
}
}
|
asyncPipe
一般情况下,我们会在 ngOnInit 中请求组件需要的初始化数据;但是这会导致相关的副作用散落在组件的各个部分。
借助 Angular 提供的 asyncPipe,我们可以以流的形式来处理这部分逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
@Component({
template: `
<nz-table
(nzPageIndexChange)="handlePageIndexChange($event)"
(nzPageSizeChange)="handlePageSizeChange($event)"
[nzData]="banlanceList$ | async as banlanceList"
[nzPageIndex]="paginationData.pageIndex"
[nzPageSize]="paginationData.pageSize"
[nzTotal]="paginationData.total"
[nzLoading]="loading"
>
<tbody>
<tr *ngFor="let item of banlanceList">...</tr>
</tbody>
</nz-table>
`
})
export class ExclusiveAccountBalanceComponent {
// 数据流起始于路由查询参数
banlanceList$ = this.route.queryParams.pipe(
// 执行“请求发起前”的副作用
tap((params: SearchParams) => {
this.lastQueryParams = params;
this.loading = true;
}),
// 转换数据流,发起请求
switchMap(params =>
this.apiService.exclusiveDailyAccountBalancePage(params)
),
// 执行“请求成功后”的副作用
tap(({ pages = 0, paginationData = {} }) => {
this.paginationData = paginationData as PaginationData;
this.pages = pages;
this.loading = false;
}),
// 对数据流进行格式化
map(({ records = [] }) => records)
);
paginationData: PaginationData = { pageIndex: 1, pageSize: 10, total: 0 };
pages = 0;
lastQueryParams: SearchParams = {};
loading = false;
constructor(
private apiService: ApiService,
private router: Router,
private route: ActivatedRoute
) {}
handleSearched(queryParams) {
// 路由查询参数变化,新的数据被放入数据流中
this.router.navigate([], {
relativeTo: this.route,
queryParams,
queryParamsHandling: 'merge'
});
}
handleRefreshed() {
// 将上次的查询数据,重新放入数据流中
(this.banlanceList$ as AnonymousSubject<SearchParams>).next(
this.lastQueryParams
);
}
}
|
directive
指令的好处是,可以在同一元素上叠加多个指令来完成不同的逻辑。
- 指令 carousel 绑定了三个 key:from、autoplay、withDelay;
- 对应传入的值也有三个:images、‘on’、2000。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
@Directive({
selector: '[carousel]'
})
export class CarouselDirective implements OnInit, OnDestroy {
@Input(carouselFrom) images: string[];
@Input(carouselAutoplay)
set autoplay(autoplay: 'on' | 'off') {
autoplay === 'on' ? this.setAutoplayTimer() : this.clearAutoplayTimer();
}
@Input(carouselWithDelay)
set delay(delay: number) {
this._autoplayDelay = delay;
}
get delay() {
return this._autoplayDelay || 1000;
}
private _autoplayDelay: number;
private setAutoplayTimer() {
this.timerId = setInterval(() => {
this.next();
}, this.delay);
}
private clearAutoplayTimer() {
clearInterval(this.timerId);
}
timerId: number | null = null;
context: CarouselContext | null = null;
index: number | null = 0;
constructor(
private tpl: TemplateRef<CarouselContext>,
private vcr: ViewContainerRef
) {}
ngOnInit() {
this.context = {
$implicit: this.images[0],
controller: {
next: () => this.next(),
prev: () => this.prev()
}
};
this.vcr.createEmbeddedView(this.tpl, this.context);
}
ngOnDestroy() {
this.clearAutoplayTimer();
}
next() {
this.index++;
if (this.index >= this.images.length) {
this.index = 0;
}
this.context.$implicit = this.images[this.index];
}
prev() {
this.index--;
if (this.index < 0) {
this.index = this.images.length - 1;
}
this.context.$implicit = this.images[this.index];
}
}
|
使用指令:
1
2
3
4
5
6
7
8
|
<div *carousel="let item from images autoplay 'on' withDelay 1500">
<img [src]="item" />
</div>
<div *carousel="let item from images; let ctrl = controller autoplay 'off'">
<img [src]="item" />
<button (click)="ctrl.prev()"><</button>
<button (click)="ctrl.next()">></button>
</div>
|
runOutsideAngular
在 Angular 中,DOM 事件的触发会导致 Ng 进行“变更检测”;如果该事件的副作用最终没有造成“绑定值”的更新;那么当前的“变更检测”就是多余的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
@Component({
selector: 'app-event-listener',
template: `
<button nz-button nzType="primary" (click)="handleClick($event)">
点击
</button>
`
})
export class EventListenerComponent implements AfterViewChecked {
ngAfterViewChecked(): void {
// 每次“变更检测”之后调用
console.log('AfterViewChecked');
}
handleClick(e: MouseEvent) {
// 执行的副作用和 Angular 无关
console.log(e);
}
}
/**
* 每次点击按钮,控制台依次输出:
* PointerEvent {...}
* AfterViewChecked
* ...
*/
|
这么看来,最理想的状态是,只对会造成“绑定值”更新的副作用进行“变更检测”(这不就是 Vue 吗?);好在 NgZone 提供了一个 runOutsideAngular 方法,可以使副作用的执行不触发“变更检测”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
@Component({
selector: 'app-event-listener',
template: `
<button nz-button nzType="primary" (click)="handleClick($event)">
点击
</button>
`
})
export class EventListenerComponent implements AfterViewChecked {
constructor(private zone: NgZone) {}
ngAfterViewChecked(): void {
// 每次“变更检测”之后调用
console.log('AfterViewChecked');
}
handleClick(e: MouseEvent) {
// 使用 runOutsideAngular 包裹“副作用”
this.zone.runOutsideAngular(() => {
console.log(e);
});
}
}
/**
* 每次点击按钮,控制台依次输出:
* PointerEvent {...}
* AfterViewChecked
* ...
*/
|
可以看到 runOutsideAngular 并没有起作用,原因是事件回调 handleClick 注册在了 runOutsideAngular 外部:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
@Component({
selector: 'app-event-listener',
template: `
<button #btn nz-button nzType="primary">点击</button>
`
})
export class EventListenerComponent implements AfterViewInit, AfterViewChecked {
@ViewChild('btn') btn!: ElementRef<HTMLButtonElement>;
constructor(private renderer: Renderer2, private zone: NgZone) {}
ngAfterViewInit(): void {
this.zone.runOutsideAngular(() => {
// 以下三种均在 runOutsideAngular 内部完成事件的绑定
this.setupClickListener1();
// this.setupClickListener2();
// this.setupClickListener3();
});
}
ngAfterViewChecked(): void {
console.log('AfterViewChecked');
}
setupClickListener1() {
(this.btn as any).elementRef.nativeElement.addEventListener(
'click',
(e: MouseEvent) => {
console.log(e);
}
);
}
setupClickListener2() {
this.renderer.listen(
(this.btn as any).elementRef.nativeElement,
'click',
(e: MouseEvent) => {
console.log(e);
}
);
}
setupClickListener3() {
fromEvent((this.btn as any).elementRef.nativeElement, 'click').subscribe(
(e: MouseEvent) => {
console.log(e);
}
);
}
}
/**
* 以上三种绑定方式,每次点击按钮,控制台依次输出:
* PointerEvent {...}
* ...
*/
|
可以使用“指令”将这部分逻辑进行封装:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
@Directive({
selector: '[click.zoneless]'
})
export class ClickZonelessDirective implements OnInit, OnDestroy {
@Output('click.zoneless') clickZoneless = new EventEmitter<MouseEvent>();
private removeListener: Function;
constructor(
private readonly zone: NgZone,
private readonly el: ElementRef,
private readonly renderer: Renderer2
) {}
ngOnInit() {
this.zone.runOutsideAngular(() => {
this.setupListener();
});
}
ngOnDestroy() {
this.removeListener();
}
setupListener() {
this.removeListener = this.renderer.listen(
this.el.nativeElement,
'click',
(e: MouseEvent) => {
this.clickZoneless.emit();
console.log(e);
}
);
}
}
/**
* 每次点击按钮,控制台依次输出:
* PointerEvent {...}
* ...
*/
|
但是这种方式不够灵活,总不能一种事件就写一个指令。可以借助
EventManager
实现同样的效果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
// zoneless-event.service.ts
@Injectable({
providedIn: 'root'
})
export class ZonelessEventService {
manager: EventManager;
supports(eventName: string): boolean {
return eventName.endsWith('.zoneless');
}
addEventListener(
element: HTMLElement,
eventName: keyof HTMLElementEventMap,
handler: EventListener
): Function {
const [nativeEventName] = eventName.split('.');
this.manager.getZone().runOutsideAngular(() => {
element.addEventListener(nativeEventName, handler);
});
return () => element.removeEventListener(nativeEventName, handler);
}
}
// app.module.ts
@NgModule({
bootstrap: [AppComponent],
declarations: [AppComponent],
imports: [BrowserModule],
providers: [
{
provide: EVENT_MANAGER_PLUGINS,
useClass: ZonelessEventService,
multi: true
}
]
})
export class AppModule {}
|
ExpressionChangedAfterItHasBeenCheckedError
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@Component({
template: `
<label nz-checkbox [(ngModel)]="checkbox">
<span [textContent]="time | date : 'hh:mm:ss:SSS'"></span>
</label>
`,
selector: 'app-example'
})
export class ListComponent {
checkbox = false;
get time() {
return Date.now();
}
}
/**
* 每次勾选多选框,控制台输出:
* NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked.
* ...
*/
|
控制台会报**
NG100 错误
**,原因是多选框的勾选会改变 checkbox,从而触发“变更检测”,导致 DOM 更新,在 DOM 重新渲染的过程中,timeGetter 的返回值和在“变更检测”过程中的返回值不同;渲染前后的值的不同被“发现”了,导致错误被抛出。
值的变化是什么时候被发现的呢?在组件被渲染到页面的过程中,组件内部会有一个被称为 View 的数据结构,保存着对组件实例的引用和绑定表达式的值;在变更检测过程中,编译器会将需要更新的 DOM 属性打上标记;对于每个被标记的属性,编译器都会创建一个绑定,该绑定定义了要更新的属性名称以及用来获取新值的表达式。
在“变更检测”过程中,会运行编译器为视图生成的所有绑定表达式,并将执行结果和储存在 View 中的 oldValue进行比较,这就是“脏”检查的由来;如果值有变化,那么就会更新“界面”和“oldValue”;当前组件的“变更检测”进行完毕,就会对“子组件”进行相同的操作。
在上述例子中,Angular 在 spanView 和 timeGetter 之间建立了一种绑定关系;在每次检测过程中,绑定表达式被执行,timeGetter 返回的时间戳并被日期管道处理,日期管道返回的值会和 oldValue 进行比较,如果检测到值的差异,spanView 的 textContent 会被更新。
在开发环境中,每个“变更检测周期(整个组件树完成变更检测)”之后会同步进行一次“额外的检测”,以确保“最终值”和变更检测过程中的值一致;不同于“变更检测”,在“额外检测”过程中,如果检测到“值的不同”,不会进行视图更新,而是抛出ExpressionChangedAfterItHasBeenCheckedError错误。
简单的说,如果值总是在“变更检测”期间发生变化,就会造成“变更检测死循环”;“ExpressionChangedAfterItHasBeenCheckedError”错误就是在出现“变更检测死循环”的情况下被抛出的;或者说,当应用内部状态和渲染结果不匹配时,就会抛出“ExpressionChangedAfterItHasBeenCheckedError”。
参考