经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » JS/JS库/框架 » React » 查看文章
详解使用React制作一个模态框
来源:jb51  时间:2019/3/14 11:21:19  对本文有异议

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

组件设计

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

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

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

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

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

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

  1. import React, { useEffect, useState } from 'react';
  2. import Modal from './components/modal';
  3.  
  4. function App() {
  5.  const [modalVisible, setModalVisible] = useState(true);
  6.  const openModal = function() { setModalVisible(true) };
  7.  const closeModal = function() { setModalVisible(false) };
  8.  return (
  9.   <>
  10.    <button onClick={openModal}>Click</div>
  11.    <Modal visible={modalVisible} onClose={closeModal} header="Create a modal">
  12.     <p>This is my content</p>
  13.    </Modal>
  14.   </>
  15.  );
  16. }
  17. export default App;

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

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

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

  1. 模态框组件应该挂载在 body 的第一层中,不要将模态框放置到父组件中,因为模态框放置到父组件中很容易受到其他元素的干扰。

  2. 模态框显示后,模态框背后的背景不能随着鼠标滚轮而滚动。

  3. 点击模态框的遮罩层后,应该关闭模态框。

基础功能

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

  1. import React, { PureComponent } from 'react';
  2. class Modal extends PureComponent {
  3.  render() {
  4.   const { visible, onClose, header, children } = this.props;
  5.   return (
  6.    <div className={`overlay ${visible ? 'visible' : ''}`}>
  7.     <div className="content">
  8.      <div className="header">
  9.       {header}
  10.       <button onClick={onClose}>Close</button>
  11.      </div>
  12.      <div className="content">{children}</div>
  13.     </div>
  14.    </div>
  15.   );
  16.  }
  17. }

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

  1. .overlay {
  2.  display: none;
  3.  position: fixed;
  4.  top: 0;
  5.  right: 0;
  6.  bottom: 0;
  7.  right: 0;
  8.  background: rgba(0, 0, 0, 0.3);
  9.  visibility: hidden;
  10. }
  11. .overlay.visible {
  12.  display: block;
  13.  visibility: visible;
  14. }

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

  1. .container {
  2.  margin: 80px auto;
  3.  width: 80%;
  4.  min-height: 800px;
  5.  background: #fff;
  6.  border-radius: 4px;
  7. }
  8.  
  9. .header {
  10.  display: flex;
  11.  justify-content: space-between;
  12.  padding: 16px;
  13.  font-size: 24px;
  14.  border-bottom: 1px solid #d3d3d3;
  15. }
  16.  
  17. .body {
  18.  padding: 16px;
  19. }
  20.  
  21. .closeBtn {
  22.  outline: none;
  23.  border: none;
  24.  appearance: none;
  25.  font-size: 18px;
  26.  color: #d5d5d5;
  27.  cursor: pointer;
  28. }

这样,我们最基础的一版模态框就做好了,但是这个模态框是渲染在父组件中,那么如何才能将这个模态框放到 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 的显示/隐藏。

  1. import React, { PureComponent } from 'react';
  2. import { createPortal } from 'react-dom'
  3. class ModalPortal extends PureComponent {
  4.  constructor(props) {
  5.   super(props);
  6.   // createElement 是一个封装后的函数,方便在创建元素时添加属性
  7.   this.node = createElement('div', {
  8.    class: `modal-${random()} ${props.className}`,
  9.   });
  10.   document.body.appendChild(this.node);
  11.  }
  12.  
  13.  componentDidMount() {
  14.    this.checkIfVisible();
  15.   }
  16.  
  17.  componentDidUpdate(prevProps) {
  18.   if (prevProps.visible !== this.props.visible) {
  19.    this.checkIfVisible();
  20.   }
  21.  }
  22.  
  23.  // 控制 overlay 的显示隐藏
  24.  checkIfVisible = () => {
  25.   const { visible } = this.props;
  26.   if (visible) {
  27.    this.node.classList.add(styles.visible);
  28.   } else {
  29.    this.node.classList.remove(styles.visible);
  30.   }
  31.  };
  32.  
  33.  
  34.  render() {
  35.   const { children } = this.props;
  36.   return createPortal(children, this.node);
  37.  }
  38. }
  39.  
  40. class Modal extends PureComponent {
  41.  ...
  42.  render() {
  43.   return (
  44.    <ModalPortal className='overlay' overlay={overlay}>
  45.     ...
  46.    </ModalPortal>
  47.   )
  48.  }
  49. }

阻止背景滚动

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

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

  1. class ModalPortal extends PureComponent {
  2.  constructor(props) {
  3.   ...
  4.   this.savedBodyOverflow = document.body.style.overflow;
  5.  }
  6.  ...
  7.  checkIfVisible = () => {
  8.   const { visible } = this.props;
  9.   if (visible) {
  10.    this.node.classList.add(styles.visible);
  11.    document.body.style.overflow = 'hidden';
  12.   } else {
  13.    this.node.classList.remove(styles.visible);
  14.    document.body.style.overflow = this.saveBodyOverflow;
  15.   }
  16.  }
  17. }

点击遮罩层关闭

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

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

  1. class ModalPortal extends PureComponent {
  2.  constructor(props) {
  3.   ...
  4.   this.node.addEventListener('click', this.handleClick);
  5.  }
  6.  
  7.  componentWillUnmount() {
  8.   this.node.removeEventListener('click', this.handleClick);
  9.  }
  10.  
  11.  handleClick = e => {
  12.   const { closeModal } = this.props;
  13.   const target = e.path[0];
  14.   if (target === this.node) {
  15.    onClose();
  16.   }
  17.  };
  18.  ...
  19. }

按下 ESC 关闭

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

  1. const ESC_KEY = 27;
  2.  
  3. class ModalPortal extends PureComponent {
  4.  constructor(props) {
  5.   ...
  6.   this.node = createElement('div', {
  7.    class: `modal-${random()} ${props.className}`,
  8.    tabIndex: 0,
  9.   });
  10.   this.node.addEventListener('keydown', this.handleKeyDown);
  11.  }
  12.  
  13.  componentWillUnmount() {
  14.   ...
  15.    this.node.removeEventListener('keydown', this.handleKeyDown);
  16.  }
  17.  
  18.  checkIfVisible = () => {
  19.   const { visible } = this.props;
  20.   if (visible) {
  21.    ...
  22.    this.node.focus();
  23.   } else {
  24.    ...
  25.   }
  26.  };
  27.  
  28.  handleKeyDown = e => {
  29.   const { closeModal } = this.props;
  30.   if (e.keyCode === ESC_KEY) {
  31.    closeModal();
  32.   }
  33.  };
  34.  ...
  35. }

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

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

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

  1. const calcScrollBarWidth = function() {
  2.  const testNode = createElement('div', {
  3.   style: 'visibility: hidden; position: absolute; width: 100px; height: 100px; z-index: -999; overflow: scroll;'
  4.  });
  5.  document.body.appendChild(testNode);
  6.  const scrollBarWidth = testNode.offsetWidth - testNode.clientWidth;
  7.  document.body.removeChild(testNode);
  8.  return scrollBarWidth;
  9. };
  10.  
  11. const preventJitter = function() {
  12.  const scrollBarWidth = calcScrollBarWidth();
  13.  if (parseInt(document.documentElement.style.marginRight) === scrollBarWidth) {
  14.   document.documentElement.style.marginRight = 0;
  15.  } else {
  16.   document.documentElement.style.marginRight = scrollBarWidth + 'px';
  17.  }
  18. };

结语

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

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

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号