从源码看angular/material2 中 dialog模块的实现方法


Posted in Javascript onOctober 18, 2017

本文将探讨material2中popup弹窗即其Dialog模块的实现。

使用方法

  1. 引入弹窗模块
  2. 自己准备作为模板的弹窗内容组件
  3. 在需要使用的组件内注入 MatDialog 服务
  4. 调用 open 方法创建弹窗,并支持传入配置、数据,以及对关闭事件的订阅

深入源码

进入material2的源码,先从 MatDialog 的代码入手,找到这个 open 方法:

open<T>(
 componentOrTemplateRef: ComponentType<T> | TemplateRef<T>,
 config?: MatDialogConfig
): MatDialogRef<T> {
 // 防止重复打开
 const inProgressDialog = this.openDialogs.find(dialog => dialog._isAnimating());
 if (inProgressDialog) {
  return inProgressDialog;
 }
 // 组合配置
 config = _applyConfigDefaults(config);
 // 防止id冲突
 if (config.id && this.getDialogById(config.id)) {
  throw Error(`Dialog with id "${config.id}" exists already. The dialog id must be unique.`);
 }
 // 第一步:创建弹出层
 const overlayRef = this._createOverlay(config);
 // 第二步:在弹出层上添加弹窗容器
 const dialogContainer = this._attachDialogContainer(overlayRef, config);
 // 第三步:把传入的组件添加到创建的弹出层中创建的弹窗容器中
 const dialogRef = this._attachDialogContent(componentOrTemplateRef, dialogContainer, overlayRef, config);
 // 首次弹窗要添加键盘监听
 if (!this.openDialogs.length) {
  document.addEventListener('keydown', this._boundKeydown);
 }
 // 添加进队列
 this.openDialogs.push(dialogRef);
 // 默认添加一个关闭的订阅 关闭时要移除此弹窗
 // 当是最后一个弹窗时触发全部关闭的订阅并移除键盘监听
 dialogRef.afterClosed().subscribe(() => this._removeOpenDialog(dialogRef));
 // 触发打开的订阅
 this.afterOpen.next(dialogRef);
 return dialogRef;
}

总体看来弹窗的发起分为三部曲:

  1. 创建一个弹出层(其实是一个原生DOM,起宿主和入口的作用)
  2. 在弹出层上创建弹窗容器组件(负责提供遮罩和弹出动画)
  3. 在弹窗容器中创建传入的弹窗内容组件(负责提供内容)

弹出层的创建

对于其他组件,仅仅封装模板以及内部实现就足够了,最多还要增加与父组件的数据、事件交互,所有这些事情,单使用angular Component就足够实现了,在何处使用就将组件选择器放到哪里去完事。

但对于弹窗组件,事先并不知道会在何处使用,因此不适合实现为一个组件后通过选择器安放到页面的某处,而应该将其作为弹窗插座放置到全局,并通过服务来调用。

material2也要面临这个问题,这个弹窗插座是避免不了的,那就在内部实现它,在实际调用弹窗方法时动态创建这个插座就可以了。要实现效果是:对用户来说只是在单纯调用一个 open 方法,由material2内部来创建一个弹出层,并在这个弹出层上创建弹窗。

找到弹出层的创建代码如下:

create(config: OverlayConfig = defaultConfig): OverlayRef {
 const pane = this._createPaneElement(); // 弹出层DOM 将被添加到宿主DOM中
 const portalHost = this._createPortalHost(pane); // 宿主DOM 将被添加到<body>末端
 return new OverlayRef(portalHost, pane, config, this._ngZone); // 弹出层的引用
}
private _createPaneElement(): HTMLElement {
 let pane = document.createElement('div');
 pane.id = `cdk-overlay-${nextUniqueId++}`;
 pane.classList.add('cdk-overlay-pane');
 this._overlayContainer.getContainerElement().appendChild(pane); // 将创建好的带id的弹出层添加到宿主
 return pane;
}
private _createPortalHost(pane: HTMLElement): DomPortalHost {
 // 创建宿主
 return new DomPortalHost(pane, this._componentFactoryResolver, this._appRef, this._injector);
}

其中最关键的方法其实是 getContainerElement() , material2把最"丑"最不angular的操作放在了这里面,看看其实现:

getContainerElement(): HTMLElement {
 if (!this._containerElement) { this._createContainer(); }
 return this._containerElement;
}
protected _createContainer(): void {
 let container = document.createElement('div');
 container.classList.add('cdk-overlay-container');
 document.body.appendChild(container); // 在body下创建顶层的宿主 姑且称之为弹出层容器(OverlayContainer)
 this._containerElement = container;
}

弹窗容器的创建

跳过其他细节,现在得到了一个弹出层引用 overlayRef。material2接下来给它添加了一个弹窗容器组件,这个组件是material2自己写的一个angular组件,打开弹窗时的遮罩部分以及弹窗的外轮廓其实就是这个组件,对于为何要再套这么一层容器,有其一些考虑。

动画效果的保护

这样动态创建的组件有一个缺点,那就是其销毁是无法触发angular动画的,因为一瞬间就销毁掉了,所以material2为了实现动画效果,多加了这么一个容器来实现动画,在关闭弹窗时,实际上是在播放弹窗的关闭动画,然后监听容器的动画状态事件,在完成关闭动画后才执行销毁弹窗的一系列代码,这个过程与其为难用户来实现,不如自己给封装了。

注入服务的保护

目前版本的angular关于在动态创建的组件中注入服务还存在一个注意点,就是直接创建出的组件无法使用隐式的依赖注入,也就是说,直接在组件的 constructor 中声明服务对象的实例是不起作用的,而必须先注入 Injector ,再使用这个 Injector 把注入的服务都 get 出来:

private 服务;

constructor(
 private injector: Injector
 // private 服务: 服务类 // 这样是无效的
) {
 this.服务 = injector.get('服务类名');
}

解决的办法是不直接创建出组件来注入服务,而是先创建一个指令,再在这个指令中创建组件并注入服务使用,这时隐式的依赖注入就又有效了,material2就是这么干的:

<ng-template cdkPortalHost></ng-template>

其中的 cdkPortalHost 指令就是用来后续创建组件的。

所以创建这么一个弹窗容器组件,用户就感觉不到这一点,很顺利的像普通组件一样注入服务并使用。

创建弹窗容器的核心方法在 dom-portal-host.ts 中:

attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
 // 创建工厂
 let componentFactory = this._componentFactoryResolver.resolveComponentFactory(portal.component);
 let componentRef: ComponentRef<T>;
 if (portal.viewContainerRef) {
  componentRef = portal.viewContainerRef.createComponent(
   componentFactory,
   portal.viewContainerRef.length,
   portal.injector || portal.viewContainerRef.parentInjector);
  this.setDisposeFn(() => componentRef.destroy());
  // 暂不知道为何有指定宿主后面还要把它添加到宿主元素DOM中
 } else {
  componentRef = componentFactory.create(portal.injector || this._defaultInjector);
  this._appRef.attachView(componentRef.hostView);
  this.setDisposeFn(() => {
  this._appRef.detachView(componentRef.hostView);
   componentRef.destroy();
  });
  // 到这一步创建出了经angular处理的DOM
 }
 // 将创建的弹窗容器组件直接append到弹出层DOM中
 this._hostDomElement.appendChild(this._getComponentRootNode(componentRef));
 // 返回组件的引用
 return componentRef;
}

所做的事情无非就是动态创建组件的四步曲:

  1. 创建工厂
  2. 使用工厂创建组件
  3. 将组件整合进AppRef(同时设置一个移除的方法)
  4. 在DOM中插入这个组件的原始节点

弹窗内容

从上文可以知道,得到的弹窗容器组件中存在一个宿主指令,实际上是在这个宿主指令中创建弹窗内容组件。进入宿主指令的代码可以找到 attachComponentPortal 方法:

attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
 portal.setAttachedHost(this);
 // If the portal specifies an origin, use that as the logical location of the component
 // in the application tree. Otherwise use the location of this PortalHost.
 // 如果入口已经有宿主则使用那个宿主
 // 否则使用 PortalHost 作为宿主
 let viewContainerRef = portal.viewContainerRef != null ?
  portal.viewContainerRef :
  this._viewContainerRef;
 // 在宿主上动态创建组件的代码
 let componentFactory = this._componentFactoryResolver.resolveComponentFactory(portal.component);
 let ref = viewContainerRef.createComponent( // 使用 ViewContainerRef 动态创建组件到当前视图容器(也就是弹窗容器指令)
  componentFactory, viewContainerRef.length,
  portal.injector || viewContainerRef.parentInjector
 );
 super.setDisposeFn(() => ref.destroy());
 this._portal = portal;
 return ref;
}

最后这一步就非常明了了,正是官方文档中使用的动态创建组件的方式(ViewContainerRef),至此弹窗已经成功弹出到界面中了。

弹窗的关闭

还有最后一个要注意的点就是弹窗如何关闭,从上文可以知道应该要先执行关闭动画,然后才能销毁弹窗,material2的弹窗容器组件添加了一堆节点:

host: {
 'class': 'mat-dialog-container',
 'tabindex': '-1',
 '[attr.role]': '_config?.role',
 '[attr.aria-labelledby]': '_ariaLabelledBy',
 '[attr.aria-describedby]': '_config?.ariaDescribedBy || null',
 '[@slideDialog]': '_state',
 '(@slideDialog.start)': '_onAnimationStart($event)',
 '(@slideDialog.done)': '_onAnimationDone($event)',
}

其中需要关注的就是material2在容器组件中添加了一个动画叫 slideDialog ,并为其设置了动画事件,现在关注动画完成事件的回调:

_onAnimationDone(event: AnimationEvent) {
  if (event.toState === 'enter') {
    this._trapFocus();
  } else if (event.toState === 'exit') {
    this._restoreFocus();
  }
  this._animationStateChanged.emit(event);
  this._isAnimating = false;
}

这里发射了这个事件,并在 MatDialogRef 中订阅:

constructor(
  private _overlayRef: OverlayRef,
  private _containerInstance: MatDialogContainer,
  public readonly id: string = 'mat-dialog-' + (uniqueId++)
) {
  // 添加弹窗开启的订阅 这里的 RxChain 是material2自己对rxjs的工具类封装
  RxChain.from(_containerInstance._animationStateChanged)
  .call(filter, event => event.phaseName === 'done' && event.toState === 'enter')
  .call(first)
  .subscribe(() => {
    this._afterOpen.next();
    this._afterOpen.complete();
  });
  // 添加弹窗关闭的订阅,并且需要在收到回调后销毁弹窗
  RxChain.from(_containerInstance._animationStateChanged)
  .call(filter, event => event.phaseName === 'done' && event.toState === 'exit')
  .call(first)
  .subscribe(() => {
    this._overlayRef.dispose();
    this._afterClosed.next(this._result);
    this._afterClosed.complete();
    this.componentInstance = null!;
  });
}
/**
* 这个也就是实际使用时的关闭方法
* 所做的事情是添加beforeClose的订阅并执行 _startExitAnimation 以开始关闭动画
* 底层做的事是 改变了弹窗容器中 slideDialog 的状态值
*/
close(dialogResult?: any): void {
  this._result = dialogResult; // 把传入的结果赋值给私有变量 _result 以便在上面的 this._afterClosed.next(this._result) 中使用
  // Transition the backdrop in parallel to the dialog.
  RxChain.from(this._containerInstance._animationStateChanged)
  .call(filter, event => event.phaseName === 'start')
  .call(first)
  .subscribe(() => {
    this._beforeClose.next(dialogResult);
    this._beforeClose.complete();
    this._overlayRef.detachBackdrop();
  });
  this._containerInstance._startExitAnimation();
}

总结

以上就是整个material2 dialog能力走通的过程,可见即使是 angular 这么完善又庞大的框架,想要完美解耦封装弹窗能力也不能完全避免原生DOM操作。

除此之外给我的感觉还有——无论是angular还是material2,它们对TypeScript的使用都让我自叹不如,包括但不限于抽象类、泛型等装逼技巧,把它们的源码慢慢看下来,着实能学到不少东西。

Javascript 相关文章推荐
jQuery CSS()方法改变现有的CSS样式表
Sep 09 Javascript
基于jQuery实现的文字按钮表单特效整理
Dec 07 Javascript
基于JS判断iframe是否加载成功的方法(多种浏览器)
May 13 Javascript
前端微信支付js代码
Jul 25 Javascript
又一枚精彩的弹幕效果jQuery实现
Jul 25 Javascript
js控制li的隐藏和显示实例代码
Oct 15 Javascript
JQueryEasyUI之DataGrid数据显示
Nov 23 Javascript
基于bootstrap写的一点localStorage本地储存
Nov 21 Javascript
微信小程序中吸底按钮适配iPhone X方案
Nov 29 Javascript
vue发送ajax请求详解
Oct 09 Javascript
es6函数之箭头函数用法实例详解
Apr 25 Javascript
vue项目中js-cookie的使用存储token操作
Nov 13 Javascript
详解http访问解析流程原理
Oct 18 #Javascript
js实现会跳动的日历效果(完整实例)
Oct 18 #Javascript
打字效果动画的4种实现方法(超简单)
Oct 18 #Javascript
Angularjs 手写日历的实现代码(不用插件)
Oct 18 #Javascript
基于JavaScript表单脚本(详解)
Oct 18 #Javascript
VUE饿了么树形控件添加增删改功能的示例代码
Oct 17 #Javascript
vue-router实现tab标签页(单页面)详解
Oct 17 #Javascript
You might like
php一个找二层目录的小东东
2012/08/02 PHP
PHP对表单提交特殊字符的过滤和处理方法汇总
2014/02/18 PHP
windows服务器中检测PHP SSL是否开启以及开启SSL的方法
2014/04/25 PHP
PHP采用自定义函数实现遍历目录下所有文件的方法
2014/08/19 PHP
Javascript 复制数组实现代码
2009/11/26 Javascript
javascript常用方法、属性集合及NodeList 和 HTMLCollection 的浏览器差异
2010/12/25 Javascript
Javascript的getYear、getFullYear、getUTCFullYear异同分享
2011/11/30 Javascript
js使用onmousemove和onmouseout获取鼠标坐标的方法
2015/03/31 Javascript
JavaScript对象参数的引用传递
2016/01/14 Javascript
Three.js快速入门教程
2016/09/09 Javascript
两种简单的跨域方法(jsonp、php)
2017/01/02 Javascript
jQuery实现的简单拖动层示例
2017/02/22 Javascript
使用vue.js写一个tab选项卡效果
2017/03/25 Javascript
强大的 Angular 表单验证功能详细介绍
2017/05/23 Javascript
详解Angular中通过$location获取地址栏的参数
2018/08/02 Javascript
js中自定义react数据验证组件实例详解
2018/10/19 Javascript
JavaScript实现Tab标签页切换的最简便方式(4种)
2020/06/28 Javascript
Javascript表单序列化原理及实现代码详解
2020/10/30 Javascript
在vue项目中promise解决回调地狱和并发请求的问题
2020/11/09 Javascript
[02:41]DOTA2英雄基础教程 亚巴顿
2014/01/02 DOTA
[18:32]DOTA2 HEROS教学视频教你分分钟做大人-谜团
2014/06/12 DOTA
python解析xml文件实例分析
2015/05/27 Python
Python验证文件是否可读写代码分享
2017/12/11 Python
Python实现对字典分别按键(key)和值(value)进行排序的方法分析
2018/12/19 Python
python批量下载抖音视频
2019/06/17 Python
用python3 返回鼠标位置的实现方法(带界面)
2019/07/05 Python
详解Python绘图Turtle库
2019/10/12 Python
Django的CVB实例详解
2020/02/10 Python
英国时尚高尔夫服装购物网站:Trendy Golf
2020/01/10 全球购物
C语言中break与continue的区别
2012/07/12 面试题
精彩自我鉴定
2014/01/16 职场文书
大学生英语演讲稿
2014/04/24 职场文书
社会实践活动总结范文
2014/07/03 职场文书
2014年团委工作总结
2014/11/13 职场文书
民事撤诉申请书范本
2015/05/18 职场文书
使用feign服务调用添加Header参数
2021/06/23 Java/Android