0%

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可以获取内联样式、内部样式和外部样式表。

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(...).

How to get DOM elements on page

获取Dom元素总体来说有两类方法:

  • getElementByXXX
  • queryXXX

这两种方法都是从document对象开始查找,所以可以直接使用,不需要先获取document对象。

get方法

getElementById

这是最常用的方法,如果你的元素有id属性,那么可以使用这个方法获取元素,返回的是一个Element对象。如果没有找到,则返回null。
html代码:

1
<div id="root"></div>

js代码:

1
2
3
4
const element = document.getElementById('root');
if (element) {
// do something
}

getElementsByClassName

这个方法可以获取到所有class属性中包含指定类名的元素,返回的是一个array-like对象HTMLCollection,可以使用Array.from()方法将其转换为数组。
html代码:

1
2
3
<div class="container"></div>
<div class="container"></div>
<div class="container"></div>

js代码:

1
2
const elements = document.getElementsByClassName('container');
const elementsArray = Array.from(elements);

需要注意的是,这个方法返回的是一个动态的集合,也就是说,如果你在获取到元素之后,再向页面中添加或者删除元素,那么这个集合也会跟着改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<body>
<button class="button">A</button>
<button class="button">B</button>
<button class="button">C</button>
<script>
// 初始时,页面上有三个按钮。
const buttons = document.getElementsByClassName("button");
console.log(buttons); // HTMLCollection(3) [button, button, button]

// 添加一个按钮, 此时再打印buttons,则输出四个按钮。
const buttonD = document.createElement("button");
buttonD.innerHTML = "D";
buttonD.className = "button";
document.body.appendChild(buttonD);
console.log(buttons); // HTMLCollection(4) [button, button, button, button]
</script>
</body>

query方法

query类的方法参数是CSS选择器,比如

  • “#root” - 查找id为root的结点,
  • “.container” - 查找class为container的结点,
  • “div” - 查找所有div结点。

querySelector

这个方法可以获取到第一个匹配的元素,它的返回值类型是Element。如果没有匹配的元素,返回null。

1
<div class="container"></div>

js代码:

1
const element = document.querySelector('.container');

querySelectorAll

这个方法可以获取到所有匹配的元素,返回的是一个类数组对象NodeList,可以使用Array.from()方法将其转换为数组。

1
2
3
<div class="container"></div>
<div class="container"></div>
<div class="container"></div>

js代码:

1
2
const elements = document.querySelectorAll('.container');
const elementsArray = Array.from(elements);

querySelectorAll方法返回的是一个静态的集合,也就是说,如果你在获取到元素之后,再向页面中添加或者删除元素,那么这个集合不会跟着改变。

需要注意的是,尽管querySelectorAll方法返回的NodeList是静态的,但是NodeList在有些情况下却是动态的,比如 Node.childNodes 返回的就是一个动态的NodeList。

总结成如下表格

方法 返回值类型 返回值个数 查不到时返回值 返回值状态:动态/静态
getElementById Element 1 null -
getElementsByClassName HTMLCollection(Array-like object) 0~n 空的HTMLCollection 动态
querySelector Element 1 null -
querySelectorAll NodeList(Array-like object) 0~n 空的NodeList 静态

References

getElementById
getElementsByClassName
querySelector
querySelectorAll

Array operations

Create array

Array constructor

ES5中Array的构造函数语法比较混乱,传入不同的值会得到不同的结果。

1
2
3
4
5
6
7
8
const a1 = new Array(1, 2);
console.log(a1); // [1, 2]

const a3 = new Array('2');
console.log(a3); // ['2']

const a2 = new Array(2);
console.log(a2); // [undefined, undefined], why?

通常情况下,Array的构造函数就是根据传入的参数初始化数组,只有一种情况例外:当且仅当传入一个number类型的参数时。此时,构造函数会创建一个指定长度的数组,而不是包含一个元素的数组。比如上面的数组a2,它的长度是2,且两个元素都是undefined,而不是一个包含数字2的数组。这种行为是不符合直觉的,

注意:在实际编程中,我们一般不使用Array构造函数来创建数组,而是使用数组字面量的方式[]来创建数组。

1
2
3
4
5
6
7
8
const a = [1, 2];
console.log(a); // [1, 2]

const b = ['2'];
console.log(b); // ['2']

const c = [2];
console.log(c); // [2]

为了解决Array constructor这种混乱,ES6引入了Array.ofArray.from方法。

Array.of

Array.of方法用于创建一个具有可变数量参数的数组实例,用户传入什么,就用什么创建数组,简单明了。

1
2
3
4
5
6
7
8
const a1 = Array.of(1, 2);
console.log(a1); // [1, 2]

const a2 = Array.of('2');
console.log(a2); // ['2']

const a3 = Array.of(2);
console.log(a3); // [2]

Array.from

既然有了Array.of,那么为啥还需要Array.from呢?Array.from主要是用于将其他类型转换为数组的。可以转换的类型有:

  • Array-like objects (arguments in function or NodeList from DOM)
  • Iterable objects (Set, Map, String, etc.)

Array-like objects

在ES6之前,把Array-like objects转换为数组大致有两种方式:

  1. 逐个元素复制,因为Array-like objects有length属性且可用下标迭代。
1
2
3
4
5
let arr = [];
for (let i = 0; i < arguments.length; i++) {
arr.push(args[i]);
}
return arr;
  1. 使用Array.prototype.slice.call(arguments),这种方式更简洁。
1
return Array.prototype.slice.call(arguments);

ES6之后,我们可以使用Array.from来转换Array-like objects为数组。
下面是一个求和函数,函数sum的参数是一个类数组对象,我们可以使用Array.from将其转换为数组,并累加求和。

1
2
3
4
5
// Array-like objects
function sum() {
return Array.from(arguments).reduce((acc, cur) => acc + cur, 0);
}
sum(1, 2, 3); // 6

当然,更好的写法是使用扩展运算符...,这样更简洁。

1
2
3
4
function sum(...args) {
return args.reduce((acc, cur) => acc + cur, 0);
}
sum(1, 2, 3); // 6

Iterable objects

下面是一个将字符串转换为数组的例子。

1
2
3
const str = 'hello';
const arr = Array.from(str);
console.log(arr); // ['h', 'e', 'l', 'l', 'o']

将Set转换为数组。

1
2
3
const set = new Set([1, 2, 3]);
const arr = Array.from(set);
console.log(arr); // [1, 2, 3]

将Map转换为数组。

1
2
3
const map = new Map([[1, 'one'], [2, 'two'], [3, 'three']]);
const arr = Array.from(map);
console.log(arr); // [[1, 'one'], [2, 'two'], [3, 'three']]

注意:Array.from方法的第二个参数是一个map函数,可以对数组中的每个元素进行处理。

1
2
const arr = Array.from([1, 2, 3], x => x * x);
console.log(arr); // [1, 4, 9]

Search in array

Please see this blog post: JavaScript Search in Array

插入,删除,替换数组元素

1
2
3
4
5
6
7
8
9
10
11
12
13
const numbers = [1, 2, 3, 4, 5];

// insert 6 at index 2
numbers.splice(2, 0, 6);
console.log(numbers); // [1, 2, 6, 3, 4, 5]

// remove 2 elements starting from index 3
numbers.splice(3, 2);
console.log(numbers); // [1, 2, 6, 5]

// replace 1 element at index 2 with 7
numbers.splice(2, 1, 7);
console.log(numbers); // [1, 2, 7, 5]

获取最后一个元素

使用length属性

1
2
3
const numbers = [1, 2, 3, 4, 5];
const result = numbers[numbers.length - 1];
console.log(result); // 5

使用slice方法

1
2
3
const numbers = [1, 2, 3, 4, 5];
const result = numbers.slice(-1)[0];
console.log(result); // 5

使用at方法:

1
2
3
const numbers = [1, 2, 3, 4, 5];
const result = numbers.at(-1);
console.log(result); // 5

原本JavaScript并不支持负值索引,但是有了at方法后,则可以使用负值索引。
注意at方法需要新版的浏览器和Node.js支持,详情看这里:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at

截取数组的一部分

使用slice方法

slice return a shallow copy of a portion of an array into a new array object selected from begin to end (not included). The original array will not be modified.

1
2
3
const numbers = [1, 2, 3, 4, 5];
const result = numbers.slice(1, 3);
console.log(result); // [2, 3]

合并两个数组

使用concat方法

1
2
3
4
const numbers1 = [1, 2, 3, 4, 5];
const numbers2 = [6, 7, 8, 9, 10];
const result = numbers1.concat(numbers2);
console.log(result); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

使用扩展运算符

1
2
3
4
const numbers1 = [1, 2, 3, 4, 5];
const numbers2 = [6, 7, 8, 9, 10];
const result = [...numbers1, ...numbers2];
console.log(result); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

数组去重

使用Set对象

1
2
3
const numbers = [1, 2, 3, 4, 5, 1, 2, 3, 4, 5];
const result = [...new Set(numbers)];
console.log(result); // [1, 2, 3, 4, 5]

使用filter方法

这个方法比较巧妙,对于数组中的某个元素item,如果它在数组中的索引等于它第一次出现的索引,那么就保留它,否则就过滤掉。如果数组中有重复元素,在使用indexOf查找其下标时,会返回第一个出现的下标。这会导致indexOf(item) !== index,所以重复元素会被过滤掉。

1
2
3
const numbers = [1, 2, 3, 4, 5, 1, 2, 3, 4, 5];
const result = numbers.filter((item, index) => numbers.indexOf(item) === index);
console.log(result); // [1, 2, 3, 4, 5]

使用reduce方法

1
2
3
const numbers = [1, 2, 3, 4, 5, 1, 2, 3, 4, 5];
const result = numbers.reduce((acc, cur) => acc.includes(cur) ? acc : [...acc, cur], []);
console.log(result); // [1, 2, 3, 4, 5]

数组diff

使用filter方法

1
2
3
4
const numbers1 = [1, 2, 3, 4, 5];
const numbers2 = [3, 4, 5, 6, 7];
const result = numbers1.filter(item => !numbers2.includes(item));
console.log(result); // [1, 2]

使用reduce方法

1
2
3
4
const numbers1 = [1, 2, 3, 4, 5];
const numbers2 = [3, 4, 5, 6, 7];
const result = numbers1.reduce((acc, cur) => numbers2.includes(cur) ? acc : [...acc, cur], []);
console.log(result); // [1, 2]

数组交集

使用filter方法

1
2
3
4
const numbers1 = [1, 2, 3, 4, 5];
const numbers2 = [3, 4, 5, 6, 7];
const result = numbers1.filter(item => numbers2.includes(item));
console.log(result); // [3, 4, 5]

使用reduce方法

1
2
3
4
const numbers1 = [1, 2, 3, 4, 5];
const numbers2 = [3, 4, 5, 6, 7];
const result = numbers1.reduce((acc, cur) => numbers2.includes(cur) ? [...acc, cur] : acc, []);
console.log(result); // [3, 4, 5]

数组并集

使用Set对象

1
2
3
4
const numbers1 = [1, 2, 3, 4, 5];
const numbers2 = [3, 4, 5, 6, 7];
const result = [...new Set([...numbers1, ...numbers2])];
console.log(result); // [1, 2, 3, 4, 5, 6, 7]

使用filter方法

1
2
3
4
const numbers1 = [1, 2, 3, 4, 5];
const numbers2 = [3, 4, 5, 6, 7];
const result = [...numbers1, ...numbers2].filter((item, index, arr) => arr.indexOf(item) === index);
console.log(result); // [1, 2, 3, 4, 5, 6, 7]

使用reduce方法

1
2
3
4
const numbers1 = [1, 2, 3, 4, 5];
const numbers2 = [3, 4, 5, 6, 7];
const result = [...numbers1, ...numbers2].reduce((acc, cur) => acc.includes(cur) ? acc : [...acc, cur], []);
console.log(result); // [1, 2, 3, 4, 5, 6, 7]

数组差集

使用filter方法

1
2
3
4
const numbers1 = [1, 2, 3, 4, 5];
const numbers2 = [3, 4, 5, 6, 7];
const result = [...numbers1, ...numbers2].filter(item => !numbers1.includes(item) || !numbers2.includes(item));
console.log(result); // [1, 2, 6, 7]

使用reduce方法

1
2
3
4
const numbers1 = [1, 2, 3, 4, 5];
const numbers2 = [3, 4, 5, 6, 7];
const result = [...numbers1, ...numbers2].reduce((acc, cur) => numbers1.includes(cur) && numbers2.includes(cur) ? acc : [...acc, cur], []);
console.log(result); // [1, 2, 6, 7]

打平数组

使用flat方法

1
2
3
4
5
6
7
8
9
10
11
const numbers = [1, [2, 3], [4, [5, 6, [7, 8]]]];

const result1 = numbers.flat(); // 默认只展开一层
console.log(result1); // [ 1, 2, 3, 4, [ 5, 6, [ 7, 8 ] ] ];

const result2 = numbers.flat(2); // 展开两层
console.log(result2); // [1, 2, 3, 4, 5, 6, [7, 8]];

const result = numbers.flat(Infinity); // Infinity 表示展开所有层级
console.log(result); // [1, 2, 3, 4, 5, 6, 7, 8]

Integrate Jest to Angular App

  1. Create your angular app
  2. Install jest
    npm install jest jest-preset-angular @types/jest
  3. In your project root, create a setup-jest.ts file with the following contents:
    import 'jest-preset-angular/setup-jest';
  4. Create the jest.config.js file in your project root directory with the following contents:
    1
    2
    3
    4
    5
    module.exports = {
    preset: 'jest-preset-angular',
    setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
    globalSetup: 'jest-preset-angular/global-setup',
    };
  5. Adjust your tsconfig.spec.json in your project root
    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "extends": "./tsconfig.json",
    "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "module": "CommonJs",
    "types": ["jest"]
    },
    "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
    };
  6. update package.json file in your project root as below.
    1
    2
    "test": "jest --verbose",
    "test:watch": "jest --watch"
  7. Open your terminal and run npm run test, enjoy!
  8. If you want to remove karma and Jasmine and switch to Jest completely, run the following command.
    1
    npm uninstall karma karma-chrome-launcher karma-coverage-istanbul-reporter karma-jasmine karma-jasmine-html-reporter jasmine-core jasmine-spec-reporter

references:

xfive.co/blog/testing-angular-faster-jest/