From 517a42ec056b04e56bc75ec3ea16596c683326ee Mon Sep 17 00:00:00 2001 From: Dream <1012377328@qq.com> Date: Thu, 23 Jul 2026 16:49:19 +0800 Subject: [PATCH] perf(@vben-core/form-ui): benchmark form hot paths --- .../form-component-performance.benchmark.ts | 207 ++++++++++++++++++ .../__tests__/form-integration.test.ts | 28 +++ .../__tests__/form-performance.benchmark.ts | 28 +++ packages/@core/ui-kit/form-ui/package.json | 1 + .../@core/ui-kit/form-ui/src/form-runtime.ts | 13 +- .../ui-kit/form-ui/src/vben-use-form.vue | 101 +++++---- pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 1 + 8 files changed, 334 insertions(+), 51 deletions(-) create mode 100644 packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts b/packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts new file mode 100644 index 00000000..498fa83d --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts @@ -0,0 +1,207 @@ +import type { FormSchema } from '../src/types'; + +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h, nextTick } from 'vue'; + +import { afterAll, bench, describe } from 'vitest'; +import { z } from 'zod'; + +import { setupVbenForm } from '../src/config'; +import { useVbenForm } from '../src/use-vben-form'; + +const BENCHMARK_OPTIONS = { time: 750, warmupTime: 150 } as const; +const FIELD_COUNT = 100; +const MOUNT_FIELD_COUNT = 50; + +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 createFlatSchema( + fieldCount: number, + withRules: boolean = false, +): FormSchema[] { + const rule = withRules ? z.string().min(1) : undefined; + return Array.from({ length: fieldCount }, (_, index) => ({ + component: TestInput, + defaultValue: `Value ${index}`, + fieldName: `field${index}`, + label: `Field ${index}`, + rules: rule, + })); +} + +function createFlatValues(prefix: string) { + return Object.fromEntries( + Array.from({ length: FIELD_COUNT }, (_, index) => [ + `field${index}`, + `${prefix} ${index}`, + ]), + ); +} + +setupVbenForm({ config: {}, rules: {} }); + +const flatSchema = createFlatSchema(FIELD_COUNT); +const [FlatForm, flatFormApi] = useVbenForm>({ + schema: flatSchema, + showDefaultActions: false, +}); +const flatWrapper = mount(FlatForm); + +const [ValidationForm, validationFormApi] = useVbenForm>( + { + schema: createFlatSchema(FIELD_COUNT, true), + showDefaultActions: false, + }, +); +const validationWrapper = mount(ValidationForm); + +const dependencySchema: FormSchema[] = [ + { + component: TestInput, + defaultValue: 'editable', + fieldName: 'mode', + label: 'Mode', + }, + ...Array.from({ length: 50 }, (_, index) => ({ + component: TestInput, + defaultValue: `Value ${index}`, + dependencies: { + resolve: ({ values }) => ({ disabled: values.mode === 'locked' }), + triggerFields: ['mode'], + }, + fieldName: `dependent${index}`, + label: `Dependent ${index}`, + })), +]; +const [DependencyForm, dependencyFormApi] = useVbenForm>( + { + schema: dependencySchema, + showDefaultActions: false, + }, +); +const dependencyWrapper = mount(DependencyForm); + +await flushPromises(); + +const batchValues = [createFlatValues('Alpha'), createFlatValues('Beta')]; +const schemaPatches = [false, true].map((disabled) => + Array.from({ length: FIELD_COUNT }, (_, index) => ({ + componentProps: { disabled }, + fieldName: `field${index}`, + })), +); +let batchIteration = 0; +let dependencyIteration = 0; +let fieldIteration = 0; +let resetIteration = 0; +let schemaIteration = 0; + +afterAll(() => { + dependencyWrapper.unmount(); + flatWrapper.unmount(); + validationWrapper.unmount(); +}); + +describe('form render performance', () => { + bench( + 'initialize, mount, and unmount 50 fields', + async () => { + const [Form] = useVbenForm>({ + schema: createFlatSchema(MOUNT_FIELD_COUNT), + showDefaultActions: false, + }); + const wrapper = mount(Form); + await flushPromises(); + wrapper.unmount(); + }, + BENCHMARK_OPTIONS, + ); +}); + +describe('form value performance', () => { + bench( + 'update one field in a 100-field form', + async () => { + fieldIteration += 1; + await flatFormApi.setFieldValue('field50', `Value ${fieldIteration}`); + await nextTick(); + }, + BENCHMARK_OPTIONS, + ); + + bench( + 'set 100 fields in one batch', + async () => { + batchIteration += 1; + await flatFormApi.setValues(batchValues[batchIteration % 2] ?? {}); + await nextTick(); + }, + BENCHMARK_OPTIONS, + ); + + bench( + 'reset 100 fields to alternate values', + async () => { + resetIteration += 1; + await flatFormApi.reset( + { values: batchValues[resetIteration % 2] ?? {} }, + { force: true }, + ); + await nextTick(); + }, + BENCHMARK_OPTIONS, + ); +}); + +describe('form validation performance', () => { + bench( + 'validate 100 fields with zod rules', + async () => { + await validationFormApi.validate(); + }, + BENCHMARK_OPTIONS, + ); +}); + +describe('form schema performance', () => { + bench( + 'update 100 schema entries', + async () => { + schemaIteration += 1; + flatFormApi.updateSchema(schemaPatches[schemaIteration % 2] ?? []); + await nextTick(); + }, + BENCHMARK_OPTIONS, + ); + + bench( + 'resolve 50 dependencies from one trigger', + async () => { + dependencyIteration += 1; + await dependencyFormApi.setFieldValue( + 'mode', + dependencyIteration % 2 === 0 ? 'editable' : 'locked', + ); + await flushPromises(); + }, + BENCHMARK_OPTIONS, + ); +}); diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts index 24d5c8a3..e5491a9d 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts @@ -104,6 +104,34 @@ describe('useVbenForm integration', () => { expect(validateValue).toHaveBeenCalledTimes(initialValidationCount + 1); }); + it('keeps values reactive when exposed through the default slot', async () => { + const [Form, formApi] = useVbenForm({ + schema: [ + { + component: TestInput, + defaultValue: 'Ada', + fieldName: 'name', + }, + ], + showDefaultActions: false, + }); + const wrapper = mount(Form, { + slots: { + default: ({ values }: { values: Record }) => + h('span', { class: 'slot-value' }, values.name), + }, + }); + wrappers.push(wrapper); + await flushPromises(); + + expect(wrapper.get('.slot-value').text()).toBe('Ada'); + + await formApi.setFieldValue('name', 'Grace'); + await flushPromises(); + + expect(wrapper.get('.slot-value').text()).toBe('Grace'); + }); + it('supports a field-level change event fallback for legacy components', async () => { const [Form, formApi] = useVbenForm({ schema: [ diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts b/packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts index 867e3f1b..11e7c8a9 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts @@ -106,7 +106,12 @@ const [ArrayForm, arrayFormApi] = useVbenForm({ }); const arrayWrapper = mount(ArrayForm); await flushPromises(); +const arraySchemaPatches = [false, true].map((disabled) => ({ + componentProps: { disabled }, + fieldName: 'contacts.name', +})); let arrayEditIteration = 0; +let arraySchemaIteration = 0; afterAll(() => { arrayWrapper.unmount(); @@ -151,4 +156,27 @@ describe('form array performance', () => { }, { time: 1000, warmupTime: 200 }, ); + + bench( + 'append and remove one row from a 100-row array', + async () => { + arrayFormApi.form.pushFieldValue('contacts', { name: 'Temporary' }); + await nextTick(); + await arrayFormApi.form.removeFieldValue('contacts', ROW_COUNT); + await nextTick(); + }, + { time: 1000, warmupTime: 200 }, + ); + + bench( + 'update one child schema across 100 rows', + async () => { + arraySchemaIteration += 1; + arrayFormApi.updateSchema([ + arraySchemaPatches[arraySchemaIteration % 2] ?? {}, + ]); + await nextTick(); + }, + { time: 1000, warmupTime: 200 }, + ); }); diff --git a/packages/@core/ui-kit/form-ui/package.json b/packages/@core/ui-kit/form-ui/package.json index 87e5b46b..cc54a078 100644 --- a/packages/@core/ui-kit/form-ui/package.json +++ b/packages/@core/ui-kit/form-ui/package.json @@ -40,6 +40,7 @@ } }, "dependencies": { + "@tanstack/store": "catalog:", "@tanstack/vue-form": "catalog:", "@vben-core/composables": "workspace:*", "@vben-core/icons": "workspace:*", diff --git a/packages/@core/ui-kit/form-ui/src/form-runtime.ts b/packages/@core/ui-kit/form-ui/src/form-runtime.ts index e0b64475..8f20dabd 100644 --- a/packages/@core/ui-kit/form-ui/src/form-runtime.ts +++ b/packages/@core/ui-kit/form-ui/src/form-runtime.ts @@ -12,6 +12,7 @@ import { computed, shallowRef } from 'vue'; import { mergeWithArrayOverride } from '@vben-core/shared/utils'; +import { batch } from '@tanstack/store'; import { useForm } from '@tanstack/vue-form'; import { createRuntimeFieldComponent } from './form-runtime-field'; @@ -289,11 +290,13 @@ export function useFormRuntime( } }, async setValues(values, shouldValidate) { - for (const [fieldName, value] of Object.entries(values)) { - rawForm.setFieldValue(fieldName as never, value as never, { - dontValidate: !shouldValidate, - }); - } + batch(() => { + for (const [fieldName, value] of Object.entries(values)) { + rawForm.setFieldValue(fieldName as never, value as never, { + dontValidate: !shouldValidate, + }); + } + }); if (shouldValidate) { await validate(); } diff --git a/packages/@core/ui-kit/form-ui/src/vben-use-form.vue b/packages/@core/ui-kit/form-ui/src/vben-use-form.vue index 85804649..d6cac0f4 100644 --- a/packages/@core/ui-kit/form-ui/src/vben-use-form.vue +++ b/packages/@core/ui-kit/form-ui/src/vben-use-form.vue @@ -87,8 +87,13 @@ watch(values, (currentValues, previousValues) => { if (!valuesChangeReady) { return; } + const handleValuesChange = forward.value.handleValuesChange; + const submitOnChange = state?.value.submitOnChange; + if (!handleValuesChange && !submitOnChange) { + return; + } const fields = state?.value.schema?.map((item) => item.fieldName) ?? []; - if (forward.value.handleValuesChange && fields.length > 0) { + if (handleValuesChange && fields.length > 0) { const changedFields = fields.filter((field) => { return !isEqual( get(currentValues, field), @@ -96,14 +101,14 @@ watch(values, (currentValues, previousValues) => { ); }); if (changedFields.length > 0) { - forward.value.handleValuesChange( - readonly(currentValues), - changedFields, - () => formApi.formatValues(currentValues), + handleValuesChange(readonly(currentValues), changedFields, () => + formApi.formatValues(currentValues), ); } } - handleValuesChangeDebounced(); + if (submitOnChange) { + handleValuesChangeDebounced(); + } }); @@ -130,46 +135,50 @@ watch(values, (currentValues, previousValues) => { > diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a51b710f..d31b203f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ catalogs: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3 + '@tanstack/store': + specifier: ^0.11.0 + version: 0.11.0 '@tanstack/vue-form': specifier: ^1.33.2 version: 1.33.2 @@ -1400,6 +1403,9 @@ importers: packages/@core/ui-kit/form-ui: dependencies: + '@tanstack/store': + specifier: 'catalog:' + version: 0.11.0 '@tanstack/vue-form': specifier: 'catalog:' version: 1.33.2(vue@3.5.40(typescript@6.0.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fce71719..1770501c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -62,6 +62,7 @@ catalog: '@stylistic/stylelint-plugin': ^5.2.1 '@tailwindcss/typography': ^0.5.20 '@tailwindcss/vite': ^4.3.3 + '@tanstack/store': ^0.11.0 '@tanstack/vue-form': ^1.33.2 '@tanstack/vue-query': ^5.101.2 '@tanstack/vue-store': ^0.11.0