perf(@vben-core/form-ui): optimize array field updates
This commit is contained in:
@@ -59,6 +59,7 @@
|
||||
"preview": "turbo-run preview",
|
||||
"publint": "vsh publint",
|
||||
"reinstall": "pnpm run clean -- --del-lock && pnpm install",
|
||||
"test:benchmark": "vitest bench --run",
|
||||
"test:unit": "vitest run --dom",
|
||||
"test:e2e": "turbo run test:e2e",
|
||||
"update:deps": "npx taze -r -w",
|
||||
|
||||
@@ -513,9 +513,57 @@ describe('useVbenForm integration', () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('input').element).toBe(inputElement);
|
||||
expect((input.element as HTMLInputElement).value).toBe('Ada Lovelace');
|
||||
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 () => {
|
||||
const resolve = vi.fn(({ schema }: Record<string, any>) => ({
|
||||
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,
|
||||
VbenRenderContent,
|
||||
} 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 FormField from '../form-render/form-field.vue';
|
||||
@@ -71,14 +71,16 @@ if (!form) {
|
||||
throw new Error('Form api is required in <VbenFormFieldArray />');
|
||||
}
|
||||
const formActions = form;
|
||||
const arrayValue = formActions.useFieldValue(props.name);
|
||||
|
||||
const fields = computed<Record<string, any>[]>(() => {
|
||||
return Array.isArray(arrayValue.value) ? arrayValue.value : [];
|
||||
const arrayLength = formActions.useSelector((state) => {
|
||||
const value = get(state.values, props.name);
|
||||
return Array.isArray(value) ? value.length : 0;
|
||||
});
|
||||
const rowIndexes = computed(() =>
|
||||
Array.from({ length: arrayLength.value }, (_, index) => index),
|
||||
);
|
||||
|
||||
const canAdd = computed(() => fields.value.length < props.max);
|
||||
const canRemove = computed(() => fields.value.length > props.min);
|
||||
const canAdd = computed(() => arrayLength.value < props.max);
|
||||
const canRemove = computed(() => arrayLength.value > props.min);
|
||||
const gridStyle = computed(() => {
|
||||
const columns = [
|
||||
...(props.showIndex ? ['3rem'] : []),
|
||||
@@ -133,6 +135,10 @@ function rowSchemas(index: number) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedRowSchemas = computed(() =>
|
||||
Array.from({ length: arrayLength.value }, (_, index) => rowSchemas(index)),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,7 +169,7 @@ function rowSchemas(index: number) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(_, index) in fields"
|
||||
v-for="index in rowIndexes"
|
||||
:key="`${arrayPath}-${index}`"
|
||||
class="border-border/60 border-b p-3 last:border-b-0 sm:grid sm:p-0"
|
||||
:style="gridStyle"
|
||||
@@ -177,7 +183,7 @@ function rowSchemas(index: number) {
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-for="(childSchema, childIndex) in rowSchemas(index)"
|
||||
v-for="(childSchema, childIndex) in normalizedRowSchemas[index]"
|
||||
:key="childSchema.fieldName"
|
||||
>
|
||||
<div class="min-w-0 py-2 sm:px-2">
|
||||
@@ -206,7 +212,7 @@ function rowSchemas(index: number) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="fields.length === 0"
|
||||
v-if="arrayLength === 0"
|
||||
class="text-muted-foreground py-6 text-center text-sm"
|
||||
>
|
||||
{{ emptyText }}
|
||||
|
||||
@@ -83,6 +83,7 @@ if (!formApi) {
|
||||
throw new Error('Form api is required in <FormField />');
|
||||
}
|
||||
const error = formApi.useFieldError(fieldName);
|
||||
const fieldValue = formApi.useFieldValue(fieldName);
|
||||
const compact = computed(() => formRenderProps.compact);
|
||||
const isInValid = computed(() => Boolean(error.value));
|
||||
const shouldApplyInvalidStyle = computed(() => {
|
||||
@@ -297,7 +298,7 @@ function createFieldSlotProps(slotProps: RuntimeFieldSlotProps) {
|
||||
...slotProps,
|
||||
componentField: {
|
||||
name: fieldName,
|
||||
modelValue: field.state.value,
|
||||
modelValue: fieldValue.value,
|
||||
onBlur: field.handleBlur,
|
||||
onChange: handleChange,
|
||||
onInput: handleChange,
|
||||
|
||||
Reference in New Issue
Block a user