详解使用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 学习之旅 (3)
Feb 05 Javascript
用JavaScript页面不刷新时全选择,全删除(GridView)
Apr 14 Javascript
in.js 一个轻量级的JavaScript颗粒化模块加载和依赖关系管理解决方案
Jul 26 Javascript
浅析JavaScript原型继承的陷阱
Dec 03 Javascript
jQuery实现网站添加高亮突出显示效果的方法
Jun 26 Javascript
Vue.js对象转换实例
Jun 07 Javascript
详解用vue.js和laravel实现微信支付
Jun 23 Javascript
ES6学习之变量的两种命名方法示例
Jul 18 Javascript
Vue子组件向父组件通信与父组件调用子组件中的方法
Jun 22 Javascript
js根据需要计算数组中重复出现某个元素的个数
Jan 18 Javascript
jQuery实现图片随机切换、抽奖功能(实例代码)
Oct 23 jQuery
vant时间控件使用方法详解
Dec 24 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
详解php的魔术方法__get()和__set()使用介绍
2012/09/19 PHP
解析如何修改phpmyadmin中的默认登陆超时时间
2013/06/25 PHP
php查询操作实现投票功能
2016/05/09 PHP
PHP简单字符串过滤方法示例
2016/09/04 PHP
php魔术方法功能与用法实例分析
2016/10/19 PHP
PHP截取发动短信内容的方法
2017/07/04 PHP
php微信开发之图片回复功能
2018/06/14 PHP
Laravel中validation验证 返回中文提示 全局设置的方法
2019/09/29 PHP
php设计模式之适配器模式实例分析【星际争霸游戏案例】
2020/04/07 PHP
javascript中的window.location.search方法简介
2013/09/02 Javascript
js时间日期格式化封装函数
2014/12/02 Javascript
纯JS实现本地图片预览的方法
2015/07/31 Javascript
jquery实现顶部向右伸缩的导航区域代码
2015/09/02 Javascript
JS分页的实现(同步与异步)
2017/09/16 Javascript
vue.js 实现评价五角星组件的实例代码
2018/08/13 Javascript
vue自定义指令的创建和使用方法实例分析
2018/12/04 Javascript
小程序如何获取多个formId实现详解
2019/09/20 Javascript
在Vue 中获取下拉框的文本及选项值操作
2020/08/13 Javascript
[00:32]2018DOTA2亚洲邀请赛Liquid出场
2018/04/03 DOTA
Python 代码性能优化技巧分享
2012/08/07 Python
Python 爬虫图片简单实现
2017/06/01 Python
python jieba分词并统计词频后输出结果到Excel和txt文档方法
2018/02/11 Python
深入解析python中的实例方法、类方法和静态方法
2019/03/11 Python
flask 实现上传图片并缩放作为头像的例子
2020/01/09 Python
Python3.6 中的pyinstaller安装和使用教程
2020/03/16 Python
Python变量及数据类型用法原理汇总
2020/08/06 Python
使用iframe+postMessage实现页面跨域通信的示例代码
2020/01/14 HTML / CSS
美国唇部护理专家:Sara Happ
2019/06/19 全球购物
主键(Primary Key)约束和唯一性(UNIQUE)约束的区别
2013/05/29 面试题
行政文员岗位职责
2013/11/08 职场文书
最经典的大学生职业生涯规划范文
2014/03/05 职场文书
应急处置方案
2014/06/16 职场文书
护士求职信
2014/07/05 职场文书
2014年惩防体系建设工作总结
2014/12/01 职场文书
沈阳故宫导游词
2015/01/31 职场文书
2016幼儿园毕业感言
2015/12/08 职场文书