检测 Angular 组件外的点击事件

180

如何在Angular中检测组件外部的点击事件?


请参考 https://dev59.com/GlsW5IYBdhLWcg3wATLe#35527852。 - Günter Zöchbauer
还有这个答案:https://dev59.com/dlUK5IYBdhLWcg3w4TKN#51152404 - Mohammad Kermani
12个回答

297
import { Component, ElementRef, HostListener, Input } from '@angular/core';

@Component({
  selector: 'selector',
  template: `
    <div>
      {{text}}
    </div>
  `
})
export class AnotherComponent {
  public text: String;

  @HostListener('document:click', ['$event'])
  clickout(event) {
    if(this.eRef.nativeElement.contains(event.target)) {
      this.text = "clicked inside";
    } else {
      this.text = "clicked outside";
    }
  }

  constructor(private eRef: ElementRef) {
    this.text = 'no clicks yet';
  }
}

一个可工作的示例 - 点击这里


25
当触发元素内部存在一个由ngIf控制的元素时,这种方法就不起作用了,因为在点击事件发生之前,ngIf将该元素从DOM中移除。参考链接:http://plnkr.co/edit/spctsLxkFCxNqLtfzE5q?p=preview - J. Frankenstein
它能在通过以下方式动态创建的组件上工作吗: const factory = this.resolver.resolveComponentFactory(MyComponent); const elem = this.vcr.createComponent(factory); - Avi Moraly
2
这个主题的一篇不错的文章:https://christianliebel.com/2016/05/angular-2-a-simple-click-outside-directive/ - Miguel Lara

87

AMagyar的答案的另一种选择。这个版本适用于你点击从DOM中通过ngIf移除的元素。

http://plnkr.co/edit/4mrn4GjM95uvSbQtxrAS?p=preview

  private wasInside = false;

  @HostListener('click')
  clickInside() {
    this.text = "clicked inside";
    this.wasInside = true;
  }

  @HostListener('document:click')
  clickout() {
    if (!this.wasInside) {
      this.text = "clicked outside";
    }
    this.wasInside = false;
  }


1
这也可以完美地与ngif或动态更新配合使用。 - Vikas Kandari
3
如果点击发生在元素内部,那么clickInside是否保证会比clickout先被调用? - LeonTheProfessional

75

@Hostlistener绑定到文档的点击事件上是昂贵的。如果过度使用它(例如,在构建自定义下拉组件时创建了多个实例并且使用频繁),它将会产生明显的性能影响。

我建议仅在您的主应用程序组件中添加@Hostlistener()到文档点击事件一次。该事件应将所点击的目标元素的值推送到存储在全局实用程序服务中的公共主题中。

@Component({
  selector: 'app-root',
  template: '<router-outlet></router-outlet>'
})
export class AppComponent {

  constructor(private utilitiesService: UtilitiesService) {}

  @HostListener('document:click', ['$event'])
  documentClick(event: any): void {
    this.utilitiesService.documentClickedTarget.next(event.target)
  }
}

@Injectable({ providedIn: 'root' })
export class UtilitiesService {
   documentClickedTarget: Subject<HTMLElement> = new Subject<HTMLElement>()
}

对于所点击的目标元素感兴趣的人应该订阅我们的公共实用程序服务主题,并在组件被销毁时取消订阅。

export class AnotherComponent implements OnInit {

  @ViewChild('somePopup', { read: ElementRef, static: false }) somePopup: ElementRef

  constructor(private utilitiesService: UtilitiesService) { }

  ngOnInit() {
      this.utilitiesService.documentClickedTarget
           .subscribe(target => this.documentClickListener(target))
  }

  documentClickListener(target: any): void {
     if (this.somePopup.nativeElement.contains(target))
        // Clicked inside
     else
        // Clicked outside
  }

7
我认为这个答案应该被接受,因为它允许许多优化,比如在这个例子中。 - edoardo849
3
没错,我讲过这个。请再读一遍答案。退订的实现方法留给你自己决定(可以用 takeUntil()、Subscription.add() 等)。别忘了取消订阅! - ginalx
1
HostListener是否有一种方式只在有订阅者对事件感兴趣时才处于活动状态? - David
即使没有任何订阅,这会在每次单击时触发tick()两次。我在https://dev59.com/dlUK5IYBdhLWcg3w4TKN#51152404中找到了一个使用Renderer2的示例,它也表现出相同的行为,但是我将其包装在runOutsideAngular()周围。现在我的下拉组件不会触发浪费的变更检测。https://medium.com/claritydesignsystem/four-ways-of-listening-to-dom-events-in-angular-part-3-renderer2-listen-14c6fe052b59 - Kerry Johnson

15

改进J. Frankenstein的答案

  @HostListener('click')
  clickInside($event) {
    this.text = "clicked inside";
    $event.stopPropagation();
  }

  @HostListener('document:click')
  clickOutside() {
      this.text = "clicked outside";
  }


解决方案不错,但 stopPropagation 可能会影响组件外的逻辑:Google Analytics、另一个组件的关闭等。 - Vincente
1
现在$event返回未定义。 - Shadoweb
@Shadoweb 应该添加监听器的第二个参数:@HostListener('click', ['$event'])。 - gzn
在多个相同组件被初始化的情况下,它不能正常工作。 - Fikret

7
之前的回答是正确的,但如果你在从相关组件失去焦点后进行了繁重的处理呢?为此,我提出了一个解决方案,使用两个标志位,只有在从相关组件失去焦点时才会触发失焦事件处理。
isFocusInsideComponent = false;
isComponentClicked = false;

@HostListener('click')
clickInside() {
    this.isFocusInsideComponent = true;
    this.isComponentClicked = true;
}

@HostListener('document:click')
clickout() {
    if (!this.isFocusInsideComponent && this.isComponentClicked) {
        // Do the heavy processing

        this.isComponentClicked = false;
    }
    this.isFocusInsideComponent = false;
}

5

ginalx的答案在我看来应该设为默认值:这种方法可以进行很多优化。

问题

假设我们有一个项目列表,每个项目都需要包含一个需要切换的菜单。我们在按钮上包含一个切换,它会监听自己的click事件(click)="toggle()",但我们也希望在用户点击菜单外部时切换菜单。如果项目列表增长,并且我们在每个菜单上附加一个@HostListener('document:click'),则即使菜单关闭,每个加载在项目中的菜单也将开始在整个文档中监听单击事件。除了明显的性能问题之外,这是不必要的。

例如,您可以订阅每次通过单击切换弹出窗口并仅在那时开始监听“外部单击”。


isActive: boolean = false;

// to prevent memory leaks and improve efficiency, the menu
// gets loaded only when the toggle gets clicked
private _toggleMenuSubject$: BehaviorSubject<boolean>;
private _toggleMenu$: Observable<boolean>;

private _toggleMenuSub: Subscription;
private _clickSub: Subscription = null;


constructor(
 ...
 private _utilitiesService: UtilitiesService,
 private _elementRef: ElementRef,
){
 ...
 this._toggleMenuSubject$ = new BehaviorSubject(false);
 this._toggleMenu$ = this._toggleMenuSubject$.asObservable();

}

ngOnInit() {
 this._toggleMenuSub = this._toggleMenu$.pipe(
      tap(isActive => {
        logger.debug('Label Menu is active', isActive)
        this.isActive = isActive;

        // subscribe to the click event only if the menu is Active
        // otherwise unsubscribe and save memory
        if(isActive === true){
          this._clickSub = this._utilitiesService.documentClickedTarget
           .subscribe(target => this._documentClickListener(target));
        }else if(isActive === false && this._clickSub !== null){
          this._clickSub.unsubscribe();
        }

      }),
      // other observable logic
      ...
      ).subscribe();
}

toggle() {
    this._toggleMenuSubject$.next(!this.isActive);
}

private _documentClickListener(targetElement: HTMLElement): void {
    const clickedInside = this._elementRef.nativeElement.contains(targetElement);
    if (!clickedInside) {
      this._toggleMenuSubject$.next(false);
    }    
 }

ngOnDestroy(){
 this._toggleMenuSub.unsubscribe();
}

而在*.component.html文件中:


<button (click)="toggle()">Toggle the menu</button>


2
虽然我同意你的想法,但我建议不要把所有逻辑都塞在 tap 操作符中。相反,使用以下方式:skipWhile(() => !this.isActive), switchMap(() => this._utilitiesService.documentClickedTarget), filter((target) => !this._elementRef.nativeElement.contains(target)), tap(() => this._toggleMenuSubject$.next(false))这样可以更充分地利用 RxJs 并跳过一些订阅。 - Gizrah

2

与MVP相比,你只需要关注事件

@HostListener('focusout', ['$event'])
  protected onFocusOut(event: FocusEvent): void {
    console.log(
      'click away from component? :',
      event.currentTarget && event.relatedTarget
    );
  }

你所说的“MVP”是什么意思?是指“模型-视图-控制器”吗?还是其他什么东西? - Peter Mortensen

1

1

使用event.stopPropagation()的另一个可能的解决方案:

  1. 在最顶层的父组件上定义点击监听器,清除click-inside变量
  2. 在子组件上定义点击监听器,首先调用event.stopPropagation(),然后设置click-inside变量

1

解决方案

获取所有父级元素

var paths       = event['path'] as Array<any>;

检查任何父组件是否是该组件

var inComponent = false;    
paths.forEach(path => {
    if (path.tagName != undefined) {
        var tagName = path.tagName.toString().toLowerCase();
        if (tagName == 'app-component')
            inComponent = true;
    }
});

如果组件是父级,则在组件内单击。
if (inComponent) {
    console.log('clicked inside');
}else{
    console.log('clicked outside');
}

完整方法

@HostListener('document:click', ['$event'])
clickout(event: PointerEvent) {
    
    var paths       = event['path'] as Array<any>;
    
    var inComponent = false;    
    paths.forEach(path => {
        if (path.tagName != undefined) {
            var tagName = path.tagName.toString().toLowerCase();
            if (tagName == 'app-component')
                inComponent = true;
        }
    });
    
    if (inComponent) {
        console.log('clicked inside');
    }else{
        console.log('clicked outside');
    }
    
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接