如何在 react 中优雅的使用弹层~

315 阅读7分钟

如果在不使用 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 }
}


啦啦啦~