可重用的 Modals
Modal as a Function
例如一个 Picker, 从 list 中 pick 一个 Item, 将选择的行为封装为一个 function
1
| async function pick(): Promise<Item | undefined>
|
List 要么外部传入, 要么比较通用, 由 Modal 自己获取. 实现代码如下
假设
- list 由 Modal 自己获取
type Item = string
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| import Emittery from 'emittery'
const defaultProps = { show: false, onHide, onConfirm, }
const emitter = new Emittery<{ hide: undefine, confirm: string }>()
function onHide() { emitter.emit('hide') updateProps({ show: false }) }
function onConfirm(result: string) { emitter.emit('confirm') onHide() }
async function pick(): Promise<string | undefined> { updateProps({ show: true }) return emitter.once(['hide', 'confirm']) }
const { proxyProps, updateProps } = wrapComponent<IProps>({ Component: SomeModal, containerClassName: 'SomeModal', defaultProps, })
function SomeModal({show, onHide, onConfirm}: typeof defaultProps) { const {data: list} = useRequest(fetchList) return <Modal> {* Modal render ... *} </Modal> }
|
- 借助
Emittery
触发 hide
or confirm
的时候 resolve pending Promise
- hide 可以是
Esc
/ Close Window Button
/ ModalFooter cancel Button
触发, resolve undefine
, 表示取消
- confirm: resolve string
Pros & Cons
- Pros: 这样比较纯粹, Modal 不会耦合其他逻辑
- Cons: Bad UX, 通常对于 pick 结果的操作如果涉及 http request 等可能出错的动作, 由于
pick()
调用结束, Modal 已经 close, 没有 retry 的可能. 或者在其他地方提供 retry 的入口(如一个可交互的 toast / message / notification), 但丢失了 pick 结果的上下文. (不知道 pick 的是什么)
更好的设计: async confirm callback
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| import Emittery from 'emittery'
const defaultProps = { show: false, onHide, pickCallback: undefined as (PickCallback | undefined) }
type PickCallback = (result: string) => Promise<void | undefined | boolean>
const emitter = new Emittery<{ hide: undefine; confirm: string; }>()
function onHide() { emitter.emit('hide') updateProps({ show: false }) }
async function pick(pickCallback: PickCallback): Promise<void> { updateProps({ show: true, pickCallback }) return emitter.once(['hide']) }
const { proxyProps, updateProps } = wrapComponent<IProps>({ Component: SomeModal, containerClassName: 'SomeModal', defaultProps, })
function SomeModal({show, onHide, pickCallback}: typeof defaultProps) { const {data: list} = useRequest(fetchList) const pickCallbackReq = useRequest((result: string) => pickCallback?.(result), { manual: true }) const onCofirm = useMemoizedFn(() => { if(!result) return const success = await pickCallbackReq.runAsync(result) if (success) return onHide() }) return <Modal> {* Modal render ... *} </Modal> }
|
- 传入
pickCallback
, 返回值 Promise<void | undefined | boolean>
, 表示 action 是否 success
- success: 已完成
- not success, 保留 Modal 状态, 给用户 retry 的机会
- 实现
onConfirm
, 在 modal 确定的时候调用 pickCallback
, 同样没有耦合具体业务逻辑, 但可以保留上下文式的重试
Helper Function
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| import { once } from 'es-toolkit' import { createRoot } from 'react-dom/client' import { proxy, useSnapshot } from 'valtio' import type { ComponentType } from 'react'
type AnyFunction = (...args: any[]) => any
export function wrapComponent<IProps extends object>({ Component, defaultProps, containerClassName, }: { Component: ComponentType<IProps> defaultProps: IProps containerClassName?: string }) { const proxyProps = proxy<IProps>(defaultProps)
function WrappedComponent() { const props = useSnapshot(proxyProps) return <Component {...props} /> }
const mount = once(() => { const div = document.createElement('div') div.className = containerClassName document.body.appendChild(div) createRoot(div).render(<WrappedComponent />) })
function wrapAction<T extends AnyFunction>(action: T) { return (...args: Parameters<T>): ReturnType<T> => { mount() return action(...args) } }
const updateProps = wrapAction((newProps: Partial<IProps>) => { Object.assign(proxyProps, newProps) })
return { WrappedComponent, proxyProps, mount, wrapAction, updateProps, } }
|