fix(form): 统一 extendState 合并逻辑

This commit is contained in:
roymondchen 2026-07-22 16:51:07 +08:00
parent d27710c464
commit 0feada8d1f
5 changed files with 106 additions and 25 deletions

View File

@ -40,7 +40,7 @@ import { setValueByKeyPath } from '@tmagic/utils';
import Container from './containers/Container.vue';
import { getConfig } from './utils/config';
import { initValue } from './utils/form';
import { applyExtendState, initValue } from './utils/form';
import type {
ChangeRecord,
ContainerChangeEventData,
@ -222,11 +222,10 @@ const formState: FormState = reactive<FormState>({
* 这里不再重复同步因此 `props.initValues` 这类高频变化也不会再触发
* `extendState` 重跑旧版的性能问题修复点
*
* 实现细节
* - data 描述符通过 `formState[key] = value` reactive proxy set
* 触发依赖通知与旧版逐项赋值语义完全等价
* - accessor 描述符`{ get stage() {...} }`按原样写入 formState调用方
* 可以自行控制读时求值强制 `configurable: true` 以便下一次重跑可再 define
* 实现细节合并逻辑统一收口在 `applyExtendState`utils/form
* data 描述符走 reactive proxy set 触发依赖通知与旧版逐项赋值语义等价
* accessor 描述符按原样 defineProperty 支持读时求值
* props 派生的只读 getter 字段keyProp 以普通字段形式返回时会被跳过并告警
*/
watchEffect(async (onCleanup) => {
const { extendState } = props;
@ -246,14 +245,7 @@ watchEffect(async (onCleanup) => {
}
if (stale) return;
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
if ('value' in descriptor) {
(formState as any)[key] = (state as any)[key];
} else {
descriptor.configurable = true;
Object.defineProperty(formState, key, descriptor);
}
}
applyExtendState(formState, state);
});
provide('mForm', formState);

View File

@ -18,6 +18,7 @@
import { type AppContext, type Component, createApp, defineComponent, h, nextTick, type Ref, ref, watch } from 'vue';
import { applyExtendState } from './utils/form';
import Form from './Form.vue';
import type { ChangeRecord, FormConfig, FormState } from './schema';
@ -192,17 +193,10 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
console.error('[MForm] extendState failed:', e);
return;
}
const apply = (state: Record<string, any> | null | undefined) => {
if (!state) return;
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
if ('value' in descriptor) {
(form.formState as any)[key] = (state as any)[key];
} else {
descriptor.configurable = true;
Object.defineProperty(form.formState, key, descriptor);
}
}
};
// 合并逻辑收口在 applyExtendStateprops 派生的只读 getter 字段
// keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错
const apply = (state: Record<string, any> | null | undefined) =>
applyExtendState(form.formState, state);
if (result && typeof result.then === 'function') {
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
} else {

View File

@ -441,6 +441,43 @@ export const sortChange = (data: any[], { prop, order }: SortProp) => {
}
};
/**
* extendState formState
*
* - data `formState[key] = value` reactive proxy set
*
* - accessor `{ get stage() { return ... } }` defineProperty
* `configurable: true` 便 define
*
* formState props keyProp / popperClass / config / initValues /
* isCompare / lastValues / parentValues getter setterextendState
* key proxy set trap
* `TypeError: 'set' on proxy: trap returned falsish`
* extendState get 访
*/
export const applyExtendState = (formState: FormState, state: Record<string, any> | null | undefined): void => {
if (!state) return;
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
if (!('value' in descriptor)) {
descriptor.configurable = true;
Object.defineProperty(formState, key, descriptor);
continue;
}
const targetDescriptor = Object.getOwnPropertyDescriptor(formState, key);
if (targetDescriptor && !('value' in targetDescriptor) && typeof targetDescriptor.set !== 'function') {
console.warn(
`[MForm] extendState: "${key}" is a read-only field derived from props and cannot be assigned a plain value. ` +
'Return it as a getter accessor if you really need to override it.',
);
continue;
}
(formState as any)[key] = (state as any)[key];
}
};
export const createObjectProp = (prop: string, key: string, name?: string | number) => {
if (prop === '') {
return key;

View File

@ -173,6 +173,48 @@ describe('Form.vue —— extendState', () => {
expect(v2).toMatch(/^stage-/);
});
test('extendState 以普通字段返回 props 派生的只读字段时跳过并告警,不抛错', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const wrapper = mountForm({
keyProp: 'id',
extendState: () => ({ keyProp: 'custom', config: [], extra: 'ok' }),
});
await nextTick();
await nextTick();
await nextTick();
// 只读派生字段保持 props 值,未被覆盖
expect((wrapper.vm.formState as any).keyProp).toBe('id');
expect(Array.isArray((wrapper.vm.formState as any).config)).toBe(true);
// 其他字段正常合并
expect((wrapper.vm.formState as any).extra).toBe('ok');
expect(warnSpy).toHaveBeenCalled();
expect(wrapper.find('.m-form').exists()).toBe(true);
warnSpy.mockRestore();
});
test('extendState 以 get 访问器返回只读字段同名 key 时允许显式覆盖', async () => {
const wrapper = mountForm({
keyProp: 'id',
extendState: () =>
Object.defineProperties(
{},
{
keyProp: { enumerable: true, get: () => 'custom-key' },
},
),
});
await nextTick();
await nextTick();
await nextTick();
expect((wrapper.vm.formState as any).keyProp).toBe('custom-key');
});
test('extendState 同步段读到的响应式数据变化时会重跑', async () => {
const username = ref('alice');
const calls: string[] = [];

View File

@ -79,6 +79,22 @@ describe('submitForm', () => {
expect(extendState).toHaveBeenCalled();
});
test('extendState 返回 keyProp 等只读派生字段时不抛错且正常 resolve', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const values = await submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'foo' },
extendState: () => ({ keyProp: 'custom', extra: 'value' }),
appContext,
});
expect(values).toEqual({ text: 'foo' });
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
test('在嵌套 items 配置下也能正确 resolve', async () => {
const values = await submitForm({
config: [