jiayisheji / blog

没事写写文章,喜欢的话请点star,想订阅点watch,千万别fork!
https://jiayisheji.github.io/blog/
505 stars 49 forks source link

Angular 事件绑定扩展增强 - Angular Events Plugin #33

Open jiayisheji opened 4 years ago

jiayisheji commented 4 years ago

Angular提供了许多事件类型来与你的应用进行通信。 Angular中的事件可帮助你在特定条件下触发操作,例如单击,滚动,悬停,聚焦,提交等。

通过事件,可以在Angular应用中触发组件的逻辑。

Angular事件

Angular 组件和 DOM 元素通过事件与外部进行通信, Angular 事件绑定语法对于组件和 DOM 元素来说是相同的 - (eventName)="expression"

DOM 元素触发的一些事件通过 DOM 层级结构传播。这种传播过程称为事件冒泡。事件首先由最内层的元素开始,然后传播到外部元素,直到它们到根元素。DOM 事件冒泡与 Angular 可以无缝工作。

Angular事件分为原生事件和自定义事件:

Angular Events 常用列表

(click)="myFunction()"      
(dblclick)="myFunction()"   

(submit)="myFunction()"

(blur)="myFunction()"  
(focus)="myFunction()" 

(scroll)="myFunction()"

(cut)="myFunction()"
(copy)="myFunction()"
(paste)="myFunction()"

(keyup)="myFunction()"
(keypress)="myFunction()"
(keydown)="myFunction()"

(mouseup)="myFunction()"
(mousedown)="myFunction()"
(mouseenter)="myFunction()"

(drag)="myFunction()"
(drop)="myFunction()"
(dragover)="myFunction()"

默认处理的事件应从原始HTML DOM组件的事件映射:

关于原生事件有哪些,可以参照W3C标准事件

只需删除on前缀即可。

import { Component } from '@angular/core';

@Component({
  selector: 'my-app',
  template: '<button (click)="myFunction($event)">Click Me</button>',
  styleUrls: ['./app.component.css']
})

export class AppComponent {
  myFunction(event) {
    console.log('Hello World');
  }
}

当我们点击按钮时候,控制台就会打印Hello World

Angular 允许开发者通过 @Output() 装饰器和 EventEmitter 自定义事件。它不同于 DOM 事件,因为它不支持事件冒泡。

@Component({
  selector: 'my-selector',
  template: `
    <div>
      <button (click)="callSomeMethodOfTheComponent()">Click</button>
      <sub-component (my-event)="callSomeMethodOfTheComponent($event)"></sub-component>
    </div>
  `,
  directives: [SubComponent]
})
export class MyComponent {
  callSomeMethodOfTheComponent() {
    console.log('callSomeMethodOfTheComponent called');
  }
}

@Component({
  selector: 'sub-component',
  template: `
    <div>
      <button (click)="myEvent.emit()">Click (from sub component)</button>
    </div>
  `
})
export class SubComponent {
  @Output()
  myEvent: EventEmitter;

  constructor() {
    this.myEvent = new EventEmitter();
  }
}

自定义事件写法和原生Dom事件一样。那么它们需要注意:

  1. DOM 事件冒泡机制,允许在父元素监听由子元素触发的 DOM 事件
  2. Angular 支持 DOM 事件冒泡机制,但不支持自定义事件的冒泡。
  3. 自定义事件名称与 DOM 事件的名称如 (click,change,select,submit) 同名,可能会导致问题。虽然可以使用 stopPropagation()方法解决问题,但实际工作中,不建议这样使用。
  4. 自定义事件不要使用on前缀,方法名可以使用on开头,参考风格指南-不要给输出属性加前缀
  5. 原生事件的$event返回是 DOM Events
  6. 自定义事件的$event返回是 EventEmitter.emit() 传递的值,也可以使用 EventEmitter.next()

事件修饰

在实际项目中,我们经常需要在事件处理器中调用 preventDefault()stopPropagation() 方法。

还有一个比较少用功能比较强大,stopPropagation 增强版 stopImmediatePropagation()

preventDefault()最常见的例子就是 <a> 阻止标签跳转链接

<a id="link" href="https://www.baidu.com">baidu</a>
<script>
    document.getElementById('link').onclick = function(ev) {
        ev.preventDefault(); // 阻止浏览器默认动作 (页面跳转)
        // 处理一些其他事情
        window.open(this.href); // 在新窗口打开页面
    };
</script>

在Angular中使用:

preventDefault()页面直接使用:

<a id="link" href="https://www.baidu.com" (click)="$event..preventDefault(); myFunction()">baidu</a>

还可以使用:

```html
<a id="link" href="https://www.baidu.com" (click)="myFunction($event); false">baidu</a>

stopPropagation()页面直接使用:

<a id="link" href="https://www.baidu.com" (click)="$event.stopPropagation(); myFunction($event)">baidu</a>

在事件处理方法里面使用和原生一样。

myFunction(e: Event) {

    e.stopPropagation();
    e.preventDefault();

   // ...code

    return false;
}

看完 Angular 提供写法,写法太麻烦。

项目中最常用当属stopPropagation(),懒惰的程序员就想到各种方法:

方法1:

import {Directive, HostListener} from "@angular/core";

@Directive({
    selector: "[click-stop-propagation]"
})
export class ClickStopPropagation
{
    @HostListener("click", ["$event"])
    public onClick(event: any): void
    {
        event.stopPropagation();
    }
}

弄一个阻止冒泡的指令

<div click-stop-propagation>Stop Propagation</div>

方法2:

import { Directive, EventEmitter, Output, HostListener } from '@angular/core';
@Directive({
  selector: '[appClickStop]'
})
export class ClickStopDirective {
  @Output() clickStop = new EventEmitter<MouseEvent>();
  constructor() { }

  @HostListener('click', ['$event'])
  clickEvent(event: MouseEvent) {
    event.stopPropagation();
    event.preventDefault();
    this.clickStop.emit(event);
  }
}

弄一个阻止冒泡的自定义事件指令

<div appClickStop (clickStop)="testClick()"></div>

看起来很不错,就是支持click事件,我要支持多种事件,我需要些更多的指令。

用过 Vue - 事件修饰( Event modifiers ) 的同学,一定让使用 Angular 的同学很羡慕。

<button v-on:click="add(1)"></button> # 普通事件
<button v-on:click.once="add(1)"></button>  # 这里只监听一次
<a v-on:click.prevent="click" href="http://google.com">click me</a> # 阻止默认事件
<div class="parent" v-on:click="add(1)">
   <div class="child"  v-on:click.stop="add(1)">click me</div> # 阻止冒泡
</div>

那 Angular 可以实现吗?当然

import { Directive, EventEmitter, Output, HostListener, OnDestroy, OnInit, Input } from '@angular/core';
import { Subject,  } from 'rxjs';
import { takeUntil,  throttleTime} from 'rxjs/operators';

@Directive({
  selector: '[click.stop]',
})
export class ClickStopDirective implements OnInit ,OnDestroy{
  @Output('click.stop') clickStop = new EventEmitter<MouseEvent>();
  /// 自定义间隔
  @Input() throttleTime = 1000;

  click$: Subject<MouseEvent> = new Subject<MouseEvent>()
  onDestroy$ = new Subject();

  @HostListener('click', ['$event'])
  clickEvent(event: MouseEvent) {
    event.stopPropagation();
    event.preventDefault();
    this.click$.next(event);
  }

  constructor() {   }

  ngOnInit() {
    this.click$.pipe(
      takeUntil(this.onDestroy$),
      throttleTime(this.throttleTime)
    ).subscribe((event)  => {
      this.clickStop.emit(event);
    }) 
  }

  ngOnDestroy() {
    /// 销毁并取消订阅
    this.onDestroy$.next();
    this.onDestroy$.complete();
  }
}

扩展一个原生事件指令

<div class="parent" (click)="add(1)">
   <div class="child"  (click.stop)="add(1)">click me</div>
</div>

看起来很美好,还支持防抖骚操作,缺点还是支持一个事件,如果需要多种事件需要写更多的事件指令。

Angular 不支持 (事件名.修饰) 这种语法吗?

如果你用过键盘事件,你就会发现,Angular 提供一系列的快捷操作:

当绑定到Angular模板中的keyup或keydown事件时,可以指定键名。 这使得仅在按下特定键时才很容易触发事件。

<input (keydown.enter)="onKeydown($event)">

还可以将按键组合在一起以仅在触发按键组合时触发事件。 在以下示例中,仅当同时按下Control和1键时才会触发事件:

<input (keyup.control.1)="onKeydown($event)">

此功能适用于特殊键和修饰键,例如EnterEscShiftAltTabBackspaceCommand,但它也适用于字母,数字,方向箭头和F键(F1-F12)。

<input (keydown.enter)="...">
<input (keydown.a)="...">
<input (keydown.esc)="...">
<input (keydown.shift.esc)="...">
<input (keydown.control)="...">
<input (keydown.alt)="...">
<input (keydown.meta)="...">
<input (keydown.9)="...">
<input (keydown.tab)="...">
<input (keydown.backspace)="...">
<input (keydown.arrowup)="...">
<input (keydown.shift.arrowdown)="...">
<input (keydown.shift.control.z)="...">
<input (keydown.f4)="...">

这个看起来很不错呀,和 Vue 那个事件修饰写法一致。这种可以 Angular 原生实现,那一定有方法可以做到。

我们去查看源码:https://github.com/angular/angular/blob/master/packages/platform-browser/src/dom/events/key_events.ts

在源码里面由一个突出的导入:

import {EventManagerPlugin} from './event_manager';

而的实现,

export class KeyEventsPlugin extends EventManagerPlugin {}

就是继承了这个抽象类

export abstract class EventManagerPlugin {
  constructor(private _doc: any) {}

  manager!: EventManager;

  abstract supports(eventName: string): boolean;

  abstract addEventListener(element: HTMLElement, eventName: string, handler: Function): Function;

  addGlobalEventListener(element: string, eventName: string, handler: Function): Function {
    const target: HTMLElement = getDOM().getGlobalEventTarget(this._doc, element);
    if (!target) {
      throw new Error(`Unsupported event target ${target} for event ${eventName}`);
    }
    return this.addEventListener(target, eventName, handler);
  }
}

抽象类里面我们需要实现supportsaddEventListener方法。

DomEventsPlugin 的类实现:

addEventListener(element: HTMLElement, eventName: string, handler: Function): Function {
    element.addEventListener(eventName, handler as EventListener, false);
    return () => this.removeEventListener(element, eventName, handler as EventListener);
}

在我们使用 Renderer2.listen 绑定事件时候:如果需要销毁事件

// 绑定事件
const fn = Renderer2.listen();
// 销毁事件
fn();

这种操作就是源码是这样的实现的。

listen(target: 'window'|'document'|'body'|any, event: string, callback: (event: any) => boolean):
      () => void {
    NG_DEV_MODE && checkNoSyntheticProp(event, 'listener');
    if (typeof target === 'string') {
      return <() => void>this.eventManager.addGlobalEventListener(
          target, event, decoratePreventDefault(callback));
    }
    return <() => void>this.eventManager.addEventListener(
               target, event, decoratePreventDefault(callback)) as () => void;
  }

关于 Angular Events Plugin 的文章介绍很少,所以很多人不知道可以有以下的骚操作。

我们也来实现事件修饰符:

新建三个文件:

once.plugin.ts
stop.plugin.ts
prevent.plugin.ts

先从常用的 .stop 开始:

注意:EventManagerPlugin是一个内部抽象类,所以我们无法扩展它

import { Injectable, Inject } from '@angular/core';
import { EventManager } from '@angular/platform-browser';

const MODIFIER = '.stop';

@Injectable()
export class StopEventPlugin {
  manager: EventManager;

  supports(eventName: string): boolean {
    return eventName.indexOf(MODIFIER) !== -1;
  }

  addEventListener(
    element: HTMLElement,
    eventName: string,
    handler: Function
  ): Function {
    const stopped = (event: Event) => {
      event.stopPropagation();
      handler(event);
    }

    return this.manager.addEventListener(
      element,
      eventName.replace(MODIFIER, ''),
      stopped,
    );
  }

  addGlobalEventListener(element: string, eventName: string, handler: Function): Function {
    const stopped = (event: Event) => {
      event.stopPropagation();
      handler(event);
    }

    return this.manager.addGlobalEventListener(
      element,
      eventName.replace(MODIFIER, ''),
      stopped,
    );
  }
}

我们这里使用先去supports 查询,只有事件名里面有.stop,才会执行StopEventPlugin

addEventListener里面调用的EventManager.addEventListener,我们只需要对事件处理函数进行包装一下即可:

  const stopped = (event: Event) => {
      event.stopPropagation();
      handler(event);
    }

在把包装之后的处理函数返还给EventManager.addEventListener,并且去掉.stop,防止死循环。

.prevent基本和.stop一模一样:

import { Injectable, Inject } from '@angular/core';
import { EventManager } from '@angular/platform-browser';

const MODIFIER = '.prevent';

@Injectable()
export class PreventEventPlugin {
  manager: EventManager;

  supports(eventName: string): boolean {
    return eventName.indexOf(MODIFIER) !== -1;
  }

  addEventListener(
    element: HTMLElement,
    eventName: string,
    handler: Function
  ): Function {
    const prevented = (event: Event) => {
      event.preventDefault();
      handler(event);
    }

    return this.manager.addEventListener(
      element,
      eventName.replace(MODIFIER, ''),
      prevented,
    );
  }

  addGlobalEventListener(element: string, eventName: string, handler: Function): Function {
    const prevented = (event: Event) => {
      event.preventDefault();
      handler(event);
    }

    return this.manager.addGlobalEventListener(
      element,
      eventName.replace(MODIFIER, ''),
      prevented,
    );
  }
}

.once 有点特殊:

import { Injectable, Inject } from '@angular/core';
import { EventManager } from '@angular/platform-browser';

const MODIFIER = '.once';

@Injectable()
export class OnceEventPlugin { 
  manager: EventManager;

  supports(eventName: string): boolean {
    return eventName.indexOf(MODIFIER) !== -1;
  }

  addEventListener(
    element: HTMLElement,
    eventName: string,
    handler: Function
  ): Function {    
    const fn = this.manager.addEventListener(
      element,
      eventName.replace(MODIFIER, ''),
      (event: Event) => {
        handler(event);
        fn();
      },
    );

    return () => {};
  }

  addGlobalEventListener(element: string, eventName: string, handler: Function): Function {
    const fn =  this.manager.addGlobalEventListener(
      element,
      eventName.replace(MODIFIER, ''),
      (event: Event) => {
        handler(event);
        fn();
      },
    );

    return () => {};
  }
}

fn 返回的就是 return () => this.removeEventListener(element, eventName, handler as EventListener);.once操作就是使用一次就注销事件操作。所以我们先把fn获取到,然后事件调用完成以后取消绑定即可。最后要返回一个空函数,不然手动销毁事件就会抛出错误。

在根模块注册插件:

import { EVENT_MANAGER_PLUGINS } from '@angular/platform-browser';
import { PreventEventPlugin } from './prevent.plugin';
import { StopEventPlugin } from './stop.plugin';
import { OnceEventPlugin } from './once.plugin';

@NgModule({
  imports: [BrowserModule, FormsModule],
  declarations: [
    AppComponent,
  ],
  providers: [
    ....,
    {
      provide: EVENT_MANAGER_PLUGINS,
      useClass: PreventEventPlugin,
      multi: true,
    }, 
    {
      provide: EVENT_MANAGER_PLUGINS,
      useClass: StopEventPlugin,
      multi: true,
    }, 
    {
      provide: EVENT_MANAGER_PLUGINS,
      useClass: OnceEventPlugin,
      multi: true,
    }, 
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

这样我们就可以正常使用了

<a href="https://www.baidu.com" (click.prevent)="onConsole($event)">baidu</a>

<div (click)="onConsole($event)">
  标题
  <div  (click.stop)="onConsole($event)">内容</div>
</div>

<form (submit.stop)="onConsole($event)">
  <input name="username">
  <button type="submit">提交</button>
</form>

<div (click)="onConsole($event)">
  标题
  <div  (click.once)="onConsole($event)">内容</div>
</div>

事件处理函数:

  onConsole($event: Event) {
    console.log('onConsole',$event.target)
  }

我们已经实现普遍版本的事件修饰,如果想要加上防抖,节流更风骚的操作我们该如何做了,这个留个大家一个悬念,可以思考一下,欢迎和我交流心得。

最后:我们不光可以做事件修饰插件还可以做事件打印日志插件,你看完上面的例子,应该很简单操作了。如果不知道怎么下手,欢迎和我交流心得。

今天就到这里吧,伙计们,玩得开心,祝你好运