基於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 }) } } ] }) }
- msg類型
- 自定義多按鈕
rcpop({ title: '標題', content: `<div style="color:#f90"> <p>顯示自定義彈窗內容</p> </div>`, btns: [ { text: '稍後提示' }, { text: '取消', click: () => rcpop.close() }, { text: '立即更新', style: {color: '#09f'}, click: () => { // ... } } ] })
- ios彈窗類型
- android彈窗類型
- 長按/右鍵菜單
- 自定義內容
<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實現自定義彈窗的一些小分享,希望對大家有所幫助~~💪