手摸手实现一个轻量级可扩展的模态框(Modal)组件

简介: 本文是笔者写组件设计的第九篇文章, 今天带大家实现一个轻量级且可灵活配置组合的模态框(Modal)组件, 该组件在诸如Antd或者elementUI等第三方组件库中都会出现,主要用来提供系统的用户反馈.


前言


本文是笔者写组件设计的第九篇文章, 今天带大家实现一个轻量级且可灵活配置组合的模态框(Modal)组件, 该组件在诸如Antd或者elementUI等第三方组件库中都会出现,主要用来提供系统的用户反馈.


之所以会写组件设计相关的文章,是因为作为一名前端优秀的前端工程师,面对各种繁琐而重复的工作,我们不应该按部就班的去"辛勤劳动",而是要根据已有前端的开发经验,总结出一套自己的高效开发的方法.


[笔记]前端组件的一般分类:



  • 通用型组件: 比如Button, Icon等.
  • 布局型组件: 比如Grid, Layout布局等.
  • 导航型组件: 比如面包屑Breadcrumb, 下拉菜单Dropdown, 菜单Menu等.
  • 数据录入型组件: 比如form表单, Switch开关, Upload文件上传等.
  • 数据展示型组件: 比如Avator头像, Table表格, List列表等.
  • 反馈型组件: 比如Progress进度条, Drawer抽屉, Modal对话框等.
  • 其他业务类型


所以我们在设计组件系统的时候可以参考如上分类去设计,该分类也是antd, element, zend等主流UI库的分类方式.


正文


在开始组件设计之前希望大家对css3和js有一定的基础,并了解基本的react/vue语法.我们先来解构一下Modal组件, 一个Modal分为以下几个部分:



每一个区块都可以自定义配置, 也可以组合其他组件. 实现后的组件效果:



1. 组件设计思路



按照之前笔者总结的组件设计原则,我们第一步是要确认需求. 模态框(Modal)组件一般会有如下需求点:


  • 能控制Modal主体的样式
  • 提供Modal完全关闭后的回调
  • 能控制取消按钮文字和样式
  • 能控制确认按钮文字和样式
  • 控制modal展示的位置
  • 控制是否显示右上角的关闭按钮
  • 可以配置自定义关闭图标
  • 配置关闭时是否销毁Modal里的子元素
  • 自定义模态框底部内容
  • 控制是否支持键盘esc关闭
  • 控制是否展示遮罩
  • 控制点击蒙层是否允许关闭
  • 自定义遮罩样式
  • 自定义标题
  • 控制对话框是否可见
  • 自定义对话框宽度
  • 暴露点击遮罩层或右上角叉或取消按钮的回调
  • 提供点击确定回调


需求收集好之后,作为一个有追求的程序员, 会得出如下线框图:



对于react选手来说,如果没用typescript,建议大家都用PropTypes, 它是react内置的类型检测工具,我们可以直接在项目中导入. vue有自带的属性检测方式,这里就不一一介绍了.


2. 基于react实现一个Modal组件



2.1. Modal组件框架设计


首先我们先根据需求将组件框架写好,这样后面写业务逻辑会更清晰:


import PropTypes from 'prop-types'
import './index.less'
/**
 * Modal Modal组件
 * @param {afterClose} func Modal完全关闭后的回调
 * @param {bodyStyle} object Modal body的样式
 * @param {cancelText} string|ReactNode 取消按钮文字
 * @param {centered} bool 居中展示Modal
 * @param {closable} bool 是否展示右上角的关闭按钮
 * @param {closeIcon} ReactNode 自定义关闭图标
 * @param {destroyOnClose} bool 关闭时销毁Modal里的子元素
 * @param {footer} null|ReactNode 底部内容,当不需要底部默认按钮时,可以设置为footer={null}
 * @param {keyboard} bool 是否支持键盘的esc键退出
 * @param {mask} bool 是否展示遮罩
 * @param {maskclosable} bool 点击蒙层是否允许关闭
 * @param {maskStyle} object 遮罩样式
 * @param {okText} string|ReactNode 确认按钮的文本
 * @param {title} string|ReactNode 标题内容
 * @param {visible} bool Modal是否可见
 * @param {width} string Modal宽度
 * @param {onCancel} func 点击遮罩或者取消按钮,或者键盘esc按键时的回调
 * @param {onOk} func 点击确定的回调
 */
function Modal(props) {
  const {
    afterClose,
    bodyStyle,
    cancelText,
    centered,
    closable,
    closeIcon,
    destroyOnClose,
    footer,
    keyboard,
    mask,
    maskclosable,
    maskStyle,
    okText,
    title,
    visible,
    width,
    onCancel,
    onOk
  } = props
  return <div className="xModalWrap">
    <div className="xModalContent">
      <div className="xModalHeader">
      </div>
      <div className="xModalBody">
      </div>
      <div className="xModalFooter">
      </div>
    </div>
    <div className="xModalMask"></div>
  </div>
}
export default Modal

有了这个框架,我们来一步步往里面实现内容吧.


2.2 实现基础配置功能


基础配置功能往往和业务逻辑无关, 仅仅用来控制元素的显示隐藏等,由于其非常容易实现,所以我们先来实现以下这些属性的功能:


  • bodyStyle
  • cancelText
  • closable
  • closeIcon
  • footer
  • mask
  • maskStyle
  • okText
  • title
  • width


这几个功能在框架搭建好之后已经部分实现了,是因为他们都比较简单,不会牵扯到其他复杂逻辑.只需要对外暴露属性并使用属性即可. 具体实现如下:


// ...
function Modal(props) {
  // ...
  return <div className="xModalWrap">
    <div 
      className="xModalContent"
      style={{
        width
      }}
    >
      <div className="xModalHeader">
        <div className="xModalTitle">
          { title }
        </div>
      </div>
      {
        closable && 
        <span className="xModalCloseBtn">
          { closeIcon || <Icon type="FaTimes" /> }
        </span>
      }
      <div className="xModalBody" style={bodyStyle}>
        { children }
      </div>
      {
        footer === null ? null :
          <div className="xModalFooter">
            {
              footer ? footer :
                <div className="xFooterBtn">
                  <Button className="xFooterBtnCancel" type="pure">{ cancelText }</Button>
                  <Button className="xFooterBtnOk">{ okText }</Button>
                </div>
            }
          </div>
      }
    </div>
    {
      mask && <div className="xModalMask" style={maskStyle}></div>
    }
  </div>
}

通过以上实现,我们很容易控制一个modal组件具体显示那些元素,以及那些元素是可关闭modal的,具体案例如下:


  1. 去除footer(通过设置footer为null)



  1. 去除右上角的关闭按钮



  1. 去除mask遮罩


2.3 实现visible(带有弹窗出来和隐藏的动画animation)


熟悉antd或者element的朋友都知道,visible用来控制modal的显示和隐藏,我们这里也来实现同样的功能,关于隐藏和显示的动画,我们这里用transform:scale来实现。先来看看实现效果吧:



这里笔者使用了react hooks的useState这个API,来设置弹窗可见性的state,modal默认不可见。具体逻辑如下:

let [isHidden, setHidden] = useState(!props.visible)
const handleClose = () => {
    setHidden(false)
}

html结构如下:

<div className="xModalWrap" style={{display: isHidden ? 'none' : 'block'}}>

由以上代码我们知道模态框的显示隐藏是通过设置display:none/block来控制的,但是我们都知道display:none是不能执行动画效果的,为了实现内容弹窗的动画,我们这里采用了@keyframe动画,对于低版本浏览器也采用了很好的向下兼容。具体css代码如下:


@keyframes xSpread {
    0% {
        opacity: 0;
        transform: scale(0);
    }
    100% {
        opacity: 1;
        transform: scale(1);
    }
}

2.5  实现centered


centered属性的作用就是来控制弹窗内容距离整个遮罩或者可视区域的位置的,值为true则居与遮罩或者可视区域的正中心。因为我们默认设置的modal内容区域的位置是左右居中,顶部距离可视区域顶部100px,所以这里我们实现如下:


<div className={`xModalContent${centered ? ' xCentered' : ''}`}>

css代码如下:

&.xCentered {
    top: 50%;
    transform: translateY(-50%);
}

这个实现也非常简单,就是通过属性centered来动态的设置类名即可。


2.6 实现destroyOnClose


这个功能意思是在弹窗关闭时是否清除子元素,我在:《精通react/vue组件设计》之配合React Portals实现一个功能强大的抽屉(Drawer)组件这篇文章中有详细的介绍,大家感兴趣可以研究以下,这里我指介绍实现过程。 当destroyOnClose为true时,我们销毁子元素即可,通过维护一个state来实现组件的重新渲染。要想实现该功能,我们需要处理如下几个事件:


  • 当点击关闭按钮时,根据destroyOnClose销毁子组件
  • 当点击确认按钮时,根据destroyOnClose销毁子组件
  • 当visible为true,根据destroyOnClose将子组件重新渲染出来 具体实现代码如下:


// 关闭事件(关闭和确认事件逻辑基本一致,这里就不单独写了)
const handleClose = () => {
    setHidden(true)
    if(destroyOnClose) {
      setDestroyChild(true)
    }
    document.body.style.overflow = 'auto'
    onCancel && onCancel()
}
// visivle/destroyOnClose更新时,重新渲染子组件
useEffect(() => {
    if(visible) {
      if(destroyOnClose) {
        setDestroyChild(true)
      }
    }
  }, [visible, destroyOnClose])

这样我们就实现了弹窗关闭时销毁组件的功能。


2.7 实现键盘按键ESC时关闭模态框(Modal)


为了更好的用户体检,笔者的Modal组件支持键盘事件,我们都知道键盘的ESC对应的事件码为27,那么我们就能根据这个原理来实现键盘按键ESC时关闭模态框:

useEffect(() => {
    document.onkeydown = function (event) {
      let e = event || window.event || arguments.callee.caller.arguments[0]
      if (e && e.keyCode === 27) { 
        handleClose()
      }
    }
  }, [])

因为事件监听只需要执行一次,所以useEffect的依赖设置为空数组即可。虽然这样已经基本实现了键盘关闭的功能,但是这样的代码明显不够优雅,所以我们来完善以下,我们可以将键盘关闭的方法抽离出来,然后在useEffect的第一个回调函数中返回另一个函数(该函数里是组件卸载前的钩子),当组件卸载时我们将事件监听移除,这样可以提高一些性能,对内存优化也有帮助:


const closeModal = function (event) {
    let e = event || window.event || arguments.callee.caller.arguments[0]
    if (e && e.keyCode === 27) { 
      handleClose()
    }
  }
 useEffect(() => {
    document.addEventListener('keydown', closeModal, false)
    return () => {
      document.removeEventListener('keydown', closeModal, false)
    }
  }, [])

通过这种方式,代码和功能实现上是不是会更优雅呢?


2.8 实现afterClose


afterClose的作用主要是在模态框关闭之后执行某个回调函数。我们使用class组件很好实现这个功能,因为setState可以传两个参数,一个是更新state的回调,另一个是state更新之后的回调,我们只需要把afterClose放到更新后的回调即可,也就是第二个参数回调里。但是我们modal组件目前是用react hooks和函数式组件写的,那么怎么实现状态更新后的回调呢?笔者这里提供一个实现思路,利用闭包来实现,核心代码如下:


// 函数组件外部
let hiddenCount = 0;
//  函数组件内部
useEffect(() => {
    if(isHidden && hiddenCount) {
      hiddenCount = 0
      afterClose && afterClose()
    }
    hiddenCount = 1
  }, [isHidden])

我们知道useEffect不仅仅可以实现监听挂载组件的钩子,也同样能监听state更新,我们利用这一点来实现该功能,值得注意的是我们要在执行afterClose前重置hiddenCount,避免其他使用modal组件的函数的影响。


2.9 健壮性支持, 我们采用react提供的propTypes工具:


import PropTypes from 'prop-types'
// ...
Modal.propTypes = {
  afterClose: PropTypes.func,
  bodyStyle: PropTypes.object,
  cancelText: PropTypes.oneOfType([
    PropTypes.string,
    PropTypes.element
  ]),
  centered: PropTypes.bool,
  closable: PropTypes.bool,
  closeIcon: PropTypes.element,
  destroyOnClose: PropTypes.bool,
  footer: PropTypes.oneOfType([
    PropTypes.element,
    PropTypes.object
  ]),
  keyboard: PropTypes.bool,
  mask: PropTypes.bool,
  maskclosable: PropTypes.bool,
  maskStyle: PropTypes.object,
  okText: PropTypes.oneOfType([
    PropTypes.string,
    PropTypes.element
  ]),
  title: PropTypes.oneOfType([
    PropTypes.string,
    PropTypes.element
  ]),
  visible: PropTypes.bool,
  width: PropTypes.string,
  onCancel: PropTypes.func,
  onOk: PropTypes.func
}

关于prop-types的使用官网上有很详细的案例,这里说一点就是oneOfType的用法, 它用来支持一个组件可能是多种类型中的一个. 组件完整css代码如下:

.xModalWrap {
    position: fixed;
    z-index: 999;
    top: 0;
    left: 0;
    width: 100%;
    bottom: 0;
    overflow: hidden;
    .xModalContent {
        position: relative;
        z-index: 1000;
        margin-left: auto;
        margin-right: auto;
        position: relative;
        top: 100px;
        background-color: #fff;
        background-clip: padding-box;
        border-radius: 4px;
        -webkit-box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
        pointer-events: auto;
        animation: xSpread .3s;
        &.xCentered {
            top: 50%;
            transform: translateY(-50%);
        }
        .xModalHeader {
            padding: 16px 24px;
            color: rgba(0, 0, 0, 0.65);
            background: #fff;
            border-bottom: 1px solid #e8e8e8;
            border-radius: 4px 4px 0 0;
            .xModalTitle {
                margin: 0;
                color: rgba(0, 0, 0, 0.85);
                font-weight: 500;
                font-size: 16px;
                line-height: 22px;
                word-wrap: break-word;
            }
        }
        .xModalCloseBtn {
            position: absolute;
            top: 0;
            right: 0;
            z-index: 10;
            padding: 0;
            width: 56px;
            height: 56px;
            color: rgba(0, 0, 0, 0.45);
            font-size: 16px;
            line-height: 56px;
            text-align: center;
            text-decoration: none;
            background: transparent;
            border: 0;
            outline: 0;
            cursor: pointer;
        }
        .xModalBody {
            padding: 16px 24px;
        }
        .xModalFooter {
            padding: 10px 16px;
            text-align: right;
            background: transparent;
            border-top: 1px solid #e8e8e8;
            border-radius: 0 0 4px 4px;
            .xFooterBtn {
                .xFooterBtnCancel, .xFooterBtnOk {
                    margin-left: 6px;
                    margin-right: 6px;
                }
            }
        }
    }
    .xModalMask {
        position: fixed;
        z-index: 999;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        overflow: hidden;
        background-color: rgba(0,0,0, .5);
    }
}
@keyframes xSpread {
    0% {
        opacity: 0;
        // 之所以要再加translateY(-50%),是为了防止动画抖动
        transform: translateY(-50%) scale(0);
    }
    100% {
        opacity: 1;
        transform: translateY(-50%) scale(1);
    }
}

通过以上步骤, 一个健壮的的Modal组件就完成了.Modal组件算是组件库中中等复杂的组件,如果不懂的可以在评论区提问,笔者看到后会第一时间解答.


2.5 使用Modal组件


我们可以通过如下方式使用它:

<Modal title="xui基础弹窗" centered mask={false} visible={false}>
    <p>我是弹窗内容</p>
    <p>我是弹窗内容</p>
    <p>我是弹窗内容</p>
    <p>我是弹窗内容</p>
</Modal>

笔者已经将实现过的组件发布到npm上了,大家如果感兴趣可以直接用npm安装后使用,方式如下:

npm i @alex_xu/xui
// 导入xui
import { 
  Button,
  Skeleton,
  Empty,
  Progress,
  Tag,
  Switch,
  Drawer,
  Badge,
  Alert
} from '@alex_xu/xui'

该组件库支持按需导入,我们只需要在项目里配置babel-plugin-import即可,具体配置如下:

// .babelrc
"plugins": [
  ["import", { "libraryName": "@alex_xu/xui", "style": true }]
]

npm库截图如下:



最后


后续笔者将会继续实现


  • badge(徽标),
  • table(表格),
  • tooltip(工具提示条),
  • Skeleton(骨架屏),
  • Message(全局提示),
  • form(form表单),
  • switch(开关),
  • 日期/日历,
  • 二维码识别器组件


等组件, 来复盘笔者多年的组件化之旅.


如果对于react/vue组件设计原理不熟悉的,可以参考我的之前写的组件设计系列文章:



笔者已经将组件库发布到npm上了, 大家可以通过npm安装的方式体验组件.



目录
相关文章
|
缓存 并行计算 C++
实践教程|旋转目标检测模型-TensorRT 部署(C++)
实践教程|旋转目标检测模型-TensorRT 部署(C++)
378 0
|
1月前
|
人工智能 自然语言处理 搜索推荐
《生成式引擎优化(GEO)服务商选择指南》:让AI大模型主动推荐你
GEO(生成式引擎优化)是针对AI生成式搜索引擎的优化策略,旨在让企业信息在AI回答中优先呈现,实现“无点击曝光”。与传统SEO不同,GEO强调语义理解、权威内容和结构化数据,提升品牌在AI推荐中的可见性与可信度。企业需关注GEO服务商的技术实力、行业匹配度、服务流程完整性等维度,确保优化效果。
447 0
|
人工智能 弹性计算 运维
开启运维新纪元!阿里云OS Copilot深度评测 & 体验分享
OS Copilot是Alibaba Cloud为Linux推出的一款基于大模型的智能助手,它能理解自然语言、辅助命令执行和系统运维。目前仅支持Alibaba Cloud Linux 3的x86_64架构。安装过程涉及线上和本地体验,包括申请试用、配置环境变量、安装组件等步骤。OS Copilot提供命令行和多轮交互模式,能进行代码生成和摘要,辅助开发和运维工作。产品体验评测中,OS Copilot因其自然语言理解和高效辅助得到高度评价,尤其对运维人员来说,能大幅提升工作效率。然而,目前仅限于特定操作系统,是其局限性。未来有望扩展更多功能和支持更多平台。
133965 26
|
11月前
|
数据库连接 网络安全 数据库
数据库网站连接错误怎么办?
数据库网站连接错误怎么办?
|
运维 监控 关系型数据库
【一文搞懂PGSQL】7. PostgreSQL + repmgr + witness 高可用架构
该文档介绍了如何构建基于PostgreSQL的高可用架构,利用repmgr进行集群管理和故障转移,并引入witness节点增强网络故障检测能力。repmgr是一款轻量级的开源工具,支持一键部署、自动故障转移及分布式节点管理。文档详细描述了环境搭建步骤,包括配置postgresql参数、安装与配置repmgr、注册集群节点以及配置witness节点等。此外,还提供了故障手动与自动切换的方法及常用命令,确保集群稳定运行。
|
JavaScript 前端开发 CDN
ThreeJs Demo 之创建星空效果
ThreeJs Demo 之创建星空效果
498 2
|
监控 测试技术 数据库
Python自动化测试之异常处理机制
总体而言,妥善设计的异常处理策略让自动化测试更加稳定和可靠,同时也使得测试结果更加清晰、易于理解和维护。在设计自动化测试脚本时,务必考虑到异常处理机制的实现,以保证测试过程中遇到意外情况时的鲁棒性和信息的有效传达。
183 2
|
数据采集 算法 数据处理
Python中的并发编程:异步IO与多线程对比分析
传统的多线程编程在Python中因为全局解释器锁(GIL)的存在受到限制,导致多线程并不能充分利用多核处理器的优势。本文将探讨Python中的异步IO编程与多线程编程的差异与优劣,并分析适合的应用场景。
|
小程序 前端开发
微信综合购物商城小程序ui模板源码
微信电商小程序前端页面,综合购物商城ui界面模板。主要功能包含:电商主页、商品分类、购物车、购物车结算、我的个人中心管理、礼券、签到、新人专享、专栏、商品详情页、我的订单、我的余额、我的积分、我的收藏、我的地址、我的礼券等。这是一款非常齐全的电商小程序前端模板。
422 4
|
API Swift iOS开发
【Swift开发专栏】Swift中的游戏开发入门
【4月更文挑战第30天】本文介绍了使用Swift进行移动游戏开发的基础知识,包括Apple的开发平台(iOS, macOS)、工具(Xcode)、2D/3D游戏框架(SpriteKit, SceneKit)以及Metal图形API。Swift游戏开发涉及游戏循环、UI设计、逻辑、图形音效和网络编程。通过实例教程展示如何在Xcode中创建2D游戏,从创建项目到实现用户交互、音效和测试。掌握这些基础知识,开发者可快速入门并逐步进阶到更复杂的游戏中。
544 1

热门文章

最新文章