React Dialog 组件的个人实践

2,038 阅读4分钟

最近项目频繁用到了 dialog ,由于是面向 C 端, 所用的 ui 框架没有样式符合的,因此封装了一个 Dialog 组件,也摸索出了自己的一套实践。本文虽然是面向 react ,但同样的思路可以用在 vue3 中。


初步封装

html5 已经有了 <dialog> 标签,从语义化的角度我推荐你用它,但在项目中我们更应该考虑的是兼容性,目前各浏览器对 <dialog> 标签的支持度并不好,除非你面向的是比较 geek 的群体,比如 github 。

我们可以利用 react 的 children 和 react-dom 的 createPortal api,create portal 从字面意思是创建一个传送门,它提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案,因此,可以利用这个 api ,可以把 dialog 挂载在 body 节点下。

以下为代码

// Dialog.tsx
import styles from '../styles/Dialog.module.css'
import {createPortal} from 'react-dom'

type DialogProps = {
    children?: object
}

const Dialog = ({children}: DialogProps): JSX.Element => {

    return createPortal(
        <>
            <section className={styles.overlay}>
                <main className={styles.wrapper}>
                    {children}
                </main>
            </section>
        </>,
        document.body
    )
}

export default Dialog

在上面的组件中 overlay 为遮罩,wrapper 通常用于设置圆角阴影等属性,Dialog 的大小应该由传入的 children 决定,当然如果你的项目 Dialog 大小固定,也可以写死。使用也非常简单,创建一个 state 变量来控制就行。

const [display, setDisplay] = useState(false)

return <div>

    <button onClick={() => {
        setDisplay(true)
    }}>
        Display
    </button>

    {
        display
        &&
        <Dialog>
            <div style={{
                overflow: 'hidden',
                width: '200px',
                height: '200px',
                textAlign: 'center'
            }}>
                <h1> First </h1>
                <button onClick={() => {
                    setDisplay(false)
                }}>
                    close
                </button>
            </div>
        </Dialog>
    }

</div>

不足点

该组件虽然实现了需求,但还存在下列几个问题:

  • 如果该页面存在多个 Dialog ,会显著增加页面的代码,与业务代码耦合在一起,对可维护性也是一种挑战
  • 多个 Dialog 相互触发会造成状态管理的混乱
  • 如果页面有滚动条,仍会触发滚动

使用自定义 Hook 来管理组件

useReducer 可以让我们通过 dispatch 来管理组件状态。考虑到页面可能需要打开的 dialog 不止一个,使用 Array 作为状态。再考虑到这样一种情况,Dialog A 触发了 Dialog B,B 又触发了 Dialog C , 那么通常的关闭顺序是什么呢?最大可能是先关闭 C ,再关闭 B,最后 A。是不是很符合栈(先进后出)的特征,因此下面我的命名采用了 dialogStack,主要写了 4 个分支,close 关闭全部,open 打开 dialog,replace 替换当前 dialog, back 回退上一级,可以根据实际需求扩展。

interface DialogStackItem  {
    dialogFlag: string
    dialogProps?: any
}

type Action = {
    type: 'close' | 'back'
} | {
    type: 'open' | 'replace',
    dialogFlag: string
    dialogProps?: {[key: string]: any}
}

const dialogReducer = (dialogStack: DialogStackItem[], action: Action): DialogStackItem[] => {
    switch (action.type) {
        case 'close':
            return []
        case 'open':
            return [
                ...dialogStack,
                {
                    dialogFlag: action.dialogFlag,
                    dialogProps: action.dialogProps
                }
            ]
        case 'replace':
            return [
                {
                    dialogFlag: action.dialogFlag,
                    dialogProps: action.dialogProps
                }
            ]
        case 'back':
            return dialogStack.slice(0, dialogStack.length - 1)
        default:
            return dialogStack
    }
}

const initDialogState: DialogStackItem[] = []

接着,创建一个自定义 Hook,并解决滚动条问题,当 dialogStack.length 长度不为 0 时,则说明页面中存在 dialog ,此时设置 body 节点 overflow: hidden; ,禁用滚动条。

const useDialog = () => {
    const [dialogStack, dispatch] = useReducer(dialogReducer, initDialogState)

    useEffect(() => {
        document.body.style.cssText = dialogStack.length ? 'overflow: hidden;' : ''
    }, [dialogStack.length])

    return {
        dialogStack,
        dispatch
    }
}

export default useDialog

最后导出 type ,供后续文件使用

export type {
    DialogStackItem,
    Action
}

Context 全局引用

设置一个 ContextuseReducer 的数据提供给所有的页面。

// DialogContext.ts
import {createContext, Dispatch} from "react"
import {DialogStackItem, Action} from "../hooks/useDialog"

type Value = {
    dialogStack: DialogStackItem[]
    dispatch: Dispatch<Action>
}

const defaultValue: Value = {
    dialogStack: [],
    dispatch: (action) => {}
}

const DialogContext = createContext<Value>(defaultValue)

export default DialogContext

接下来在顶层引入

// App.tsx
import Dialog from './components/dialog'
import DialogContext from './contexts/dialogContext'
import useDialog from './hooks/useDialog'

function App() {

  const { dialogStack, dispatch } = useDialog()

  return (
    <DialogContext.Provider value={{ dialogStack, dispatch }}>
      <>
        <PageRouter />
        <Dialog />
        <Loading />
      </>
    </DialogContext.Provider>
  )
}

export default App

Dialog 子组件

首先,让我们引入一个概念:Dialog 子组件,其也是组件,通过 dispatch 命令来进行匹配,匹配成功会作为 Dialog 组件内部的子组件。对于 Dialog 子组件的数据,你可以在内部定义,也可以通过 dispatch 传递 dialogProps 把需要的数据全部传入,我更推荐后一种。

我创建了两个 Dialog 子组件,方便后续的演示,由于是 demo ,直接使用内联的样式写法,正式开发不推荐这种做法。

  • AboutUs

    import React from "react"
    
    // close 关闭 dialog,openElse 打开其他 dialog
    const AboutUs = ({dialogProps}): JSX.Element => {
    
        return <div style={{
            overflow: 'hidden',
            width: '200px',
            height: '130px',
            textAlign: 'center'
        }}>
            <h1>
                About Us
            </h1>
            <div>
                <button onClick={dialogProps.close}>
                    close
                </button>
                <button onClick={dialogProps.openElse} style={{
                    marginLeft: '10px'
                }}>
                    open else
                </button>
            </div>
        </div>
    }
    
    export default AboutUs
    
  • ContactUs

    import React from "react"
    
    // back 用于关闭当前组件,回到前一个 dialog
    const ContactUs = ({dialogProps}): JSX.Element => {
    
        return <div style={{
            overflow: 'hidden',
            width: '300px',
            height: '170px',
            textAlign: 'center'
        }}>
            <h1>
                Contact Us
            </h1>
            <div>
                <button onClick={dialogProps.back}>
                    back
                </button>
            </div>
        </div>
    }
    
    export default ContactUs
    

改造 Dialog Component

让我们把 Dialog 子组件引入,并创建一个 match 函数进行匹配。我使用了 switch,也可以使用 Map ,但在 typescript 下有时会报错,最好应该建立一个 Not_Found 的 Dialog 子组件,作为容错处理。

import AboutUs from "./aboutUs"
import ContactUs from "./contactUs"

const matchComponent = (dialogFlag: string) => {
    switch (dialogFlag) {
        case 'About_Us': return AboutUs
        case 'Contact_Us': return ContactUs
    }
}

使用 map 对匹配到的 Dialog 子组件进行渲染

import styles from '../styles/Dialog.module.css'
import {DialogStackItem} from "../../hooks/useDialog"

const Dialog = (): JSX.Element => {

    const { dialogStack } = useContext(DialogContext)

    return createPortal(
        <>
            {
                dialogStack.map(({ dialogFlag, dialogProps }: DialogStackItem, index) => {
                    const DialogContent = matchComponent(dialogFlag)
                    return (
                        DialogContent
                        &&
                        <section key={dialogFlag + index} style={styles.overlay}>
                            <main style={styles.wrapper}>
                                <DialogContent dialogProps={dialogProps} />
                            </main>
                        </section>
                    )
                })
            }
        </>,
        document.body
    )
}

export default Dialog

使用

使用 useContext 拿到 useDialog 的数据,通过 dispatch 进行调用,以下为具体代码:

import {useContext} from "react"
import DialogContext from "../contexts/dialogContext"

const Demo = () => {

    const {dispatch} = useContext(DialogContext)

    const back = () => {
        dispatch({
            type: 'back'
        })
    }

    const close = () => {
        dispatch({
            type: 'close'
        })
    }

    const openElse = () => {
        dispatch({
            type: 'open',
            dialogFlag: 'Contact_Us',
            dialogProps: {
                back
            }
        })
    }

    const openDialog = () => {
        dispatch({
            type: 'open',
            dialogFlag: 'About_Us',
            dialogProps: {
                close,
                openElse
            }
        })
    }

    return <div>
        <button onClick={openDialog}> dispatch Dialog</button>

        {/*调出滚动条*/}
        <div style={{
            width: '200px',
            height: '1500px',
            backgroundColor: '#2b2b2b'
        }}/>
    </div>
}

export default Demo

最后,来看下具体效果:

Dialog Demo.gif


很完美地实现了需求,欢迎各位在评论区交流。如果对你有帮助,请给我点个赞吧