模态框是一个常见的组件,下面让咱们使用 React 实现一个现代化的模态框吧。
模态框想必你们都很熟悉,是工做中经常使用的组件,可让咱们填写或展现一些信息而没必要打开一个新页面。在开始编码以前,咱们先来了解一个 React 模态框组件应该如何设计。
React 是一个状态(数据)驱动的前端框架,一个模态框最重要的状态就是打开和关闭,visible,当 visible 为 true 时,模态框打开,反之亦然。css
因为 React 所提倡的是一种声明式,组件化的开发体验,每一个组件都是 状态 => 界面 的映射,因此,咱们把 visible 作为模态框组件的一个 prop,经过传入 prop 来控制
模态框的显示和隐藏,同时该组件还接受一个 onClose 的 prop,用来关闭模态框。前端
<Modal visible={modalVisble} onClose={this.onModalClose} />
一个完整的模态框还须要标题和内容,所以,咱们还须要一个 header 的 prop 来传递模态框的 header,并把 Modal 组件的 children 做为模态框的内容 content。最后,咱们的模态框 Modal 的调用方式是这样的:node
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 等等,
但这里为了保持教程的简单,这些简单的配置就不一一实现了,若是感兴趣能够自行练习。react
肯定了咱们的模态框的调用方式,如今咱们来总结一下完整的模态框应该具有那些特性:git
上面分析玩模态框的功能后,让咱们先开始实现一版最基础的模态框。从 HTML 结构上来说,模态框组件分为 overlay 遮罩层和 content 内容两部分组成,其中 content 里面还应该分为 header, content, footer(这里咱们没有实现)三部分组成。
因此,模态框的最基本的结构以下github
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 的样式应该为chrome
.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 是 React 16 中的新功能,就像它的名称传送门同样,这个功能的做用就是将组件的 DOM 嗖的一下传送到另一个地方,换句话说就是可让你的组件渲染到其余地方,而不只仅是在父组件中。从上面的描述中,咱们知道 Portal 是一个做用于 DOM 的功能,因此 Portal 就在 react-dom 这个包下,react-dom 提供了 createPortal 方法来建立 Portal,它的第一参数是 React 组件,第二个参数则是接收这个组件的 DOM 节点。app
回到咱们的模态框来,为了方便的使用 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 关闭这个功能。通点击事件同样,咱们只须要监听 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 我会尽快修正。代码