事件起因
由于在之前使用Vue配合Element Plus的开发中,使用过ElMessageBox.prompt,可以快速的应付对单个字段修改类似的业务。
而它的api式的写法,也很方便所需数据的获取
在使用React配合Antd中,并没有提供类似的组件,因此我们只能拿Modal自行实现。
最终实现效果
function Demo2() {
const click = async () => {
const val = await Prompt({
content: "请填写手机号",
inputPattern: /^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\d{8}$/,
inputErrorMessage: "手机号格式错误"
})
console.log(val)
}
return (
<div>
<button onClick={click}>点击</button>
</div>
)
}
弹窗的实现
我们使用 Antd-Modal 可以很方便的实现我们所需要的弹框
// 一个弹框组件
const Prompt = forwardRef<PromptHolderRef, {}>((_, ref) => {
const state = useReactive<CustomRequired<PromptProps, "title" | "type" | "value" | "placeholder">>({
title: "提示",
type: "text",
value: "",
placeholder: "请输入"
})
/**
* 校验的状态
*/
const [status, setStatus] = useState<"" | "error" | "warning">("")
/**
* 弹框是否开启
*/
const [isModalOpen, setIsModalOpen] = useState(false)
/**
* useReactive 不支持 Element 和 RegExp 类型
*
* 单独领出来保存
*/
const [Content, setContent] = useState<JSX.Element>()
const [inputPattern, setInputPattern] = useState<RegExp>()
function changeVal(e) {
let val = e.target.value
if (state.type === "number") {
val = val.replace(/[^\d]/g, "")
}
state.value = val
}
function handleSubmit() {
if (inputPattern) {
const result = inputPattern.test(state.value)
setStatus(result ? "" : "error")
if (!result) return
}
state.onOk && state.onOk(state.value)
setIsModalOpen(false)
state.value = ""
}
function handleCancel() {
state.onCancel && state.onCancel()
setIsModalOpen(false)
state.value = ""
}
/**
* 开启弹框
*
* useReactive 不支持 Element 和 RegExp 类型
* 将 content 和 inputPattern 单独储存
* @param options
*/
const open = (options: PromptProps) => {
if (isElement(options.content)) {
setContent(options.content as JSX.Element)
options.content = ""
}
if (options.inputPattern) {
setInputPattern(options.inputPattern)
}
Object.assign(state, options)
setIsModalOpen(true)
}
/**
* 导出 open
*/
useImperativeHandle(ref, () => ({
open
}))
const IptDom = () => {
switch (state.type) {
case "textarea":
return <Input.TextArea value={state.value} onChange={e => changeVal(e)} placeholder={state.placeholder}></Input.TextArea>
case "number":
return <InputNumber value={state.value} onChange={e => changeVal(e)} placeholder={state.placeholder} />
default:
return (
<Input
status={status}
value={state.value}
onChange={e => changeVal(e)}
onPressEnter={e => changeVal(e)}
placeholder={state.placeholder}
/>
)
}
}
return (
<Modal title={state.title} open={isModalOpen} onOk={() => handleSubmit()} onCancel={() => handleCancel()} destroyOnClose>
{Content ? Content : state.content}
{IptDom()}
{/* 校验 */}
<p className="my-1 text-red-600">{status === "error" ? state.inputErrorMessage : ""}</p>
</Modal>
)
})
在以上代码中我们实现了一个单输入框的组件, 它的样子如下
为了达到可以api使用的效果,设置了一个open方法,并使用useImperativeHandle将它抛出,这样就可以达到如下的使用方式。
const Demo = () => {
const promptRef = useRef(null)
const openPrompt = async () => {
const val = await promptRef.current?.open({
title: "提示",
content: "请输入手机号"
})
console.log(val)
}
return (
<>
<Prompt ref={promptRef} />
<button onClick={openPrompt}>点击</button>
</>
)
}
为了方便使用,预设了Textarea、Input、InputNumber三种类型,组件中所有需要用到的参数都从open函数中传入。
记录一个BUG
在记录参数时使用了useReact,传入的参数可能包含content: string | JSX.Element 以及 inputPattern: RegExp,使用时就会遇到奇怪的报错,主要是对于组件类型的不支持以及正则的Proxy包裹问题
找到问题点后就向ahooks提了个Issues,好在已经有大佬在修复了。
实现API化
在前面我们实现了单输入框的弹框,那么现在进入正题,实现组件的API化。
思路:与Vue中实现全局Api组件相同,我们要通过API将组件实例化,并挂载body上,而React也提供了与Vue Teleport功能类似的createPortal。
不过我们并不需要使用createPortal,因为Modal本身就是挂载到body上的😀
import { ConfigProvider } from "antd"
import { createRoot } from "react-dom/client"
import { getThemeConfig } from "@/styles/styles"
const CompToApi = <
T extends {
open: (o: P) => void
}, // 组件的实例
P extends Record<string | number, any>, // open函数中的参数
R // 用户输入的内容类型
>(
Comp: React.ForwardRefExoticComponent<React.RefAttributes<T>>,
ok: keyof P = "onOk",
cancel: keyof P = "onCancel"
) => {
let holder: T | undefined, root: Root
function unmount() {
root && root.unmount()
if (holder) {
holder = undefined
}
}
/**
* 卸载任务
*
* 在弹框关闭的一定时间后,卸载所有组件
*/
const unmontTask: {
delay: number
timer: NodeJS.Timeout | null
pause: () => void
start: () => void
} = {
delay: 1500,
timer: null,
pause() {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
},
start() {
this.timer = setTimeout(unmount, this.delay)
}
}
return (props: P): Promise<R | null> => {
unmontTask.pause()
const themeConfig = getThemeConfig()
return new Promise(resolve => {
const openRun = () => {
if (holder) {
holder.open({
...props,
[ok]: value => {
unmontTask.start()
resolve(value)
},
[cancel]: () => {
unmontTask.start()
resolve(null)
}
})
} else {
Promise.resolve().then(() => {
openRun()
})
}
}
if (!holder) {
/**
* 我们创建一个虚拟节点
*
* 将Prompt组件渲染进去
*
* 注意:这是一个新的React root
*/
const holderFragment = document.createDocumentFragment()
root = createRoot(holderFragment)
root.render(
/**
* 业务中调整了主题样式, 所以这里要包一层ConfigProvider
*/
<ConfigProvider theme={themeConfig}>
<Comp
ref={(node: T) => {
/**
* 将Prompt实例赋值到holder上,开启弹框
*/
Promise.resolve().then(() => {
if (!holder && node) {
holder = node
openRun()
}
})
}}
/>
</ConfigProvider>
)
} else {
openRun()
}
})
}
}
export default CompToApi
之后我们就可以将实例化后的Prompt导出使用了,顺便贴上类型
export default CompToApi<PromptHolderRef, PromptProps, string>(Prompt)
现在就可以愉快的使用API组件了。
function Demo2() {
const click = async () => {
const val = await Prompt({
content: "请填写手机号",
inputPattern: /^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\d{8}$/,
inputErrorMessage: "手机号格式错误"
})
console.log(val)
}
return (
<div>
<button onClick={click}>点击</button>
</div>
)
}
注意点
await 的使用
在初次尝试时,我将取消事件通过reject发出,在使用Api时通过await调用,此时并不等收到reject中返回的值,如果使用try...catch 又觉得比较麻烦,所以将确认与取消的事件都通过resolve发出,在使用时只需要做一次非空判断即可
const val = await Prompt()
if (!val) return
组件的卸载
在上述代码中,我实现了一个unmontTask,用于组件的卸载,虽然我们不进行卸载的话Modal也会自行将内部的节点删除,但我们所用到的holder root却还会一直保留,这未尝不是一种浪费...
所以在确认和取消操作结束后的1.5秒后(关闭动画结束后),我们开始进行卸载与清除