22 changed files with 818 additions and 163 deletions
@ -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<Record<string, string>>({ |
|||
schema: flatSchema, |
|||
showDefaultActions: false, |
|||
}); |
|||
const flatWrapper = mount(FlatForm); |
|||
|
|||
const [ValidationForm, validationFormApi] = useVbenForm<Record<string, string>>( |
|||
{ |
|||
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<Record<string, string>>( |
|||
{ |
|||
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<Record<string, string>>({ |
|||
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, |
|||
); |
|||
}); |
|||
@ -0,0 +1,182 @@ |
|||
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(); |
|||
const arraySchemaPatches = [false, true].map((disabled) => ({ |
|||
componentProps: { disabled }, |
|||
fieldName: 'contacts.name', |
|||
})); |
|||
let arrayEditIteration = 0; |
|||
let arraySchemaIteration = 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 }, |
|||
); |
|||
|
|||
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 }, |
|||
); |
|||
}); |
|||
@ -0,0 +1,47 @@ |
|||
import type { Dayjs } from 'dayjs'; |
|||
|
|||
import dayjs from 'dayjs'; |
|||
import { describe, expect, it } from 'vitest'; |
|||
|
|||
import { createDateRangeCodec } from '../../src/utils/date-range-codec'; |
|||
|
|||
interface SearchFormValues extends Record<string, unknown> { |
|||
createdAt?: [Dayjs, Dayjs]; |
|||
keyword?: string; |
|||
} |
|||
|
|||
const codec = createDateRangeCodec<SearchFormValues>()({ |
|||
endField: 'finishedAt', |
|||
rangeField: 'createdAt', |
|||
startField: 'startedAt', |
|||
}); |
|||
|
|||
describe('date range codec', () => { |
|||
it('encodes and decodes configurable date range fields', () => { |
|||
const submitValues = codec.encode({ |
|||
createdAt: [dayjs('2026-07-01'), dayjs('2026-07-23')], |
|||
keyword: 'admin', |
|||
}); |
|||
|
|||
expect(submitValues).toEqual({ |
|||
finishedAt: '2026-07-23', |
|||
keyword: 'admin', |
|||
startedAt: '2026-07-01', |
|||
}); |
|||
const formValues = codec.decode(submitValues); |
|||
expect(formValues.keyword).toBe('admin'); |
|||
expect( |
|||
formValues.createdAt?.map((value) => value.format('YYYY-MM-DD')), |
|||
).toEqual(['2026-07-01', '2026-07-23']); |
|||
}); |
|||
|
|||
it('does not reconstruct a range with a missing bound', () => { |
|||
expect( |
|||
codec.decode({ |
|||
finishedAt: undefined, |
|||
keyword: 'admin', |
|||
startedAt: '2026-07-01', |
|||
}), |
|||
).toEqual({ keyword: 'admin' }); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,64 @@ |
|||
import type { Dayjs } from 'dayjs'; |
|||
|
|||
import dayjs from 'dayjs'; |
|||
|
|||
type DateRange = [Dayjs, Dayjs]; |
|||
|
|||
type DateRangeSubmitValues< |
|||
TFormValues extends Record<string, unknown>, |
|||
TRangeField extends keyof TFormValues, |
|||
TStartField extends string, |
|||
TEndField extends string, |
|||
> = Omit<TFormValues, TRangeField> & |
|||
Record<TEndField | TStartField, string | undefined>; |
|||
|
|||
interface DateRangeCodecOptions< |
|||
TFormValues extends Record<string, unknown>, |
|||
TRangeField extends keyof TFormValues & string, |
|||
TStartField extends string, |
|||
TEndField extends string, |
|||
> { |
|||
endField: TEndField; |
|||
rangeField: TRangeField; |
|||
startField: TStartField; |
|||
} |
|||
|
|||
export function createDateRangeCodec< |
|||
TFormValues extends Record<string, unknown>, |
|||
>() { |
|||
return function createCodec< |
|||
TRangeField extends keyof TFormValues & string, |
|||
TStartField extends string, |
|||
TEndField extends string, |
|||
>({ |
|||
endField, |
|||
rangeField, |
|||
startField, |
|||
}: DateRangeCodecOptions<TFormValues, TRangeField, TStartField, TEndField>) { |
|||
type SubmitValues = DateRangeSubmitValues< |
|||
TFormValues, |
|||
TRangeField, |
|||
TStartField, |
|||
TEndField |
|||
>; |
|||
|
|||
return { |
|||
decode(values: Readonly<SubmitValues>): TFormValues { |
|||
const { [endField]: end, [startField]: start, ...formValues } = values; |
|||
return { |
|||
...formValues, |
|||
...(start && end ? { [rangeField]: [dayjs(start), dayjs(end)] } : {}), |
|||
} as TFormValues; |
|||
}, |
|||
encode(values: Readonly<TFormValues>): SubmitValues { |
|||
const { [rangeField]: value, ...formValues } = values; |
|||
const range = value as DateRange | undefined; |
|||
return { |
|||
...formValues, |
|||
[endField]: range?.[1]?.format('YYYY-MM-DD'), |
|||
[startField]: range?.[0]?.format('YYYY-MM-DD'), |
|||
} as SubmitValues; |
|||
}, |
|||
}; |
|||
}; |
|||
} |
|||
Loading…
Reference in new issue