perf(@vben-core/form-ui): optimize array field updates

This commit is contained in:
Dream
2026-07-23 16:22:15 +08:00
parent 2e69c3fefc
commit bacfdf5e42
5 changed files with 221 additions and 11 deletions

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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 },
);
});

View File

@@ -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 }}

View File

@@ -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,