0%

reusable-modals

可重用的 Modals

例如一个 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,
})

// onHide, onConfirm 可以从 props 结构, 或直接引用 module scope function
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,
})

// onHide, onConfirm 可以从 props 结构, 或直接引用 module scope function
function SomeModal({show, onHide, pickCallback}: typeof defaultProps) {
const {data: list} = useRequest(fetchList)
const pickCallbackReq = useRequest((result: string) => pickCallback?.(result), { manual: true })
const onCofirm = useMemoizedFn(() => {
// the picked result, get it from useState or somehow
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)
// https://github.com/emotion-js/emotion/issues/3245
// @ts-ignore
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,
}
}