perf(@vben-core/form-ui): optimize array field updates
This commit is contained in:
@@ -59,6 +59,7 @@
|
|||||||
"preview": "turbo-run preview",
|
"preview": "turbo-run preview",
|
||||||
"publint": "vsh publint",
|
"publint": "vsh publint",
|
||||||
"reinstall": "pnpm run clean -- --del-lock && pnpm install",
|
"reinstall": "pnpm run clean -- --del-lock && pnpm install",
|
||||||
|
"test:benchmark": "vitest bench --run",
|
||||||
"test:unit": "vitest run --dom",
|
"test:unit": "vitest run --dom",
|
||||||
"test:e2e": "turbo run test:e2e",
|
"test:e2e": "turbo run test:e2e",
|
||||||
"update:deps": "npx taze -r -w",
|
"update:deps": "npx taze -r -w",
|
||||||
|
|||||||
@@ -513,9 +513,57 @@ describe('useVbenForm integration', () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(wrapper.get('input').element).toBe(inputElement);
|
expect(wrapper.get('input').element).toBe(inputElement);
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('Ada Lovelace');
|
||||||
expect(document.activeElement).toBe(inputElement);
|
expect(document.activeElement).toBe(inputElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates optimized array rows when values and schemas change', async () => {
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
component: TestInput,
|
||||||
|
fieldName: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultValue: [{ name: 'Ada' }, { name: 'Grace' }],
|
||||||
|
fieldName: 'contacts',
|
||||||
|
type: 'array',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const wrapper = mount(Form);
|
||||||
|
wrappers.push(wrapper);
|
||||||
|
await flushPromises();
|
||||||
|
const firstInput = wrapper.get('input[name="contacts[0].name"]');
|
||||||
|
const firstInputElement = firstInput.element;
|
||||||
|
|
||||||
|
await formApi.setFieldValue('contacts[0].name', 'Ada Lovelace');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.get('input[name="contacts[0].name"]').element).toBe(
|
||||||
|
firstInputElement,
|
||||||
|
);
|
||||||
|
expect(firstInput.element.getAttribute('value')).toBe('Ada Lovelace');
|
||||||
|
|
||||||
|
formApi.updateSchema([
|
||||||
|
{
|
||||||
|
componentProps: { disabled: true },
|
||||||
|
fieldName: 'contacts.name',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.get('input[name="contacts[0].name"]').attributes(),
|
||||||
|
).toHaveProperty('disabled');
|
||||||
|
expect(
|
||||||
|
wrapper.get('input[name="contacts[1].name"]').attributes(),
|
||||||
|
).toHaveProperty('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
it('scopes resolve dependencies to array rows', async () => {
|
it('scopes resolve dependencies to array rows', async () => {
|
||||||
const resolve = vi.fn(({ schema }: Record<string, any>) => ({
|
const resolve = vi.fn(({ schema }: Record<string, any>) => ({
|
||||||
componentProps: {
|
componentProps: {
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
import { defineComponent, h, nextTick } from 'vue';
|
||||||
|
|
||||||
|
import { afterAll, bench, describe } from 'vitest';
|
||||||
|
|
||||||
|
import { setupVbenForm } from '../src/config';
|
||||||
|
import { FormApi } from '../src/form-api';
|
||||||
|
import { encodeFormValues } from '../src/form-codec';
|
||||||
|
import { useVbenForm } from '../src/use-vben-form';
|
||||||
|
|
||||||
|
interface ContactValues {
|
||||||
|
enabled: boolean;
|
||||||
|
metadata: {
|
||||||
|
permissions: string[];
|
||||||
|
team: string;
|
||||||
|
};
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PerformanceFormValues extends Record<string, unknown> {
|
||||||
|
contacts: ContactValues[];
|
||||||
|
settings: {
|
||||||
|
alerts: boolean;
|
||||||
|
locale: string;
|
||||||
|
sections: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_COUNT = 100;
|
||||||
|
|
||||||
|
const TestInput = defineComponent({
|
||||||
|
inheritAttrs: false,
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(_props, { attrs, emit }) {
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof HTMLInputElement) {
|
||||||
|
emit('update:modelValue', target.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h('input', {
|
||||||
|
...attrs,
|
||||||
|
onInput: handleInput,
|
||||||
|
value: attrs.modelValue ?? '',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function createFormValues(): PerformanceFormValues {
|
||||||
|
return {
|
||||||
|
contacts: Array.from({ length: ROW_COUNT }, (_, index) => ({
|
||||||
|
enabled: index % 2 === 0,
|
||||||
|
metadata: {
|
||||||
|
permissions: ['read', 'write', 'review'],
|
||||||
|
team: `team-${index % 10}`,
|
||||||
|
},
|
||||||
|
name: ` Contact ${index} `,
|
||||||
|
phone: `10086-${index}`,
|
||||||
|
tags: ['primary', 'on-call', `group-${index % 5}`],
|
||||||
|
})),
|
||||||
|
settings: {
|
||||||
|
alerts: true,
|
||||||
|
locale: 'zh-CN',
|
||||||
|
sections: ['profile', 'security', 'notifications'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const codec = {
|
||||||
|
decode: (values: Readonly<PerformanceFormValues>) => ({ ...values }),
|
||||||
|
encode: (values: Readonly<PerformanceFormValues>) => ({
|
||||||
|
...values,
|
||||||
|
contacts: values.contacts.map((contact) => ({
|
||||||
|
...contact,
|
||||||
|
name: contact.name.trim(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const formValues = createFormValues();
|
||||||
|
const codecFormApi = new FormApi<PerformanceFormValues>({ codec });
|
||||||
|
codecFormApi.mount({ meta: {}, values: formValues } as never, new Map());
|
||||||
|
|
||||||
|
setupVbenForm({ config: {}, rules: {} });
|
||||||
|
const [ArrayForm, arrayFormApi] = useVbenForm({
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
component: TestInput,
|
||||||
|
fieldName: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultValue: Array.from({ length: ROW_COUNT }, (_, index) => ({
|
||||||
|
name: `Contact ${index}`,
|
||||||
|
})),
|
||||||
|
fieldName: 'contacts',
|
||||||
|
type: 'array',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const arrayWrapper = mount(ArrayForm);
|
||||||
|
await flushPromises();
|
||||||
|
let arrayEditIteration = 0;
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
arrayWrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form codec performance', () => {
|
||||||
|
bench(
|
||||||
|
'encode 100 nested rows without isolation',
|
||||||
|
() => {
|
||||||
|
encodeFormValues(codec, formValues);
|
||||||
|
},
|
||||||
|
{ time: 1000, warmupTime: 200 },
|
||||||
|
);
|
||||||
|
|
||||||
|
bench(
|
||||||
|
'encode 100 nested rows with isolated input',
|
||||||
|
() => {
|
||||||
|
codecFormApi.formatValues(formValues);
|
||||||
|
},
|
||||||
|
{ time: 1000, warmupTime: 200 },
|
||||||
|
);
|
||||||
|
|
||||||
|
bench(
|
||||||
|
'create submit snapshot for 100 nested rows',
|
||||||
|
async () => {
|
||||||
|
await codecFormApi.getValueSnapshot();
|
||||||
|
},
|
||||||
|
{ time: 1000, warmupTime: 200 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form array performance', () => {
|
||||||
|
bench(
|
||||||
|
'edit one field in a 100-row array',
|
||||||
|
async () => {
|
||||||
|
arrayEditIteration += 1;
|
||||||
|
await arrayFormApi.setFieldValue(
|
||||||
|
'contacts[50].name',
|
||||||
|
`Contact ${arrayEditIteration}`,
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
},
|
||||||
|
{ time: 1000, warmupTime: 200 },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
VbenIconButton,
|
VbenIconButton,
|
||||||
VbenRenderContent,
|
VbenRenderContent,
|
||||||
} from '@vben-core/shadcn-ui';
|
} from '@vben-core/shadcn-ui';
|
||||||
import { cn, set } from '@vben-core/shared/utils';
|
import { cn, get, set } from '@vben-core/shared/utils';
|
||||||
|
|
||||||
import { injectRenderFormProps } from '../form-render/context';
|
import { injectRenderFormProps } from '../form-render/context';
|
||||||
import FormField from '../form-render/form-field.vue';
|
import FormField from '../form-render/form-field.vue';
|
||||||
@@ -71,14 +71,16 @@ if (!form) {
|
|||||||
throw new Error('Form api is required in <VbenFormFieldArray />');
|
throw new Error('Form api is required in <VbenFormFieldArray />');
|
||||||
}
|
}
|
||||||
const formActions = form;
|
const formActions = form;
|
||||||
const arrayValue = formActions.useFieldValue(props.name);
|
const arrayLength = formActions.useSelector((state) => {
|
||||||
|
const value = get(state.values, props.name);
|
||||||
const fields = computed<Record<string, any>[]>(() => {
|
return Array.isArray(value) ? value.length : 0;
|
||||||
return Array.isArray(arrayValue.value) ? arrayValue.value : [];
|
|
||||||
});
|
});
|
||||||
|
const rowIndexes = computed(() =>
|
||||||
|
Array.from({ length: arrayLength.value }, (_, index) => index),
|
||||||
|
);
|
||||||
|
|
||||||
const canAdd = computed(() => fields.value.length < props.max);
|
const canAdd = computed(() => arrayLength.value < props.max);
|
||||||
const canRemove = computed(() => fields.value.length > props.min);
|
const canRemove = computed(() => arrayLength.value > props.min);
|
||||||
const gridStyle = computed(() => {
|
const gridStyle = computed(() => {
|
||||||
const columns = [
|
const columns = [
|
||||||
...(props.showIndex ? ['3rem'] : []),
|
...(props.showIndex ? ['3rem'] : []),
|
||||||
@@ -133,6 +135,10 @@ function rowSchemas(index: number) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedRowSchemas = computed(() =>
|
||||||
|
Array.from({ length: arrayLength.value }, (_, index) => rowSchemas(index)),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -163,7 +169,7 @@ function rowSchemas(index: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="(_, index) in fields"
|
v-for="index in rowIndexes"
|
||||||
:key="`${arrayPath}-${index}`"
|
:key="`${arrayPath}-${index}`"
|
||||||
class="border-border/60 border-b p-3 last:border-b-0 sm:grid sm:p-0"
|
class="border-border/60 border-b p-3 last:border-b-0 sm:grid sm:p-0"
|
||||||
:style="gridStyle"
|
:style="gridStyle"
|
||||||
@@ -177,7 +183,7 @@ function rowSchemas(index: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
v-for="(childSchema, childIndex) in rowSchemas(index)"
|
v-for="(childSchema, childIndex) in normalizedRowSchemas[index]"
|
||||||
:key="childSchema.fieldName"
|
:key="childSchema.fieldName"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 py-2 sm:px-2">
|
<div class="min-w-0 py-2 sm:px-2">
|
||||||
@@ -206,7 +212,7 @@ function rowSchemas(index: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="fields.length === 0"
|
v-if="arrayLength === 0"
|
||||||
class="text-muted-foreground py-6 text-center text-sm"
|
class="text-muted-foreground py-6 text-center text-sm"
|
||||||
>
|
>
|
||||||
{{ emptyText }}
|
{{ emptyText }}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ if (!formApi) {
|
|||||||
throw new Error('Form api is required in <FormField />');
|
throw new Error('Form api is required in <FormField />');
|
||||||
}
|
}
|
||||||
const error = formApi.useFieldError(fieldName);
|
const error = formApi.useFieldError(fieldName);
|
||||||
|
const fieldValue = formApi.useFieldValue(fieldName);
|
||||||
const compact = computed(() => formRenderProps.compact);
|
const compact = computed(() => formRenderProps.compact);
|
||||||
const isInValid = computed(() => Boolean(error.value));
|
const isInValid = computed(() => Boolean(error.value));
|
||||||
const shouldApplyInvalidStyle = computed(() => {
|
const shouldApplyInvalidStyle = computed(() => {
|
||||||
@@ -297,7 +298,7 @@ function createFieldSlotProps(slotProps: RuntimeFieldSlotProps) {
|
|||||||
...slotProps,
|
...slotProps,
|
||||||
componentField: {
|
componentField: {
|
||||||
name: fieldName,
|
name: fieldName,
|
||||||
modelValue: field.state.value,
|
modelValue: fieldValue.value,
|
||||||
onBlur: field.handleBlur,
|
onBlur: field.handleBlur,
|
||||||
onChange: handleChange,
|
onChange: handleChange,
|
||||||
onInput: handleChange,
|
onInput: handleChange,
|
||||||
|
|||||||
Reference in New Issue
Block a user