这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战。
前言
今天将会继续我们两周一个小组件的专题文章分享。本章要介绍的组件是 Dialog
, 相信各位 coder 在日常的开发中都接触过这类的组件。许多主流的组件库提供了使用方便的 Dialog
组件,所以在这篇文章中我们将会介绍如何写一个简易的基于 react
的 Dialog
组件。
本篇文章将会从构成一个 Dialog
组件的三个主要部分展开介绍,它们分别是 DOM
元素、事件处理、动画效果。
DOM元素
在我们常见的 Dialog
组件中,其 DOM
结构主要包含三个角色 wrapper
、 content
、mask
;
它们的 html
结构如下:
<!--mask-->
<Mask prefixCls={this.prefixCls} visible={mask && visible} />
<!--wrapper-->
<div
ref={this.wrapperRef}
role="dialog"
>
<!--content-->
<Content
...
>
{children}
</Content>
</div>
复制代码
mask
mask
层作为作为 Dialog
遮罩层,提供一个半透明的元素覆盖网页的可视区域,其主要作用只是提供更好的视觉效果,实际上该层元素在整个 Dialog
层级最低,并不存在任何实际作用(因此 mask
往往是 可选的)。 实现一个 mask
可以通过设置 css 简单完成;通过设置为 position: fixed
使其固定在视窗上;
&-mask {
position: fixed;
top: 0;
right: 0;
left: 0;
bottom: 0;
background-color: rgb(55, 55, 55);
background-color: rgba(55, 55, 55, 0.6);
height: 100%;
filter: alpha(opacity=50);
z-index: 1050;
}
复制代码
wrapper
wrapper
层往往是被忽略的一层,但是实现一个完整的 Dialog
却是至关重要的。它是承载 Dialog 内容主体 content
的元素,并且在后面将要介绍的事件机制中也发挥着作用。它同样采用 fixed
定位,并且跟 mask 保持相同的 z-index
, 但遵循 html
流,wrapper
层实际在 mask
层之上。
&-wrapper {
position: fixed;
overflow: auto;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1050;
-webkit-overflow-scrolling: touch;
outline: 0;
}
复制代码
content
content
层是 Dialog
组件的核心,负责渲染Dialog
内具体的内容,并且也是后续事件机制和动画效果的主体。content 层基础样式设置比较简单:
.@{prefixCls} {
position: relative;
width: auto;
margin: 10px;
}
复制代码
当 html
和 css
准备就绪后,需要把他们插入页面中,作为一个 Dialog
组件,往往是存在于 document
的外层,作为 body
标签的子元素。
使用 react-dom
的 API
ReactDOM.createPortal 只需要简单的几行代码就可以实现将节点渲染到指定的 DOM
节点。
ReactDOM.createPortal(dialogDom, this.el);
复制代码
不过在这之前我们需要先完成在 body
中创建一个子元素,将 className
设置为 dialog-root
class Dialog<P> extends React.Component<IDialog> {
...
constructor(props: IDialog) {
super(props);
this.el = document.createElement('div');
this.el.className = 'dialog-root';
}
componentDidMount() {
document.body.appendChild(this.el);
}
componentWillUnmount() {
document.body.removeChild(this.el);
}
...
}
复制代码
这样就完成了实现一个 Dialog
所需的 DOM
元素。
Dialog 居中展示
如果需要 dialog 居中的效果,只需要几行简单的 css
样式: 设置 wrapper 层 text-align: center
,并结合伪元素,应用 vertical-align: middle
,使 content
层居中:
&-wrapper--center {
text-align: center;
&::before {
display: inline-block;
width: 0;
height: 100%;
vertical-align: middle;
content: '';
}
}
&-wrapper-center .@{prefixCls} {
top: 0;
display: inline-block;
text-align: left;
vertical-align: middle;
}
复制代码
事件处理
事件处理是构成 Dialog
组件的核心,我们可以将这些事件根据它的作用大致分为两类:焦点管理和交互事件。
焦点管理
当在页面上呈现出 Dialog
, 往往意味着我们页面的 focus
元素发生变化,所以在整个 Dialog 相关的焦点管理中,我们希望实现三个功能:
- 当
Dialog
可见时,自动focus
到Dialog
上。 - 当
Dialog
隐藏时,焦点自动回到页面的上一个focus
元素上。 - 阻止通过
TAB
键切换到Dialog
之外的元素。
实现功能点1只需要在 Dialog
的 visible
属性变化为 true
时,调用 DOM
元素的 focus API
。
实现功能点2则需要我们在实现功能1之前,先保存上一个 focus
的元素。 利用 document.activeElement ,可以轻松获取当前页面的 active
元素。先在切换至 Dialog
之前保存起来,直到 Dialog
的 viable
为false,利用focus API
重新聚焦。
实现功能点3则需要借助两个空白占位元素,位于 content
层内,真实渲染内容的前后。
const emptyStyle = { width: 0, height: 0, overflow: 'hidden', outline: 'none' };
...
<div tabIndex={0} ref={emptyStartRef} style={emptyStyle} aria-hidden="true" />
{dialogContent}
<div tabIndex={0} ref={emptyEndRef} style={emptyStyle} aria-hidden="true" />
复制代码
有了这样两个元素,实现控制 TAB
键切换就会很简单。只需要当按下 TAB
键将元素聚焦到其中一个元素时,通过 focus API
将聚焦元素转移至另外一个。这样就可以使无论怎么按 TAB
键,焦点元素只会在两个占位元素之间切换。
const { activeElement } = document;
if (activeElement === emptyEndRef.current) {
emptyStartRef.current.focus();
} else if (activeElement === emptyStartRef.current) {
emptyEndRef.current.focus();
}
复制代码
实现以上三个功能的完整代码:
监听visible
变化,切换焦点(功能1和功能2):
const onVisibleChanged = (newVisible: boolean) => {
if (newVisible) {
lastOutSideActiveElementRef.current = document.activeElement as HTMLElement;
contentRef.current?.focus();
} else {
if (lastOutSideActiveElementRef.current) {
lastOutSideActiveElementRef.current.focus({ preventScroll: true });
lastOutSideActiveElementRef.current = null;
}
}
};
复制代码
监听 wapper
层的键盘 TAB
键,控制焦点(功能3):
function onWrapperKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
// // keep focus inside dialog
if (visible) {
// TAB健 的 keyCode === 9
if (e.keyCode === 9) {
const { activeElement } = document;
if (activeElement === emptyEndRef.current) {
emptyStartRef.current.focus();
} else if (activeElement === emptyStartRef.current) {
emptyEndRef.current.focus();
}
}
」
}
复制代码
交互事件
我们主要在 Dialog
上定义两个交互事件:
- 鼠标点击
Dialog
外的区域触发关闭。 - 键盘按键
ESC
触发关闭。
功能1需要监听 wrapper
层的点击事件,并执行 onDialogClose()
。不过需要注意的一点是,content
层位于 wrapper
层内,点击 content
层也会触发 wrapper
层的点击事件。这显然不符合我们的预期,因此需要额外处理,屏蔽 content
层点击事件的影响:
onWrapperClick = (e) => {
if (wrapperRef.current === e.target) {
onDialogClose(e);
}
};
复制代码
功能2监听 wrapper
层的 按键事件,判断是否为 ESC
:
function onWrapperKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
// ESC 键的keyCode === 27
if (e.keyCode === 27) {
e.stopPropagation();
onDialogClose(e);
return;
}
}
复制代码
这样就把 Dialog
中所需的事件处理完成了。
动画效果
动画效果是构成 Dialog
的灵魂,附加动画效果的 Dialog
能够在视觉呈现得更加完美,我们主要实现两个动画特效:
mask
层增加淡入淡出动画。content
层增加放大缩小动画。
实现动画效果需要用到 css3
的动画特性,animation
或 transition
。
mask
淡入淡出
mask
的动画设置,主要是在 mask
出现和隐藏时,增加透明度 opacity
从0到1和从1到0的过渡:
&-fade-enter&-fade-enter-active,&-fade-appear&-fade-appear-active {
animation-name: rcDialogFadeIn;
animation-play-state: running;
}
&-fade-leave&-fade-leave-active {
animation-name: rcDialogFadeOut;
animation-play-state: running;
}
@keyframes maskFadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes maskFadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
复制代码
content
放大缩小
content
层的动画相对复杂些,我们希望呈现的效果是在 Dialog
出现时,从点击触发 Dialog
的位置逐渐放大到最终位置; Dialog
隐藏时,又从最终位置逐渐缩小到初始位置。如下图:
content
层的放大缩小的实现,只需要简单的控制动画属性,通过 transform: scale(0,0)
到 transform: scale(1,1)
实现按比例从0到1,从1到0的放大缩小:
@keyframes dialogZoomIn {
0% {
opacity: 0;
transform: scale(0, 0);
}
100% {
opacity: 1;
transform: scale(1, 1);
}
}
@keyframes dialogZoomOut {
0% {
transform: scale(1, 1);
}
100% {
opacity: 0;
transform: scale(0, 0);
}
}
复制代码
而实现向某个位置的方向变化,首先需要获取点击事件的位置 position
的 x
值和 y
值。我们通过定义一个点击事件监听的类,获取到每次点击事件的位置,并设置鼠标位置信息的有效时间,默认为100ms(这样能够兼容非点击事件触发 Dialog
的情景,只保留放大缩小的效果)。
// 点击事件监听类
export class MonitorClickEvent {
constructor(state: IState) {
this.init();
// 设置鼠标位置信息的有效时间,默认为100ms
this.time = state.time || 100;
}
private time: number;
public mousePosition: { x: number; y: number } | null;
// 监听点击事件,获取点击事件的位置
getClickPosition = (e: MouseEvent) => {
// 设置点击事件的位置,超过有效时间后置为 null
this.mousePosition = {
x: e.pageX,
y: e.pageY,
};
setTimeout(() => {
this.mousePosition = null;
}, this.time);
};
init() {
document.documentElement.addEventListener('click', this.getClickPosition, true);
}
}
复制代码
获取到点击事件的位置后,我们需要结合 css3
的 transform-origin
属性,该属性可以控制元素变形的原点。
通过点击事件位置和 content
层本身的位置 top
和 left
,可以计算出两者的偏移量,并将偏移量赋值给transform-origin
属性。 这样我们的放大缩小的动画,就会向相应的位置偏移,呈现出我们想要的动画效果。
contentStyle.transformOrigin =
`${mousePosition.x - elementOffset.left}px
${mousePosition.y - elementOffset.top}px`
复制代码
以上便完成了 Dialog
所需的全部动画效果。
在真正实现过程中,为了使控制动画更加方便,还封装了 CSSTrasition
组件应用在其中。在之后的文章中会详细的介绍该组件,对文中使用的 css
动画也会进行更加深入详细的介绍。
总结
通过以上对构成 Dialog
组件的三个主要成分:DOM元素、事件处理、动画效果的详细介绍,以及贴出的部分主要代码,希望对你理解 Dialog
的实现原理以及动手自制简易的 Dialog
组件有所帮助,后续会有更多精彩的文章呈现,敬请期待吧!