tmagic-editor/packages/form/src/submitForm.ts
roymondchen bf8df74864 fix(form): 完善 mountFormInstance 清理逻辑,避免临时表单实例泄漏
补充 mount 全流程异常清理、非正数 timeout 兜底,并支持 AbortSignal 主动中断 debug 弹层。
2026-07-24 16:51:42 +08:00

795 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type AppContext,
type Component,
createApp,
defineComponent,
h,
nextTick,
provide,
type Ref,
ref,
watch,
} from 'vue';
import { applyExtendState } from './utils/form';
import Form from './Form.vue';
import { type ChangeRecord, FORM_SILENT_MODE_KEY, type FormConfig, type FormState } from './schema';
// #region SubmitFormOptions
/**
* submitForm 函数参数(与 Form.vue 组件 props 对齐)
*/
export interface SubmitFormOptions {
/** 表单配置 */
config: FormConfig;
/** 表单初始值 */
initValues?: Record<string, any>;
/** 需对比的值(开启对比模式时传入) */
lastValues?: Record<string, any>;
/** 是否开启对比模式 */
isCompare?: boolean;
parentValues?: Record<string, any>;
labelWidth?: string;
disabled?: boolean;
height?: string;
stepActive?: string | number;
size?: 'small' | 'default' | 'large';
inline?: boolean;
labelPosition?: string;
keyProp?: string;
popperClass?: string;
preventSubmitDefault?: boolean;
/**
* 表单校验失败时,错误提示前缀是否使用字段的 text 文案(通过 `getTextByName` 从 config 中查找)。
* 默认 `true`,置为 `false` 时直接使用字段 name。
*/
useFieldTextInError?: boolean;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 透传给 Form.submitForm 的参数:是否直接返回原始响应式 values */
native?: boolean;
/**
* 是否在 resolve 结果中携带 changeRecords变更记录
* 开启后 resolve 的结果为 `{ values, changeRecords }`,否则仅 resolve values。
*/
returnChangeRecords?: boolean;
/**
* 父级应用上下文用于继承全局组件、指令、provide 等。
* 通常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取。
*/
appContext?: AppContext | null;
/** 等待表单初始化的最长时间(毫秒),超时将以错误 reject。默认 10000ms */
timeout?: number;
/**
* 调试模式。默认 `false`。
*
* - `false`:表单以隐藏方式挂载,初始化完成后自动提交(原有行为)。
* - `true`:将表单以弹层形式可见地渲染在页面上,需手动点击「确定」才会触发校验/提交,
* 点击「取消」则以 reject 中断;校验失败时保留弹层并展示错误信息,便于修正后重试。
* 调试模式下 `timeout` 不生效(等待人工操作)。
*/
debug?: boolean;
typeMatchValid?: boolean;
/**
* 外部中断信号。abort 时会立即以 `signal.reason` reject 并卸载临时表单实例、移除容器。
* 主要用于 `debug` 模式(无超时兜底)下取消一个被放弃的表单弹层,避免其无限驻留在页面上。
*/
signal?: AbortSignal;
}
// #endregion SubmitFormOptions
// #region SubmitFormResult
/**
* 开启 `returnChangeNodes` 时 submitForm 的返回结果
*/
export interface SubmitFormResult {
/** 校验通过后的表单值 */
values: any;
/** 表单变更记录 */
changeRecords: ChangeRecord[];
}
// #endregion SubmitFormResult
// #region mountFormInstance
/**
* 构造 wrapper 组件的工厂:在合适时机调用 MForm 实例方法并 resolve/reject、清理实例。
*
* 由调用方决定「等待 `initialized` 后自动执行」还是「等待人工触发」debug 模式),
* 以及「调用 `submitForm` 还是 `validate`」「结果如何包装」,从而复用公共脚手架。
*/
type FormWrapperFactory<T> = (ctx: {
/** 透传给 Form 组件的 props由工厂统一注入避免调用方重复闭包 */
formProps: Record<string, any>;
/** 指向挂载的 MForm 实例 */
formRef: Ref<any>;
/** 卸载实例并移除容器resolve/reject 后必须调用以避免泄漏 */
cleanup: () => void;
/** resolve 外层 Promise */
resolve: (value: T | PromiseLike<T>) => void;
/** reject 外层 Promise */
reject: (reason?: any) => void;
}) => Component;
interface MountFormInstanceOptions<T> {
/** 透传给 Form 组件的 props */
formProps: Record<string, any>;
/** 父级应用上下文用于继承全局组件、指令、provide 等 */
appContext?: AppContext | null;
/** 等待表单初始化的最长时间(毫秒),<=0 时回退到默认超时以保证兜底清理生效 */
timeout: number;
/** 超时 reject 的错误文案 */
timeoutMessage: string;
/** 是否以 `display:none` 隐藏容器。调试模式需可见,应传 `false` */
hidden?: boolean;
/** 是否跳过超时注册。调试模式等待人工操作,应传 `true` */
skipTimeout?: boolean;
/** 外部中断信号abort 时会 reject 并卸载实例、移除容器,用于取消无超时(如 debug的挂载 */
signal?: AbortSignal;
/** 构造 wrapper 组件 */
createWrapper: FormWrapperFactory<T>;
}
/** 未指定或传入非正数 timeout 时的兜底超时(毫秒),保证非 debug 挂载始终能被清理 */
const DEFAULT_MOUNT_TIMEOUT = 10000;
/**
* submitForm / validateForm 的公共脚手架:
*
* 创建临时容器 → 挂载一个 wrapper 组件(内含 MForm→ 提供统一的 cleanup / timeout / appContext 继承 →
* 由 `createWrapper` 决定「何时调用 MForm 的哪个方法、如何 resolve/reject」。
*
* 调用方只需关注差异逻辑(调用 `submitForm` 还是 `validate`、结果如何包装、是否需要调试弹层),
* 容器创建、卸载、超时、上下文注入等模板代码在此统一收口。
*/
const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T> => {
const {
formProps,
appContext,
timeout,
timeoutMessage,
hidden = true,
skipTimeout = false,
signal,
createWrapper,
} = options;
return new Promise<T>((resolve, reject) => {
// 已中断则直接 reject不创建任何容器/实例
if (signal?.aborted) {
reject(signal.reason ?? new Error('mountFormInstance aborted'));
return;
}
let cleaned = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let onAbort: (() => void) | null = null;
// 用 holder 持有 app使 cleanup 可在 app 创建之前定义const app + 无 TDZ / 无 use-before-define
const instance: { app: ReturnType<typeof createApp> | null } = { app: null };
const container = document.createElement('div');
if (hidden) {
container.style.display = 'none';
}
document.body.appendChild(container);
const cleanup = () => {
if (cleaned) return;
cleaned = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
if (signal && onAbort) {
signal.removeEventListener('abort', onAbort);
onAbort = null;
}
try {
instance.app?.unmount();
} catch {
// ignore
}
container.parentNode?.removeChild(container);
};
// 支持外部通过 AbortSignal 主动中断debug 模式无超时兜底,若调用方放弃了该 Promise
// 可通过 abort 卸载实例、移除遮罩/容器,避免无限驻留在 DOM 中。
if (signal) {
onAbort = () => {
if (cleaned) return;
reject(signal.reason ?? new Error('mountFormInstance aborted'));
cleanup();
};
signal.addEventListener('abort', onAbort);
}
// 从容器创建到 mount 的全流程统一 try/catch任一步骤createWrapper/createApp/上下文合并/mount
// 抛错都会走到 cleanup避免已插入 body 的 container 及未挂载的 app 残留导致泄漏。
try {
const formRef = ref<any>(null);
// 将 extendState 从 formProps 中剥离:不由 Form.vue 的 async watchEffect 异步应用,
// 而是在 wrapper 中通过 sync watch 在 formRef 就绪后直接写入 formState
// 避免 display 等 filterFunction 在首次渲染时读到 undefined。
// 与 CompareForm / FormPanel 中「formRef.value.formState.services = ...」的做法一致。
const { extendState, ...restFormProps } = formProps;
const userWrapper = createWrapper({ formRef, formProps: restFormProps, cleanup, resolve, reject });
const wrapperComponent =
typeof extendState === 'function'
? defineComponent({
name: 'MFormExtendStateInjector',
setup() {
watch(
() => formRef.value,
(form) => {
if (!form) return;
let result: any;
try {
result = extendState(form.formState);
} catch (e) {
console.error('[MForm] extendState failed:', e);
return;
}
// formState 的内置 key 快照:在 extendState 合并前捕获,
// 供 applyExtendState 禁止 extendState 覆盖这些已有字段(只能新增),
// 与 Form.vue 中 reservedStateKeys 的语义保持一致。
const reservedStateKeys = new Set<string | symbol>(Reflect.ownKeys(form.formState));
// 合并逻辑收口在 applyExtendStateprops 派生的只读 getter 字段
// keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错
const apply = (state: Record<string, any> | null | undefined) =>
applyExtendState(form.formState, state, reservedStateKeys);
if (result && typeof result.then === 'function') {
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
} else {
apply(result);
}
},
{ flush: 'sync', immediate: true },
);
return () => h(userWrapper);
},
})
: userWrapper;
// 静默隐藏挂载模式下注入静默标记vs-code 等重型字段组件可据此跳过自身渲染,
// 校验/取值依赖 FormItem 与 model 值,与叶子 UI 组件无关(见 FORM_SILENT_MODE_KEY 注释)。
// 用组件级 provide 而非 app.provideappContext 合并后 app._context.provides 与父级应用
// 共享引用app.provide 会把标记泄漏到父级应用。
const rootComponent = hidden
? defineComponent({
name: 'MFormSilentProvider',
setup() {
provide(FORM_SILENT_MODE_KEY, true);
return () => h(wrapperComponent);
},
})
: wrapperComponent;
const app = createApp(rootComponent);
instance.app = app;
// 继承父级应用上下文components / directives / provides / config 等)
if (appContext) {
Object.assign(app._context, appContext);
}
// 非 debug未跳过超时场景始终注册超时兜底timeout 为非正数时回退到默认值,
// 避免表单永不初始化时实例/容器/watcher 无限驻留而泄漏。
if (!skipTimeout) {
const effectiveTimeout = timeout > 0 ? timeout : DEFAULT_MOUNT_TIMEOUT;
timer = setTimeout(() => {
if (!cleaned) {
reject(new Error(timeoutMessage));
cleanup();
}
}, effectiveTimeout);
}
app.mount(container);
} catch (err) {
reject(err);
cleanup();
}
});
};
// #endregion mountFormInstance
// #region createDebugWrapper
interface DebugWrapperOptions {
/** 指向挂载的 MForm 实例 */
formRef: Ref<any>;
/** 透传给 Form 组件的 props */
formProps: Record<string, any>;
/** 弹层标题 */
title: string;
/** wrapper 组件名 */
name: string;
/**
* 点击「确定」触发;接收 `setError`,校验失败时可调用以在弹层展示错误并保留弹层供修正后重试,
* 校验通过则由调用方自行 resolve + cleanup。
*/
onConfirm: (setError: (msg: string) => void) => void | Promise<void>;
/** 点击「取消」触发(通常 reject + cleanup */
onCancel: () => void;
}
/**
* 构造调试模式下的可见弹层 wrapper以 fixed 遮罩居中渲染 MForm提供「确定 / 取消」按钮与错误展示区。
*
* submitForm 与 validateForm 的调试弹层 UI 完全一致,仅「确定」时调用的实例方法、错误处理与取消文案不同,
* 这些差异通过 `onConfirm` / `onCancel` 注入,弹层结构在此统一收口。
*/
const createDebugWrapper = (options: DebugWrapperOptions): Component => {
const { formRef, formProps, title, name, onConfirm, onCancel } = options;
const btnBase = {
padding: '8px 20px',
fontSize: '14px',
lineHeight: '1',
border: '1px solid #dcdfe6',
borderRadius: '4px',
cursor: 'pointer',
};
return defineComponent({
name,
setup() {
// 校验失败信息展示区
const errorMsg = ref('');
const setError = (msg: string) => {
errorMsg.value = msg;
};
return () =>
h(
'div',
{
style: {
position: 'fixed',
inset: '0',
zIndex: '10000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.5)',
},
},
[
h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
width: '600px',
maxWidth: '90vw',
maxHeight: '85vh',
background: '#fff',
borderRadius: '8px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.2)',
overflow: 'hidden',
},
},
[
h(
'div',
{
style: {
padding: '16px 20px',
fontSize: '16px',
fontWeight: '600',
borderBottom: '1px solid #ebeef5',
},
},
title,
),
h(
'div',
{
style: {
flex: '1',
padding: '20px',
overflow: 'auto',
},
},
[
h(Form as Component, { ...formProps, ref: formRef }),
errorMsg.value
? h('div', {
style: {
marginTop: '12px',
color: '#f56c6c',
fontSize: '13px',
lineHeight: '1.5',
},
innerHTML: errorMsg.value,
})
: null,
],
),
h(
'div',
{
style: {
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
padding: '12px 20px',
borderTop: '1px solid #ebeef5',
},
},
[
h('button', { type: 'button', onClick: onCancel, style: { ...btnBase } }, '取消'),
h(
'button',
{
type: 'button',
onClick: () => onConfirm(setError),
style: {
...btnBase,
color: '#fff',
background: '#409eff',
borderColor: '#409eff',
},
},
'确定',
),
],
),
],
),
],
);
},
});
};
// #endregion createDebugWrapper
/**
* 以命令式方式调用 Form.vue 完成一次表单校验/提交。
*
* 类似 ElMessage 的用法:传入 props包含 `config`/`initValues` 等),函数内部会临时挂载
* 一个不可见的 Form 组件实例,等待初始化完成后调用其 `submitForm` 方法,
* 校验通过则 resolve 表单值,校验失败则 reject 错误信息,最后自动卸载实例。
*
* @example
* ```ts
* import { submitForm } from '@tmagic/form';
*
* try {
* const values = await submitForm({
* config: [...],
* initValues: { name: 'foo' },
* });
* console.log(values);
* } catch (e) {
* console.error(e);
* }
*
* // 需要同时获取变更记录时:
* const { values, changeRecords } = await submitForm({
* config: [...],
* initValues: { name: 'foo' },
* returnChangeRecords: true,
* });
*
* // 调试模式:可见地渲染表单,点击「确定」才提交:
* const values = await submitForm({
* config: [...],
* initValues: { name: 'foo' },
* debug: true,
* });
* ```
*/
export const submitForm = (options: SubmitFormOptions): Promise<any> => {
const { native, appContext, timeout = 10000, returnChangeRecords, debug = false, signal, ...formProps } = options;
return mountFormInstance<any>({
formProps,
appContext,
timeout,
signal,
// 调试模式需把表单展示出来;普通模式隐藏挂载
hidden: !debug,
// 调试模式等待人工操作,不应用超时
skipTimeout: debug,
timeoutMessage: `submitForm timeout after ${timeout}ms: form is not initialized.`,
createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => {
/**
* 执行一次提交nextTick 等待子组件渲染 → 快照 changeRecords → 调用实例 submitForm → resolve。
* 校验失败时交给 `onValidateError` 决定「保留弹层展示错误debug」还是「reject 并清理(普通)」,
* 从而让 debug 与普通模式共享同一份提交逻辑。
*/
const doSubmit = async (onValidateError: (err: any) => void) => {
try {
// 等待子组件FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
await nextTick();
// submitForm 校验通过后会清空 changeRecords需在调用前先做快照
const changeRecords: ChangeRecord[] = [...(formRef.value?.changeRecords ?? [])];
const result = await formRef.value.submitForm(native);
resolve(returnChangeRecords ? { values: result, changeRecords } : result);
cleanup();
} catch (err) {
onValidateError(err);
}
};
// 调试模式:可见地渲染表单,点击「确定」才提交,点击「取消」则中断
if (debug) {
return createDebugWrapper({
formRef,
formProps,
name: 'MFormSubmitWrapper',
title: 'submitForm 调试',
onConfirm: (setError) =>
doSubmit((err) => {
// 校验失败时保留弹层并展示错误,便于修正后重新提交
setError(err instanceof Error ? err.message : String(err));
}),
onCancel: () => {
reject(new Error('submitForm canceled in debug mode.'));
cleanup();
},
});
}
// 普通模式:表单初始化完成后自动提交
return defineComponent({
name: 'MFormSubmitWrapper',
setup() {
const stop = watch(
() => formRef.value?.initialized,
(initialized) => {
if (!initialized) return;
stop();
doSubmit((err) => {
reject(err);
cleanup();
});
},
{ flush: 'post', immediate: true },
);
return () => h(Form as Component, { ...formProps, ref: formRef });
},
});
},
});
};
// #region ValidateFormOptions
/**
* validateForm 函数参数(与 Form.vue 组件 props 对齐,取校验所需子集)
*/
export interface ValidateFormOptions {
/** 表单配置 */
config: FormConfig;
/** 待校验的表单值 */
initValues?: Record<string, any>;
parentValues?: Record<string, any>;
labelWidth?: string;
keyProp?: string;
/**
* 校验失败时,错误提示前缀是否使用字段的 text 文案(通过 `getTextByName` 从 config 中查找)。
* 默认 `true`,置为 `false` 时直接使用字段 name。
*/
useFieldTextInError?: boolean;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* 父级应用上下文用于继承全局组件、指令、provide 等。
* 通常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取。
*/
appContext?: AppContext | null;
/** 等待表单初始化的最长时间(毫秒),超时将以错误 reject。默认 10000ms */
timeout?: number;
/**
* 调试模式。默认 `false`。
*
* - `false`:以隐藏方式挂载,初始化完成后自动校验并 resolve 错误文案(原有静默行为)。
* - `true`:将表单以弹层形式可见地渲染在页面上,需手动点击「确定」才会触发校验,
* 点击「取消」则以 reject 中断;校验失败时在弹层内展示错误信息并保留弹层,便于修正后重试,
* 校验通过则 resolve 空字符串。调试模式下 `timeout` 不生效(等待人工操作)。
*/
debug?: boolean;
typeMatchValid?: boolean;
/**
* 外部中断信号。abort 时会立即以 `signal.reason` reject 并卸载临时表单实例、移除容器。
* 主要用于 `debug` 模式(无超时兜底)下取消一个被放弃的表单弹层,避免其无限驻留在页面上。
*/
signal?: AbortSignal;
}
// #endregion ValidateFormOptions
// #region stripTabLazy
/**
* 深拷贝配置值保留函数display / onTabClick 等回调)引用,避免破坏配置中的回调。
*/
const cloneConfigValue = (value: any): any => {
if (Array.isArray(value)) {
return value.map(cloneConfigValue);
}
if (value && typeof value === 'object') {
return Object.keys(value).reduce<Record<string, any>>((acc, key) => {
acc[key] = cloneConfigValue(value[key]);
return acc;
}, {});
}
return value;
};
/**
* 深度遍历配置,去掉所有 type 为 'tab' 的容器中各标签页items的 lazy 配置。
*/
const removeTabItemsLazy = (node: any): void => {
if (Array.isArray(node)) {
node.forEach(removeTabItemsLazy);
return;
}
if (!node || typeof node !== 'object') return;
if (node.type === 'tab' && Array.isArray(node.items)) {
node.items.forEach((pane: any) => {
if (pane && typeof pane === 'object') {
delete pane.lazy;
}
});
}
Object.keys(node).forEach((key) => {
removeTabItemsLazy(node[key]);
});
};
/**
* 返回一份去除了 tab 标签页 lazy 的配置副本。
*
* tab 容器开启 lazy 时,非激活标签页的内容不会渲染,导致 validateForm 静默挂载后
* 无法校验到这些标签页内的字段。校验场景需要一次性渲染全部字段,故在此统一去除 lazy。
* 处理基于深拷贝,不会污染调用方传入的原始 config。
*/
export const stripTabItemsLazy = (config: FormConfig): FormConfig => {
const cloned = cloneConfigValue(config);
removeTabItemsLazy(cloned);
return cloned;
};
// #endregion stripTabLazy
/**
* 以命令式方式对一份「表单配置 + 值」做一次静默校验,**不依赖也不影响任何已渲染的表单**。
*
* 与 `submitForm` 类似,内部会临时挂载一个不可见的 MForm 实例,等待其初始化完成后调用
* 实例的 `validate` 方法,返回汇总后的错误文案,随后自动卸载实例。
*
* 与 `submitForm` 的区别:
* - 「静默」:校验失败不抛异常、不触发 `error` 事件、不返回表单值;
* - 仅用于「探测」配置是否合法,适合源码保存后校验、批量校验组件配置等场景。
*
* 由于每次都新建一个独立的 MForm 实例,调用方无需持有任何表单 ref也不会污染
* 页面上正在展示的表单状态。
*
* @returns 校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。
* 仅在初始化超时或挂载失败等异常情况下才会 reject。
*
* @example
* ```ts
* import { validateForm } from '@tmagic/form';
*
* const error = await validateForm({
* config: [...],
* initValues: { name: 'foo' },
* appContext: getCurrentInstance()?.appContext,
* });
* if (error) {
* // 配置不合法error 为错误文案
* }
*
* // 调试模式:可见地渲染表单,点击「确定」才校验,校验失败保留弹层可修正重试:
* const error = await validateForm({
* config: [...],
* initValues: { name: 'foo' },
* debug: true,
* });
* ```
*/
export const validateForm = (options: ValidateFormOptions): Promise<string> => {
const { appContext, timeout = 10000, debug = false, config, signal, ...rest } = options;
// 去掉 tab 容器各标签页的 lazy确保懒加载标签页内的字段也参与校验
const formProps = { ...rest, config: stripTabItemsLazy(config) };
return mountFormInstance<string>({
formProps,
appContext,
timeout,
signal,
// 调试模式需把表单展示出来;普通模式隐藏挂载
hidden: !debug,
// 调试模式等待人工操作,不应用超时
skipTimeout: debug,
timeoutMessage: `validateForm timeout after ${timeout}ms: form is not initialized.`,
createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => {
/**
* 执行一次校验nextTick 等待子组件渲染 → 调用实例 validate → 通过则 resolve ''。
* 校验失败(返回非空错误文案)时交给 `onInvalid` 决定「静默 resolve 错误文案(普通)」
* 还是「在弹层展示错误并保留供重试debug从而让两种模式共享同一份校验逻辑。
*/
const doValidate = async (onInvalid: (error: string) => void) => {
try {
// 等待子组件FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
await nextTick();
// 复用 Form.vue 实例的静默校验方法:校验通过返回 '',失败返回错误文案,均不抛异常
const error = await formRef.value.validate();
if (error) {
onInvalid(error);
} else {
resolve('');
cleanup();
}
} catch (err) {
reject(err);
cleanup();
}
};
// 调试模式:可见地渲染表单,点击「确定」才校验,点击「取消」则中断
if (debug) {
return createDebugWrapper({
formRef,
formProps,
name: 'MFormValidateWrapper',
title: 'validateForm 调试',
onConfirm: (setError) =>
doValidate((error) => {
// 校验失败时保留弹层并展示错误,便于修正后重新校验
setError(error);
}),
onCancel: () => {
reject(new Error('validateForm canceled in debug mode.'));
cleanup();
},
});
}
// 普通模式:表单初始化完成后自动校验(静默 resolve 错误文案)
return defineComponent({
name: 'MFormValidateWrapper',
setup() {
const stop = watch(
() => formRef.value?.initialized,
(initialized) => {
if (!initialized) return;
stop();
doValidate((error) => {
// 静默:校验失败也以错误文案 resolve不抛异常
resolve(error);
cleanup();
});
},
{ flush: 'post', immediate: true },
);
return () => h(Form as Component, { ...formProps, ref: formRef });
},
});
},
});
};