详解使用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 相关文章推荐
javascript 有趣而诡异的数组
Apr 06 Javascript
JavaScript小技巧 2.5 则
Sep 12 Javascript
封装的原生javascript弹出层代码
Sep 24 Javascript
基于Jquery 解决Ajax请求的页面 浏览器后退前进功能,页面刷新功能实效问题
Dec 11 Javascript
js螺旋动画效果的具体实例
Nov 15 Javascript
JavaScript验证18位身份证号码最后一位正确性的实现代码
Aug 07 Javascript
jQuery将表单序列化成一个Object对象的实例
Nov 29 Javascript
基于cookie实现zTree树刷新后展开状态不变
Feb 28 Javascript
如何在AngularJs中调用第三方插件库
May 21 Javascript
利用原生的JavaScript实现简单拼图游戏
Nov 18 Javascript
Node.js中package.json中库的版本号(~和^)
Apr 02 Javascript
javascript实现贪吃蛇小游戏
Jul 28 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
《超神学院》霸气归来, 天使彦上演维多利亚的秘密
2020/03/02 国漫
PHP入门教程之上传文件实例详解
2016/09/11 PHP
基于 Swoole 的微信扫码登录功能实现代码
2018/01/15 PHP
PHP fopen函数用法实例讲解
2019/02/15 PHP
php常用日期时间函数实例小结
2019/07/04 PHP
js小技巧--自动隐藏红叉叉
2007/08/13 Javascript
javascript document.referrer 用法
2009/04/30 Javascript
jquery不会自动回收xmlHttpRequest对象 导致了内存溢出
2012/06/18 Javascript
js控制CSS样式属性语法对照表
2012/12/11 Javascript
由ReactJS的Hello world说开来
2015/07/02 Javascript
jQuery满意度星级评价插件特效代码分享
2015/08/19 Javascript
深入理解关于javascript中apply()和call()方法的区别
2016/04/12 Javascript
原生JS实现自定义滚动条效果
2020/10/27 Javascript
Vue.js在数组中插入重复数据的实现代码
2017/11/17 Javascript
使用Electron构建React+Webpack桌面应用的方法
2017/12/15 Javascript
JS实现随机点名器
2020/04/12 Javascript
vue实现购物车案例
2020/05/30 Javascript
[02:12]Dota 2 推出全新英雄—— 电炎绝手
2019/08/23 DOTA
python实现2014火车票查询代码分享
2014/01/10 Python
python写的一个squid访问日志分析的小程序
2014/09/17 Python
在Python中使用PIL模块处理图像的教程
2015/04/29 Python
Python中如何导入类示例详解
2019/04/17 Python
python做反被爬保护的方法
2019/07/01 Python
Python安装Bs4的多种方法
2020/11/28 Python
Python LMDB库的使用示例
2021/02/14 Python
CSS3 网页下拉菜单代码解释 中文翻译
2010/02/27 HTML / CSS
JD Sports法国:英国篮球和运动时尚的领导者
2017/09/28 全球购物
领导党的群众路线教育实践活动个人对照检查材料
2014/09/23 职场文书
关于运动会广播稿200字
2014/10/08 职场文书
幼儿园见习报告
2014/10/30 职场文书
2014小学教师年度考核工作总结
2014/12/03 职场文书
结婚幸福感言
2015/08/01 职场文书
筑梦中国心得体会
2016/01/18 职场文书
小学体育队列队形教学反思
2016/02/16 职场文书
《学会生存》读后感3篇
2019/12/09 职场文书
Python合并pdf文件的工具
2021/07/01 Python