最近项目频繁用到了 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 全局引用
设置一个 Context 将 useReducer 的数据提供给所有的页面。
// 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
最后,来看下具体效果:
很完美地实现了需求,欢迎各位在评论区交流。如果对你有帮助,请给我点个赞吧