0%

Android Studio Tips -1

1. Wireless debugging with Android device

  1. Select Pair Devices using Wi-Fi under emulator dropdown list.
  2. Enable debug mode on your android device(see here for more details).
  3. On your android device, select Settings | Developer options | Wireless debugging | Pair using QR code to enable wireless debugging.
    Alt text

2. Fix Manifest merged errors

  1. Open AndroidManifest.xml file.
  2. Click Merged Manifest tab at the bottom of the editor.
  3. Scroll down to the bottom to see the errors.
    Alt text

3. View database file in Android Studio

  1. Select View | Tools Window | App Inspection from the main menu.
  2. Launch your app on a device/Emulator running API level 26 or higher.
    Alt text
  3. If you see a red close icon on your database file which means your database was not opened, you can operate on your app to open the database(such as click a button, open a fragment or whatever action which can open the database).
  4. If you database was opened, you can click the table under this database to view the data.

4. Filter logs in Logcat

  1. Select Logcat at the bottom of Android Studio.
  2. Select the emulator/device where your app runs on.
  3. Focus the filter input box, and press Ctrl + Space to open the filter dialog. then select the filter options you want.
    Alt text
  4. For example, if you want to see only error logs for package com.jiyuzhai.kaishuzidian, you can input package: com.jiyuzhai.kaishuzidian level: error as a filter.

5. Open xml layout file in split mode

Do you ever encounter this situation? whenever you open an xml layout file, it opens in design mode by default. You can do the following to open it in split mode by default.

  1. Open Settings | Editor | Design Tools from the main menu.
  2. Check Prefer XML editor option.
  3. Click OK button.
    Alt text

File xxx already exists, it cannot be overwritten by SerializableChange(file=xxx, fileStatus=NEW, normalizedPath=xxx.class).

  1. In Android menu, select Build | Clean Project, then rebuild, that’s it!

Android emulator stop working.

Delete the lock file under avd folder, here is the lock file path under android sdk folder.

1
$android_sdk_dir\.android\avd\Pixel_XL_API_34.avd\xxx.lock

xxx is already defined in a single-type import

在Android Java中,同一个java文件不能导入两个同名的包,比如两个来自不同module的R文件,解决方法是使用全路径名,比如:

1
2
com.jiyuzhai.kaishuzidian.R.string.app_name
com.jiyuzhai.kaishuzidian2.R.string.app_name

java.lang.OutOfMemoryError: Java heap space

Gradle内存过小导致的错误,可以增加gradle的内存。

  1. 打开gradle.properties文件,如果没有则新建一个。
  2. 添加org.gradle.jvmargs=-Xmx4096m,其中4096m是内存大小,可以根据需要调整。
    我的电脑是32G内存,所以给出如下配置,最高8g, 最低4g内存。
1
2
org.gradle.jvmargs=-Xmx8g -XX:MaxPermSize=4g -XX:+HeapDumpOnOutOfMemoryError -Dfile\
.encoding=UTF-8

Android live reload

  1. Run your app in Emulator or device.
  2. Change your code.
  3. Click Apply Changes button in the toolbar.
    android-apply-changes

Connect to Google App Vitals

  1. Sign in to your developer account in Android Studio using the profile icon at the end of the toolbar.
  2. Open App Quality Insights by clicking the tool window in Android Studio or clicking View > Tool Windows > App Quality Insights.
  3. Click the Android vitals tab within App Quality Insights.

Can not resolve symbol ‘BuildConfig’

2023年中秋节,国庆节

短暂的假期结束了,今天是大多数人上班的日子,而我还在休息,因为在外企工作,所以不会串休,我请了十月五号,六号两天的假期,所以一共能休息九天(含两个周末)。

有人在朋友圈戏称,“八天假期很短吧?马上你就知道七天上班有多长了!”

今年的假期安排是回媳妇的老家—山西运城,前三天在家扒苞米,10月四日去了趟壶口瀑布,然后就是一直在家待着看抖音直播,我戏称,这回真是实现了看直播自由。

运城雨水丰沛,十一期间几乎每天下雨,不过都是小雨,不像大连这边的雨,下起来很大,而且一阵就结束了。

扒苞米这活讲真,我真的是十多年没有干了,小时候在家每年都干这活。一家人围在玉米堆旁边,一边扒,一边聊天,其乐融融。

壶口瀑布非常壮观,这是我第一次看见黄河,有趣的是,壶口瀑布位于山西和陕西两省的交界处,为两省共有,我们在河这边看,陕西的游客在河对面看,不过山西这边的视角更好些。(听网友说的)

本来想去李家大院的,无奈去壶口瀑布开车太累了,最后七公里的路程,堵了两个小时,所以就放弃了。

十月五号返回大连,勇男老弟开车来接,又去他家小坐,拿了很多好吃的,回到旅顺已经快十二点了。

Android monorepo in action(Android monorepo实践)

What is monorepo(什么是monorepo)

In version control systems, a monorepo (“mono” meaning ‘single’ and “repo” being short for ‘repository’) is a software development strategy where code for many projects is stored in the same repository.

Steps

  1. Create a folder named monorepo, this is the root folder of the monorepo.
  2. Create folder mono-libraries under monorepo, this is the folder for shared libraries.
  3. Create foldermono-build-logic under monorepo, this is the folder for gradle files.

注意事项

该monorepo要求每个side project都要打开一个Android Studio实例,如果同时打开多个,那么只能有一个设置为主项目。

Reference(参考)

https://blog.blundellapps.co.uk/make-a-monorepo-for-your-android-projects/

Replace Temp with Query

以查询取代临时变量的方法只适用于处理某些类型的临时变量,即那些只被赋值一次,而且之后再也没有被修改的临时变量。
看下面的代码:我们可以将 price()中的临时变量 basePricediscountFactor 替换为查询函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Order {
constructor(quantity, item) {
this.quantity = quantity;
this.item = item;
}

get price() {
const basePrice = this.quantity * this.item.price;
let discountFactor = 0.98;
if (basePrice > 1000) {
discountFactor -= 0.03;
}
return basePrice * discountFactor;
}
}

修改后如下:可以看到,修改后,price()内的代码明显更加简洁,更加容易理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
constructor(quantity, item) {
this.quantity = quantity;
this.item = item;
}

get price() {
return this.basePrice * this.discountFactor;
}

// Use this method to replace the temp variable basePrice
get basePrice() {
return this.quantity * this.item.price;
}

// Use this method to replace the temp variable discountFactor
get discountFactor() {
let discountFactor = 0.98;
if (this.basePrice > 1000) {
discountFactor -= 0.03;
}
return discountFactor;
}

最后,discountFactor()函数还可以进一步简化:

1
2
3
get discountFactor() {
return this.basePrice > 1000 ? 0.95 : 0.98;
}

如果是在不支持gettersetter的语言中,我们可以使用extract function的方法来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
constructor(quantity, item) {
this.quantity = quantity;
this.item = item;
}

getPrice() {
return this.getBasePrice() * this.getDiscountFactor();
}

// Use this method to replace the temp variable basePrice
getBasePrice() {
return this.quantity * this.item.price;
}

// Use this method to replace the temp variable discountFactor
getDiscountFactor() {
return this.getBasePrice() > 1000 ? 0.95 : 0.98;
}

Angular Standalone Component

standalone component是Angular 14的一个新特性,它可以让我们在不创建module的情况下创建一个component。注意:DirectivePipe也可以是standalone的。

创建一个standalone component

组件代码如下:

1
2
3
4
5
6
7
8
9
10
11
import { Component, OnInit } from '@angular/core';
@Component({
standalone: true, // 添加这一句,表示这是一个standalone component。
imports: [CommonModule],
selector: 'lifecycle-order',
templateUrl: './order.component.html',
styleUrls: ['./order.component.less'],
})
export class OrderComponent{
constructor() {}
}

standalone component和module的交互使用有如下两种情况。

standalone component引入其他module

如果standalone component需要引入其他module,那么需要在imports属性中引入其他module,比如上面代码中引入的CommonModule

注意:只有standalone component才能引入其他module,普通的component(non-standalone component)不能引入其他module。所以只有standalone component才能使用imports属性。

如果你遇到如下报错,那么说明你在非standalone组件中使用了imports属性,这是不允许的。

1
error NG2010: 'imports' is only valid on a component that is standalone.

这种情况经常发生在,ComponentA(非独立组件)想在template中使用ComponentB(独立组件),那么应该在ComponentA所在的module中的imports属性中引入ComponentB。

module引入standalone component

如果一个module要使用一个standalone component,那么不再需要在declarations属性中声明这个standalone component,只需要在imports属性中引入这个standalone component所在的module即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
@NgModule({
declarations: [
AppComponent,
// OrderComponent, // no need to declare standalone component here
],
imports: [
BrowserModule,
OrderComponent, // imports standalone component here
],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}

lazy load module

在NgModel时代,我们可以使用loadChildren来实现懒加载,比如下面的product module

1
2
3
4
5
6
export const routes: Routes = [
{path: 'home', component: HomeComponent},
{path: 'product', loadChildren: () => import('./product/product.module').then(m => m.ProductModule)},
{path: 'about', component: AboutComponent},
{path: '**', component: NotFoundComponent},
];

Lazy load standalone component

在Angular 14中,我们可以使用loadComponent来实现懒加载standalone component,比如下面的order component

1
2
3
4
5
6
export const routes: Routes = [
{path: 'home', component: HomeComponent},
{path: 'order', loadComponent: () => import('./order/order.component').then(m => m.OrderComponent)},
{path: 'about', component: AboutComponent},
{path: '**', component: NotFoundComponent},
];

summary

  • lazy load module: loadChildren
  • lazy load standalone component: loadComponent

Bootstrap standalone component

See here

References:

Angular standalone component

Angular Module

Angular是模块化的,模块是Angular应用的基本构建块,每个Angular应用都至少有一个模块,即根模块,Angular模块是一个带有@NgModule装饰器的类。

模块是一个逻辑单元,是一个自我完备的功能集合,它可以包含组件、服务、指令、管道等,模块可以导入其他模块,也可以导出组件、指令、管道等供其他模块使用,模块还可以提供Service。

以一个电商网站App为例,可以包含如下模块:登录模块(包含登录,注册,找回密码等功能),购物模块,订单模块,支付模块等等。

Generate Angular module with Angular CLI

使用Angular CLI生成一个新的模块,运行如下命令:

1
ng generate module moduleName // or ng g m moduleName

NgModule的组成

以下是一个典型的入口模块的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@NgModule({
declarations: [
AppComponent,
],
imports: [
BrowserModule,
AppRoutingModule,
FormsModule,
HttpClientModule,
],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}

declarations

declarations属性用来声明属于该模块的组件、指令、管道等,这些组件、指令、管道等只能在该模块中使用,其他模块不能使用。

  • declarations只能包含component, directive,pipe.
  • component, directive,pipe - 统称为declarable, 每个declarable只能隶属于一个NgModule,如果在多个NgModule中导入同一个declarable,那么会出现如下错误:
1
xxx is declared in multiple Angular modules: AppModule and AppContentModule

imports

imports属性用来导入其他模块,一经导入,那么被导入的模块中包含的组件、指令、管道等可以在当前模块中使用。比如当前模块依赖另一个模块A,那么要把模块A导入到当前模块中,这样当前模块才可以使用模块A中的组件、指令、管道等。

  • imports只能包含module, 或者standalone component.

providers

providers属性用来声明该模块中的服务,这些服务可以在该模块中使用,也可以在该模块导入的其他模块中使用。(我在现实中看见有人把Component放到providers中也能work,这不是好的编程习惯。)

exports

exports属性用来声明该模块中导出的组件、指令、管道等,这些导出的组件、指令、管道等可以在该模块导入的其他模块中使用。注意导出的组件、指令、管道等必须同时在declarations属性中声明。
也就是说exportsdeclarations的子集。

Angular应用的主模块app.module.ts中的exports属性是空的,因为根模块中的组件、指令、管道等都是可以在其他模块中使用的,所以不需要导出。

bootstrap

bootstrap属性用来声明该模块的根组件,根组件是Angular应用的入口组件,一个Angular应用只能有一个根组件,根组件只能在根模块中声明。bootstrap中声明的组件会插入到index.html中的<app-root></app-root>标签中。

注意:bootstrap中可以指定多个组件,这些组件会被插入到index.html中,index.html中根据组件的选择器加在不同的组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
<!--index.html-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LearningAngular</title>
</head>
<body>
<app-header></app-header>
<app-content></app-content>
<app-footer></app-footer>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';

import { AppHeaderComponent } from './app-header/app-header.component';
import { AppContentComponent } from './app-content/app-content.component';
import { AppFooterComponent } from './app-footer/app-footer.component';

@NgModule({
declarations: [
AppHeaderComponent,
AppContentComponent,
AppFooterComponent,
],
imports: [
BrowserModule,
HttpClientModule,
],
providers: [],
bootstrap: [AppHeaderComponent, AppContentComponent, AppFooterComponent],
})
export class AppModule {}

注意: 只有根模块才需要声明根组件,其他模块不需要声明根组件。所以除了app.module.ts之外,其他的module都不需要声明bootstrap属性。

component and module

在Angular standalone component出现以前,任何一个component都必须隶属于一个module。但是在Angular standalone component出现以后,component就可以不用隶属于一个module了,这种component称为standalone component,也就是独立的component。

详情请看Angular Standalone Component

How Angular module identifies components?

Angular module是如何识别Component的呢?对于非standalone component,Angular module是通过declaration来标记的。对于standalone component,Angular module是通过imports来标记的。

非独立组件还分为两种情况,有对应的angular module或者没有对应的angular module

  • angular module - 当前module需要导入对应的angular module,才能使用导入module中的component。(待使用的component需在其module中exports数组中导出)
  • 没有angular module - 则需要将component声明在当前module的declarations中。

Example

假设我们需要定义如下两个组件, 这两个组件都有对应的module

  • ProductHomeModule包含ProductHomeComponent - 产品首页, 该组件内部又调用了ProductDetailComponent
  • ProductDetailModule包含ProductDetailComponent - 产品详情页

首先,需要在ProductDetailModule中声明ProductDetailComponent,并导出ProductDetailComponent

1
2
3
4
5
6
7
8
9
10
11
// product-detail.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProductDetailComponent } from './product-detail.component';

@NgModule({
declarations: [ProductDetailComponent], // <-- declare ProductDetailComponent
imports: [CommonModule],
exports: [ProductDetailComponent], // <-- export ProductDetailComponent
})
export class ProductDetailModule {}

然后,需要在ProductHomeModule中导入ProductDetailModule,这样ProductHomeComponent就可以使用ProductDetailComponent了。

1
2
3
4
5
6
7
8
9
10
11
12
// product-home.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProductHomeComponent } from './product-home.component';
import { ProductDetailModule } from '../product-detail/product-detail.module';

@NgModule({
declarations: [ProductHomeComponent],
imports: [CommonModule, ProductDetailModule], // <-- import ProductDetailModule
exports: [ProductHomeComponent],
})
export class ProductHomeModule {}

最后,在ProductHomeComponent模板中使用ProductDetailComponent

1
2
<!-- product-home.component.html --> 
<app-product-detail /> <!-- use ProductDetailComponent -->

References:

Angular杂项

什么操作会触发Angular执行change detection?

这个问题一直没有搞明白,有待进一步研究。
https://angular.io/guide/change-detection
https://www.youtube.com/watch?v=-tB-QDrPmuI

这个问题,现在大概了解了一些,Angular内部使用Zone.js来实现change detection,Zone.js对所有异步操作都进行了monkey patch,当有异步操作发生时,Angular会自动进行更新检测。所以这些动作会出发Angular执行change detection:

  1. setTimeout/setInterval
  2. XMLHttpRequest/fetch
  3. EventListener: click, mouseover, keydown, …
  4. Promise.then
  5. Async/Await

Change detection相关参考资料

  1. https://blog.angular-university.io/how-does-angular-2-change-detection-really-work/ - 尚未阅读。
  2. https://hackernoon.com/everything-you-need-to-know-about-change-detection-in-angular-8006c51d206f - 这个比较老了,用的还是Angular4.0的源码讲解的。
  3. https://stackoverflow.com/questions/42643389/why-do-we-need-ngdocheck/42807309#42807309 - ngDoCheck的作用。

Content

在AOT编译器下,Private变量无法绑定到模板中。

如果在组件中定义了一个私有变量,是不能使用双括弧绑定到模板中的,比如下面的代码:

1
2
3
4
5
6
7
8
@Component({
selector: 'app-order',
templateUrl: './order.component.html',
styleUrls: ['./order.component.less'],
})
export class OrderComponent {
private count = 0;
}
1
<p>{{count}}</p>

这样写是不行的,因为在AOT编译器下,私有变量会被删除,所以无法绑定到模板中,如果想要绑定到模板中,需要将变量定义为公共变量,或者使用get方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Component({
selector: 'app-order',
templateUrl: './order.component.html',
styleUrls: ['./order.component.less'],
})
export class OrderComponent {
private _count = 0;

get count(): number {
return this._count;
}
}
1
<p>{{count}}</p>

或者使用getCount()方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Component({
selector: 'app-order',
templateUrl: './order.component.html',
styleUrls: ['./order.component.less'],
})
export class OrderComponent {
private count = 0;

private getCount(): number {
return this.count;
}
}
1
<p>{{getCount()}}</p>

AOT vs JIT

Monkey Patching

In computer programming, monkey patching is a technique used to dynamically update the behavior of a piece of code at run-time. It is used to extend or modify the runtime code of dynamic languages such as Smalltalk, JavaScript, Objective-C, Ruby, Perl, Python, Groovy, and Lisp without altering the original source code. - Wikipedia

Angular找不到selector对应的组件时,不会报错。

如果你在Angular template中使用了一个选择器,但是这个选择器没有对应任何组件,那么Angular不会报错,只会将选择器当作一个普通的HTML标签处理。这个问题在开发过程中很容易出现,因为Angular不会报错,所以很难发现。

ng-repeat vs ngFor

  • ng-repeat是AngularJS中的指令,用于循环遍历数组或对象。
  • ngFor是Angular中的指令,用于循环遍历数组或对象。

Angular中的viewProviders和providers

竟然还有viewProvider?真是孤陋寡闻了。

基于配置的换肤方案

因为Angular的模板和样式是在Component元数据中指定的,所以我们可以定义一个变量用来控制模板和样式的变化,从而实现换肤功能。

首先:我们在config目录下新建一个theme.ts文件,用来存放主题相关的配置。这里我们定义一个USE_LIGHT_THEME变量,用来控制是否使用浅色主题。

1
2
// config/theme.ts
export const USE_LIGHT_THEME = true;

然后:我们在app.component.ts中引入USE_LIGHT_THEME变量,根据这个变量的值来选择不同的模板和样式。

1
2
3
4
5
6
7
8
// app.component.ts
import { Component } from '@angular/core';
import { USE_LIGHT_THEME } from './config/theme';

@Component({
selector: 'app-root',
templateUrl: USE_LIGHT_THEME ? './app.component-light.html' : './app.component-dark.html',
styleUrls: USE_LIGHT_THEME ? ['./app.component-light.css'] : ['./app.component-dark.css'],

以上就是一个简单的实现方式,通过更改USE_LIGHT_THEME的值,就可以实现换肤功能。使用这种方法时,我们可以为已有的项目添加换肤功能,而不需要修改原有的代码,只需为每个组件添加一个额外的模板和样式文件即可。(有的甚至只需要添加一个样式文件就行了)。

如何让Angular每五分钟进行一次更新检测?

为什么会有如此奇怪的需求呢,现实中还真有。比如我们使用websocket来实时更新数据,但是websocket的数据更新不会触发Angular的change detection,所以我们需要定时调用ChangeDetectorRef.detectChanges()方法来手动触发更新检测。

这里的做法是首先使用ChangeDetectorRef.detach()方法来暂时关闭自动更新检测,然后使用setInterval()方法每隔5秒调用一次ChangeDetectorRef.detectChanges()方法来手动触发更新检测。

1
2
3
4
5
6
constructor(private ref: ChangeDetectorRef) {
ref.detach();
setInterval(() => {
this.ref.detectChanges();
}, 5000);
}

HostBinding主要用来做什么?

HostBinding装饰器用来绑定宿主元素的属性,比如下面的例子,我们使用HostBinding装饰器将class属性绑定到app-root元素上。

1
2
3
4
5
6
7
8
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
@HostBinding('class') class = 'app-root';
}

这样就相当于在app-root元素上添加了一个class="app-root"属性。

MDC

MDC = Material Design Components.

Angular challenges

https://angular-challenges.vercel.app/guides/getting-started/

Angular interview.

https://creators.spotify.com/pod/show/daniel-glejzner/episodes/Angular-Job-Interview---with-GDE-Chau-Tran--Brecht-Billiet-e22mf51/a-a9mqru9

Angular zone.js

  1. https://angular.love/from-zone-js-to-zoneless-angular-and-back-how-it-all-works
  2. https://dev.to/vivekdogra02/angular-zonejs-change-detection-understanding-the-core-concepts-16ek
  3. https://medium.com/@krzysztof.grzybek89/how-runoutsideangular-might-reduce-change-detection-calls-in-your-app-6b4dab6e374d
  4. https://medium.com/@sehban.alam/what-is-zone-js-in-angular-e0029c21c32f
  5. https://www.youtube.com/watch?v=pGBh5oNB2wE

UI framework

  1. PrimeNg - https://primeng.org/
  2. Angular Material - https://material.angular.io/
  3. DevUI - https://devui.design/home

Angular Form

  1. Template-driven form
  2. Reactive form

[(ngModel)] 使用双向绑定必须先导入FormsModule模块。

1
2
3
4
5
6
7
import { FormsModule } from '@angular/forms';

@NgModule({
imports: [
FormsModule
]
})

如何查看Angular编译后的结果?

可以使用ngc命令,详情看这里:https://zdd.github.io/2024/05/21/angular-ngc/

各种Ref

  1. ChangeDetectorRef - 构造函数注入,用于手动触发更新检测。
  2. ElementRef - 构造函数注入,用于获取当前元素的引用, 常用于自定义指令。
  3. ComponentRef - 用于获取组件实例。
  4. ViewContainerRef - 构造函数注入,用于动态创建组件。
  5. TemplateRef - 构造函数注入,用于动态创建组件。
  6. EmbeddedViewRef -
  7. ViewRef

发现一个Angular宝藏

  1. https://angular.love/ - 有空整个通读一下吧,质量非常之高!
  2. https://angular-university.io/ - 这个质量就一般了。

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 name="viewport" content="width=device-width,initial-scale=1"/>
<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 id='my-button' href="https://www.baidu.com">link</a>
</div>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
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