banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

為什麼我更推薦命令式 Modal

更新 24.3:已封裝成組件庫,歡迎大家來使用。

Modal 模態對話框。

一個非常常見的組件。不管是 C 端還是 B 端都能看到它的身影。但是你真的會用它嗎?

什麼是聲明式 Modal#

組件庫中一般都會內置這類組件,最為參見的聲明式 Modal 定義。

例如 Antd 5 中的聲明式 Modal 是這樣定義的。

const App: React.FC = () => {
  const [isModalOpen, setIsModalOpen] = useState(false)

  const showModal = () => {
    setIsModalOpen(true)
  }

  const handleOk = () => {
    setIsModalOpen(false)
  }

  const handleCancel = () => {
    setIsModalOpen(false)
  }

  return (
    <>
      <Button type="primary" onClick={showModal}>
        打開 Modal
      </Button>
      <Modal
        title="基本 Modal"
        open={isModalOpen}
        onOk={handleOk}
        onCancel={handleCancel}
      >
        <p>一些內容...</p>
        <p>一些內容...</p>
        <p>一些內容...</p>
      </Modal>
    </>
  )
}

上面是一個受控的聲明式 Modal 定義,寫起來非常臃腫。你需要手動控制 Modal 的 Open 狀態。並且你需要首先定義一個狀態,然後在編寫 UI,將狀態和 UI 綁定。

這樣的寫法,我們需要在同一個組件定義一個狀態,一個觸發器(例如 Button)-> 控制狀態 -> 流轉到 Modal 顯示。不僅寫起來複雜,後期維護起來也很困難。

業務越積越多,後面你的頁面上可能是這樣的。

<>
  <Button type="primary" onClick={showModal}>
    打開 Modal 1
  </Button>

  <Button type="primary" onClick={showModal}>
    打開 Modal 2
  </Button>

  {/* 更多按鈕 */}
  <Modal
    title="基本 Modal"
    open={isModalOpen}
    onOk={handleOk}
    onCancel={handleCancel}
  >
    <p>一些內容...</p>
  </Modal>

  <Modal
    title="基本 Modal 2"
    open={isModalOpen}
    onOk={handleOk}
    onCancel={handleCancel}
  >
    <p>一些內容...</p>
  </Modal>
  <Modal
    title="基本 Modal 3"
    open={isModalOpen}
    onOk={handleOk}
    onCancel={handleCancel}
  >
    <p>一些內容...</p>
  </Modal>
</>

一個組件中填滿了無數個 Modal 和 Button。

這個時候你會想去抽離 Modal 到外部。像這樣:

const App: React.FC = () => {
  const [isModalOpen, setIsModalOpen] = useState(false)

  const showModal = () => {
    setIsModalOpen(true)
  }

  const handleOk = () => {
    setIsModalOpen(false)
  }

  const handleCancel = () => {
    setIsModalOpen(false)
  }

  return (
    <>
      <Button type="primary" onClick={showModal}>
        打開 Modal
      </Button>
      <BaseModal1 {...{ isModalOpen, handleOk, handleCancel }} />
    </>
  )
}
const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
  return (
    <Modal
      title="基本 Modal"
      open={isModalOpen}
      onOk={handleOk}
      onCancel={handleCancel}
    >
      <p>一些內容...</p>
    </Modal>
  )
}

然後你會發現控制 Modal 的狀態還是在父組件頂層。導致父組件狀態堆積越來越多。

const App: React.FC = () => {
  const [isModalOpen, setIsModalOpen] = useState(false)
  const [isModalOpen2, setIsModalOpen2] = useState(false)
  const [isModalOpen3, setIsModalOpen3] = useState(false)
  // ....
}

然後你思來想去,直接把 Modal 和 Button 抽離到一起。

const App: React.FC = () => {
  return <BaseModal1 />
}
const BaseModal1 = () => {
  const [isModalOpen, setIsModalOpen] = useState(false)

  const showModal = () => {
    setIsModalOpen(true)
  }

  const handleOk = () => {
    setIsModalOpen(false)
  }

  const handleCancel = () => {
    setIsModalOpen(false)
  }
  return (
    <>
      <Button type="primary" onClick={showModal}>
        打開 Modal
      </Button>
      <Modal
        title="基本 Modal"
        open={isModalOpen}
        onOk={handleOk}
        onCancel={handleCancel}
      >
        <p>一些內容...</p>
      </Modal>
    </>
  )
}

好了,這樣 Button 和 Modal 直接耦合了,後續你想單獨復用 Modal 幾乎不可能了。

想來想去,再把 Modal 拆了。像這樣:

const App: React.FC = () => {
  return <BaseModal1WithButton />
}
const BaseModal1WithButton = () => {
  const [isModalOpen, setIsModalOpen] = useState(false)

  const showModal = () => {
    setIsModalOpen(true)
  }

  const handleOk = () => {
    setIsModalOpen(false)
  }

  const handleCancel = () => {
    setIsModalOpen(false)
  }
  return (
    <>
      <Button type="primary" onClick={showModal}>
        打開 Modal
      </Button>
      <BaseModal1 open={isModalOpen} onOk={handleOk} onCancel={handleCancel} />
    </>
  )
}

const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
  return (
    <Modal
      title="基本 Modal"
      open={isModalOpen}
      onOk={handleOk}
      onCancel={handleCancel}
    >
      <p>一些內容...</p>
    </Modal>
  )
}

我去,為了解耦一個 Modal 居然要寫這麼多代碼,而且還是不可復用的,亂七八糟的狀態。

想像一下這才一個 Modal,就要寫這麼多。

後來,你又會遇到了這樣的問題,因為控制 Modal 狀態下沉了,導致你的 Modal 無法在父組件中直接控制。

然後你會直接在外部 Store 或者 Context 中下放這個狀態。

import { atom } from 'jotai'
const BasicModal1OpenedAtomContext = createContext(atom(false))
const App: React.FC = () => {
  const ctxValue = useMemo(() => atom(false), [])

  return (
    <BasicModal1OpenedAtomContext.Provider value={ctxValue}>
      <button
        onClick={() => {
          jotaiStore.set(ctxValue, true)
        }}
      >
        打開 Modal 1
      </button>
      <BaseModal1WithButton />
    </BasicModal1OpenedAtomContext.Provider>
  )
}

const BaseModal1 = ({ handleOk, handleCancel }) => {
  const [isModalOpen, setIsModalOpen] = useAtom(
    useContext(BasicModal1OpenedAtomContext),
  )
  return (
    <Modal
      title="基本 Modal"
      open={isModalOpen}
      onOk={handleOk}
      onCancel={handleCancel}
    >
      <p>一些內容...</p>
    </Modal>
  )
}

最後 ctx 或者 Store 裡面的狀態越來越多,你會發現你的代碼越來越難以維護。最後你都不知道這個 Modal 狀態到底需不需要。

何況,Modal 就算沒有顯示,但是 Modal 還是存在於 React tree 上的,祖先組件的狀態更新,也會導致 Modal 重新渲染產生性能開銷。

快試試命令式 Modal#

某些組件庫中也會提供命令式 Modal,在 Antd 5 中是這樣的。

function Comp() {
  const [modal, contextHolder] = Modal.useModal()

  return (
    <>
      <Button
        onClick={async () => {
          const confirmed = await modal.confirm(config)
          console.log('已確認: ', confirmed)
        }}
      >
        確認
      </Button>
      {contextHolder}
    </>
  )
}

上面的寫法是不是簡單了很多,不再需要外部狀態控制顯示隱藏。

但是看上去這個命令式 Modal 定義過於簡單,一般只適用於對話框提示。並不會去承載複雜的業務邏輯。

實現一個命令式 Modal#

好了,按照這樣的思路,我們可以嘗試一下自己實現一個命令式 Modal。我們實現的 Modal 需要做到最小化遷移原先的聲明式 Modal,同時又能夠承載複雜的業務邏輯。

總體思路,我們需要在應用頂層使用一個 Context 來存儲所有 Modal 的狀態。當 Modal 使用 present 時,創建一個 Modal 實例記錄到 Context 中。當 Modal 被關閉時,銷毀 Modal 實例。所以在頂層 ModalStack 中的狀態應該只包含現在渲染的 Modal 實例。最大化節省內存資源。

接下來我使用 Antd Modal + Jotai 的進行實現。其他類似組件實現方式基本一致。

首先,我們實現 ModalStack

import { ModalProps as AntdModalProps, Modal } from 'antd'

type ModalProps = {
  id?: string

  content: ReactNode | ((props: ModalContentProps) => ReactNode)
} & Omit<AntdModalProps, 'open'>

const modalStackAtom = atom([] as (Omit<ModalProps, 'id'> & { id: string })[])

const ModalStack = () => {
  const stack = useAtomValue(modalStackAtom)

  return (
    <>
      {stack.map((props, index) => {
        return <ModalImpl key={props.id} {...props} index={index} />
      })}
    </>
  )
}

定義 useModalStack 用於喚出 Modal。

const modalIdToPropsMap = {} as Record<string, ModalProps>

export const presetModal = (
  props: ModalProps,
  modalId = ((Math.random() * 10) | 0).toString(),
) => {
  jotaiStore.set(modalStackAtom, (p) => {
    const modalProps = {
      ...props,
      id: props.id ?? modalId,
    } satisfies ModalProps
    modalIdToPropsMap[modalProps.id!] = modalProps
    return p.concat(modalProps)
  })

  return () => {
    jotaiStore.set(modalStackAtom, (p) => {
      return p.filter((item) => item.id !== modalId)
    })
  }
}

export const useModalStack = () => {
  const id = useId()
  const currentCount = useRef(0)
  return {
    present(props: ModalProps) {
      const modalId = `${id}-${currentCount.current++}`
      return presetModal(props, modalId)
    },
  }
}

上面的代碼,我們定義了 modalStackAtom 用於存儲 Modal 實例。presetModal 用於喚出 Modal。useModalStackpresent 用於喚出一個新的 Modal。

由於我們使用了 Jotai 外部狀態去管理 Modal 實例,所以 presetModal 被提取到了外部,日後我們可以直接脫離 React 使用。

注意這個類型定義,我們基本直接繼承了原有的 ModalProps,但是過濾了 open 屬性。因為我們不需要外部控制 Modal 的顯示隱藏,而是直接在 ModalStack 中控制 Modal 的顯隱。

content 屬性,後續方便我們去擴展傳入的 props。比如這裡,我們可以傳入一個 ModalActions 作為 props。那麼以後定義 Content 時候可以直接接受一個 props,通過 dismiss 方法關閉當前 Modal。

type ModalContentProps = {
  dismiss: () => void
}

type ModalProps = {
  id?: string

  content: ReactNode | ((props: ModalContentProps) => ReactNode)
} & Omit<AntdModalProps, 'open' | 'content'>

<ModalImpl /> 的實現是非常簡單的,在此之前,我們先定義一下 ModalActionContext,後續可以在 Modal 中直接調用使用。

const actions = {
  dismiss(id: string) {
    jotaiStore.set(modalStackAtom, (p) => {
      return p.filter((item) => item.id !== id)
    })
  },
  dismissTop() {
    jotaiStore.set(modalStackAtom, (p) => {
      return p.slice(0, -1)
    })
  },
  dismissAll() {
    jotaiStore.set(modalStackAtom, [])
  },
}

改進 useModalStack

export const useModalStack = () => {
  const id = useId()
  const currentCount = useRef(0)
  return {
    present: useCallback((props: ModalProps) => {
      const modalId = `${id}-${currentCount.current++}`
      return presetModal(props, modalId)
    }, []),
+    ...actions
  }
}

現在可以通過 useModalStack().dismiss 關閉某個 Modal 了,也可以通過 useModalStack().dismissTop 關閉最上層的 Modal 等等。

現在編寫 <ModalImpl />

const ModalActionContext = createContext<{
  dismiss: () => void
}>(null!)

export const useCurrentModalAction = () => useContext(ModalActionContext)

const ModalImpl: FC<
  Omit<ModalProps, 'id'> & {
    id: string
    index: number
  }
> = memo((props) => {
  const { content } = props
  const [open, setOpen] = useState(true)
  const setStack = useSetAtom(modalStackAtom)

  const removeFromStack = useEventCallback(() => {
    setStack((p) => {
      return p.filter((item) => item.id !== props.id)
    })
  })

  useEffect(() => {
    let isCancelled = false
    let timerId: any
    if (!open) {
      timerId = setTimeout(() => {
        if (isCancelled) return
        removeFromStack()
      }, 1000) // 這裡控制一個時間差,等待 Modal 關閉後的動畫完成,銷毀 Modal 實例
    }
    return () => {
      isCancelled = true
      clearTimeout(timerId)
    }
  }, [open, removeFromStack])
  const onCancel = useEventCallback(() => {
    setOpen(false)
    props.onCancel?.()
  })

  return (
    <ModalActionContext.Provider // 這裡在當前 Modal 上下文提供一些 Modal Actions
      value={useMemo(() => ({ dismiss: onCancel }), [onCancel])}
    >
      <Modal {...props} open={open} destroyOnClose onCancel={onCancel}>
        {typeof content === 'function'
          ? createElement(content, { dismiss: onCancel }) // 這裡可以通過 props 傳遞參數到 content 中
          : content}
      </Modal>
    </ModalActionContext.Provider>
  )
})
ModalImpl.displayName = 'ModalImpl'

OK,這樣就整體實現完了。

現在我們來到 React App 頂層組件,掛載 <ModalStack />

const App = document.getElementById('root')
const Root: FC = () => {
  return (
    <div>
      <ModalStack />
    </div>
  )
}

然後像這樣使用:

  <div>
      <ModalStack />
+      <Page />
  </div>
const Page = () => {
  const { present } = useModalStack()
  return (
    <>
      <div>
        <button
          onClick={() => {
            present({
              title: '標題',
              content: <ModalContent />,
            })
          }}
        >
          Modal 堆疊
        </button>
      </div>
    </>
  )
}

const ModalContent = () => {
  const { dismiss } = useCurrentModalAction() // 控制當前 Modal 的 actions

  return (
    <div>
      這個 Modal 內容。
      <br />
      <button onClick={dismiss}>關閉</button>
    </div>
  )
}

當然你也可以在 Modal 內部繼續使用 useModalStack 喚出新的 Modal。

const ModalContent = () => {
  const { dismiss } = useCurrentModalAction()
  const { present, dismissAll } = useModalStack()

  return (
    <div>
      這個 Modal 內容。
      <ButtonGroup>
        <Button
          onClick={() => {
            present({
              title: '標題',
              content: <ModalContent />,
            })
          }}
        >
          顯示新 Modal
        </Button>
        <Button onClick={dismiss}>關閉這個</Button>
        <Button onClick={dismissAll}>關閉所有</Button>
      </ButtonGroup>
    </div>
  )
}
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。