🚀 React 弹窗还能这样写?手撸一个高质量 Modal 玩起来!

用了这么久 React,是时候自己封装个组件玩玩了。这次我们不讲虚的,从 0 开始动手写一个 Modal,支持受控、Portal 挂载等功能,写完你绝对能吹。

🧠 为什么要自己写 Modal?

在我们的日常开发中,Modal(弹窗)组件几乎无处不在,从简单提示框到复杂表单提交,需求层出不穷。

虽然大多数时候我们依赖 UI 框架的现成组件,但一旦遇到复杂场景,例如自定义动画、拖拽行为、Portal 渲染或状态控制,内置 Modal 往往显得力不从心。

那问题来了,UI 库不给力的时候,我们能不能自己写一个更灵活、能打的 Modal?

答案是:当然可以!而且写完你会发现,真的不难,还挺有成就感~

为什么要自己写?

  • 满足复杂业务需求
  • 灵活定制与扩展
  • 理解组件设计模式
  • 提升架构与编码能力

🎯 明确我们要实现什么

先搞定基础功能,再逐步扩展:

  • 支持打开 / 关闭
  • 自定义标题与内容
  • 支持挂载到 body
  • 支持受控 / 非受控模式

路要一步一步走,饭要一口一口吃,先实现基本功能,再去扩展后续的:动画过渡、自定义footer、拖拽等...

最终我们将拥有一个高可扩展、易维护的 Modal 组件。

🛠 搭好开发环境

这里直接使用最新的 React 19,使用 Create React App 去创建项目。

ts 复制代码
npx create-react-app modal-component

再装个 sass 来写样式:

ts 复制代码
npm install --save sass

1. 搭好基础结构

为了方便看思路,css 部分代码会在小节最后贴上,实操可先把 css 贴上再跟着下面逻辑一步一步操作。

先在 App.jsx 里加个按钮控制弹窗的显示:

src/App.jsx

ts 复制代码
import { useState } from 'react'
import Modal from './Modal'
import './App.scss'
​
export default function App() {
  const [isOpen, setIsOpen] = useState(false)
​
  return (
    <div className="App">
      <button onClick={() => setIsOpen(true)}>打开弹窗</button>
​
      {isOpen && (
        <Modal ></Modal>
      )}
    </div>
  )
}

src/Modal/index.jsx

ts 复制代码
import './index.scss'
​
export default function Modal()  {
  return (
    <div className="modal-overlay">
      <div className="modal">
        <h3>弹窗</h3>
        <div className="modal-body">这是您需要确认的内容</div>
        <div className="modal-footer">
          <button>确认</button>
          <button>取消</button>
        </div>
      </div>
    </div>
  );
};

我们创建 isOpen 去控制弹窗的显示隐藏,创建了弹窗组件 Modal,现在实现了正常 打开弹窗 功能。

但是我们打开了弹窗,还没有关闭功能,所以我们需要通过 父子传值 ,给 Modal 传递关闭弹窗的方法。

ts 复制代码
// App.jsx
<Modal onClose={() => setIsOpen(false)}></Modal>
​
// Modal/index.jsx
<button onClick={onClose}>确认</button>
<button onClick={onClose}>取消</button>

到这里,弹窗基本能正常 开关自如 了,虽然还有点原始,但好歹是能用了。

2. 支持自定义标题 & 内容

平时使用 UI 库的弹窗组件,弹窗标题跟内容通常都是自定义传入的,所以我们继续优化:

Modal

使用方式也更灵活:

App

我们把 title、children 进行抽离,通过传入去展示。

现在弹窗上的标题跟内容已经是我们传入的自定义内容了。

接下来我们进行去优化弹窗的 通用性

假设现在有一个 小tips,定位到按钮的下方,而且层级要比弹窗高,那么会发生什么呢?

可以看到我们的弹窗组件被这个 小tips 遮挡住了,那肯定是不行的,我们可以通过修改弹窗层级去修复问题:

虽然看似可以通过提高 z-index 解决,但随着复杂度增加,z-index 管理容易混乱。

但是我们总不能每出现一次这种问题都去改一下层级吧,那我们应该怎么解决呢?

3. 用 Portal 实现层级隔离

通过 ReactDOM.createPortal 将弹窗渲染到 <body> 下,脱离父容器 DOM 层级限制。

我们优化下代码:

ts 复制代码
// Modal
import { useEffect } from 'react'
import ReactDOM from 'react-dom'
import './index.scss'
​
export default function Modal({ title, children, onClose }) {
  // 为 Portal 动态创建挂载节点
  const portalRoot = document.createElement('div')
​
  useEffect(() => {
    // 挂载到 body
    document.body.appendChild(portalRoot)
    return () => {
      // 组件卸载时移除节点
      document.body.removeChild(portalRoot)
    }
  }, [portalRoot])
​
  return ReactDOM.createPortal(
    <div className="modal-overlay">
        ...
    </div>,
    portalRoot
  )
}

我们做了啥:

  • 创建 DOM 节点存储当前 Modal 组件。
  • 通过 ReactDOM.createPortal 将弹窗渲染到 <body> 下。
  • 需注意组件卸载时移除节点。

点击打开弹窗按钮,观察 DOM 变化:

可以看到,Modal 直接挂载到 body 节点下了。

这样有什么好处呢:

当多个弹窗同时存在时,挂载到 <body> 可以统一管理 z-index,避免层级错乱。

功能是实现了,但其实我们上面的代码是 存在问题 的。

  • 每次组件渲染都会执行 document.createElement('div'),即使组件已经挂载。
  • useEffect 的依赖项是 portalRoot(每次渲染都不同)。

上面代码不仅会影响性能,还可能会造成内存泄露。

又可以用上前面我们 useRef 篇文章学习到的知识点了,我们优化一下:

ts 复制代码
...  
  const portalRootRef = useRef(null)
​
  useEffect(() => {
    if (!portalRootRef.current) {
      portalRootRef.current = document.createElement('div')
      document.body.appendChild(portalRootRef.current)
    }
​
    return () => {
      // 确保安全移除
      if (document.body.contains(portalRootRef.current)) {
        document.body.removeChild(portalRootRef.current)
      }
      portalRootRef.current = null // 避免空引用
    }
  }, [])
...

我们做了:

  • useRef 保证组件生命周期内始终使用同一个 DOM 节点。
  • document.body.contains() 检查避免重复移除。
  • 卸载时置空 portalRootRef.current 释放内存。

4. 支持受控与非受控双模式

在优化前我们先理解两种模式的区别:

模式 状态管理方 适用场景
受控 父组件控制 需要同步外部状态的场景(如表单)
非受控 组件内部管理 独立弹窗,不需要外部状态同步

想深入理解可以看我上篇文章:🧠 面试官:受控组件都分不清?还敢说自己写过 React?

受控/非受控模式,我们用是否传入 isOpen 进行区分:

ts 复制代码
// Modal
export default function Modal({ isOpen: controlledIsOpen, title, children, onClose }) {
  // 非受控状态
  const [isInternalOpen, setInternalOpen] = useState(false)
  // 是否受控
  const isControlled = controlledIsOpen !== undefined
  // 实际使用的状态 受控/非受控
  const isOpen = isControlled ? controlledIsOpen : isInternalOpen
​
  const handleCloss = () => {
    if (isControlled) {
      onClose?.()
    } else {
      setInternalOpen(false)
    }
  }
  ...
  return isOpen
    ? ReactDOM.createPortal(
        ...
      )
    : null
}

优化点:

  • 添加 props: isOpen,传入 isOpen 表示受控模式。
  • 受控模式由调用方通过 isOpen 控制是否关闭组件。
  • 非受控模式由组件内部 isInternalOpen 控制是否关闭组件。

我们在 App 里使用看下,首先是 受控模式

ts 复制代码
// App
export default function App() {
  const [isOpen, setIsOpen] = useState(false)
​
  return (
    <div className="App">
      <button onClick={() => setIsOpen(true)}>受控模式打开</button>
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="受控弹窗">
        <p>父组件完全控制开关状态</p>
      </Modal>
    </div>
  )
}

我们在弹窗组件上打个 debugger 看看:

断点信息告诉我们,当前由外部传入的 onClose 控制 Modal 组件。

接下来我们再试下 非受控模式

由于非受控模式不再依赖父组件传递的 isOpen 控制弹窗是否打开,所以我们需要通过 ref 把自身方法暴露出去给父组件使用。

Modal

ts 复制代码
// Modal
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'
​
const Modal = forwardRef(({ isOpen: controlledIsOpen, title, children, onClose }, ref) => {
...
  // 暴露 open/close 方法供父组件调用(用于非受控模式)
  useImperativeHandle(ref, () => ({
    open: () => setInternalOpen(true),
    close: () => setInternalOpen(false)
  }))
...
})
​
export default Modal

App

ts 复制代码
export default function App() {
  const [isOpen, setIsOpen] = useState(false)
  const modalRef = useRef()
​
  return (
    <div className="App">
      <button onClick={() => modalRef.current.open()}>打开非受控弹窗</button>
​
      <Modal ref={modalRef} title="非受控弹窗标题">
        <p>这个弹窗完全自己管理状态!</p>
        <p>父组件不需要传递任何状态</p>
      </Modal>
    </div>
  )
}

我们做了啥:

  • 通过 forwardRef, useImperativeHandle 向父组件暴露 open 方法。
  • 父组件通过 open 可以直接操作弹窗状态。
  • 不传递 isOpen/onClose ,非受控模式下父组件无需管理状态,通过 ref 即可控制

5. 自定义 Hook

虽然说实现了 受控/非受控模式支持 ,但同时我们也在 Modal 里写了一坨代码,不利于后续维护,所以我们抽离出一个自定义 Hook useControlledState,优化一下代码。

新建文件 hooks/useControlledState.js,把 受控/非受控模式支持 逻辑抽离出来:

ts 复制代码
import { useState } from 'react'
​
export default function useControlledState(value, onChange, defaultValue) {
  const [internalValue, setInternalValue] = useState(defaultValue)
​
  const isControlled = value !== undefined
  const finalValue = isControlled ? value : internalValue
​
  const setValue = newValue => {
    if (isControlled) {
      onChange?.(newValue)
    } else {
      setInternalValue(newValue)
    }
  }
​
  return [finalValue, setValue]
}

再去优化 Modal

ts 复制代码
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'
import ReactDOM from 'react-dom'
import useControlledState from '../hooks/useControlledState'
import './index.scss'
​
const Modal = forwardRef((props, ref) => {
  const [isOpen, setIsOpen] = useControlledState(props.isOpen, props.onClose, false)
​
  useImperativeHandle(ref, () => ({
    open: () => setIsOpen(true),
    close: () => setIsOpen(false)
  }))
​
  const portalRootRef = useRef(null)
​
  useEffect(() => {
    if (!portalRootRef.current) {
      portalRootRef.current = document.createElement('div')
      document.body.appendChild(portalRootRef.current)
    }
​
    return () => {
      // 确保安全移除
      if (document.body.contains(portalRootRef.current)) {
        document.body.removeChild(portalRootRef.current)
      }
      portalRootRef.current = null // 避免空引用
    }
  }, [])
​
  return isOpen
    ? ReactDOM.createPortal(
        <div className="modal-overlay">
          <div className="modal">
            <h3>{props.title}</h3>
            <div className="modal-body">{props.children}</div>
            <div className="modal-footer">
              <button onClick={() => setIsOpen(false)}>确认</button>
              <button onClick={() => setIsOpen(false)}>取消</button>
            </div>
          </div>
        </div>,
        portalRoot
      )
    : null
})
​
export default Modal

优化完成,我们再去页面上看下是否正常使用:

没毛病!

6. 小总结

到这里我们的弹窗组件一步一步完善了:

  • ✅ 支持打开/关闭
  • ✅ 有基本的确认/取消按钮
  • ✅ 支持挂载到 body
  • ✅ 支持受控/非受控 模式
  • ✅ 抽离自定义 Hook,优化代码

完整代码:

css部分

ts 复制代码
// App.scss
.App {
  position: relative;
  text-align: center;
  > button {
    margin: 400px auto;
  }
  .tips {
    position: absolute;
    top: 440px;
    left: 50%;
    transform: translate(-50%, 0);
    z-index: 999;
    background: pink;
  }
}
​
// Modal/index.scss
.modal-overlay {
  z-index: 999999999;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}
​
.modal {
  background: white;
  padding: 20px;
  border-radius: 8px;
  min-width: 300px;
}
​
.modal-footer {
  margin-top: 20px;
  text-align: right;
}
​
button {
  margin-left: 10px;
  padding: 6px 12px;
  cursor: pointer;
}

完整项目代码可前往我的 Github 仓库 查看。

🚀 接下来的进阶扩展?

没错!这还只是起点,下一篇我们将加上:

  • 🎞️ 弹窗动画
  • ⌨️ 支持按下 Esc 键关闭
  • 🧩 footer 插槽定制化
  • 🧲 支持拖动弹窗
  • 🔒 防止背景滚动穿透

想挑战自己,做出一个真正"好用 + 好看 + 好扩展"的 Modal 吗?我们下篇见!

🧩 最后

自己封装组件不仅能提升 React 能力,还能提升你架构设计的思维方式。

如果你觉得这篇文章对你有帮助,欢迎点赞 👍、收藏 ⭐、评论 💬 让我知道你在看! 后续我也会持续输出更多 高性能 React 实战技巧,敬请期待!❤️

相关推荐
低代码布道师21 分钟前
第二部分:网页的妆容 —— CSS(下)
前端·css
一纸忘忧27 分钟前
成立一周年!开源的本土化中文文档知识库
前端·javascript·github
涵信1 小时前
第九节:性能优化高频题-首屏加载优化策略
前端·vue.js·性能优化
前端小巷子1 小时前
CSS单位完全指南
前端·css
SunTecTec2 小时前
Flink Docker Application Mode 命令解析 - 修改命令以启用 Web UI
大数据·前端·docker·flink
软件技术NINI2 小时前
html css js网页制作成品——HTML+CSS甜品店网页设计(4页)附源码
javascript·css·html
涵信2 小时前
第十一节:性能优化高频题-响应式数据深度监听问题
javascript·vue.js·性能优化
codingandsleeping2 小时前
Express入门
javascript·后端·node.js
Vaclee2 小时前
JavaScript-基础语法
开发语言·javascript·ecmascript
拉不动的猪3 小时前
前端常见数组分析
前端·javascript·面试