基于React18 Hooks实现手机端弹框组件RcPop
react-popup 基于react18+hook自定义多功能弹框组件。整合了msg/alert/dialog/toast及android/ios弹窗效果。支持20+自定义参数、组件式+函数式调用方式,全方位满足各种弹窗场景需求。

引入组件
在需要使用弹窗的页面引入组件。
- // 引入自定义组件
- import RcPop, { rcpop } from './components/rcpop'
RcPop支持 组件式+函数式 两种调用方式。

组件写法
- <RcPop
- visible={visible}
- title="标题"
- content="弹窗内容"
- type="android"
- shadeClose="false"
- closeable
- :btns="[
- {text: '取消', click: () => setVisible(false)},
- {text: '确认', style: {color: '#09f'}, click: handleOK},
- ]"
- @onOpen={handleOpen}
- @onClose={handleClose}
- />
- <div>这里是自定义弹窗内容,优先级高于content内容。</div>
- </RcPop>
函数写法
- function handlePopup() {
- rcpop({
- title: '标题',
- content: `<div style="padding:20px;">
- <p>函数式调用:<em style="color:#999;">rcpop({...})</em></p>
- </div>`,
- btns: [
- {
- text: '取消',
- click: () => {
- // 关闭弹窗
- rcpop.close()
- }
- },
- {
- text: '确认',
- style: {color: '#09f'},
- click: () => {
- rcpop({
- type: 'toast',
- icon: 'loading',
- content: '加载中...',
- opacity: .2,
- time: 2
- })
- }
- }
- ]
- })
- }


- rcpop({
- title: '标题',
- content: `<div style="color:#f90">
- <p>显示自定义弹窗内容</p>
- </div>`,
- btns: [
- { text: '稍后提示' },
- { text: '取消', click: () => rcpop.close() },
- {
- text: '立即更新',
- style: {color: '#09f'},
- click: () => {
- // ...
- }
- }
- ]
- })











- <RcPop
- visible={visible}
- closeable
- xposition="top"
- content="这里是内容信息"
- btns={[
- {text: '确认', style: {color: '#00d8ff'}, click: () => setVisible(false)},
- ]}
- onOpen={()=> {
- console.log('弹窗开启...')
- }}
- onClose={()=>{
- console.log('弹窗关闭...')
- setVisible(false)
- }}
- >
- <div style={{padding: '15px'}}>
- <img src={reactLogo} width="60" onClick={handleContextPopup} />
- <h3 style={{color:'#f60', 'paddingTop':'10px'}}>当 content 和 自定义插槽 内容同时存在,只显示插槽内容。</h3>
- </div>
- </RcPop>
- function handleContextPopup(e) {
- let points = [e.clientX, e.clientY]
- rcpop({
- type: 'contextmenu',
- follow: points,
- opacity: 0,
- btns: [
- {text: '标记备注信息'},
- {
- text: '删除',
- style: {color:'#f00'},
- click: () => {
- rcpop.close()
- }
- }
- ]
- })
- }
这次主打的是学习 React Hooks 开发自定义弹窗,之前也有开发过类似的弹层组件。
https://www.cnblogs.com/xiaoyan2017/p/14085142.html
https://www.cnblogs.com/xiaoyan2017/p/11589149.html

编码开发

在components目录下新建rcpop文件夹。
rcpop支持如下参数配置
- // 弹窗默认参数
- const defaultProps = {
- // 是否显示弹出层
- visible: false,
- // 弹窗唯一性标识
- id: null,
- // 弹窗标题
- title: '',
- // 弹窗内容
- content: '',
- // 弹窗类型(toast | footer | actionsheet | actionsheetPicker | ios | android | androidSheet | contextmenu)
- type: '',
- // toast图标(loading | success | fail)
- icon: '',
- // 是否显示遮罩层
- shade: true,
- // 点击遮罩层关闭
- shadeClose: true,
- // 遮罩透明度
- opacity: '',
- // 自定义遮罩层样式
- overlayStyle: {},
- // 是否圆角
- round: false,
- // 是否显示关闭图标
- closeable: false,
- // 关闭图标位置(left | right | top | bottom)
- closePosition: 'right',
- // 关闭图标颜色
- closeColor: '',
- // 动画类型(scaleIn | fadeIn | footer | fadeInUp | fadeInDown)
- anim: 'scaleIn',
- // 弹窗出现位置(top | right | bottom | left)
- position: '',
- // 长按/右键弹窗(坐标点)
- follow: null,
- // 弹窗关闭时长,单位秒
- time: 0,
- // 弹窗层级
- zIndex: 2023,
- // 弹窗按钮组(text | style | disabled | click)
- btns: null,
- // 指定挂载的节点(仅对标签组件有效)
- // teleport = () => document.body,
- teleport: null,
- // 弹窗打开回调
- onOpen: () => {},
- // 弹窗关闭回调
- onClose: () => {},
- // 点击遮罩层回调
- onClickOverlay: () => {},
- // 自定义样式
- customStyle: {},
- // 类名
- className: null,
- // 默认插槽内容
- children: null
- }
弹窗组件模板
- const renderNode = () => {
- return (
- <div ref={ref} className={classNames('rc__popup', options.className, {'rc__popup-closed': closed})} id={options.id} style={{'display': !opened.current ? 'none' : undefined}}>
- {/* 遮罩层 */}
- { isTrue(options.shade) && <div className="rcpopup__overlay" onClick={handleShadeClick} style={{'opacity': options.opacity, 'zIndex': oIndex-1, ...options.overlayStyle}}></div> }
- {/* 窗体 */}
- <div className="rcpopup__wrap" style={{'zIndex': oIndex}}>
- <div
- ref={childRef}
- className={classNames(
- 'rcpopup__child',
- {
- [`anim-${options.anim}`]: options.anim,
- [`popupui__${options.type}`]: options.type,
- 'round': options.round
- },
- options.position
- )}
- style={popStyles}
- >
- { options.title && <div className="rcpopup__title">{options.title}</div> }
- { (options.type == 'toast' && options.icon) && <div className={classNames('rcpopup__toast', options.icon)} dangerouslySetInnerHTML={{__html: ToastIcon[options.icon]}}></div> }
- {/* 内容 */}
- { options.children ? <div className="rcpopup__content">{options.children}</div> : options.content ? <div className="rcpopup__content" dangerouslySetInnerHTML={{__html: options.content}}></div> : null }
- {/* 按钮组 */}
- { options.btns &&
- <div className="rcpopup__actions">
- {
- options.btns.map((btn, index) => {
- return <span className={classNames('btn', {'btn-disabled': btn.disabled})} key={index} style={btn.style} dangerouslySetInnerHTML={{__html: btn.text}} onClick={e => handleActions(e, index)}></span>
- })
- }
- </div>
- }
- { isTrue(options.closeable) && <div className={classNames('rcpopup__xclose', options.closePosition)} style={{'color': options.closeColor}} onClick={close}></div> }
- </div>
- </div>
- </div>
- )
- }
完整代码块
- /**
- * @title 基于react18 hooks自定义移动端弹窗组件
- * @author YXY Q: 282310962
- * @date 2023/07/25
- */
- import { useState, useEffect, createRef, useRef, forwardRef, useImperativeHandle } from 'react'
- import { createPortal } from 'react-dom'
- import { createRoot } from 'react-dom/client'
-
- // ...
- const RcPop = forwardRef((props, ref) => {
- const mergeProps = {
- ...defaultProps,
- ...props
- }
-
- const [options, setOptions] = useState(mergeProps)
- const [oIndex, setOIndex] = useState(options.zIndex)
- const [closed, setClosed] = useState(false)
- const [followStyle, setFollowStyle] = useState({
- position: 'absolute',
- left: '-999px',
- top: '-999px'
- })
- const opened = useRef(false)
- const childRef = useRef()
- const stopTimer = useRef(null)
- const popStyles = options.follow ? { ...followStyle, ...options.customStyle } : { ...options.customStyle }
- const isTrue = (str) => /^true$/i.test(str)
- const ToastIcon = {
- loading: '<svg viewBox="25 25 50 50"><circle fill="none" cx="50" cy="50" r="20"></circle></svg>',
- success: '<svg viewBox="0 0 1024 1024"><path d="M512 85.333c235.648 0 426.667 191.019 426.667 426.667S747.648 938.667 512 938.667 85.333 747.648 85.333 512 276.352 85.333 512 85.333zm-74.965 550.4l-90.582-90.581a42.667 42.667 0 1 0-60.33 60.33l120.704 120.705a42.667 42.667 0 0 0 60.33 0L768.811 424.49a42.667 42.667 0 1 0-60.288-60.331L436.992 635.648z" /></svg>',
- error: '<svg viewBox="0 0 1024 1024"><path d="M512 85.333C276.352 85.333 85.333 276.352 85.333 512S276.352 938.667 512 938.667 938.667 747.648 938.667 512 747.648 85.333 512 85.333zm128.427 606.72l-129.75-129.749-129.066 129.024a35.968 35.968 0 1 1-50.902-50.901L459.733 511.36 329.301 380.928a35.968 35.968 0 1 1 50.859-50.944l130.475 130.475 129.706-129.75a35.968 35.968 0 1 1 50.944 50.902L561.536 511.36l129.75 129.75a35.968 35.968 0 1 1-50.902 50.943z" /></svg>',
- warning: '<svg viewBox="0 0 1024 1024"><path d="M512 941.12q-89.28 0-167.52-34.08t-136.32-92.16T116 678.08t-34.08-168T116 342.56t92.16-136.32 136.32-92.16T512 80t168 34.08 136.8 92.16 92.16 136.32 34.08 167.52-34.08 168-92.16 136.8T680 907.04t-168 34.08zM460.16 569.6q0 23.04 14.88 38.88T512 624.32t37.44-15.84 15.36-38.88V248q0-23.04-15.36-36.96T512 197.12t-37.44 14.4-15.36 37.44zM512 688.64q-27.84 0-47.52 19.68t-19.68 47.52 19.68 47.52T512 823.04t48-19.68 20.16-47.52T560 708.32t-48-19.68z"/></svg>',
- info: '<svg viewBox="0 0 1024 1024"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm84 343.1l-87 301.4c-4.8 17.2-7.2 28.6-7.2 33.9 0 3.1 1.3 6 3.8 8.7s5.2 4 8.1 4c4.8 0 9.6-2.1 14.4-6.4 12.7-10.5 28-29.4 45.8-56.8l14.4 8.5c-42.7 74.4-88 111.6-136.1 111.6-18.4 0-33-5.2-43.9-15.5-10.9-10.3-16.3-23.4-16.3-39.2 0-10.5 2.4-23.7 7.2-39.9l58.9-202.7c5.7-19.5 8.5-34.2 8.5-44.1 0-6.2-2.7-11.7-8.1-16.5-5.4-4.8-12.7-7.2-22-7.2-4.2 0-9.3.1-15.3.4l5.5-17L570.4 407H596v.1zm17.8-88.7c-12.2 12.2-26.9 18.2-44.1 18.2-17 0-31.5-6.1-43.7-18.2-12.2-12.2-18.2-26.9-18.2-44.1s6-31.9 18-44.1c12-12.1 26.6-18.2 43.9-18.2 17.5 0 32.3 6.1 44.3 18.2 12 12.2 18 26.9 18 44.1s-6.1 31.9-18.2 44.1z"/></svg>',
- }
- /**
- * 开启弹窗
- */
- function open(params) {
- params && setOptions({ ...options, ...params })
- if(options.type == 'toast') {
- options.time = options.time || 3
- }
- if(opened.current) return
- opened.current = true
-
- setOIndex(++index + options.zIndex)
- options.onOpen?.()
- // 右键/长按菜单
- if(options.follow) {
- setTimeout(() => {
- let rcpop = childRef.current
- let oW, oH, winW, winH, pos
- oW = rcpop.clientWidth
- oH = rcpop.clientHeight
- winW = window.innerWidth
- winH = window.innerHeight
- pos = getPos(options.follow[0], options.follow[1], oW, oH, winW, winH)
- setFollowStyle({
- ...followStyle,
- left: pos[0],
- top: pos[1]
- })
- })
- }
- if(options.time) {
- clearTimeout(stopTimer.current)
- stopTimer.current = setTimeout(() => {
- close()
- }, options.time * 1000)
- }
- }
- /**
- * 关闭弹窗
- */
- function close() {
- if(!opened.current) return
- setClosed(true)
- setTimeout(() => {
- setClosed(false)
- opened.current = false
-
- options.onClose?.()
- clearTimeout(stopTimer.current)
- }, 200)
- }
- // 点击遮罩层
- function handleShadeClick(e) {
- options.onClickOverlay?.(e)
- if(isTrue(options.shadeClose)) {
- close()
- }
- }
- // 点击按钮组
- function handleActions(e, index) {
- let btn = options.btns[index]
- if(!btn.disabled) {
- btn?.click?.(e)
- }
- }
- // 抽离的React的classnames操作类
- function classNames() {
- var hasOwn = {}.hasOwnProperty
- var classes = []
- for (var i = 0; i < arguments.length; i++) {
- var arg = arguments[i]
- if (!arg) continue
- var argType = typeof arg
- if (argType === 'string' || argType === 'number') {
- classes.push(arg)
- } else if (Array.isArray(arg) && arg.length) {
- var inner = classNames.apply(null, arg)
- if (inner) {
- classes.push(inner)
- }
- } else if (argType === 'object') {
- for (var key in arg) {
- if (hasOwn.call(arg, key) && arg[key]) {
- classes.push(key)
- }
- }
- }
- }
- return classes.join(' ')
- }
- // 获取挂载节点
- function getTeleport(getContainer) {
- const container = typeof getContainer == 'function' ? getContainer() : getContainer
- return container || document.body
- }
- // 设置挂载节点
- function renderTeleport(getContainer, node) {
- if(getContainer) {
- const container = getTeleport(getContainer)
- return createPortal(node, container)
- }
- return node
- }
- // 获取弹窗坐标点
- function getPos(x, y, ow, oh, winW, winH) {
- let l = (x + ow) > winW ? x - ow : x;
- let t = (y + oh) > winH ? y - oh : y;
- return [l, t];
- }
- const renderNode = () => {
- return (
- <div ref={ref} className={classNames('rc__popup', options.className, {'rc__popup-closed': closed})} id={options.id} style={{'display': !opened.current ? 'none' : undefined}}>
- {/* 遮罩层 */}
- { isTrue(options.shade) && <div className="rcpopup__overlay" onClick={handleShadeClick} style={{'opacity': options.opacity, 'zIndex': oIndex-1, ...options.overlayStyle}}></div> }
- {/* 窗体 */}
- <div className="rcpopup__wrap" style={{'zIndex': oIndex}}>
- <div
- ref={childRef}
- className={classNames(
- 'rcpopup__child',
- {
- [`anim-${options.anim}`]: options.anim,
- [`popupui__${options.type}`]: options.type,
- 'round': options.round
- },
- options.position
- )}
- style={popStyles}
- >
- { options.title && <div className="rcpopup__title">{options.title}</div> }
- { (options.type == 'toast' && options.icon) && <div className={classNames('rcpopup__toast', options.icon)} dangerouslySetInnerHTML={{__html: ToastIcon[options.icon]}}></div> }
- {/* 内容 */}
- {/*{ (options.children || options.content) && <div className="rcpopup__content">{options.children || options.content}</div> }*/}
- { options.children ? <div className="rcpopup__content">{options.children}</div> : options.content ? <div className="rcpopup__content" dangerouslySetInnerHTML={{__html: options.content}}></div> : null }
- {/* 按钮组 */}
- { options.btns &&
- <div className="rcpopup__actions">
- {
- options.btns.map((btn, index) => {
- return <span className={classNames('btn', {'btn-disabled': btn.disabled})} key={index} style={btn.style} dangerouslySetInnerHTML={{__html: btn.text}} onClick={e => handleActions(e, index)}></span>
- })
- }
- </div>
- }
- { isTrue(options.closeable) && <div className={classNames('rcpopup__xclose', options.closePosition)} style={{'color': options.closeColor}} onClick={close}></div> }
- </div>
- </div>
- </div>
- )
- }
- useEffect(() => {
- props.visible && open()
- !props.visible && close()
- }, [props.visible])
- // 暴露指定的方法给父组件调用
- useImperativeHandle(ref, () => ({
- open,
- close
- }))
-
- return renderTeleport(options.teleport || mergeProps.teleport, renderNode())
- })
react动态设置className,于是抽离封装了classNames函数。
- // 抽离的React的classnames操作类
- function classNames() {
- var hasOwn = {}.hasOwnProperty
- var classes = []
- for (var i = 0; i < arguments.length; i++) {
- var arg = arguments[i]
- if (!arg) continue
- var argType = typeof arg
- if (argType === 'string' || argType === 'number') {
- classes.push(arg)
- } else if (Array.isArray(arg) && arg.length) {
- var inner = classNames.apply(null, arg)
- if (inner) {
- classes.push(inner)
- }
- } else if (argType === 'object') {
- for (var key in arg) {
- if (hasOwn.call(arg, key) && arg[key]) {
- classes.push(key)
- }
- }
- }
- }
- return classes.join(' ')
- }
非常方便的实现各种动态操作className类。
通过 createRoot 将弹窗组件挂载到body,实现函数式调用。
- /**
- * 函数式弹窗组件
- * rcpop({...}) | rcpop.close()
- */
- let popRef = createRef()
- function Popup(options = {}) {
- options.id = options.id || 'rcpopup-' + Math.floor(Math.random() * 10000)
- // 判断id唯一性
- let rnode = document.querySelector(`#${options.id}`)
- if(options.id && rnode) return
- const div = document.createElement('div')
- document.body.appendChild(div)
- const root = createRoot(div)
- root.render(
- <RcPop
- ref={popRef}
- visible={true}
- {...options}
- onClose={() => {
- let node = document.querySelector(`#${options.id}`)
- if(!node) return
- root.unmount()
- document.body.removeChild(div)
- }}
- />
- )
- return popRef
- }
OK,以上就是react18 hook实现自定义弹窗的一些小分享,希望对大家有所帮助~~??
