Angular CDK Overlay 源码解析
开发组件时,浮层是一个很常见的需求,比如弹出式对话框、上下文菜单、通知等都需要使用浮层。
在开发 overlay 时,有这些问题需要考虑:
- 在指定位置动态创建元素
- 根据元素大小、页面边框和页面滚动、缩放等事件调整元素位置
- 控制键盘事件响应顺序
- 控制主页面的行为
Angular CDK 的 overlay 模块为这些问题提供了完备的解决方案:
Overlay
PositionStrategy
KeyboardDispatcher
ScrollStrategy
这一系列文章将带你阅读 Angular CDK 中 overlay 模块的代码,分析这些机制是如何工作的,组件开发者们又该如何利用该模块开发组件。
该系列文章分为两篇(暂定),第一篇文章介绍 overlay 的核心机制,第二篇文章介绍 overlay 模块提供的一些 directive,以及跟随元素改变位置的 ConnectedStrategy 机制。
例子
为了使大家更好地理解本文内容,我们先引入一个例子。这个例子来自 Angular Mateiral 的 BottomSheet 组件。
打开第一个 demo 里的 BottomSheet 组件,打开开发者工具,定位到相关元素(省略了部分无关内容,美化了格式):
<!-- container element -->
<div class="cdk-overlay-container">
<!-- backdrop element -->
<div
class="cdk-overlay-backdrop cdk-overlay-dark-backdrop cdk-overlay-backdrop-showing"
></div>
<!-- host element -->
<div
class="cdk-global-overlay-wrapper"
dir="ltr"
style="justify-content: center;align-items: flex-end;"
>
<!-- pane element -->
<div
id="cdk-overlay-0"
class="cdk-overlay-pane"
style="max-width: 100%;pointer-events: auto;position: static;margin-bottom: 0px;"
>
<div
tabindex="0"
class="cdk-visually-hidden cdk-focus-trap-anchor"
aria-hidden="true"
></div>
<!-- component content -->
<mat-bottom-sheet-container
aria-modal="true"
class="mat-bottom-sheet-container ng-tns-c23-3 ng-trigger ng-trigger-state ng-star-inserted mat-bottom-sheet-container-medium"
role="dialog"
tabindex="-1"
style="transform: translateY(0%);"
>
<bottom-sheet-overview-example-sheet>
</bottom-sheet-overview-example-sheet>
</mat-bottom-sheet-container>
<div
tabindex="0"
class="cdk-visually-hidden cdk-focus-trap-anchor"
aria-hidden="true"
></div>
</div>
</div>
</div>
我们继续。
目录结构
该模块代码的目录(局部)如下:
.
├── BUILD.bazel
├── _overlay.scss
├── fullscreen-overlay-container.ts
├── index.ts
├── keyboard // 处理键盘事件
├── overlay-config.ts
├── overlay-container.ts
├── overlay-directives.ts
├── overlay-module.ts
├── overlay-prebuilt.scss
├── overlay-ref.ts
├── overlay-reference.ts
├── overlay.ts
├── position // 处理浮层定位
├── public-api.ts
├── scroll // 处理文档的滚动
主要机制
OverlayContainer
OverlayContainer (opens in a new tab) 在 body 元素的最后创建了一个元素,用于包裹全部的浮层元素。之后我们会称该元素为 container element。
<div class="cdk-overlay-container"></div>
该元素会在 getContainerElement
方法第一次被调用的时候创建(惰性实例化)。
注意这个服务是全局的。
Overlay
Overlay (opens in a new tab) 是一个服务,通过它的 create (opens in a new tab) 方法可以创建一个新的浮层,这个过程中主要做了以下几件事:
- 创建一个 host element 和一个 pane element,然后将 pane element 作为
PortalOutlet
的挂载点,而这里PortalOutlet
的类型就是我在之前一篇文章中讲过的DomPortalOutlet
,它将会被用来挂在组件内容。 - 创建一个
OverlayConfig
对象,OverlayConfig
的构造方法仅仅是把 plain object 上面的非undefined
属性转移到新创建的OverlayConfig
对象上。 - 创建一个
OverlayRef
并返回,值得注意的是第一步中创建的PortalOutlet
会被传递给OverlayRef
的构造方法。
OverlayRef
类非常重要,它负责了浮层机制的绝大部分逻辑,并且是暴露给组件开发者操纵浮层的接口对象。
OverlayRef
OverlayRef (opens in a new tab) 的构造方法确定了该浮层的 scroll strategy 和 position strategy,这部分我们之后来谈。
组件开发者在新创建的浮层上添加组件时,应该调用 OverlayRef 的 attach 方法 (opens in a new tab),参数应该是一个 Portal
对象。这个方法做了如下几件事情:
- 将
Portal
attach 到DomPortalOutlet
上,这一步会动态创建组件开发者定义的内容 - 启用 position strategy
- 通过 _updateStackingOrder 方法 (opens in a new tab)更新 host element 在 container element 中的位置,最新创建的浮层应该在 DOM 树的最上方
- 通过 _updateElementSize 方法更新 pane element 元素的样式
- 启用 scroll strategy
- 在 Angular zone 稳定之后(一般是组件 DOM 已经创建)调整浮层的位置
- 打开浮层的鼠标事件支持
- 根据配置创建 backdrop(之后再讲)
- 根据配置修改 pane element 的 CSS 类
- 派发 attach 事件
- 将自己注册到
KeyboardDispatcher
中(之后再讲)
OverlayRef 类还有以下几个重要的方法:
- detach (opens in a new tab),卸载当前浮层添加的组件。
- dispose (opens in a new tab),销毁当前浮层。
篇幅所限,这里就不带读者们阅读了。
PositionStrategy
attach
方法的第二步是启用 position strategy,这里我们先来讲解比较简单的 GlobalPositionStrategy
,也是 BottomSheet 组件所使用的。
position strategy 就是定位策略,提供了一组定位浮层内元素的方法。
GlobalPositionStrategy (opens in a new tab) 实现了 PositionStrategy (opens in a new tab) 接口,用户也可以通过实现该接口自定义一个 position strategy。
attach 方法 (opens in a new tab)在浮层启用 position strategy 时被调用。对于 GlobalPositionStrategy
而言,主要是对 host element 增加了 cdk-global-overlay-wrapper
CSS 类。
.cdk-global-overlay-wrapper {
display: flex;
position: absolute;
z-index: 1000;
pointer-events: none;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
apply 方法 (opens in a new tab)在需要调整浮层元素位置时被调用。该方法通过修改 host element 和 pane element 的样式来控制浮层元素的位置。
还有如下方法比较重要:
dispose (opens in a new tab),在 position strategy 被销毁(比如跟随浮层被销毁,或者浮层切换了 position strategy)的时候做回复操作。
其他方法都是暴露出来修改定位的,这里就不 cover 了。
辅助机制
Backdrop
有些浮层需要有一个后置的全屏图层,来凸显浮层内容,同时作为 MouseEvent 的 target,支持“点击浮层外关闭”这样的功能。
Backdrop 由 _attachBackdrop
方法所创建,实质上是创建了这样一个元素
<div class="cdk-overlay-backdrop cdk-overlay-backdrop-showing"></div>
并把它插入到 host element 之前,保持图层叠加的顺序。
同时在浮层上绑定 (opens in a new tab)了一个 click 事件的 handler,通过此 handle 派发 _backdropClick
事件。
KeyboardDispatcher
KeyboardDispatcher (opens in a new tab) 负责将键盘事件分派给最近打开的浮层。
之前讲到浮层 attach 的时候会调用 KeyboardDispatcher 的 add 方法 (opens in a new tab),该方法会将调用此方法的 overlay 注册在 _attachedOverlays
数组的最后,且会第一个 overlay 注册的时候在 document 上绑定 keydown 事件的 handler,而该 handler (opens in a new tab) 会从数组尾部开始逆序查找监听了 keydown 事件的 overlay,并对它派发 keydown 事件。
KeyboardDispatcher
使得最近一个打开的 overlay 才能监听键盘事件,一种常见的使用场景就是支持按 esc 键时有序地关闭 overlay。
ScrollStrategy
scroll strategy 确定了在浮层展开时,原文档应当如何滚动。任意的 scroll strategy 都需要实现 ScrollStrategy (opens in a new tab) 接口。
我们以 CDK 提供的 CloseScrollStrategy (opens in a new tab) 为例,这种 strategy 会在页面内容滚动时关闭浮层。
在 OverlayRef
初始化时会调用 attach 方法 (opens in a new tab),而 Overlay 的 attach 方法会调用 enable 方法 (opens in a new tab),这个方法会监听全局滚动事件,并根据滚动范围和设置的门限调用 _detach 方法 (opens in a new tab),最终是调用 OverlayRef
的 detach 方法卸载浮层内容。
例子
下面以 BottomSheet 组件为例,看一下 overlay 是如何使用的。
用户用 open 方法 (opens in a new tab)创建一个新的 BottomSheet 组件,这个方法会通过 _createOverlay (opens in a new tab) 创建一个新的浮层,该方法的全部代码如下:
/**
* Creates a new overlay and places it in the correct location.
* @param config The user-specified bottom sheet config.
*/
private _createOverlay(config: MatBottomSheetConfig): OverlayRef {
const overlayConfig = new OverlayConfig({
direction: config.direction,
hasBackdrop: config.hasBackdrop,
disposeOnNavigation: config.closeOnNavigation,
maxWidth: '100%',
scrollStrategy: config.scrollStrategy || this._overlay.scrollStrategies.block(),
positionStrategy: this._overlay.position().global().centerHorizontally().bottom('0')
});
if (config.backdropClass) {
overlayConfig.backdropClass = config.backdropClass;
}
return this._overlay.create(overlayConfig);
}
可以看到默认使用的是 BlockScrollStrategy (opens in a new tab) 和 GlobalPositionStrategy (opens in a new tab)。
实际上是通过工厂类 OverlayPositionBuilder (opens in a new tab) 和 ScrollStrategyOptions (opens in a new tab) 创建的。
个人觉得这不是个好设计,会导致没用到的 Strategy 没法被 tree shake 掉。
然后 _attachContainer 方法 (opens in a new tab)就会将 BottomSheep 组件内容 attach 到 portal 上了。
const containerRef: ComponentRef<MatBottomSheetContainer> =
overlayRef.attach(containerPortal)