Skip to content

Commit 67d0e27

Browse files
committed
perf(runtime-vapor): cache dynamic prop/attr and slot function sources using computed to prevent redundant calls.
1 parent 92c2d8c commit 67d0e27

File tree

3 files changed

+184
-26
lines changed

3 files changed

+184
-26
lines changed

packages/runtime-vapor/__tests__/componentProps.spec.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,4 +591,157 @@ describe('component: props', () => {
591591
render({ msg: () => 'test' })
592592
expect(`Invalid prop name: "$foo"`).toHaveBeenWarned()
593593
})
594+
595+
describe('dynamic props source caching', () => {
596+
test('v-bind object should be cached when child accesses multiple props', () => {
597+
let sourceCallCount = 0
598+
const obj = ref({ foo: 1, bar: 2, baz: 3 })
599+
600+
const t0 = template('<div></div>')
601+
const Child = defineVaporComponent({
602+
props: ['foo', 'bar', 'baz'],
603+
setup(props: any) {
604+
const n0 = t0()
605+
// Child component accesses multiple props
606+
renderEffect(() => {
607+
setElementText(n0, `${props.foo}-${props.bar}-${props.baz}`)
608+
})
609+
return n0
610+
},
611+
})
612+
613+
const { host } = define({
614+
setup() {
615+
return createComponent(Child, {
616+
$: [
617+
() => {
618+
sourceCallCount++
619+
return obj.value
620+
},
621+
],
622+
})
623+
},
624+
}).render()
625+
626+
expect(host.innerHTML).toBe('<div>1-2-3</div>')
627+
// Source should only be called once even though 3 props are accessed
628+
expect(sourceCallCount).toBe(1)
629+
})
630+
631+
test('v-bind object should update when source changes', async () => {
632+
let sourceCallCount = 0
633+
const obj = ref({ foo: 1, bar: 2 })
634+
635+
const t0 = template('<div></div>')
636+
const Child = defineVaporComponent({
637+
props: ['foo', 'bar'],
638+
setup(props: any) {
639+
const n0 = t0()
640+
renderEffect(() => {
641+
setElementText(n0, `${props.foo}-${props.bar}`)
642+
})
643+
return n0
644+
},
645+
})
646+
647+
const { host } = define({
648+
setup() {
649+
return createComponent(Child, {
650+
$: [
651+
() => {
652+
sourceCallCount++
653+
return obj.value
654+
},
655+
],
656+
})
657+
},
658+
}).render()
659+
660+
expect(host.innerHTML).toBe('<div>1-2</div>')
661+
expect(sourceCallCount).toBe(1)
662+
663+
// Update source
664+
obj.value = { foo: 10, bar: 20 }
665+
await nextTick()
666+
667+
expect(host.innerHTML).toBe('<div>10-20</div>')
668+
// Should be called again after source changes
669+
expect(sourceCallCount).toBe(2)
670+
})
671+
672+
test('v-bind object should be cached when child accesses multiple attrs', () => {
673+
let sourceCallCount = 0
674+
const obj = ref({ foo: 1, bar: 2, baz: 3 })
675+
676+
const t0 = template('<div></div>')
677+
const Child = defineVaporComponent({
678+
// No props declaration - all become attrs
679+
setup(_: any, { attrs }: any) {
680+
const n0 = t0()
681+
renderEffect(() => {
682+
setElementText(n0, `${attrs.foo}-${attrs.bar}-${attrs.baz}`)
683+
})
684+
return n0
685+
},
686+
})
687+
688+
const { host } = define({
689+
setup() {
690+
return createComponent(Child, {
691+
$: [
692+
() => {
693+
sourceCallCount++
694+
return obj.value
695+
},
696+
],
697+
})
698+
},
699+
}).render()
700+
701+
expect(host.innerHTML).toBe('<div foo="1" bar="2" baz="3">1-2-3</div>')
702+
// Source should only be called once
703+
expect(sourceCallCount).toBe(1)
704+
})
705+
706+
test('mixed static and dynamic props', async () => {
707+
let sourceCallCount = 0
708+
const obj = ref({ foo: 1 })
709+
710+
const t0 = template('<div></div>')
711+
const Child = defineVaporComponent({
712+
props: ['id', 'foo', 'class'],
713+
setup(props: any) {
714+
const n0 = t0()
715+
renderEffect(() => {
716+
setElementText(n0, `${props.id}-${props.foo}-${props.class}`)
717+
})
718+
return n0
719+
},
720+
})
721+
722+
const { host } = define({
723+
setup() {
724+
return createComponent(Child, {
725+
id: () => 'static',
726+
$: [
727+
() => {
728+
sourceCallCount++
729+
return obj.value
730+
},
731+
{ class: () => 'bar' },
732+
],
733+
})
734+
},
735+
}).render()
736+
737+
expect(host.innerHTML).toBe('<div>static-1-bar</div>')
738+
expect(sourceCallCount).toBe(1)
739+
740+
obj.value = { foo: 2 }
741+
await nextTick()
742+
743+
expect(host.innerHTML).toBe('<div>static-2-bar</div>')
744+
expect(sourceCallCount).toBe(2)
745+
})
746+
})
594747
})

packages/runtime-vapor/src/componentProps.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
validateProps,
2121
warn,
2222
} from '@vue/runtime-dom'
23-
import { ReactiveFlags } from '@vue/reactivity'
23+
import { type ComputedRef, ReactiveFlags, computed } from '@vue/reactivity'
2424
import { normalizeEmitsOptions } from './componentEmits'
2525
import { renderEffect } from './renderEffect'
2626
import { pauseTracking, resetTracking } from '@vue/reactivity'
@@ -35,11 +35,24 @@ export type DynamicPropsSource =
3535
| (() => Record<string, unknown>)
3636
| Record<string, () => unknown>
3737

38-
// TODO optimization: maybe convert functions into computeds
3938
export function resolveSource(
4039
source: Record<string, any> | (() => Record<string, any>),
4140
): Record<string, any> {
42-
return isFunction(source) ? source() : source
41+
return isFunction(source)
42+
? resolveFunctionSource(source as () => Record<string, any>)
43+
: source
44+
}
45+
46+
/**
47+
* Resolve a function source with computed caching.
48+
*/
49+
export function resolveFunctionSource<T>(
50+
source: (() => T) & { _cache?: ComputedRef<T> },
51+
): T {
52+
if (!source._cache) {
53+
source._cache = computed(source)
54+
}
55+
return source._cache.value
4356
}
4457

4558
export function getPropsProxyHandlers(
@@ -78,7 +91,11 @@ export function getPropsProxyHandlers(
7891
while (i--) {
7992
source = dynamicSources[i]
8093
isDynamic = isFunction(source)
81-
source = isDynamic ? (source as Function)() : source
94+
source = isDynamic
95+
? (resolveFunctionSource(
96+
source as () => Record<string, unknown>,
97+
) as any)
98+
: source
8299
for (rawKey in source) {
83100
if (camelize(rawKey) === key) {
84101
return resolvePropValue(
@@ -205,7 +222,11 @@ export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown {
205222
while (i--) {
206223
source = dynamicSources[i]
207224
isDynamic = isFunction(source)
208-
source = isDynamic ? (source as Function)() : source
225+
source = isDynamic
226+
? (resolveFunctionSource(
227+
source as () => Record<string, unknown>,
228+
) as any)
229+
: source
209230
if (source && hasOwn(source, key)) {
210231
const value = isDynamic ? source[key] : source[key]()
211232
if (merged) {
@@ -337,7 +358,7 @@ export function resolveDynamicProps(props: RawProps): Record<string, unknown> {
337358
if (props.$) {
338359
for (const source of props.$) {
339360
const isDynamic = isFunction(source)
340-
const resolved = isDynamic ? source() : source
361+
const resolved = isDynamic ? resolveFunctionSource(source) : source
341362
for (const key in resolved) {
342363
const value = isDynamic ? resolved[key] : (resolved[key] as Function)()
343364
if (key === 'class' || key === 'style') {

packages/runtime-vapor/src/componentSlots.ts

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { EMPTY_OBJ, NO, hasOwn, isArray, isFunction } from '@vue/shared'
2-
import { type ComputedRef, computed } from '@vue/reactivity'
32
import { type Block, type BlockFn, insert, setScopeId } from './block'
4-
import { rawPropsProxyHandlers } from './componentProps'
3+
import { rawPropsProxyHandlers, resolveFunctionSource } from './componentProps'
54
import {
65
type GenericComponentInstance,
76
currentInstance,
@@ -52,24 +51,9 @@ export type StaticSlots = Record<string, VaporSlot>
5251

5352
export type VaporSlot = BlockFn
5453
export type DynamicSlot = { name: string; fn: VaporSlot }
55-
export type DynamicSlotFn = (() => DynamicSlot | DynamicSlot[]) & {
56-
_cache?: ComputedRef<DynamicSlot | DynamicSlot[]>
57-
}
54+
export type DynamicSlotFn = () => DynamicSlot | DynamicSlot[]
5855
export type DynamicSlotSource = StaticSlots | DynamicSlotFn
5956

60-
/**
61-
* Get cached result of a DynamicSlotFn.
62-
* Uses computed to cache the result and avoid redundant calls.
63-
*/
64-
function resolveDynamicSlot(
65-
source: DynamicSlotFn,
66-
): DynamicSlot | DynamicSlot[] {
67-
if (!source._cache) {
68-
source._cache = computed(source)
69-
}
70-
return source._cache.value
71-
}
72-
7357
export const dynamicSlotsProxyHandlers: ProxyHandler<RawSlots> = {
7458
get: getSlot,
7559
has: (target, key: string) => !!getSlot(target, key),
@@ -90,7 +74,7 @@ export const dynamicSlotsProxyHandlers: ProxyHandler<RawSlots> = {
9074
keys = keys.filter(k => k !== '$')
9175
for (const source of dynamicSources) {
9276
if (isFunction(source)) {
93-
const slot = resolveDynamicSlot(source)
77+
const slot = resolveFunctionSource(source)
9478
if (isArray(slot)) {
9579
for (const s of slot) keys.push(String(s.name))
9680
} else {
@@ -119,7 +103,7 @@ export function getSlot(
119103
while (i--) {
120104
source = dynamicSources[i]
121105
if (isFunction(source)) {
122-
const slot = resolveDynamicSlot(source)
106+
const slot = resolveFunctionSource(source)
123107
if (slot) {
124108
if (isArray(slot)) {
125109
for (const s of slot) {

0 commit comments

Comments
 (0)