从0开始的React——API式组件的实现

515 阅读1分钟

事件起因

由于在之前使用Vue配合Element Plus的开发中,使用过ElMessageBox.prompt,可以快速的应付对单个字段修改类似的业务。

compToApi_01.png

而它的api式的写法,也很方便所需数据的获取

compToApi_02.png

在使用React配合Antd中,并没有提供类似的组件,因此我们只能拿Modal自行实现。

最终实现效果

compToApi_03.png

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>
    )
})

在以上代码中我们实现了一个单输入框的组件, 它的样子如下

compToApi_03.png

为了达到可以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>
    </>
  )
}

为了方便使用,预设了TextareaInputInputNumber三种类型,组件中所有需要用到的参数都从open函数中传入。

记录一个BUG

在记录参数时使用了useReact,传入的参数可能包含content: string | JSX.Element 以及 inputPattern: RegExp,使用时就会遇到奇怪的报错,主要是对于组件类型的不支持以及正则的Proxy包裹问题 compToApi_04.png

找到问题点后就向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秒后(关闭动画结束后),我们开始进行卸载与清除