从源码看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 相关文章推荐
js玩一玩WSH吧
Feb 23 Javascript
cloudgamer出品ImageZoom 图片放大效果
Apr 01 Javascript
解决iframe的frameborder在chrome/ff/ie下的差异
Aug 12 Javascript
超越Jquery_01_isPlainObject分析与重构
Oct 20 Javascript
JS动态添加Table的TR,TD实现方法
Jan 28 Javascript
JS实现跟随鼠标闪烁转动色块的方法
Feb 26 Javascript
jquery PrintArea 实现票据的套打功能(代码)
Mar 17 Javascript
微信扫码支付零云插件版实例详解
Apr 26 Javascript
微信小程序实现单选选项卡切换效果
Jun 19 Javascript
微信小程序的tab选项卡的实现效果
May 15 Javascript
ES6顶层对象、global对象实例分析
Jun 14 Javascript
Vue+elementUI实现多图片上传与回显功能(含回显后继续上传或删除)
Mar 23 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
一个ORACLE分页程序,挺实用的.
2006/10/09 PHP
PHP 工厂模式使用方法
2010/05/18 PHP
IIS+fastcgi下PHP运行超时问题的解决办法详解
2013/06/20 PHP
php按字符无乱码截取中文的方法
2015/03/27 PHP
php生成随机数/生成随机字符串的方法小结【5种方法】
2020/05/27 PHP
JavaScript 编程引入命名空间的方法与代码
2007/08/13 Javascript
JavaScript浏览器选项卡效果
2010/08/25 Javascript
JQuery.closest(),parent(),parents()寻找父结点
2012/02/17 Javascript
javascript parseInt() 函数的进制转换注意细节
2013/01/08 Javascript
js动态拼接正则表达式的两种方法
2014/03/04 Javascript
jquery ajax局部加载方法详解(实现代码)
2016/05/12 Javascript
使用Script元素发送JSONP请求的方法
2016/06/12 Javascript
jsonp跨域请求实现示例
2017/03/13 Javascript
vue.js项目中实用的小技巧汇总
2017/11/29 Javascript
JS基于封装函数实现的表格分页完整示例
2018/06/26 Javascript
webpack3里使用uglifyjs压缩js时打包报错的解决
2018/12/13 Javascript
JavaScript之解构赋值的理解
2019/01/30 Javascript
JavaScript数组常用的增删改查与其他属性详解
2020/10/13 Javascript
[48:11]完美世界DOTA2联赛 Magma vs GXR 第二场 11.07
2020/11/10 DOTA
python paramiko实现ssh远程访问的方法
2013/12/03 Python
浅谈python中的实例方法、类方法和静态方法
2017/02/17 Python
在PyCharm导航区中打开多个Project的关闭方法
2019/01/17 Python
通过python爬虫赚钱的方法
2019/01/29 Python
python验证码图片处理(二值化)
2019/11/01 Python
jupyter notebook 调用环境中的Keras或者pytorch教程
2020/04/14 Python
Python填充任意颜色,不同算法时间差异分析说明
2020/05/16 Python
香港通票:Hong Kong Pass
2019/02/26 全球购物
什么是属性访问器
2015/10/26 面试题
一个J2EE项目团队的主要人员组成是什么
2012/06/04 面试题
2014年五一促销活动方案
2014/03/09 职场文书
校长寄语大全
2014/04/09 职场文书
大学生活动总结怎么写
2014/04/29 职场文书
2016继续教育研修日志
2015/11/13 职场文书
职工的安全责任书范文!
2019/07/02 职场文书
500字作文之周记
2019/12/13 职场文书
pyqt5蒙版遮罩mask,setmask的使用
2021/06/11 Python