mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-08-05 20:38:34 +00:00
fix(form): 统一 extendState 合并逻辑
This commit is contained in:
parent
d27710c464
commit
0feada8d1f
@ -40,7 +40,7 @@ import { setValueByKeyPath } from '@tmagic/utils';
|
|||||||
|
|
||||||
import Container from './containers/Container.vue';
|
import Container from './containers/Container.vue';
|
||||||
import { getConfig } from './utils/config';
|
import { getConfig } from './utils/config';
|
||||||
import { initValue } from './utils/form';
|
import { applyExtendState, initValue } from './utils/form';
|
||||||
import type {
|
import type {
|
||||||
ChangeRecord,
|
ChangeRecord,
|
||||||
ContainerChangeEventData,
|
ContainerChangeEventData,
|
||||||
@ -222,11 +222,10 @@ const formState: FormState = reactive<FormState>({
|
|||||||
* 这里不再重复同步;因此 `props.initValues` 这类高频变化也不会再触发
|
* 这里不再重复同步;因此 `props.initValues` 这类高频变化也不会再触发
|
||||||
* `extendState` 重跑(旧版的性能问题修复点)。
|
* `extendState` 重跑(旧版的性能问题修复点)。
|
||||||
*
|
*
|
||||||
* 实现细节:
|
* 实现细节:合并逻辑统一收口在 `applyExtendState`(utils/form)——
|
||||||
* - data 描述符:通过 `formState[key] = value` 走 reactive proxy 的 set,
|
* data 描述符走 reactive proxy 的 set 触发依赖通知(与旧版「逐项赋值」语义等价),
|
||||||
* 触发依赖通知;与旧版「逐项赋值」语义完全等价。
|
* accessor 描述符按原样 defineProperty 支持读时求值;
|
||||||
* - accessor 描述符(`{ get stage() {...} }`)按原样写入 formState,调用方
|
* props 派生的只读 getter 字段(keyProp 等)以普通字段形式返回时会被跳过并告警。
|
||||||
* 可以自行控制读时求值;强制 `configurable: true` 以便下一次重跑可再 define。
|
|
||||||
*/
|
*/
|
||||||
watchEffect(async (onCleanup) => {
|
watchEffect(async (onCleanup) => {
|
||||||
const { extendState } = props;
|
const { extendState } = props;
|
||||||
@ -246,14 +245,7 @@ watchEffect(async (onCleanup) => {
|
|||||||
}
|
}
|
||||||
if (stale) return;
|
if (stale) return;
|
||||||
|
|
||||||
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
|
applyExtendState(formState, state);
|
||||||
if ('value' in descriptor) {
|
|
||||||
(formState as any)[key] = (state as any)[key];
|
|
||||||
} else {
|
|
||||||
descriptor.configurable = true;
|
|
||||||
Object.defineProperty(formState, key, descriptor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
provide('mForm', formState);
|
provide('mForm', formState);
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
import { type AppContext, type Component, createApp, defineComponent, h, nextTick, type Ref, ref, watch } from 'vue';
|
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 Form from './Form.vue';
|
||||||
import type { ChangeRecord, FormConfig, FormState } from './schema';
|
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);
|
console.error('[MForm] extendState failed:', e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const apply = (state: Record<string, any> | null | undefined) => {
|
// 合并逻辑收口在 applyExtendState:props 派生的只读 getter 字段
|
||||||
if (!state) return;
|
// (keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错
|
||||||
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
|
const apply = (state: Record<string, any> | null | undefined) =>
|
||||||
if ('value' in descriptor) {
|
applyExtendState(form.formState, state);
|
||||||
(form.formState as any)[key] = (state as any)[key];
|
|
||||||
} else {
|
|
||||||
descriptor.configurable = true;
|
|
||||||
Object.defineProperty(form.formState, key, descriptor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (result && typeof result.then === 'function') {
|
if (result && typeof result.then === 'function') {
|
||||||
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
|
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -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(无 setter),extendState 若以
|
||||||
|
* 普通字段形式返回同名 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) => {
|
export const createObjectProp = (prop: string, key: string, name?: string | number) => {
|
||||||
if (prop === '') {
|
if (prop === '') {
|
||||||
return key;
|
return key;
|
||||||
|
|||||||
@ -173,6 +173,48 @@ describe('Form.vue —— extendState', () => {
|
|||||||
expect(v2).toMatch(/^stage-/);
|
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 () => {
|
test('extendState 同步段读到的响应式数据变化时会重跑', async () => {
|
||||||
const username = ref('alice');
|
const username = ref('alice');
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|||||||
@ -79,6 +79,22 @@ describe('submitForm', () => {
|
|||||||
expect(extendState).toHaveBeenCalled();
|
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 () => {
|
test('在嵌套 items 配置下也能正确 resolve', async () => {
|
||||||
const values = await submitForm({
|
const values = await submitForm({
|
||||||
config: [
|
config: [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user