之前写过一个react Hook+antd弹窗,虽然功能实现了,但是再使用的时候仍然会有报错,虽然这个报错不影响使用的,但是,作为一个合格的前端切图仔,要再使用中发现问题,改正问题。
问题
- 多次调用hook会创建多个相同id的盒子加入页面,并且未初始化就已经有盛放弹窗的盒子
- 使用不够简便,一些文件的格式判断以及大小判断没有做
- 在热更新时如果弹窗处于打开状态,会报错节点容器已经被创建
问题出现原因及修改办法
针对第一个问题和第三个我呢提主要是因为在调用hook时,就创建了弹窗盛放的容器,这个是不对的,因为虽然调用hook但是并不代表就一定要弹窗,虽然这样说有点牵强,不使用为啥调用弹窗hook。但是主要目的在于我们不应该在调用hook的时候创建容器,而是在调用初始化的时候调用,具体的修改代码,可以直接看完整版的代码
针对于第二个问题,我的解决办法是给定默认大小以及文件格式,如果传递参数就使用传递参数的来判断
完整代码
- 弹窗hook
tsx
import React, { useCallback, useEffect } from "react";
import ReactDOM from "react-dom/client";
import { Button, ConfigProvider, Modal, message } from "antd";
import { useState } from "react";
import { useForm } from "./form";
import { formType } from "../types/hooksTypes/form";
import zhCN from "antd/locale/zh_CN";
import "dayjs/locale/zh-cn";
type PromiseType = {
resolve?: any;
reject?: any;
};
/*
modal类型(分为普通或者表单形式)
由于行内布局传入配置过多暂不支持布局
*/
type modalType = "nomal" | "form";
/*
按钮类型
txt: 显示的文本内容
type:按钮类型
isDanger:是否危险
*/
export type buttonType = {
txt?: string;
type?: "default" | "primary" | "dashed" | "text" | "link";
isDanger?: boolean;
};
/*
type: 弹窗类型
title: 弹窗头部显示文本
infoTxt:弹窗为普通类型时,提示文本信息
okBtn:确定按钮配置
cancelBtn:取消按钮配置
formOptions:form表单配置
isEdit:是否显示富文本
isUpload:是否上传图片
sendFn:点击确定,成功后发送数据
successCallback发送数据之后调用的函数
fileRules文件匹配规则
maxSize:文件上传大小限制(单位为m)
*/
type modalPropsType = {
type?: modalType;
title?: string;
infoTxt?: string;
okBtn?: buttonType;
cancelBtn?: buttonType;
formOptions?: formType[];
isEdit?: boolean; //是否需要显示富文本
isUpload?: boolean; //是否上传图片
sendFn?: (data: any) => Promise<any>;
successCallback?: (values?: any) => void;
editorName?: string;
fileRules?: string[];
maxSize?: number;
};
export const useModal = (props: modalPropsType = {}) => {
const {
type = "nomal",
title = "提示",
infoTxt = "这是一段提示",
okBtn = {
txt: "确定",
type: "primary",
isDanger: false,
},
cancelBtn = {
txt: "取消",
type: "default",
isDanger: false,
},
successCallback = () => {},
formOptions = [],
isEdit = false,
isUpload = false,
sendFn, //发送数据函数(记得数据处理)
editorName,
fileRules,
maxSize,
} = props;
const [show, setShow] = useState<boolean>(false);
const [promiseRes, setPromiseRes] = useState<PromiseType>();
const [containerEle, setContainerEle] = useState<HTMLElement | null>(null);
const [messageApi, contextHolder] = message.useMessage();
// 原本默认值时数组导致输入有问题
const [defaultValue, setDefaultValue] = useState<any>({});
const [root, setRoot] = useState<any>(null);
// 卸载节点
const unMounted = useCallback(() => {
if (containerEle) {
document.body.removeChild(containerEle);
setContainerEle(null);
root?.unmount();
}
}, [containerEle, root]);
// 点击确定按钮的回调函数
const success = useCallback(
async (values: any) => {
promiseRes?.resolve(type === "nomal" ? "确定" : values);
setShow(false);
unMounted();
if (sendFn) {
await sendFn(values);
// 可进行数据更新
successCallback && successCallback();
messageApi.open({
type: "warning",
content: "This is a warning message",
});
}
},
[promiseRes, unMounted, successCallback, type, sendFn, messageApi],
);
// 取消
const cancel = useCallback(() => {
promiseRes?.reject("取消");
setShow(false);
messageApi.open({
type: "warning",
content: "已取消",
});
unMounted();
}, [unMounted, promiseRes, messageApi]);
// 获取form表单结果
const { MyForm } = useForm({
cancel,
success,
okBtn,
cancelBtn,
options: formOptions,
isEdit,
isUpload,
editorName,
fileRules,
maxSize,
});
// 挂载节点
useEffect(() => {
if (!show || !containerEle) {
return;
}
// 根据类型,去判断是简单的弹窗还是form表单
root.render(
<ConfigProvider locale={zhCN}>
{contextHolder}
<Modal
onCancel={cancel}
open={show}
onOk={success}
destroyOnClose={true}
title={title}
wrapClassName="modal-wrap"
cancelButtonProps={{ shape: "round" }}
okButtonProps={{ shape: "round" }}
width={900}
footer={
type === "form"
? null
: [
<Button
key="success"
type={okBtn.type}
onClick={success}
danger={okBtn.isDanger}
>
{okBtn.txt}
</Button>,
<Button
key="cancel"
onClick={cancel}
danger={cancelBtn.isDanger}
type={cancelBtn.type}
>
{cancelBtn.txt}
</Button>,
]
}
getContainer={containerEle as HTMLElement}
>
{type === "form" && (
<MyForm defaultValue={defaultValue || {}}></MyForm>
)}
{type === "nomal" && <p>{infoTxt}</p>}
</Modal>
</ConfigProvider>,
);
}, [
show,
MyForm,
root,
cancel,
containerEle,
title,
infoTxt,
okBtn,
cancelBtn,
success,
type,
contextHolder,
defaultValue,
]);
// 初始化
const init = (defaultValue?: any) => {
defaultValue && setDefaultValue(defaultValue);
setShow(true);
// 创建挂载节点
const div = document.createElement("div");
div.id = "myContainer";
document.body.append(div);
setContainerEle(div);
setRoot(ReactDOM.createRoot(div as HTMLElement));
return new Promise((resolve, reject) => {
setPromiseRes({ resolve, reject });
});
};
return { init, messageApi };
};
- form表单生成hook
tsx
import {
Button,
Form,
FormInstance,
Input,
Space,
DatePicker,
Select,
Switch,
Radio,
InputNumber,
TimePicker,
} from "antd";
import React, { useEffect, useState } from "react";
import { useCallback } from "react";
import { buttonType } from "./modal";
import { formType } from "../types/hooksTypes/form";
import { MyEditor } from "../components/utils/MyEditor";
import { MyUpload } from "../components/utils/MyUpload";
const { RangePicker } = DatePicker;
/*
传递配置对象()
1. 成功回调
2.失败回调
3.配置对象(自动生成form表单)
4.类型是否使用自定义控件
*/
type formProp = {
success: (values: any) => void;
cancel: () => void;
okBtn: buttonType;
cancelBtn: buttonType;
options?: formType[]; //普通组件配置对象
isEdit?: boolean; //是否需要显示富文本
isUpload?: boolean; //是否上传图片
editorName?: string;
fileRules?: string[];
maxSize?: number;
};
type MyformProp = {
defaultValue: any;
};
// 使用富文本字段是comment,上传文件是file
export const useForm = (formProp: formProp) => {
const {
success,
cancel,
okBtn,
cancelBtn,
options = [],
isEdit,
isUpload,
editorName,
fileRules = ["image/png", "image/jpg", "image/jpeg", "image/webp"],
maxSize = 5,
} = formProp;
const MyForm = ({ defaultValue = {} }: MyformProp) => {
const formRef = React.useRef<FormInstance>(null);
const [html, setHtml] = useState<string>("");
const [txt, setTxt] = useState<string>("");
const [fileList, setFileList] = useState<any>([]);
// 初始化
useEffect(() => {
formRef.current?.setFieldsValue(defaultValue);
}, [defaultValue]);
const onFinish = useCallback(
(values: any) => {
if (isEdit) {
if (txt.replace(/(^\s*)|(\s*$)/g, "") === "") {
formRef.current?.setFields([
{ name: editorName!, errors: ["请输入内容"] },
]);
return;
}
values[editorName!] = html;
}
if (isUpload) {
if (fileList.length === 0) {
formRef.current?.setFields([
{ name: "file", errors: ["请上传图片"] },
]);
return;
}
const notTrueFile = fileList.filter((item: any) => {
return !fileRules.includes(item.type);
});
if (notTrueFile.length > 0) {
formRef.current?.setFields([
{ name: "file", errors: ["请上传指定格式文件"] },
]);
return;
}
// 判断文件大小
const notTrueSizeFile = fileList.filter((item: any) => {
return item.size > maxSize * 1024 * 1024;
});
if (notTrueSizeFile.length > 0) {
formRef.current?.setFields([
{ name: "file", errors: ["文件过大"] },
]);
return;
}
values.file = fileList;
}
success(values);
},
[html, fileList, txt],
);
const fileChange = useCallback((fileList: any) => {
if (fileList.length >= 0) {
formRef.current?.setFields([{ name: "file", errors: [""] }]);
}
setFileList(fileList);
}, []);
const onFinishFailed = useCallback((values: any) => {}, []);
const onReset = useCallback(() => {
formRef.current?.resetFields();
}, []);
const htmlOnChange = useCallback((values: string, txt: string) => {
if (txt.replace(/(^\s*)|(\s*$)/g, "") !== "") {
formRef.current?.setFields([{ name: editorName!, errors: [""] }]);
}
setTxt(txt);
setHtml(values);
}, []);
return (
<Form
ref={formRef}
labelCol={{ span: 3 }}
wrapperCol={{ span: 20 }}
initialValues={{ remember: true }}
autoComplete="off"
onFinish={onFinish}
onFinishFailed={onFinishFailed}
>
{options.map((item: formType, index: number) => {
let attr = {};
if (item.isMultiple) {
attr = {
mode: "multiple",
};
}
return item.Custom ? (
// 存放自定义组件
<Form.Item name={item.name} label={item.label}>
<item.Custom></item.Custom>
</Form.Item>
) : item.type === "switch" ? (
<Form.Item
key={`${index}-${item.name}`}
label={item.label}
name={item.name}
rules={item.rules}
valuePropName="checked"
>
{/* 开关 */}
{item.type === "switch" ? (
<Switch
checkedChildren={item.openTxt}
unCheckedChildren={item.closeTxt}
/>
) : null}
</Form.Item>
) : (
<Form.Item
key={`${index}-${item.name}`}
label={item.label}
name={item.name}
rules={item.rules}
>
{/* 普通输入框 */}
{item.type === "input" ? (
<Input placeholder={item.placeholder}></Input>
) : null}
{/* 时间 */}
{item.type === "timeDefault" ? (
<TimePicker format={item.format}></TimePicker>
) : null}
{/* 日期范围 */}
{item.type === "timeRange" ? (
<RangePicker format={item.format} showTime />
) : null}
{/* 多选框 */}
{item.type === "select" ? (
<Select
{...attr}
style={{ width: 300 }}
placeholder={item.placeholder}
>
{item.data?.map((data: any) => {
return (
<Select.Option
value={data[item.dataValue!]}
key={data.id}
>
{data[item.dataName!]}
</Select.Option>
);
})}
</Select>
) : null}
{/* 富文本 */}
{item.type === "editor" ? (
<MyEditor handelChange={htmlOnChange}></MyEditor>
) : null}
{/* 文本框 */}
{item.type === "textArea" ? (
<Input.TextArea
showCount={item.isShowTxtCount}
placeholder={item.placeholder}
maxLength={item.limit}
></Input.TextArea>
) : null}
{/* 文件 */}
{item.type === "file" ? (
<MyUpload
fileList={defaultValue?.file}
onChangeFn={fileChange}
limit={item.limit ? item.limit : 1}
></MyUpload>
) : null}
{/* 单选框(主要是性别) */}
{item.type === "radio" ? (
<Radio.Group>
{item.data?.map((data: any) => {
return (
<Radio value={data[item.dataValue!]} key={data.id}>
{data[item.dataName!]}
</Radio>
);
})}
</Radio.Group>
) : null}
{/* 数字框 */}
{item.type === "inputNumber" ? (
<InputNumber
min={item.minNumber}
max={item.maxNumber}
defaultValue={item.minNumber}
step={item.step}
/>
) : null}
</Form.Item>
);
})}
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
<Space wrap>
<Button type={okBtn.type} danger={okBtn.isDanger} htmlType="submit">
{okBtn.txt}
</Button>
<Button danger htmlType="button" onClick={onReset}>
重置
</Button>
<Button
onClick={cancel}
type={cancelBtn.type}
danger={cancelBtn.isDanger}
>
{cancelBtn.txt}
</Button>
</Space>
</Form.Item>
</Form>
);
};
return {
MyForm,
};
};
针对于上边的form表单类型,我还自定义了两种自己封装的类型,一个是富文本类型,一种是文件类型
富文本类型
tsx
import React, { useState, useEffect } from "react";
import "@wangeditor/editor/dist/css/style.css";
import { Editor, Toolbar } from "@wangeditor/editor-for-react";
type editorType = {
handelChange: (value: any, txt: any) => void;
};
export const MyEditor = ({ handelChange }: editorType) => {
const [editor, setEditor] = useState<any>(null); // 存储 editor 实例
const [html, setHtml] = useState<string>("");
const toolbarConfig = {};
const editorConfig = {
placeholder: "请输入内容...",
autoFocus: false,
//插入图片
MENU_CONF: {
uploadImage: {
// 单个文件的最大体积限制,默认为 2M
maxFileSize: 4 * 1024 * 1024, // 4M
// 最多可上传几个文件,默认为 100
maxNumberOfFiles: 10,
// 超时时间,默认为 10 秒
timeout: 5 * 1000, // 5 秒
// 用户自定义上传图片
async customUpload(file: any, insertFn: any) {
const formdata = new FormData();
formdata.append("file", file);
},
},
},
};
// 及时销毁 editor
useEffect(() => {
return () => {
if (editor == null) return;
editor.destroy();
setEditor(null);
};
}, [editor]);
return (
<>
<div style={{ border: "1px solid #ccc", zIndex: 100 }}>
<Toolbar
editor={editor}
defaultConfig={toolbarConfig}
mode="default"
style={{ borderBottom: "1px solid #ccc" }}
/>
<Editor
defaultConfig={editorConfig}
value={html}
onCreated={setEditor}
onChange={(editor) => {
setHtml(editor.getHtml().replace(/(^\s*)|(\s*$)/g, ""));
handelChange(
editor.getHtml().replace(/(^\s*)|(\s*$)/g, ""),
editor.getText()
);
}}
mode="default"
style={{ height: "300px" }}
/>
</div>
</>
);
};
文件类型
tsx
import React, { useEffect, useState } from "react";
import { PlusOutlined } from "@ant-design/icons";
import { Modal, Upload } from "antd";
import type { RcFile, UploadProps } from "antd/es/upload";
import type { UploadFile } from "antd/es/upload/interface";
// 传递改变函数,限制图片个数,是否裁剪
export function MyUpload({
onChangeFn,
fileList,
limit,
}: {
onChangeFn: (file: any) => void;
fileList: any;
limit: number;
}) {
const getBase64 = (file: RcFile): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState("");
const [previewTitle, setPreviewTitle] = useState("");
const [uploadFileList, setUploadFileList] = useState<any>([]);
const handleCancel = () => setPreviewOpen(false);
const handlePreview = async (file: UploadFile) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj as RcFile);
}
setPreviewImage(file.url || (file.preview as string));
setPreviewOpen(true);
setPreviewTitle(
file.name || file.url!.substring(file.url!.lastIndexOf("/") + 1),
);
};
useEffect(() => {
if (fileList) {
setUploadFileList(fileList);
onChangeFn(fileList);
}
}, [fileList, onChangeFn]);
const handleChange: UploadProps["onChange"] = ({ fileList: newFileList }) => {
setUploadFileList(newFileList);
onChangeFn(newFileList);
};
return (
<>
<Upload
listType="picture-card"
fileList={uploadFileList}
onPreview={handlePreview}
onChange={handleChange}
beforeUpload={() => false}
>
{uploadFileList.length >= limit ? null : (
<div>
<PlusOutlined />
<div style={{ marginTop: 8 }}>上传</div>
</div>
)}
</Upload>
<Modal
open={previewOpen}
title={previewTitle}
footer={null}
onCancel={handleCancel}
>
<img alt="example" style={{ width: "100%" }} src={previewImage} />
</Modal>
</>
);
}
总结
以上就是完整的代码以及解决的一些问题,随后遇到什么问题再修改吧