0%

Angular Lifecycle

今天我们来深入学习一下Angular的Lifecycle方法,Lifecycle方法是Angular中非常重要的一个概念,我们在开发中经常会用到这些方法,比如在ngOnInit中初始化数据,或者在ngOnDestroy中取消订阅等等。

首先在项目中生成一个组件,命名为lifecycle,命令如下:

1
ng g component lifecycle

lifecycle.component.html中的内容清空.然后在lifecycle.component.ts中添加如下代码。组件中的count变量用来标记每个Lifecycle方法调用的序号,这样我们就可以清楚的看到每个方法的调用顺序了。

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
import {
AfterContentChecked,
AfterContentInit,
AfterViewChecked,
AfterViewInit,
Component,
DoCheck,
OnChanges,
OnDestroy,
OnInit,
} from '@angular/core';

@Component({
selector: 'app-order',
templateUrl: './order.component.html',
styleUrls: ['./order.component.less'],
})
export class OrderComponent
implements
OnInit,
AfterViewInit,
OnChanges,
AfterContentInit,
AfterContentChecked,
AfterViewChecked,
OnDestroy,
DoCheck
{
private static count = 0;

constructor() {
console.log(`${++OrderComponent.count} constructor`);
}

ngOnInit(): void {
console.log(`${++OrderComponent.count} ngOnInit`);
}

ngDoCheck() {
console.log(`${++OrderComponent.count} ngDoCheck`);
}

ngAfterContentInit() {
console.log(`${++OrderComponent.count} ngAfterContentInit`);
}

ngAfterContentChecked() {
console.log(`${++OrderComponent.count} ngAfterContentChecked`);
}

ngAfterViewInit() {
console.log(`${++OrderComponent.count} ngAfterViewInit`);
}

ngAfterViewChecked() {
console.log(`${++OrderComponent.count} ngAfterViewChecked`);
}

ngOnChanges() {
console.log(`${++OrderComponent.count} ngOnChanges`);
}

ngOnDestroy() {
console.log(`${++OrderComponent.count} ngOnDestroy`);
}
}

运行程序,会得到如下输出:

1
2
3
4
5
6
7
1 constructor
2 ngOnInit
3 ngDoCheck
4 ngAfterContentInit
5 ngAfterContentChecked
6 ngAfterViewInit
7 ngAfterViewChecked

constructor是构造函数,并不能算是Angular生命周期函数,但是为了图个全乎,我们一并介绍。

Constructor

constructor是构造函数,它是在组件被创建时调用的,它的调用顺序是最早的,也就是说它是第一个被调用的方法,它的调用顺序是固定的,不会因为其他因素而改变。

构造函数中应该做哪些事情

一般在构造函数中会做一些初始化的工作,比如

  1. 初始化变量
  2. 订阅事件

构造函数中不应该做哪些事情?

  1. 与View相关的操作,比如操作DOM元素(应该在ngAfterViewInit中进行)
  2. 获取后台数据(应该在ngOnInit中获取)

ngOnChanges

ngOnChanges是当组件的@Input属性发生变化时调用的,它接受一个SimpleChanges类型的参数,这个参数中包含了变化的属性的信息,比如变化前的值和变化后的值等等。

调用时机:

  1. 当且仅当组件中有@Input属性时才会被调用。
  2. 先在ngOnInit之前调用一次。(为什么?)
  3. 后续每当@Input属性发生变化时调用一次。

由于我们这个组件中没有@Input属性,所以这个方法没有被调用。

ngOnInit

Initialize the directive or component after Angular first displays the data-bound properties and sets the directive or component’s input properties

调用时机

  1. ngOnChanges之后调用一次。
  2. 不管ngOnChanges是否被调用,ngOnInit都会被调用一次。
  3. 整个生命周期中只调用一次。

所以上面例子中构造函数调用之后,立即调用了ngOnInit方法。

ngDoCheck

Detect and act upon changes that Angular can’t or won’t detect on its own

该方法主要用来做自定义的更新检测。

调用时机
2. 在ngOnInit调用之后调用一次。

  1. 每次ngOnChanges调用之后,都会调用该方法。

在上例中,虽然没有调用ngOnChanges,但是ngOnInit调用了,所以该方法也调用了一次。

注意:这里的第一点Angular官网的解释并不准确,确切的说,是每次Angular进行更新检测之后,都会调用该方法,即使更新检测后,绑定的值没有任何变化,也会调用该方法。为了验证,我们可以在ngInit中添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
constructor(
private changeDetector: ChangeDetectorRef, // 注入ChangeDetectorRef
) {
console.log(`${++OrderComponent.count} constructor`);
}

ngOnInit(): void {
console.log(`${++OrderComponent.count} ngOnInit`);

// 每隔一秒手动触发一次更新检测。
setInterval(() => {
this.changeDetector.detectChanges();
}, 1000);
}

此时观察控制台,输出如下,可见,每当change detection发生时,ngDoCheck都会被调用。ngAfterContentCheckedngAfterViewChecked也会跟着被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1 constructor
2 ngOnInit
3 ngDoCheck
4 ngAfterContentInit
5 ngAfterContentChecked
6 ngAfterViewInit
7 ngAfterViewChecked

8 ngDoCheck
9 ngAfterContentChecked
10 ngAfterViewChecked

11 ngDoCheck
12 ngAfterContentChecked
13 ngAfterViewChecked

...

ngAfterContentInit

Respond after Angular projects external content into the component’s view。该方法与<ng-content>标签相关。但是需要注意的是,无论组件中是否包含<ng-content>标签,该方法都会被调用。

调用时机

  1. ngDoCheck第一次调用之后,调用一次。
  2. 整个生命周期中只调用一次。

ngAfterContentChecked

当Angular检测完组件内容变化之后调用。

调用时机

  1. ngAfterContentInit之后调用一次。
  2. 在每次ngDoCheck之后调用一次。

ngAfterViewInit

当Angular初始化完组件视图及其子视图之后调用。如果是directive中的ngAfterViewInit,则在初始化完包含该directive的视图之后调用。

调用时机

  1. ngAfterContentChecked第一次调用之后调用一次。
  2. 整个生命周期中只调用一次。

ngAfterViewChecked

当Angular检测完组件视图及其子视图之后调用。如果是directive中的ngAfterViewChecked,则在检测完包含该directive的视图之后调用。

调用时机

  1. ngAfterViewInit之后调用一次。
  2. 在每次ngAfterContentChecked之后调用一次。

ngOnDestroy

当Angular销毁组件之前调用。

调用时机

  1. 在组件被销毁之前调用。
  2. 整个生命周期中只调用一次。

要想看到该方法被调用,必须切换到切他页面,也就是离开该组件所在的页面才行。

下面我们改变页面内容,看看这些生命周期是否有变化,首先给模板文件添加内容,在lifecycle.component.html中添加如下内容:

1
<p>Lifecycle component works</p>

保存并刷新页面,可以看到输出并未变化。

接下来我们给组件添加一个@Input属性,修改lifecycle.component.ts文件,添加如下内容:

1
@Input() nameList: string[] = [];

修改模板文件,添加如下内容,用来显示输入的名字列表。

1
2
3
<ng-container *ngFor="let name of nameList">
<p>name: {{name}}</p>
</ng-container>

然后我们创建一个父组件,用来调用LifecycleComponent组件,并传入nameList属性。
lifecycle-parent.component.html

1
2
<lifecycle-order [nameList]="nameList">
</lifecycle-order>

然后运行程序,切换到life-cycle页面,可以看到控制台输出如下内容,从第二行可以看出,ngOnChanges方法被调用了,而且是在ngOnInit之前调用的。

1
2
3
4
5
6
7
8
1 - OrderComponent: constructor
2 - OrderComponent: ngOnChanges
3 - OrderComponent: ngOnInit
4 - OrderComponent: ngDoCheck
5 - OrderComponent: ngAfterContentInit
6 - OrderComponent: ngAfterContentChecked
7 - OrderComponent: ngAfterViewInit
8 - OrderComponent: ngAfterViewChecked

由于我们并没有在父组件中修改nameList属性,所以ngOnChanges方法只被调用了一次。
我们可以打印一下changes参数,看看里面有什么内容。

1
2
3
4
ngOnChanges(simpleChanges: SimpleChanges) {
console.log(`${++OrderComponent.count} - ${this.className}: ngOnChanges`);
console.log(simpleChanges); // print changes
}

控制台输出如下内容:
Alt text
因为是第一次赋值,所以previousValueundefinedcurrentValue['John, 'Mary', 'Joe']。并且firstChange为true

接下来我们在父组件中添加一个按钮,用来修改nameList属性,修改lifecycle-parent.component.html文件,添加如下内容:

1
<button (click)="changeNameList()">Change Name List</button>

修改lifecycle-parent.component.ts文件,添加如下内容:

1
2
3
changeNameList() {
this.nameList = ['John', 'Mary', 'Joe', 'Tom'];
}

运行程序,切换到life-cycle页面,点击按钮,可以看到控制台输出如下内容:可以看到,由于这次我们修改了nameList属性,所以ngOnChanges方法又被调了一次。

1
2
3
4
9 - OrderComponent: ngOnChanges
10 - OrderComponent: ngDoCheck
11 - OrderComponent: ngAfterContentChecked
12 - OrderComponent: ngAfterViewChecked

这次changes参数的内容如下图所示:
Alt text

接下来,我们修改一下代码,添加一个input框,让用户输入名字,然后将该名字显示到页面上,修改lifecycle-parent.component.html文件,添加如下内容:

1
2
<input type="text" [(ngModel)]="name">
<button (click)="addName()">Add Name</button>

修改lifecycle-parent.component.ts文件,添加如下内容:

1
2
3
4
5
6
name: string;
addName() {
if (this.name.trim()) {
this.nameList.push(this.name);
}
}

运行程序,输入zdd到input框,点击Add Name按钮,可以看到新添加的name显示到了页面上,但是onChanges方法并没有被调用,这是为什么呢?

Alt text

这是因为,Angular默认的change detection比较的是Input值的引用,而不是值本身。所以,当我们重新给nameList赋值时,ngOnChanges方法被调用了,因为此时nameList的引用改变了,但是当我们使用Array.prototype.pushnameList中添加元素时,ngOnChanges方法并没有被调用,因为nameList的引用并没有变化。

要想让ngOnChanges方法被调用,我们可以这样给nameList属性赋值:

1
this.nameList = [...this.nameList, this.name];

这样,nameList的引用就变化了,ngOnChanges方法就会被调用。

不知道大家是否注意到这样一个情况,我们在input框每输入一个字符,控制台都会打印一下内容,甚至在我们删除输入框内容的时候,也会打印。

1
2
3
96 - OrderComponent: ngDoCheck
97 - OrderComponent: ngAfterContentChecked
98 - OrderComponent: ngAfterViewChecked

看来,每当我们输入值的时候,都触发了Angular change detection,这并不是我们想要的,我们只想在点击Add Name按钮的时候,触发change detection,这样才能保证性能。

用Angular Dev tool分析一下程序的性能。

首先打开Chrome的插件商店,搜索Angular DevTools,然后安装该插件。

然后运行程序,打开该插件,切换到Profiler页面。点击Start recording,然后在input框中输入几个字符,并停止录制。

Alt text

可以看到,输入框的input事件触发了OrderComponent的change detection,这不是我们想要的。我们可以使用ChangeDetectorRef来禁用change detection.

修改lifecycle.component.ts文件,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
constructor(private changeDetector: ChangeDetectorRef) {}

ngAfterViewInit() {
// 注意,不能在ngOnInit方法中调用以下方法,否则初始数据无法显示到页面上。
this.changeDetector.detach(); // 禁用change detection.
}

onInput(event: any) {
console.log(event.data);
}
addName() {
if (this.name.trim()) {
this.nameList = [...this.nameList, this.name];
this.changeDetector.detectChanges(); // 更新数据时,要手动触发change detection.
}
}

再次运行程序,在input框中输入字符,观察控制台,你会发现,input事件不再触发change detection了。

1
2
3
4
1
12
123
1234

使用ChangeDetectionStrategy.OnPush可以提高性能,但是要注意,如果我们使用了ChangeDetectionStrategy.OnPush,那么我们就必须使用@Input属性,否则,ngOnChanges方法不会被调用。而且使用这种策略时,只有当@Input属性的引用发生变化时,才会触发change detection,如果@Input属性的发生变化,是不会触发change detection的。

比如,这样可以触发ngOnChanges方法:

1
2
3
4
5
addName() {
if (this.name.trim()) {
this.nameList = [...this.nameList, this.name];
}
}

但是这样不会触发ngOnChanges方法:

1
2
3
4
5
addName() {
if (this.name.trim()) {
this.nameList.push(this.name);
}
}

Nested Components

In Angular, components can be nested, for example, a Parent component can contain a Child component. Here is the lifecycle method order for nested components.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ParentComponent.constructor
ChildComponent.constructor
ParentComponent.ngOnChanges
ParentComponent.ngOnInit
ParentComponent.ngDoCheck
ParentComponent.ngAfterContentInit
ParentComponent.ngAfterContentChecked
ChildComponent.ngOnChanges
ChildComponent.ngOnInit
ChildComponent.ngDoCheck
ChildComponent.ngAfterContentInit
ChildComponent.ngAfterContentChecked
ChildComponent.ngAfterViewInit
ChildComponent.ngAfterViewChecked
ParentComponent.ngAfterViewInit
ParentComponent.ngAfterViewChecked
ChildComponent.ngOnDestroy
ParentComponent.ngOnDestroy

Deep nested components

What if the Child component also has a child component Descendant? Here is the lifecycle method order for deep nested components.

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
ParentComponent.constructor
ChildComponent.constructor
DescendantComponent.constructor
ParentComponent.ngOnChanges
ParentComponent.ngOnInit
ParentComponent.ngDoCheck
ParentComponent.ngAfterContentInit
ParentComponent.ngAfterContentChecked
ChildComponent.ngOnChanges
ChildComponent.ngOnInit
ChildComponent.ngDoCheck
ChildComponent.ngAfterContentInit
ChildComponent.ngAfterContentChecked
DescendantComponent.ngOnChanges
DescendantComponent.ngOnInit
DescendantComponent.ngDoCheck
DescendantComponent.ngAfterContentInit
DescendantComponent.ngAfterContentChecked
DescendantComponent.ngAfterViewInit
DescendantComponent.ngAfterViewChecked
ChildComponent.ngAfterViewInit
ChildComponent.ngAfterViewChecked
ParentComponent.ngAfterViewInit
ParentComponent.ngAfterViewChecked
DescendantComponent.ngOnDestroy
ChildComponent.ngOnDestroy
ParentComponent.ngOnDestroy
生命周期钩子 调用时机 调用次数 作用
ngOnChanges 输入属性(@Input)变化时触发 多次 响应输入属性的变化,获取新旧值并执行逻辑
ngOnInit 组件初始化后触发(在首次 ngOnChanges 之后) 一次 初始化数据(如从服务获取数据),适合执行一次性操作
ngDoCheck 每次变更检测周期中触发 多次 手动检测变更(如复杂对象变更),用于扩展默认变更检测
ngAfterContentInit 组件内容投影(如 <ng-content>)初始化完成后触发 一次 操作投影内容(如访问 @ContentChild 引用的子组件)
ngAfterContentChecked 每次内容投影变更检测完成后触发 多次 响应投影内容的变更(如动态插入子组件)
ngAfterViewInit 组件视图及子视图初始化完成后触发 一次 操作视图元素(如访问 @ViewChild 引用的 DOM 或子组件)
ngAfterViewChecked 每次视图及子视图变更检测完成后触发 多次 响应视图变更(如动态修改子组件属性),需避免在此修改状态以防止无限循环
ngOnDestroy 组件销毁前触发 一次 清理资源(如取消订阅、移除事件监听器)

Reference

  1. https://angular.dev/guide/components/lifecycle#summary

JavaScript事件模型

在JavaScript中,有两种事件的传递方向,一种是由内层元素向外层元素传递,也叫自底向上的方式,称作事件冒泡,好比水中的气泡由水底向水面上升的过程。另一种叫做事件捕获,方向刚好相反,从外层元素向内层元素传递,也叫自顶向下。

目前主流的浏览器都支持这两种事件传递方式,但是在IE8及以下版本的浏览器中,只支持事件冒泡,不支持事件捕获。

所以DOM中的事件处理分为以下三个阶段

  • capture(捕获阶段),事件由外层向内层传递
  • target(命中阶段),事件到达目标元素
  • bubbling(冒泡阶段),事件由内层向外层传递

那么如何指定事件的传递方式呢?我们可以通过addEventListener的第三个参数来指定,比如下面的代码:
当useCapture为true时,事件传递方式为事件捕获,当useCapture为false时,事件传递方式为事件冒泡。默认值为false,使用事件冒泡模式。

1
addEventListener(type, listener, useCapture)

Event.stopPropagation

  1. 当事件传递方式为捕获模式时,event.stopPropagation()会阻止事件继续向下(内层元素)传递。
  2. 当事件传递方式为冒泡模式时,event.stopPropagation()会阻止事件继续向上(外层元素)传递。

代码示例:

1
2
3
4
5
6
7
8
9
<div id="div1">
div1
<div id="div2">
div2
<div id="div3">
div3
</div>
</div>
</div>
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
const div1 = document.querySelector("#div1");
bindEventListener(div1, "click", clickHandler1, false);
const div2 = document.querySelector("#div2");
bindEventListener(div2, "click", clickHandler2, false);
const div3 = document.querySelector("#div3");
bindEventListener(div3, "click", clickHandler3, false);

function bindEventListener(element, event, listener, useCapture) {
element.addEventListener(event, listener, useCapture);
}

function clickHandler1(event) {
const text = event.currentTarget.id + " clicked";
console.log(text);
}

function clickHandler2(event) {
const text = event.currentTarget.id + " clicked";
console.log(text);
}

function clickHandler3(event) {
const text = event.currentTarget.id + " clicked";
console.log(text);
}

点击div3,输出如下,因为采用的是冒泡模式,所以事件会从内层元素向外层元素传递。即div3最先捕获事件,然后是冒泡给div2,最后是div1.

1
2
3
div3 clicked
div2 clicked
div1 clicked

如果我们在clickHandler3中加入event.stopPropagation(),再次点击div3,输出如下:

1
div3 clicked

可见,event.stopPropagation()阻止了事件继续向上(外层元素)传递。

将事件处理函数改为捕获模式

1
2
3
bindEventListener(div1, "click", clickHandler1, true);
bindEventListener(div2, "click", clickHandler2, true);
bindEventListener(div3, "click", clickHandler3, true);

再次点击div3,输出如下,因为采用的是捕获模式,所以事件会从外层元素向内层元素传递。即div1最先捕获事件,然后是div2,最后是div3.

1
2
3
div1 clicked
div2 clicked
div3 clicked

如果我们在clickHandler1中加入event.stopPropagation(),再次点击div3,输出如下:

1
div1 clicked

可见,event.stopPropagation()阻止了事件继续向下(内层元素)传递。

Event.stopImmediatePropagation

如果将上述代码中的event.stopPropagation()改为event.stopImmediatePropagation(),你会发现,输出的结果是一样的,这说明event.stopImmediatePropagation()event.stopPropagation()的作用是一样的,都是阻止事件继续传递。既然作用是一样的,那么为什么还要有event.stopImmediatePropagation()呢?这是因为event.stopImmediatePropagation()还有一个额外的功能,就是阻止事件处理函数队列中的其他函数执行,比如下面的代码:

1
2
3
bindEventListener(div1, "click", clickHandler1, false);
bindEventListener(div1, "click", clickHandler2, false);
bindEventListener(div1, "click", clickHandler3, false);

当我们点击div1时,输出如下:

1
2
3
div1 clicked
div1 clicked
div1 clicked

当多个事件处理函数绑定到同一个元素的同一个事件时,事件处理函数的执行顺序是按照绑定的顺序执行的,比如上面的代码,clickHandler1会先于clickHandler2执行,clickHandler2会先于clickHandler3执行。如果我们在clickHandler1中加入event.stopImmediatePropagation(),再次点击div1,输出如下:

1
div1 clicked

可见,event.stopImmediatePropagation()阻止了事件处理函数队列中的其他函数执行。clickHandler2和clickHandler3都被阻止了执行。

阻止默认行为

event.stopPropagation()虽然能阻止事件传播,却不能阻止事件的默认行为,比如将上例中的button换成<a>的话,即使阻止了事件传播,点击链接后a标签依然会跳转。这时,我们可以使用 event.preventDefault()来实现这个功能。

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
<html lang="zh-Hans-CN">
<head>
<meta charset="UTF-8" />
<meta content="width=device-width,initial-scale=1" name="viewport" />
<title>event-handler</title>
<style>
div#my-div {
text-align: center;
margin: 32px auto;
padding-top: 32px;
width: 400px;
height: 300px;
border: 1px solid gray;
}

a {
display: block;
margin: 16px auto;
}
</style>
</head>
<body>
<div id="my-div">
div
<a href="https://www.baidu.com" id="my-button">link</a>
</div>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
document
.getElementById('my-div')
.addEventListener('click', divClickHandler, true);
document
.getElementById('my-button')
.addEventListener('click', buttonClickHandler, true);

function divClickHandler(event) {
event.stopPropagation();
event.preventDefault(); // 阻止链接打开的默认行为
console.log('div clicked');
}

function buttonClickHandler(event) {
console.log('button clicked');
}

有以下几点需要注意:

  1. event.preventDefault()只会阻止事件默认行为,并不会阻止事件继续传播
  2. event.preventDefault()只对cancelable=true的事件起作用。

event.preventDefault()的应用场景有:

  1. 阻止<a>标签点击后跳转

  2. 阻止<checkbox>被选中

  3. 验证用户输入,比如只允许输入小写字母,当输入非小写字母时,不显示输入的字符。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function checkName(evt) {
    var charCode = evt.charCode;
    if (charCode != 0) {
    if (charCode < 97 || charCode > 122) {
    evt.preventDefault();
    displayWarning(
    "Please use lowercase letters only."
    + "\n" + "charCode: " + charCode + "\n"
    );
    }
    }
    }

References

什么是Event.target和Event.currentTarget

这个两个target在JavaScript事件处理中十分重要,来看一下他们之间的区别。

  • event.target:触发事件的元素
  • event.currentTarget:绑定事件的元素

二者之间的区别只有在元素嵌套的时候,才会有所体现。比如下面的代码:
外层div1绑定了click事件,内层div2没有绑定任何事件。当我们点击div2的时候,会输出什么呢?

1
2
3
<div id="div1">div 1
<div id="div2">div2</div>
</div>
1
2
3
4
5
const div1 = document.getElementById("div1");
div1.addEventListener("click", (event) => {
console.log(event.target);
console.log(event.currentTarget);
});

event-target-vs-event-currentTarget

  • 当我们点击div2的时候,event.targetdiv2event.currentTargetdiv1
  • 当我们点击div1的时候,event.targetdiv1event.currentTarget也是div1

由此可见,event.target永远是触发事件的元素,而event.currentTarget永远是绑定事件的元素。

如何禁止子元素触发事件

那么问题来了,有些时候我们不像让内部元素(子元素)触发事件,而是想让外部元素(父元素)触发事件,这个时候我们应该怎么做呢?考虑如下场景,假设现在我们要实现一个Card,这个Card内部有很多子元素,现在用户有一个需求,让Card实现拖拽功能,那么我们需要监听Card的dragenter事件,但是我们不想让Card内部的子元素触发dragenter事件,这个时候我们应该怎么做呢?我们可以通过判断event.currentTarget来实现这个功能,因为event.currentTarget会指向card本身,而不是其内部的子元素。

其实还有一个更加彻底的办法就是使用CSS的pointer-events属性,这个属性可以控制元素是否可以触发鼠标事件,比如上面的例子,我们可以这样禁止div2触发click事件:

1
2
3
<div id="div1">div 1
<div id="div2" style="pointer-events: none;">div2</div>
</div>

这下我们再次点击div2时,输出就变成了下面这样:可见div2没有触发click事件,而是由它的父元素div1触发了。

1
2
target: div 1
currentTarget: div 1

需要注意:pointer-events属性是CSS3中的属性,IE11及以下版本不支持。并且它具有传递性,比如我们在div2上设置了pointer-events: none;,那么div2及其子元素都不会触发鼠标事件。

Event.relatedTarget

Event中除了这两个target之外,其实还有一个relatedTarget属性,这个属性在不同的事件中有不同的含义,比如在mouseover事件中,relatedTarget表示鼠标移入的元素,而在mouseout事件中,relatedTarget表示鼠标移出的元素。两外html5中的drag and drop api也会用到这个属性,比如在dragenter事件中,relatedTarget表示被拖拽元素正在进入的元素,而在dragleave事件中,relatedTarget表示被拖拽元素正在离开的元素。

References

element.style vs window.getComputedStyle

这两种方式都可以获取元素的样式,但是它们之间有什么区别呢?首先让我们看看在前端开发中,有哪几种方式可以设置样式。

inline style(内联样式)

内联样式是指直接在元素上设置样式,比如下面的代码中,<div>元素上设置了style属性,这就是内联样式。

1
<div style="color: red;">Hello World!</div>

inner style(内部样式)

内部样式是指在<head>标签中添加<style>标签,然后在<style>标签中添加样式,比如下面的代码中div {color: red;}就是内部样式。

1
2
3
4
5
6
7
<head>
<style>
div {
color: red;
}
</style>
</head>

external stylesheet(外部样式表)

外部样式表是指将样式单独放在一个文件中,然后在<head>标签中使用<link>标签引入,比如下面的代码中,<link>标签引入了style.css这个文件,这就是外部样式表。

1
2
3
4
<!-- index.html -->
<head>
<link rel="stylesheet" href="style.css" />
</head>
1
2
3
4
/* style.css */
div {
color: red;
}

element.style 和 window.getComputedStyle的区别

再来看element.stylewindow.getComputedStyle的区别:

  1. element.style只能获取内联样式,
  2. window.getComputedStyle可以获取内联样式、内部样式和外部样式表。

所以大多数情况下,我们都是使用window.getComputedStyle来获取元素的样式。

示例代码

下面的代码中,style.css文件中设置了外部样式表。

1
2
3
4
5
6
<head>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="my-div">Hello World!</div>
</body>
1
2
3
4
/* style.css */
div {
color: blue;
}
1
2
3
const div = document.getElementById("my-div");
console.log(div.style.color); // "" - empty string. 因为div元素上没有设置内联样式。
console.log(window.getComputedStyle(div).color); // rgb(0, 0, 255)

可以看到,element.style只能获取内联样式,而window.getComputedStyle可以获取内联样式、内部样式和外部样式表。

应用场景

不同的场景可以使用不同的方式来获取元素的样式:

  • 如果你只需要获取内联样式,可以使用element.style
  • 如果想直接修改样式,可以使用element.style
  • 如果你需要获取内联样式、内部样式和外部样式表,可以使用window.getComputedStyle

总结

  1. 修改样式的时候,最好用element.style,因为它的优先级最高。
  2. 获取样式的时候,最好用window.getComputedStyle,因为它可以获取所有的样式。

需要注意的是:如果使用window.getComputedStyle获取样式,可能会引发浏览器重绘。因为它获取的总是最终计算过的样式,如果获取样式时,浏览器尚未完成渲染,那么必然会引发重绘。

References

Angular template reference variables

Template reference variables - 模板引用变量,是指在模板中声明的变量,它可以在模板中的任何地方使用,比如在模板中的表单元素中,可以使用模板引用变量来获取表单元素的值。模板引用变量的名字以#开头,比如#name#age等。模板引用变量的作用域是模板,不能在组件中使用。

模板引用变量可以引用如下内容

  • A DOM element in a template
  • a directive or component
  • a TemplateRef from an ng-template
  • a web component

Syntax

模板引用变量以#开头,比如下面的代码中,#phone就是一个模板引用变量, 它引用了<input>元素。就是说,我们可以通过#phone这个模板引用变量来获取<input>元素的值。

1
<input #phone placeholder="phone number" />

引用DOM元素

下面的代码使用#phone变量引用了input元素,在点击button的时候,获取input元素的值,并调用相应的方法。

1
2
<input #phone placeholder="phone number" />
<button (click)="callPhone(phone.value)">Call</button>

如果想在页面上显示input中输入的值,可以使用双向绑定,如下所示:注意这里添加了ngModel,没有这个的话,phone.value是获取不到值的,因为初始的时候输入框并并没有值,而且input值改变的时候,phone.value也无法感知更新。

1
2
<input ngModel #phone placeholder="phone number"/>
<p>{{phone.value}}</p>

引用DOM元素应该是模板变量最常用的场景了,有了它,我们就可以不用再使用document.getElementById()这样的方法来获取DOM元素了。

使用模板变量创建ViewChild

模板引用变量可以用来创建ViewChild,比如下面的代码中,#phone就是一个模板引用变量,它引用了<input>元素,然后我们就可以在组件中使用@ViewChild装饰器来获取<input>元素。

template.html代码

1
<input #phone placeholder="phone number" />

component.ts代码

1
@ViewChild('phone') phone: ElementRef;

这样我们就可以在组件中调用input元素的方法,比如点击某个按钮时,让input元素获取焦点。

1
2
3
focusPhone() {
this.phone.nativeElement.focus();
}

引用Component

模板引用变量可以引用组件,比如下面的代码中,#child就是一个模板引用变量,它引用了<child-component>组件,然后我们就可以在模板中使用#child这个模板引用变量来调用<child-component>组件中的方法和属性。

1
2
<child-component #child></child-component>
<button (click)="child.childMethod()">Call child method</button>

引用Directive

Directive中有一个属性:exportAs, 这个属性对应的值,就是模板引用变量中可以使用的名字。
下面是一个自定Directive,用来给某一段文字添加背景色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import {Directive, ElementRef} from '@angular/core';

@Directive({
selector: '[appHighlight]',
exportAs: 'highlightDirective',
})

export class HighlightDirective {
constructor(private element: ElementRef) {
}

ngOnInit() {
this.element.nativeElement.setAttribute('style', 'background-color: yellow;');
}

setBackgroundColor(color: string) {
this.element.nativeElement.setAttribute('style', `background-color: ${color};`);
}
}

下面是使用这个自定义指令的component对应的模板代码:页面加载后,文字的背景色为黄色,点击按钮后,文字的背景色变为红色。注意#highlight="highlightDirective"这句话,highlight是模板引用变量,highlightDirectiveDirectiveexportAs属性对应的值。这样就把模板变量和其引用的指令关联起来了。

1
2
<p appHighlight #highlight="highlightDirective">test-component works!</p>
<button (click)="highlight.setBackgroundColor('red')">Reset color</button>

引用TemplateRef

模板引用变量可以引用<ng-template>元素,这种情形经常出现在条件渲染中,就是根据不同的条件渲染不同的内容,比如下面的代码中,当conditiontrue时,渲染thenBlock,当conditionfalse时,渲染elseBlock。这里面的thenBlockelseBlock引用的都是<ng-template>元素。

1
2
3
<div *ngIf="condition; then thenBlock else elseBlock"></div>
<ng-template #thenBlock>Content to render when condition is true.</ng-template>
<ng-template #elseBlock>Content to render when condition is false.</ng-template>

引用Web Component

模板引用变量可以引用Web Component,比如下面的代码中,#wc就是一个模板引用变量,它引用了<my-custom-element>元素,然后我们就可以在模板中使用#wc这个模板引用变量来调用<my-custom-element>元素中的方法和属性。

1
2
<my-custom-element #wc></my-custom-element>
<button (click)="wc.webComponentMethod()">Call web component method</button>

什么是事件代理(也叫事件委托)

事件代理是利用事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。比如,我们有一个ul元素,里面有很多li元素,我们想要给每个li元素添加点击事件。有两种方法可以完成这件事:

  1. 给每个li元素都添加一个点击事件,弊端是如果li元素很多的话,就会导致代码很冗余,如果后面还有新的li元素添加进来,还需要给新的li元素添加点击事件,导致代码很难维护。
  2. ul元素添加一个点击事件,然后在事件处理程序中判断点击的是哪个li元素,然后执行对应的操作即可,简洁高效。这种方法就是事件代理

事件代理的原理

事件代理的原理是利用事件冒泡,将本应由被点击元素处理了的事件委托给其父元素来处理,这样就可以在事件处理程序中判断点击的是哪个元素,然后执行对应的操作。
不支持事冒泡的事件无法使用事件代理,比如blurfocusloadunload等事件。

示例代码

下面代码为ul元素添加了一个点击事件,然后在事件处理程序中判断点击的是哪个li元素,然后执行对应的操作。

1
2
3
4
5
6
7
<ul id="list">
<li>item 1</li>
<li>item 2</li>
<li>item 3</li>
<li>item 4</li>
<li>item 5</li>
</ul>
1
2
3
4
5
6
const list = document.getElementById('list');
list.addEventListener('click', (e) => {
if (e.target && e.target.nodeName === 'LI') {
console.log(e.target.innerHTML);
}
});

而下面的代码为table元素添加了点击事件,然后在事件处理程序中判断点击的是哪个td元素,然后执行对应的操作。

1
2
3
4
5
6
7
8
9
10
<table id="my-table">
<tr>
<td>1</td>
<td>2</td>
</tr>
<tr>
<td>3</td>
<td>4</td>
</tr>
</table>
1
2
3
4
5
6
7
8
9
const table = document.getElementById("my-table");
table.addEventListener("click", (e) => {
// Only handle click on td element.
if (e.target.tagName.toLowerCase() === "td") {
console.log(
`You clicked on td element with value ${e.target.innerHTML}`
);
}
});

为啥突然想到这个呢?

因为最近在做一个drag and drop的app,需要在拖拽的时候显示preview(被拖拽元素跟着鼠标走),需要一个操作就是克隆被拖拽的元素,而cloneNode这个方法是无法克隆事件的(只能克隆inline事件,无法克隆通过属性或者event listener添加的事件),而如果使用的是事件代理模式,则不存在这个问题。

什么是跨域

相信很多做前端开发的同学都在浏览器控制台遇到过如下错误。

1
Access to XMLHttpRequest at 'http://localhost:3000/api/xxx' from origin 'http://localhost:4200' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

这个错误是由于浏览器的同源策略导致的,同源策略是浏览器的一种安全策略,它要求浏览器只能向同源网址发送请求,同源网址指的是协议、域名、端口号都相同的网址。

以下几种情况都不同源,都会导致跨域。

  1. 域名不同
    remotehost vs localhost
    1
    http://localhost:4200 和 http://remotehost:3000
  2. 协议不同
    http vs https
    1
    http://localhost:3000 和 https://localhost:3000
  3. 端口不同
    3000 vs 4200
    1
    http://localhost:3000 和 http://localhost:4200

文章开头的错误消息中,http://localhost:4200(4200是Angular项目常用端口)和http://localhost:3000(3000是Node.js项目常用端口)就不是同源网址,因为它们的端口号不同。

同源策略的目的是为了防止恶意的网站窃取数据,但是对于前端开发来说,这个策略有时候就显得有点过于严格了,比如我们在开发时,前端项目和后端项目往往是分开的,前端项目一般运行在http://localhost:4200,而后端项目一般运行在http://localhost:3000,这样就导致了前端项目无法向后端项目发送请求,从而导致了上面的错误。那么如何解决这个问题呢?我们可以在后端项目中启用CORS,从而解决这个问题。下面我们就来看看如何在Node和Express中启用CORS。

什么是CORS

CORS是Cross-Origin Resource Sharing的缩写,中文名是跨域资源共享,它是一种机制,它使用额外的HTTP头来告诉浏览器,允许运行在一个源上的Web应用访问来自不同源服务器上的指定的资源。当一个资源从与该资源本身所在的服务器不同的域、协议或端口请求一个资源时,会发起一个跨域HTTP请求。

哪些请求会使用CORS呢?

  • Fetch与XMLHttpRequest
  • Web Fonts,@font-face within CSS
  • WebGL textures

简单请求与非简单请求

为什么要介绍简单请求和非简单请求呢?因为对于简单请求和非简单请求,浏览器的处理方式是不同的。

  • 简单请求, 浏览器会自动处理跨域请求,不需要额外的处理。
  • 非简单请求,浏览器会先发送一个OPTIONS请求,询问服务器是否允许跨域请求,如果服务器允许,浏览器才会发送真正的请求。

简单请求:

满足以下条件的请求是简单请求。

  1. 请求方法是以下三种方法之一:
    • HEAD
    • GET
    • POST
  2. HTTP的头信息不超出以下几种字段:
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type:只限于三个值: application/x-www-form-urlencoded, multipart/form-data, text/plain

非简单请求

除了简单请求,都是非简单请求。

Preflight请求

又称为预检请求,在非简单请求之前,浏览器会发送一个preflight请求,询问后端服务器是否支持跨域,preflight请求都是Option请求,下面是一个列子。

  1. General块中可以看到,预检请求用的是 OPTIONS请求,而且返回值是200,说明请求成功了。

General

1
2
3
4
5
Request URL: http://10.10.143.144:9898/bff/api/v1/application/sysVariable/list?_t=1641265683731
**Request Method**: OPTIONS
Status Code: 200 OK
Remote Address: 10.10.143.144:9898
Referrer Policy: strict-origin-when-cross-origin
  1. Request Header区块中Access-Control-Request-Headers指定的是GET请求,说明接下来要进行的跨域请求是GET请求,而且有自定义请求头,放在Access-Control-Request-Headers字段中,请求的来源是Origin字段标明的,是http://localhost:3001,表示当前正在本机进行调试。

Request Header

1
2
3
4
5
6
7
8
9
10
11
Accept: */*
Accept-Encoding: gzip, deflate
Accept-Language: en,zh-CN;q=0.9,zh;q=0.8,zh-TW;q=0.7
**Access-Control-Request-Headers**: app,cache-control,dcid,nounce,timestamp,userid,uuid
**Access-Control-Request-Method**: GET
Connection: keep-alive
Host: 10.10.143.144:9898
**Origin**: http://localhost:3001
Referer: http://localhost:3001/
Sec-Fetch-Mode: cors
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36
  1. Response Header区块中反应的是OPTIONS请求后的结果,图中四个标红加粗的字段表示服务器的跨域请求设置
    • Access-Control-Allow-Headers表示允许的自定义请求头。
    • Access-Control-Allow-Methods表示允许的请求方法
    • Access-Control-Allow-Origin表示允许的跨域请求的来源
    • Access-Control-Max-Age表示预检请求的缓存时间,在这个时间内,如果再发生跨域请求,则无需发送预检请求。

Response Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Access-Control-Allow-Headers: app, cache-control, dcid, nounce, timestamp, userid, uuid
Access-Control-Allow-Methods: PUT,DELETE,GET,POST,OPTIONS
Access-Control-Allow-Origin: http://localhost:3001
Access-Control-Expose-Headers: access-control-allow-headers, access-control-allow-methods, access-control-allow-origin, access-control-max-age, X-Frame-Options
Access-Control-Max-Age: 3600
Allow: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
Cache-Control: no-store
Connection: keep-alive
Content-Length: 0
Date: Tue, 04 Jan 2022 03:08:03 GMT
Pragma: no-cache
Server: nginx/1.17.5
Strict-Transport-Security: max-age=8995000; includeSubdomains
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block

解决跨域

解决跨域的方法很多,常用的有以下几种:

后端开启CORS

根据同源策略,我们可以在后端设置Access-Control-Allow-Origin这个响应头,来允许指定的域名访问该资源。下面我们来看看如何在Node和Express中启用CORS。

  1. 创建一个Express项目并安装cors
    1
    2
    3
    npm init
    npm install express --save
    npm install cors --save
  2. 在项目根目录下创建server.js文件,添加如下内容,这个服务器提供两个接口,一个是/,一个是/users,其中/users接口返回一个用户列表。而且在server.js中启用了CORS,允许http://localhost:4200这个地址访问该服务提供的接口。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    const express = require('express');
    const cors = require('cors');
    const app = express();
    const port = 3000;

    // Enable CORS
    let corsOptions = {
    origin : ['http://localhost:4200'], // 前端项目地址
    };
    app.use(cors(corsOptions));
    app.get('/', (req, res) => {
    res.send('Hello World!')
    });

    app.get('/users', (req, res) => {
    const users = [
    {name: 'John', age: 30},
    {name: 'Jane', age: 20},
    ];

    res.json(users);
    });
  3. 启动项目
    1
    node server.js
  4. 新建一个前端项目(Angular),启动项目后运行在localhost:4200,通过前端访问这个api时就不会有跨域问题了。
    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
    import { Component, OnInit } from '@angular/core';
    import { HttpClient } from '@angular/common/http';

    import {Component, OnInit} from '@angular/core';
    import {UsersModel} from "./users.model";
    import {HttpClient} from "@angular/common/http";

    @Component({
    selector: 'app-users',
    templateUrl: './users.component.html',
    styleUrls: ['./users.component.less']
    })
    export class UsersComponent implements OnInit {
    users: UsersModel[] = [];

    constructor(private http: HttpClient) {
    }

    ngOnInit(): void {
    this.http.get<UsersModel[]>('http://localhost:3000/users')
    .subscribe((users: UsersModel[]) => {
    this.users = users;
    });
    }
    }
  5. 记得在app.module.ts中启用HttpClientModule
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import {HttpClientModule} from '@angular/common/http';

    @NgModule({
    declarations: [
    AppComponent,
    ],
    imports: [
    HttpClientModule,
    ],
    providers: [],
    bootstrap: [AppComponent]
    })

使用JsonP

JSONP (JSON with Padding) 是一种跨域数据交互协议,它允许页面从不同的域名下获取数据。其实现跨域的原理主要基于浏览器对<script>标签的宽松政策,即浏览器允许页面通过<script>标签加载并执行来自任何来源(即任何域名)的JavaScript代码。

浏览器对一些html标签允许跨域访问,比如<img><link><script>等,详情参考这里

以下是JSONP实现跨域的基本步骤:

  1. 创建<script>标签:在需要请求数据的网页中动态创建一个<script>标签,并设置其src属性为要请求的数据接口地址。这个地址通常会包含一个回调函数名作为参数。

  2. 定义回调函数:在网页中定义一个JavaScript函数,该函数的名字就是之前在src属性中指定的回调函数名。当服务器响应返回时,这个函数会被调用,且响应的数据会作为参数传递给这个函数。

  3. 服务器端响应:服务器接收到请求后,会将数据包装在一个函数调用中返回。这个函数名就是客户端请求中指定的那个回调函数名。例如,如果回调函数名为handleResponse,而返回的数据是{"name": "John"},那么服务器可能会返回如下内容:

    1
    handleResponse({"name": "John"});
  4. 执行回调函数:由于<script>标签加载的是一个有效的JavaScript脚本,所以浏览器会执行这个脚本,即执行handleResponse函数,并将数据作为参数传入。这样,客户端就可以处理从服务器接收到的数据了。

JsonP实现示意图。
JSONP

JSONP的主要优点是简单易用,不需要特殊的服务器配置,且几乎所有的浏览器都支持。然而,它也存在一些限制和安全风险:

  • 仅支持GET请求:JSONP只能发起GET请求,无法使用POST等其他HTTP方法。
  • 安全性问题:因为JSONP本质上是在执行来自外部源的任意JavaScript代码,所以存在注入攻击的风险。必须确保数据来源可靠。
  • 错误处理困难:JSONP没有标准的错误处理机制,一旦请求失败,很难确定失败的原因。

因此,在选择是否使用JSONP时,开发者需要权衡其带来的便利性和潜在的安全风险。随着CORS(跨源资源共享)等更现代的解决方案的出现,JSONP的使用正在逐渐减少。

Angular中HttpClient模块也提供了JSONP的支持,使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
selector: 'app-jsonp',
templateUrl: './jsonp.component.html',
styleUrls: ['./jsonp.component.less']
})
export class JsonpComponent implements OnInit {
data: any;
constructor(private http: HttpClient) {}

ngOnInit(): void {
this.http.jsonp('http://localhost:3000/users', 'callback')
.subscribe((data: any) => {
this.data = data;
});
}
}

前端使用反向代理

这种方法一般是开发阶段使用的,因为本质上,前端是无法解决跨域问题的,只能通过后端来解决。

Angular项目

Angular项目中可以使用proxy.conf.json文件配置反向代理,然后在angular.json或者project.json(基于Nx的Mono repo)中配置proxyConfig指向该文件即可。

proxy.conf.json文件内容如下:

1
2
3
4
5
6
{
"/api": {
"target": "http://localhost:3000",
"secure": false
}
}

angular.jsonproject.json文件内容如下:

1
2
3
4
5
6
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
}
}

使用浏览器插件

这个不做过多介绍,大家自行探索

使用非安全模式启动浏览器

在Windows系统上,可以通过以下命令启动Chrome浏览器,这样就可以绕过浏览器的同源策略。这种方式也不推荐。

1
chrome.exe --user-data-dir="C://Chrome dev session" --disable-web-security

组件(指令)间通信是Angular中一个非常常见的需求,对于有直接嵌套关系(比如父子组件)的组件,我们可以使用@Input@Output来实现通信,但是对于没有嵌套关系的组件(亦或者嵌套层级很深的组件),我们就需要使用其他的方法来实现通信了,比如使用服务来实现组件间通信。
下面这个例子就是两个没有嵌套关系的指令之间通信的例子,我们将使用服务来实现这个功能。
注意:在Angular中,组件和指令是一样的,组件是一种特殊的指令。

Create a service

首先我们需要创建一个服务,用于组件间通信。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable({
providedIn: 'root'
})
export class CommunicationService {
private messageSource = new Subject<string>();

// 此变量的类型是Observable,用来接收消息, message$.subscribe()用来订阅消息。
public message$ = this.messageSource.asObservable();

// 此函数用来发消息
sendMessage(message: string) {
this.messageSource.next(message);
}
}

Directive(Send message)

该指令使用上面定义的service发送消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Directive, HostListener } from '@angular/core';
import { CommunicationService } from './communication.service';

@Directive({
selector: '[senderDirective]'
})
export class SenderDirective {
constructor(private communicationService: CommunicationService) {}

@HostListener('click')
onClick() {
// 发送消息
this.communicationService.sendMessage('Hello from sender directive!');
}
}

Directive(Receive message)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
该指令使用上面定义的service接收消息
import { Directive } from '@angular/core';
import { CommunicationService } from './communication.service';

@Directive({
selector: '[receiverDirective]'
})
export class ReceiverDirective {
constructor(private communicationService: CommunicationService) {
// 接收消息
this.communicationService.message$.subscribe(message => {
console.log('Received message:', message);
});
}
}

AppModule

我们需要在appModule中声明这两个指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { CommunicationService } from './communication.service';
import { SenderDirective } from './sender.directive';
import { ReceiverDirective } from './receiver.directive';

@NgModule({
declarations: [
AppComponent,
SenderDirective,
ReceiverDirective
],
imports: [
BrowserModule
],
providers: [CommunicationService],
bootstrap: [AppComponent]
})
export class AppModule { }

Usage

1
2
<button senderDirective>Send message</button>
<div receiverDirective></div>

html5 drag and drop events

html5中的拖放事件有以下几个:

  • dragstart
  • drag
  • dragleave
  • dragenter
  • dragover
  • drop
  • dragend

我们将从以下几个方面依次讲解这些事件,包括事件的触发条件,事件的触发源头,事件的触发频率等

dragstart

触发条件:当用户开始拖动一个元素时,会触发dragstart事件。
触发源头:被拖动的元素。
触发频率:一次。

drag

触发条件:当用户拖动一个元素时,会触发drag事件。
触发源头:被拖动的元素。
触发频率:多次。

dragleave

触发条件:当用户拖动一个元素,离开了一个可放置的目标元素时,会触发dragleave事件。
触发源头:目标区域(松开鼠标后,元素放置的区域)。
触发频率:一次。

dragenter

触发条件:当用户拖动一个元素,进入了一个可放置的目标元素时,会触发dragenter事件。
触发源头:目标区域(松开鼠标后,元素放置的区域)。
触发频率:一次。

dragover

触发条件:当用户拖动一个元素,悬停在一个可放置的目标元素上时,会触发dragover事件。
触发源头:目标区域(松开鼠标后,元素放置的区域)。
触发频率:多次。

drop

触发条件:当用户拖动一个元素,释放鼠标按钮时,会触发drop事件。
触发源头:目标区域(松开鼠标后,元素放置的区域)。
触发频率:一次。

dragend

触发条件:当用户拖动一个元素,释放鼠标按钮时,会触发dragend事件。
触发源头:被拖动的元素。
触发频率:一次。

实例讲解

下面以一个实际例子来讲解事件发生的顺序,如下图,页面上有两个容器,Container A和Container B,container A中有一个元素Child,container B中没有元素。我们将container A中的Child元素拖动到container B中,观察事件的触发顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

+-------------------+
| Container A |
| |
| |
| Child |
| | |
| | |
| | |
+--------|----------+
|
\/

+-------------------+
| Container B |
| |
| |
| |
| |
| |
| |
+-------------------+
  1. 首先触发的事件是 dragstart,触发源头是被拖动的元素,触发频率是一次。我们可以通过打印event.target来验证这一点。
1
2
3
4
5
6
7
function dragStart(e) {
e.stopPropagation(); // 阻止事件冒泡,也就是阻止事件向父元素传递。
console.log("drag start...");
console.log(e.target);
console.log(this);
e.dataTransfer.setData("text", e.target.id);
}

事件处理函数中的e.target是指触发该事件的元素,比如当用户点击某一个按钮时会触发click事件,那么按钮就是click事件的e.target。而this是指注册事件处理函数的元素,大部分情况下这二者是相同的,但是也有例外,详情请看这里

dragstart不同于其他拖拽处理函数的地方是,它是唯一可以设置transfer data的地方。我们可以通过e.dataTransfer.setData方法来设置transfer data,该方法接受两个参数,第一个参数是transfer data的类型,第二个参数是transfer data的值。这里我们设置transfer data的类型为text,值为被拖动元素的id。

  1. 然后触发的事件是drag,触发源头是被拖动的元素,该事件会在拖动过程中一直触发。在实际应用中,一般不监听这个事件,因为它没啥用,而且触发太频繁。

  2. 接下来触发的事件是dragenter,当用户拖动一个元素到一个可放置的目标时,会触发该事件,只会触发一次。由于Container A也是一个可放置的区域,而刚开始拖动时Child就已经位于Container A中,所以会触发dragenter事件。

  3. 然后触发的事件是dragover,当用户拖动一个元素,悬停在一个可放置的目标元素上时,会触发该事件,会在拖动过程中一直触发。与其他事件处理函数不同,该函数有如下两个特点。

    1. 该事件在放置目标区域上触发,而不是被拖拽的元素上触发。
    2. 这个事件是需要阻止默认行为的,否则会导致drop事件无法触发。我们可以通过e.preventDefault()来阻止默认行为。
  4. 接下来,如果用户在目标区域释放了鼠标,那么会触发drop事件,该事件会在放置目标区域上触发,只会触发一次。我们可以通过e.dataTransfer.getData来获取transfer data,该方法接受一个参数,表示要获取的transfer data的类型,这里我们传入text,表示获取类型为text的transfer data。如果我们在dragstart事件处理函数中设置了transfer data,那么在drop事件处理函数中就可以获取到transfer data了。

  5. 如果用户在目标区没有释放鼠标,而是继续拖动,直到离开目标区域,那么会触发dragleave事件,该事件会在放置目标区域上触发,只会触发一次。反之,如果用户在目标区域释放了鼠标,那么该事件不会触发。也就是说,对于同一个目标区域而言,dragleave事件和drop事件是互斥的,只会触发其中一个。

注意:dragenter, dragleave, dragover - 这里都是指鼠标的进入,离开,悬停,而不是指被拖动的元素的进入,离开,悬停。

总结如下:

事件 触发条件 触发源头 触发频率
dragstart 当用户开始拖动一个元素时 被拖动的元素 一次
drag 当用户拖动一个元素时 被拖动的元素 多次
dragleave 当用户拖动一个元素,离开了一个可放置的目标元素时 目标区域对应的元素 一次
dragenter 当用户拖动一个元素,进入了一个可放置的目标元素时 目标区域对应的元素 一次
dragover 当用户拖动一个元素,悬停在一个可放置的目标元素上时 目标区域对应的元素 多次
drop 当用户拖动一个元素,释放鼠标按钮时 目标区域对应的元素 一次
dragend 当用户拖动一个元素,释放鼠标按钮时 被拖动的元素 一次

注意事项:

  1. dragenter, dragleave, dragover - 这里都是指鼠标的进入,离开,悬停,而不是指被拖动的元素的进入,离开,悬停。

  2. 在处理从一个区域拖拽到另一个区域的情况时比较简单,比如常见的左右两个列表,将一个元素从左侧列表拖拽到右侧列表。这时候被拖拽的元素和放置目标之间的界限比较明显,处理起来比较容易,也就是说被拖拽的元素响应以下事件即可:

    • dragstart
    • drag
    • dragend

    而放置目标响应以下事件即可:

    • dragenter
    • dragover
    • drop
    • dragleave
  3. 但是如果处理一组元素之间的互相拖拽及排序,那就比较麻烦,比如一个相册列表,或者一个瀑布流布局,或者masonry布局,就是一个大容器,里面有如果子元素,我们可以拖拽任意一个元素到其他位置,在拖拽过程中要有placeholder,其他元素要给被拖拽的元素让路。这种情况下,每个元素都要响应所有事件,所以一个必要的操作就是判断被拖拽的元素和放置目标是否是同一个元素,如果是同一个元素,那么就不需要做任何处理,否则就需要做相应的处理。这里有一个技巧,可以使用e.targete.currentTarget来判断被拖拽的元素和放置目标是否是同一个元素,常见的做法如下:

  • dragstart函数中记录被拖拽的元素
    1
    2
    3
    4
    5
    let dragSrcEl = null;
    function handleDragStart(e) {
    dragSrcEl = this; // record the dragged element
    dragSrcEl.classList.add("dragging");
    }
  • dragenter, dragover, dragleave, drop等方法中判断被拖拽元素是否等于this,如果是,那么说明是同一个元素,直接return,如果不是,再进行具体的处理工作。
    1
    2
    3
    4
    5
    6
    7
    8
    function handleDragEnter(e) {
    // this event also triggered on the dragged item itself, we should ignore this case.
    if (dragSrcEl === this) {
    return;
    }

    // do something...
    }
  1. dragover事件处理函数中,需要阻止默认行为,否则会导致drop事件无法触发。我们可以通过e.preventDefault()来阻止默认行为。

    1
    2
    3
    function handleDragOver(e) {
    e.preventDefault(); // prevent default behavior
    }
  2. drop事件处理函数中,需要阻止默认行为,否则会导致浏览器打开被拖拽的元素。我们可以通过e.preventDefault()来阻止默认行为。

    1
    2
    3
    function handleDrop(e) {
    e.preventDefault(); // prevent default behavior
    }

What is array-like object?

Array-like object is an object that has a length property and indexed elements. For example, arguments is an array-like object.

Has indexed access to the elements and a non-negative length property to know the number of elements in it. These are the only similarities it has with an array.
Doesn’t have any of the Array methods like push, pop, join, map, etc.

1
2
3
4
5
6
7
8
9
const arrayLike = {
0: 'a',
1: 'b',
2: 'c',
length: 3,
};
console.log(arrayLike[1]); // 'b'
console.log(arrayLike.length); // 3
console.log(arrayLike.push); // Uncaught TypeError: arrayLike.push is not a function

What is the difference between array and array-like object?

类型 length属性 索引访问 有Array.prototype方法
Array ✔️ ✔️ ✔️
Array-like object ✔️ ✔️

Which type in JavaScript is array-like object?

There are many types in JavaScript are array-like object, including:

  1. arguments in a function
  2. NodeList(and other DOM collections)
  3. HTMLCollection

arguments in a function

1
2
3
4
function foo() {
console.log(arguments);
}
foo(1, 2, 3); // Arguments(3) [1, 2, 3, callee: ƒ, Symbol(Symbol.iterator): ƒ]

NodeList(and other DOM collections)

1
2
const nodeList = document.querySelectorAll('div');
console.log(nodeList); // NodeList(3) [div, div, div]

HTMLCollection

1
2
const htmlCollection = document.getElementsByClassName('container');
console.log(htmlCollection); // HTMLCollection(3) [div.container, div.container, div.container]

How to convert array-like object to array?

There are several ways to convert array-like object to array.

Array.from(…)

1
2
3
4
5
6
7
8
const arrayLike = {
0: 'a',
1: 'b',
2: 'c',
length: 3,
};
const array = Array.from(arrayLike);
console.log(array); // ['a', 'b', 'c']

Use ES6 spread operator

1
2
3
4
5
6
7
8
const arrayLike = {
0: 'a',
1: 'b',
2: 'c',
length: 3,
};
const array = [...arrayLike];
console.log(array); // ['a', 'b', 'c']

Array.prototype.slice.call(…)

1
2
3
4
5
6
7
8
const arrayLike = {
0: 'a',
1: 'b',
2: 'c',
length: 3,
};
const array = Array.prototype.slice.call(arrayLike);
console.log(array); // ['a', 'b', 'c']

Conclusion

  1. Array-like object is an object that has a length property
  2. Array-like object has indexed access to the elements, you access the elements by using arrayLike[n]
  3. Array-like object doesn’t have any of the Array methods like push, pop, join, map, etc.
  4. You can convert array-like object to array by using Array.from(...), ES6 spread operator or Array.prototype.slice.call(...).