如果在不使用 react 的情况下,弹层的使用是一件非常直观而且简单的事情,就像这样
var onShow = function() {
document.getElementById('ModalName').className = 'shown'
}
var onHide = function() {
document.getElementById('ModalName').className = 'hidden'
}
如果使用了封装良好的框架,也可以在不使用 react 的时候,很优雅的去使用弹层,有可能是这么个样子~
$('ModalName').show()
$('ModalName').hide()
但是,到了数据驱动的 react 中,原本使用事件驱动时很容易实现的弹层,变得复杂了很多
function Comp() {
const [modalVisible, setModalVisible] = React.useState(false)
...
const onShow = () => {
setModalVisible(true)
}
const onHide = () => {
setModalVisible(false)
}
...
return (
<div>
...
<Modal visible={modalVisible} />
</div>
)
}
function Modal(props) {
return props.visible && (
...
)
}
在 react 中这种写法应该是不会引发奇怪 bug 的标准答案,但也可以很明显的看出设置,最简单的设置 modal 是否展示的代码,需要在至少 3 个地方进行维护,如果组件很复杂的话,或者当前组件中有很多个弹层的话,在复制这些重复的代码,心情有时候会很糟糕,特别是有时候由于某个位置忘记更改,结果引发了 bug 这种 = =
以数据驱动的原则的 react,虽然在很多方面提供了大量的便利,但弹层刚好是使用 react 处理起来变得复杂的内容之一,其根本原因在于,事件驱动拿到的是要操作目标的引用,类似于 引用.干点啥() 这种感觉的操作,数据驱动的核心在于数据,所以无论如何都需要有 先定义数据 -> 根据数据的变化渲染 UI -> 封装数据可以变化的范围 这样三个步骤才行,这种方式在处理大部分数据渲染的场景时及其好用,但在处理一些事件驱动的场景时,就可能有些力不从心了 orz
如果说到引用,就会自然而然的想起另外一个东西,react 提供了一种获取 ref 的方式,是不是这样就可以比较优雅的去使用弹层了呢?
function Comp() {
const modalRef = React.useState(null)
...
const onShow = () => {
modalRef.current?.show()
}
const onHide = () => {
modalRef.current?.hide()
}
...
return (
<div>
...
<Modal onRef={modalRef} />
</div>
)
}
function Modal(props) {
const [visible, setVisible] = React.useState(false)
React.useEffect(() => {
props.onRef?.({
show: () => setVisible(true),
hide: () => setVisible(false),
})
}, [])
return visible && (
...
)
}
第一眼看上去似乎还要比刚刚复杂了一点,但由于这个时候已经将数据变化的范围整理在了一起,所以就可以使用 hoc 之类的方案将这部分逻辑做一个整合
function enchanceModal(WrappedComonent) {
return props => {
const [visible, setVisible] = React.useState(false)
React.useEffect(() => {
props.onRef?.({
show: () => setVisible(true),
hide: () => setVisible(false),
})
}, [])
return visible && <WrappedComonent {...props} />
}
}
const Modal = enchanceModal(function(props){
return (...)
})
这里只是做一个简单示例,hoc 中可以做更多的封装和参数传递,不过这种封装看起来并没有变得优雅很多,因为现在虽然没有了 定义数据,但多了 定义引用 的步骤,而且 react 官方并不是很推荐非受控组件的实现方案,所以...还是在找找有没有更优雅的方案吧 orz
比如,直接在 react 中使用类似 jquery 的框架 ?
function Comp() {
...
const onShow = () => {
$('ModalId').show()
}
const onHide = () => {
$('ModalId').hide()
}
...
return (
<div>
...
<Modal id="ModalId" />
</div>
)
}
function Modal(props) {
return (
<div id={props.id}>
...
</div>
)
}
看起来是没什么问题的样子,而且已经非常接近了事件驱动时的写法,虽然我没有真正试过,但这段代码估计也是可以比较好的执行的
但是!在数据驱动的场景下,直接使用事件驱动的方式真的好吗?
除去可以忽略不记的性能问题和体积问题之外,一个最简单而且致命的问题就是,modal 的生命周期和数据会变得不可控,假设只使用了设置 class 这种简单的方式去控制弹层显示的话,显示后的弹层的数据,是不会随着 react 的生命周期被重新渲染的,或者有可能之前设置的 class 被数据渲染时刷掉 = =
总之,在 react 中使用非 react lib 操作 ui 的时候,理论上确实不会有什么问题,但有些时候,特别是组件出现 unmount 之类的场景的时候,就很容易出现意料之外的问题
结论就是:尽量使用 react 的方式去实现 react 组件!
不然的话就只能细细品味其中的奥妙了(手动斜眼
PS: 因为没有用过这么骚的实现方式,只是直觉告诉我这里肯定有天坑在里面,各位大佬轻喷
好吧,既然没有合适的轮子可用,那就只能自己动手撸轮子喽~
如果是我的话,我希望使用的 modal 是这个样子滴~
function Comp() {
...
const modal = useModal(mProps => (
<Modal {...mProps}>
<div onClick={modal2.show}>{count}</div>
</Modal>
), [count])
...
const onShow = () => {
modal.show()
}
const onHide = () => {
modal.hide()
}
...
}
const TModal = (props) => {
const { uuid, children, className } = props
const { shown } = useModalStatus(uuid)
const [ onHide, onClose ] = useModalClose(uuid)
return (
<div
className={cx('modal', className, { shown })}
onClick={onHide}
onTransitionEnd={onClose}
>
{cloneModalContent(children)}
</div>
)
}
export const Modal = enhancePopupComponent(TModal)
对,我很希望 modal 的引用封装可以和 dom 声明强耦合在一起,因为是比较完善的代码,所以 modal 的部分会复杂那么一点点,这里仅做示意~
经过我无数次的考虑和踩坑,最终还是选择了全局性的 uuid 作为实现 orz
核心的实现方式是用了 useReducer + useContext 的方案,讲道理对于处理 UI 级别的显示状态,我越来越喜欢这对组合啦~
就是那个刚开始感觉没什么卵用的 useReducer 和 useContext,都有 redux 谁还用这种东西
真香,呸
反正同时使用 useReducer 和 useContext 可能会有意外之喜,如果之前没有用过的话
重要的事情已经说三遍了~
要贴源码么,还是先贴链接上来吧~ github.com/frontend-ki…
这个库由于封装了很多东西,所以这里贴一个 story 的链接~
核心代码如下
Popup.tsx
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import styles from './styles/Popup.module.scss'
import { transformStyles } from '../utils/style'
const cx = transformStyles(styles)
const POPUP_LAYER_ID = 'PopupLayer'
let scrolledTop = 0
const createPopupLayer = (): HTMLElement => {
const node = document.createElement('div')
node.id = POPUP_LAYER_ID
node.className = cx('popup-layer')
// node.onclick = e => e.preventDefault();
scrolledTop = window.scrollY
document.body.className = cx('popup-locked-body')
document.body.style.top = `-${scrolledTop}px`
document.body.appendChild(node)
return node
}
const removePopupLayer = (rootNode: HTMLElement) => {
if (rootNode.children.length === 0) {
if (!document.body.contains(rootNode)) {
return
}
document.body.className = ''
// document.body.style.overflow = 'auto'
document.body.removeChild(rootNode)
window.scrollTo(0, scrolledTop)
}
}
const PopupComponent = (props: any) => {
const rootNode = document.getElementById(POPUP_LAYER_ID) || createPopupLayer()
React.useEffect(() => {
return () => {
// WORKAROUND 延迟 10 毫秒关闭弹出层确保组件已经关闭并且没有新的弹窗出现
setTimeout(() => {
removePopupLayer(rootNode)
}, 10)
}
})
return ReactDOM.createPortal(React.cloneElement(props.children), rootNode)
}
export type IPopupProps = {
isOpen: boolean
onClose: () => void
onRemove: () => void
}
export const enhancePopupComponent = (
WrappedComponent: any,
layerClassName?: string,
) => (props: any): any => {
return (
<PopupComponent className={layerClassName}>
<WrappedComponent {...props} />
</PopupComponent>
)
}
ModalLayer.tsx
import * as React from 'react'
import { useInitModalContext } from '../logics/ModalLayerContext'
export const ModalContext = React.createContext<any>({})
type IModalLayerProps = {
}
export const ModalLayer: React.FC<IModalLayerProps> = props => {
const { children } = props
const context = useInitModalContext()
const { modals, opts } = context
return (
<ModalContext.Provider value={context}>
{children}
{Object.keys(modals).map((key) => {
const modal = modals[key]
const props = opts[key]?.props
return React.cloneElement(modal({ uuid: key, ...props }), { key })
})}
</ModalContext.Provider>
)
}
export function useModalContext() {
return React.useContext<any>(ModalContext)
}
export function cloneModalContent(children: any) {
return React.cloneElement(children, {
onClick: (e: React.MouseEvent) => {
e.stopPropagation()
children.props?.onClick?.()
},
})
}
ModalLayerContext.ts
import * as React from 'react'
import { createReducer } from 'react-logic-utils'
const MODAL_ACTION_OPEN = 'MODAL_ACTION_OPEN'
const MODAL_ACTION_UPDATE = 'MODAL_ACTION_UPDATE'
const MODAL_ACTION_CLOSE = 'MODAL_ACTION_CLOSE'
const MODAL_ACTION_HIDE = 'MODAL_ACTION_HIDE'
const open = (modal: any, uuid: string, props?: any) => ({ type: MODAL_ACTION_OPEN, modal, uuid, props })
const update = (modal: any, uuid: string) => ({ type: MODAL_ACTION_UPDATE, modal, uuid })
const close = (uuid: string) => ({ type: MODAL_ACTION_CLOSE, uuid })
const hide = (uuid: string) => ({ type: MODAL_ACTION_HIDE, uuid })
const initialState = {
modals: {},
opts: {},
names: {},
}
const appReducer = createReducer(state => ({
[MODAL_ACTION_OPEN]: ({ modal, uuid, props }) => {
state.modals[uuid] = modal
state.opts[uuid] = {}
if (props) {
state.opts[uuid].props = props
}
},
[MODAL_ACTION_UPDATE]: ({ modal, uuid }) => {
if (!state.modals[uuid]) {
return
}
state.modals[uuid] = modal
},
[MODAL_ACTION_CLOSE]: ({ uuid }) => {
delete state.modals[uuid]
delete state.opts[uuid]
},
[MODAL_ACTION_HIDE]: ({ uuid }) => {
const opts = state.opts[uuid]
state.opts[uuid] = { ...opts, hidden: true }
},
}))
export function useInitModalContext() {
const [state, dispatch] = React.useReducer(appReducer, initialState)
const data = {
modals: state.modals,
opts: state.opts,
names: state.names,
}
const actions = {
open: (modal: any, uuid: string, props?: any) => dispatch(open(modal, uuid, props)),
update: (modal: any, uuid: string) => dispatch(update(modal, uuid)),
close: (uuid: string) => dispatch(close(uuid)),
hide: (uuid: string) => dispatch(hide(uuid)),
}
return { ...data, ...actions }
}
ModalLayerHooks.ts
import * as React from 'react'
import uuidv4 from 'uuid/v4'
import { useModalContext } from '../containers/ModalLayer'
import { usePopupShown } from '../components/Popup'
export type IModalProps = {
uuid: string
}
type IModalType<T> = (props: IModalProps & T) => React.ReactElement
function isUIEvent(test: any): test is React.UIEvent {
return test && !!test.nativeEvent
}
export function useModal<T>(modal: IModalType<T>, deps = [] as any[]) {
const { open, hide, update, modals } = useModalContext()
const [uuid, setUuid] = React.useState('')
const uuidRef = React.useRef('')
const memoModal = React.useMemo(() => modal, deps)
const opened = !!modals[uuid]
const onShowModal = (props?: T | React.UIEvent) => {
const uuid = uuidv4()
if (isUIEvent(props)) {
open(modal, uuid)
} else {
open(modal, uuid, props)
}
setUuid(uuid)
uuidRef.current = uuid
}
const onHideModal = React.useCallback(() => {
hide(uuid || uuidRef.current)
setUuid('')
uuidRef.current = ''
}, [uuid])
const onToggleModal = React.useCallback((props?: T) => {
if (opened || (!uuid && uuidRef.current)) {
onHideModal()
} else {
onShowModal(props)
}
}, [opened, onShowModal, onHideModal])
React.useEffect(() => {
if (!uuid) {
return
}
update(memoModal, uuid)
}, [uuid, memoModal])
return {
show: onShowModal,
hide: onHideModal,
toggle: onToggleModal,
}
}
export function useModalClose(uuid: string, timeout: number = 0) {
const { hide, close, opts } = useModalContext()
const hidden = opts[uuid]?.hidden
const onHide = () => {
hide(uuid)
if (timeout === 0) {
return
}
setTimeout(() => {
close(uuid)
}, timeout)
}
// !shown 表示处于 hidden 状态
const onClose = React.useCallback(() => {
if (hidden) {
close(uuid)
}
}, [hidden])
return [onHide, onClose]
}
// opened -> shown -> hidden/shown false -> closed/opened falase
export function useModalStatus(uuid: string) {
const { modals, opts } = useModalContext()
const opened = !!modals[uuid]
const hidden = opts[uuid]?.hidden
const shown = usePopupShown(hidden)
return { opened, shown }
}
啦啦啦~