详解使用React制作一个模态框


Posted in Javascript onMarch 14, 2019

模态框是一个常见的组件,下面让我们使用 React 实现一个现代化的模态框吧。

组件设计

模态框想必大家都很熟悉,是工作中常用的组件,可以让我们填写或展示一些信息而不必打开一个新页面。在开始编码之前,我们先来了解一个 React 模态框组件应该如何设计。

React 是一个状态(数据)驱动的前端框架,一个模态框最重要的状态就是打开和关闭,visible,当 visible 为 true 时,模态框打开,反之亦然。

由于 React 所提倡的是一种声明式,组件化的开发体验,每个组件都是 状态 => 界面 的映射,所以,我们把 visible 做为模态框组件的一个 prop,通过传入 prop 来控制

模态框的显示和隐藏,同时该组件还接受一个 onClose 的 prop,用来关闭模态框。

<Modal visible={modalVisble} onClose={this.onModalClose} />

一个完整的模态框还需要标题和内容,因此,我们还需要一个 header 的 prop 来传递模态框的 header,并把 Modal 组件的 children 作为模态框的内容 content。最后,我们的模态框 Modal 的调用方式是这样的:

import React, { useEffect, useState } from 'react';
import Modal from './components/modal';

function App() {
 const [modalVisible, setModalVisible] = useState(true);
 const openModal = function() { setModalVisible(true) };
 const closeModal = function() { setModalVisible(false) };
 return (
  <>
   <button onClick={openModal}>Click</div>
   <Modal visible={modalVisible} onClose={closeModal} header="Create a modal">
    <p>This is my content</p>
   </Modal>
  </>
 );
}
export default App;

这里使用了 hooks,请升级到最新版本的 react 来体验。

实际上,一个完整的模态框组件还应该提供一些额外的配置来方便用户使用,比如 header 和 content 的自定义样式 headerClassName,contentClassName,定制操作按钮的 footer,控制是否显示关闭按钮的 showClose 等等,
但这里为了保持教程的简单,这些简单的配置就不一一实现了,如果感兴趣可以自行练习。

确定了我们的模态框的调用方式,现在我们来总结一下完整的模态框应该具备那些特性:

  1. 模态框组件应该挂载在 body 的第一层中,不要将模态框放置到父组件中,因为模态框放置到父组件中很容易受到其他元素的干扰。
  2. 模态框显示后,模态框背后的背景不能随着鼠标滚轮而滚动。
  3. 点击模态框的遮罩层后,应该关闭模态框。

基础功能

上面分析玩模态框的功能后,让我们先开始实现一版最基础的模态框。从 HTML 结构上来讲,模态框组件分为 overlay 遮罩层和 content 内容两部分组成,其中 content 里面还应该分为 header, content, footer(这里我们没有实现)三部分组成。
所以,模态框的最基本的结构如下

import React, { PureComponent } from 'react';
class Modal extends PureComponent {
 render() {
  const { visible, onClose, header, children } = this.props;
  return (
   <div className={`overlay ${visible ? 'visible' : ''}`}>
    <div className="content">
     <div className="header">
      {header}
      <button onClick={onClose}>Close</button>
     </div>
     <div className="content">{children}</div>
    </div>
   </div>
  );
 }
}

由于 overlay 元素是模态框组件的最外层的容器,所以我们可以通过控制 overlay 的显示和隐藏(在上面的基础结构中,通过 visible 属性的值来给 overlay 添加或删除类 'visible' 来控制 )实现模态框的打开关闭效果。在这里我们使用 display 实现控制 overlay 的显示和隐藏(这样在关闭时并没有删除该模态框,方便下次打开可以保存内容),同时 overlay 还是一个占据整个窗口的半透明暗色背景,所以 overlay 的样式应该为

.overlay {
 display: none;
 position: fixed;
 top: 0;
 right: 0;
 bottom: 0;
 right: 0;
 background: rgba(0, 0, 0, 0.3);
 visibility: hidden;
}
.overlay.visible {
 display: block;
 visibility: visible;
}

然后就是 content 中元素的样式,都很简单,大家看一下就好了,可以根据自己的组件规范修改这些样式。

.container {
 margin: 80px auto;
 width: 80%;
 min-height: 800px;
 background: #fff;
 border-radius: 4px;
}

.header {
 display: flex;
 justify-content: space-between;
 padding: 16px;
 font-size: 24px;
 border-bottom: 1px solid #d3d3d3;
}

.body {
 padding: 16px;
}

.closeBtn {
 outline: none;
 border: none;
 appearance: none;
 font-size: 18px;
 color: #d5d5d5;
 cursor: pointer;
}

这样,我们最基础的一版模态框就做好了,但是这个模态框是渲染在父组件中,那么如何才能将这个模态框放到 body 下,作为顶层元素呢?我们可以使用 Portal 这个 React 新提供的功能。

使用 portal 将模态框送到 body 中

Portal 是 React 16 中的新功能,就像它的名称传送门一样,这个功能的作用就是将组件的 DOM 嗖的一下传送到另外一个地方,换句话说就是可以让你的组件渲染到其他地方,而不仅仅是在父组件中。从上面的描述中,我们知道 Portal 是一个作用于 DOM 的功能,所以 Portal 就在 react-dom 这个包下,react-dom 提供了 createPortal 方法来创建 Portal,它的第一参数是 React 组件,第二个参数则是接收这个组件的 DOM 节点。

回到我们的模态框来,为了方便的使用 Portal,我们首先创建一个 ModalPortal 组件,该组件会首先使用 createElement 创建一个表示 overlay 的 div,并使用 appendChild 将此 div 插入到 body 的末尾中,然后在 render 中,使用 createPortal 将 ModalPortal 接受的所有子组件送入 overlay 这个 div 中。通过这种方式,我们就把模态框组件变成 body 中的顶层元素了。

由于 overlay 是手动创建的 DOM 元素,所以当 visible 发生变化时,我们需要使用 DOM API 来控制 overlay 的显示和隐藏,所以我们在 ModalPortal 组件的 componetDidMount 和 componetDidUpdate 两个生命周期中,根据 visible 的值来增删 overlay 的 visible 类控制 overlay 的显示/隐藏。

import React, { PureComponent } from 'react';
import { createPortal } from 'react-dom'
class ModalPortal extends PureComponent {
 constructor(props) {
  super(props);
  // createElement 是一个封装后的函数,方便在创建元素时添加属性
  this.node = createElement('div', {
   class: `modal-${random()} ${props.className}`,
  });
  document.body.appendChild(this.node);
 }

 componentDidMount() {
   this.checkIfVisible();
  }

 componentDidUpdate(prevProps) {
  if (prevProps.visible !== this.props.visible) {
   this.checkIfVisible();
  }
 }

 // 控制 overlay 的显示隐藏
 checkIfVisible = () => {
  const { visible } = this.props;
  if (visible) {
   this.node.classList.add(styles.visible);
  } else {
   this.node.classList.remove(styles.visible);
  }
 };


 render() {
  const { children } = this.props;
  return createPortal(children, this.node);
 }
}

class Modal extends PureComponent {
 ...
 render() {
  return (
   <ModalPortal className='overlay' overlay={overlay}>
    ...
   </ModalPortal>
  )
 }
}

阻止背景滚动

当我们完成上面的编码之后,我们的模态框就可以实现显示/隐藏,并且处于 body 的顶层,但是还有一个问题,那就是如果 body 内容太长出现滚动时,滚动鼠标就会发现,模态框后边的背景也在滚动,这显然不是我们希望的结果。如何应对这种情况呢?

解决办法很巧妙,就是在模态框打开时,我们给 body 添加一个 overflow: hidden 的样式让 body 不滚动,然后关闭模态框再去除这个属性。通过这样的方式,我们就是实现在模态框打开时背景不滚动的功能了。
明白来原理之后就开始修改代码了,我们首先在 constructor 中使用一个变量 savedBodyOverflow 来保持 body 原始的 overflow 值,然后修改 checkIfVisble 使之可以控制 overflow 类的增删。

class ModalPortal extends PureComponent {
 constructor(props) {
  ...
  this.savedBodyOverflow = document.body.style.overflow;
 }
 ...
 checkIfVisible = () => {
  const { visible } = this.props;
  if (visible) {
   this.node.classList.add(styles.visible);
   document.body.style.overflow = 'hidden';
  } else {
   this.node.classList.remove(styles.visible);
   document.body.style.overflow = this.saveBodyOverflow;
  }
 }
}

点击遮罩层关闭

点击遮罩层关闭,这个应该很容易实现,给 overlay 添加一个点击事件监听就好了,但是要注意一点就是,当你点击遮罩层中的 content 时,不应当关闭。我们先回顾一下 DOM2 事件模型中的规定的事件流,事件从 window 开始,执行捕获过程,然后到目标阶段,接着执行冒泡过程,回到 window,这个流程就导致我们如果点击了 content,overlay 同样也会触发点击事件(DOM 2 默认冒泡阶段触发事件)。针对这种情况,我们可以使用事件中提供的 path 属性,该属性描述了事件冒泡过程中从目标元素的 window 的一个路径,所以通过 path 的第一个参数,我们就可以判断这个 click 是哪个元素触发的了。

在我们的 modal 中,如果要实现点击遮罩层关闭,我们可以监听 overlay 元素的点击事件,然后通过 path 属性判断事件是否是 overlay 触发的,是否应该关闭模态框。因为 overlay 的 div 使我们自己生产的所以在 constructor 过程中就可以绑定事件了,注意在 componentWillUnMount 中要记得清除绑定,为了关闭模态框,别忘记将 onClose 通过 props 传递给 ModalPortal 组件。

class ModalPortal extends PureComponent {
 constructor(props) {
  ...
  this.node.addEventListener('click', this.handleClick);
 }

 componentWillUnmount() {
  this.node.removeEventListener('click', this.handleClick);
 }

 handleClick = e => {
  const { closeModal } = this.props;
  const target = e.path[0];
  if (target === this.node) {
   onClose();
  }
 };
 ...
}

按下 ESC 关闭

上面我们实现了点击遮罩层关闭模态框,然后我们应该实现按下 ESC 关闭这个功能。通点击事件一样,我们只需要监听 keydown 事件就可以了,这一次不用考虑到底是哪里触发的问题了,只要 overlay 监听到 keydown 就关闭模态框。但是这里也有一个小问题,就是 overlay 是 div,默认是监听不到 keydown 事件的,对于这个问题,我们可以给 div 添加一个 tabIndex: 0 的属性,通过指定 tabIndex,将 div 赋予 focusable 的能力,当模态框打开后,我们手动调用 focus 将焦点放到 overlay 上,这样就能监听到键盘事件。

const ESC_KEY = 27;

class ModalPortal extends PureComponent {
 constructor(props) {
  ...
  this.node = createElement('div', {
   class: `modal-${random()} ${props.className}`,
   tabIndex: 0,
  });
  this.node.addEventListener('keydown', this.handleKeyDown);
 }

 componentWillUnmount() {
  ...
   this.node.removeEventListener('keydown', this.handleKeyDown);
 }

 checkIfVisible = () => {
  const { visible } = this.props;
  if (visible) {
   ...
   this.node.focus();
  } else {
   ...
  }
 };

 handleKeyDown = e => {
  const { closeModal } = this.props;
  if (e.keyCode === ESC_KEY) {
   closeModal();
  }
 };
 ...
}

消除滚动条导致的页面抖动

在上面的防止遮罩层后面背景滚动是通过在 body 上设置 overflow: hidden 来防止滚动,但是如果 body 已经有了滚动条,那么 overflow 属性会造成滚动条消失。滚动条在 chrome 上为 15px,打开和关闭模态框会使页面不停地对这 15px 做处理,导则页面抖动。为了防止抖动,我们可以在滚动条消失后给 body 添加 15px 的右边距,滚动条出现后在删除右边距,通过这样的方法,页面就不会发生抖动了。

因为各个浏览器的标准不一致,所以我们应该想办法计算出滚动条的宽度。为了计算出滚动条的宽度,我们可以使用 innerWidth 和 offsetWidth 这两个属性。offsetWidth 是包含边框的长度,理所当然的包含了滚动条的宽度,只需要使用 offsetWidth 减去 innerWidth,得到的差值就是滚动条的宽度了。我们可以手动创建一个隐藏的有宽度的且有滚动条的元素,然后通过这个元素来获取滚动条的宽度。

const calcScrollBarWidth = function() {
 const testNode = createElement('div', {
  style: 'visibility: hidden; position: absolute; width: 100px; height: 100px; z-index: -999; overflow: scroll;'
 });
 document.body.appendChild(testNode);
 const scrollBarWidth = testNode.offsetWidth - testNode.clientWidth;
 document.body.removeChild(testNode);
 return scrollBarWidth;
};

const preventJitter = function() {
 const scrollBarWidth = calcScrollBarWidth();
 if (parseInt(document.documentElement.style.marginRight) === scrollBarWidth) {
  document.documentElement.style.marginRight = 0;
 } else {
  document.documentElement.style.marginRight = scrollBarWidth + 'px';
 }
};

结语

我们上面讨论了做好一个模态框所需要考虑的技术,但是肯定还有不完善和错误的地方,所以,如果错误的地方请给我提 issue 我会尽快修正。代码

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
收集的一些Array及String原型对象的扩展实现代码
Dec 05 Javascript
浅析JavaScript的几种Math函数,random(),ceil(),round(),floor()
Dec 22 Javascript
jQuery加密密码到cookie的实现代码
Apr 18 jQuery
Form表单上传文件(type=&quot;file&quot;)的使用
Aug 03 Javascript
vue 本地服务不能被外部IP访问的完美解决方法
Oct 29 Javascript
微信小程序使用npm支持踩坑
Nov 07 Javascript
js array数组对象操作方法汇总
Mar 18 Javascript
vue实现移动端轻量日期组件不依赖第三方库的方法
Apr 28 Javascript
ckeditor一键排版功能实现方法分析
Feb 06 Javascript
基于JS实现计算24点算法代码实例解析
Jul 23 Javascript
JS数组转字符串实现方法解析
Sep 04 Javascript
微信小程序实现锚点跳转
Nov 23 Javascript
JavaScript碎片—函数闭包(模拟面向对象)
Mar 13 #Javascript
详解js动态获取浏览器或页面等容器的宽高
Mar 13 #Javascript
详解jQuery-each()方法
Mar 13 #jQuery
详解使用Nuxt.js快速搭建服务端渲染(SSR)应用
Mar 13 #Javascript
react同构实践之实现自己的同构模板
Mar 13 #Javascript
使用Node.js实现一个多人游戏服务器引擎
Mar 13 #Javascript
你可能不知道的CORS跨域资源共享
Mar 13 #Javascript
You might like
phpmyadmin中禁止外网使用的方法
2014/11/04 PHP
php身份证号码检查类实例
2015/06/18 PHP
php数组函数array_push()、array_pop()及array_shift()简单用法示例
2020/01/26 PHP
基于jquery的合并table相同单元格的插件(精简版)
2011/04/05 Javascript
JS实现让访问者自助选择网页文字颜色的方法
2015/02/24 Javascript
简单分析javascript面向对象与原型
2015/05/21 Javascript
jQuery实现鼠标经过事件的延时处理效果
2020/08/20 Javascript
jQuery向父辈遍历的简单方法
2016/09/18 Javascript
Bootstrap Table的使用总结
2016/10/08 Javascript
Node.js操作redis实现添加查询功能
2017/05/25 Javascript
jQuery.form.js的使用详解
2017/06/14 jQuery
js中apply与call简单用法详解
2017/11/06 Javascript
详解webpack编译多页面vue项目的配置问题
2017/12/11 Javascript
Vue官网todoMVC示例代码
2018/01/29 Javascript
electron demo项目npm install安装失败的解决方法
2018/02/06 Javascript
微信小程序 组件的外部样式externalClasses使用详解
2019/09/06 Javascript
[01:03:00]DOTA2上海特级锦标赛A组败者赛 EHOME VS CDEC第一局
2016/02/25 DOTA
python局域网ip扫描示例分享
2014/04/03 Python
Python数据结构之顺序表的实现代码示例
2017/11/15 Python
简述Python2与Python3的不同点
2018/01/21 Python
python3模块smtplib实现发送邮件功能
2018/05/22 Python
在Pandas中DataFrame数据合并,连接(concat,merge,join)的实例
2019/01/29 Python
python爬虫豆瓣网的模拟登录实现
2019/08/21 Python
解决pycharm最左侧Tool Buttons显示不全的问题
2019/12/17 Python
Python输出指定字符串的方法
2020/02/06 Python
赫里福德的一家乡村零售商店:Philip Morris & Son
2017/06/25 全球购物
澳大利亚领先的美容护肤品零售商之一:SkincareStore
2018/01/22 全球购物
英国顶尖手表珠宝品牌独家授权经销商:HS Johnson
2020/10/28 全球购物
乔迁宴答谢词
2014/01/21 职场文书
土木工程师职业规划范文
2014/03/07 职场文书
高中生国庆节演讲稿范文2014
2014/09/21 职场文书
省级三好学生主要事迹材料
2015/11/03 职场文书
小型企业的绩效考核制度模板
2019/11/21 职场文书
python 机器学习的标准化、归一化、正则化、离散化和白化
2021/04/16 Python
Nginx流量拷贝ngx_http_mirror_module模块使用方法详解
2022/04/07 Servers
Ubuntu Server 安装Tomcat并配置systemctl
2022/04/28 Servers