Skip to content

Commit 70196a4

Browse files
authoredFeb 26, 2024··
perf(reactivity): optimize array tracking (#9511)
close #4318
1 parent 72bde94 commit 70196a4

File tree

7 files changed

+849
-120
lines changed

7 files changed

+849
-120
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,86 @@
11
import { bench } from 'vitest'
2-
import { computed, reactive, readonly, shallowRef, triggerRef } from '../src'
2+
import { effect, reactive, shallowReadArray } from '../src'
33

44
for (let amount = 1e1; amount < 1e4; amount *= 10) {
55
{
6-
const rawArray: any[] = []
6+
const rawArray: number[] = []
77
for (let i = 0, n = amount; i < n; i++) {
88
rawArray.push(i)
99
}
10-
const r = reactive(rawArray)
11-
const c = computed(() => {
12-
return r.reduce((v, a) => a + v, 0)
10+
const arr = reactive(rawArray)
11+
12+
bench(`track for loop, ${amount} elements`, () => {
13+
let sum = 0
14+
effect(() => {
15+
for (let i = 0; i < arr.length; i++) {
16+
sum += arr[i]
17+
}
18+
})
1319
})
20+
}
1421

15-
bench(`reduce *reactive* array, ${amount} elements`, () => {
16-
for (let i = 0, n = r.length; i < n; i++) {
17-
r[i]++
18-
}
19-
c.value
22+
{
23+
const rawArray: number[] = []
24+
for (let i = 0, n = amount; i < n; i++) {
25+
rawArray.push(i)
26+
}
27+
const arr = reactive(rawArray)
28+
29+
bench(`track manual reactiveReadArray, ${amount} elements`, () => {
30+
let sum = 0
31+
effect(() => {
32+
const raw = shallowReadArray(arr)
33+
for (let i = 0; i < raw.length; i++) {
34+
sum += raw[i]
35+
}
36+
})
37+
})
38+
}
39+
40+
{
41+
const rawArray: number[] = []
42+
for (let i = 0, n = amount; i < n; i++) {
43+
rawArray.push(i)
44+
}
45+
const arr = reactive(rawArray)
46+
47+
bench(`track iteration, ${amount} elements`, () => {
48+
let sum = 0
49+
effect(() => {
50+
for (let x of arr) {
51+
sum += x
52+
}
53+
})
54+
})
55+
}
56+
57+
{
58+
const rawArray: number[] = []
59+
for (let i = 0, n = amount; i < n; i++) {
60+
rawArray.push(i)
61+
}
62+
const arr = reactive(rawArray)
63+
64+
bench(`track forEach, ${amount} elements`, () => {
65+
let sum = 0
66+
effect(() => {
67+
arr.forEach(x => (sum += x))
68+
})
69+
})
70+
}
71+
72+
{
73+
const rawArray: number[] = []
74+
for (let i = 0, n = amount; i < n; i++) {
75+
rawArray.push(i)
76+
}
77+
const arr = reactive(rawArray)
78+
79+
bench(`track reduce, ${amount} elements`, () => {
80+
let sum = 0
81+
effect(() => {
82+
sum = arr.reduce((v, a) => a + v, 0)
83+
})
2084
})
2185
}
2286

@@ -26,15 +90,12 @@ for (let amount = 1e1; amount < 1e4; amount *= 10) {
2690
rawArray.push(i)
2791
}
2892
const r = reactive(rawArray)
29-
const c = computed(() => {
30-
return r.reduce((v, a) => a + v, 0)
31-
})
93+
effect(() => r.reduce((v, a) => a + v, 0))
3294

3395
bench(
34-
`reduce *reactive* array, ${amount} elements, only change first value`,
96+
`trigger index mutation (1st only), tracked with reduce, ${amount} elements`,
3597
() => {
3698
r[0]++
37-
c.value
3899
},
39100
)
40101
}
@@ -44,30 +105,34 @@ for (let amount = 1e1; amount < 1e4; amount *= 10) {
44105
for (let i = 0, n = amount; i < n; i++) {
45106
rawArray.push(i)
46107
}
47-
const r = reactive({ arr: readonly(rawArray) })
48-
const c = computed(() => {
49-
return r.arr.reduce((v, a) => a + v, 0)
50-
})
108+
const r = reactive(rawArray)
109+
effect(() => r.reduce((v, a) => a + v, 0))
51110

52-
bench(`reduce *readonly* array, ${amount} elements`, () => {
53-
r.arr = r.arr.map(v => v + 1)
54-
c.value
55-
})
111+
bench(
112+
`trigger index mutation (all), tracked with reduce, ${amount} elements`,
113+
() => {
114+
for (let i = 0, n = r.length; i < n; i++) {
115+
r[i]++
116+
}
117+
},
118+
)
56119
}
57120

58121
{
59-
const rawArray: any[] = []
122+
const rawArray: number[] = []
60123
for (let i = 0, n = amount; i < n; i++) {
61124
rawArray.push(i)
62125
}
63-
const r = shallowRef(rawArray)
64-
const c = computed(() => {
65-
return r.value.reduce((v, a) => a + v, 0)
126+
const arr = reactive(rawArray)
127+
let sum = 0
128+
effect(() => {
129+
for (let x of arr) {
130+
sum += x
131+
}
66132
})
67133

68-
bench(`reduce *raw* array, copied, ${amount} elements`, () => {
69-
r.value = r.value.map(v => v + 1)
70-
c.value
134+
bench(`push() trigger, tracked via iteration, ${amount} elements`, () => {
135+
arr.push(1)
71136
})
72137
}
73138

@@ -76,17 +141,14 @@ for (let amount = 1e1; amount < 1e4; amount *= 10) {
76141
for (let i = 0, n = amount; i < n; i++) {
77142
rawArray.push(i)
78143
}
79-
const r = shallowRef(rawArray)
80-
const c = computed(() => {
81-
return r.value.reduce((v, a) => a + v, 0)
144+
const arr = reactive(rawArray)
145+
let sum = 0
146+
effect(() => {
147+
arr.forEach(x => (sum += x))
82148
})
83149

84-
bench(`reduce *raw* array, manually triggered, ${amount} elements`, () => {
85-
for (let i = 0, n = rawArray.length; i < n; i++) {
86-
rawArray[i]++
87-
}
88-
triggerRef(r)
89-
c.value
150+
bench(`push() trigger, tracked via forEach, ${amount} elements`, () => {
151+
arr.push(1)
90152
})
91153
}
92154
}

‎packages/reactivity/__tests__/reactiveArray.spec.ts

+357-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { isReactive, reactive, toRaw } from '../src/reactive'
1+
import { type ComputedRef, computed } from '../src/computed'
2+
import { isReactive, reactive, shallowReactive, toRaw } from '../src/reactive'
23
import { isRef, ref } from '../src/ref'
34
import { effect } from '../src/effect'
45

@@ -252,4 +253,359 @@ describe('reactivity/reactive/Array', () => {
252253
expect(observed.lastSearched).toBe(6)
253254
})
254255
})
256+
257+
describe('Optimized array methods:', () => {
258+
test('iterator', () => {
259+
const shallow = shallowReactive([1, 2, 3, 4])
260+
let result = computed(() => {
261+
let sum = 0
262+
for (let x of shallow) {
263+
sum += x ** 2
264+
}
265+
return sum
266+
})
267+
expect(result.value).toBe(30)
268+
269+
shallow[2] = 0
270+
expect(result.value).toBe(21)
271+
272+
const deep = reactive([{ val: 1 }, { val: 2 }])
273+
result = computed(() => {
274+
let sum = 0
275+
for (let x of deep) {
276+
sum += x.val ** 2
277+
}
278+
return sum
279+
})
280+
expect(result.value).toBe(5)
281+
282+
deep[1].val = 3
283+
expect(result.value).toBe(10)
284+
})
285+
286+
test('concat', () => {
287+
const a1 = shallowReactive([1, { val: 2 }])
288+
const a2 = reactive([{ val: 3 }])
289+
const a3 = [4, 5]
290+
291+
let result = computed(() => a1.concat(a2, a3))
292+
expect(result.value).toStrictEqual([1, { val: 2 }, { val: 3 }, 4, 5])
293+
expect(isReactive(result.value[1])).toBe(false)
294+
expect(isReactive(result.value[2])).toBe(true)
295+
296+
a1.shift()
297+
expect(result.value).toStrictEqual([{ val: 2 }, { val: 3 }, 4, 5])
298+
299+
a2.pop()
300+
expect(result.value).toStrictEqual([{ val: 2 }, 4, 5])
301+
302+
a3.pop()
303+
expect(result.value).toStrictEqual([{ val: 2 }, 4, 5])
304+
})
305+
306+
test('entries', () => {
307+
const shallow = shallowReactive([0, 1])
308+
const result1 = computed(() => Array.from(shallow.entries()))
309+
expect(result1.value).toStrictEqual([
310+
[0, 0],
311+
[1, 1],
312+
])
313+
314+
shallow[1] = 10
315+
expect(result1.value).toStrictEqual([
316+
[0, 0],
317+
[1, 10],
318+
])
319+
320+
const deep = reactive([{ val: 0 }, { val: 1 }])
321+
const result2 = computed(() => Array.from(deep.entries()))
322+
expect(result2.value).toStrictEqual([
323+
[0, { val: 0 }],
324+
[1, { val: 1 }],
325+
])
326+
expect(isReactive(result2.value[0][1])).toBe(true)
327+
328+
deep.pop()
329+
expect(Array.from(result2.value)).toStrictEqual([[0, { val: 0 }]])
330+
})
331+
332+
test('every', () => {
333+
const shallow = shallowReactive([1, 2, 5])
334+
let result = computed(() => shallow.every(x => x < 5))
335+
expect(result.value).toBe(false)
336+
337+
shallow.pop()
338+
expect(result.value).toBe(true)
339+
340+
const deep = reactive([{ val: 1 }, { val: 5 }])
341+
result = computed(() => deep.every(x => x.val < 5))
342+
expect(result.value).toBe(false)
343+
344+
deep[1].val = 2
345+
expect(result.value).toBe(true)
346+
})
347+
348+
test('filter', () => {
349+
const shallow = shallowReactive([1, 2, 3, 4])
350+
const result1 = computed(() => shallow.filter(x => x < 3))
351+
expect(result1.value).toStrictEqual([1, 2])
352+
353+
shallow[2] = 0
354+
expect(result1.value).toStrictEqual([1, 2, 0])
355+
356+
const deep = reactive([{ val: 1 }, { val: 2 }])
357+
const result2 = computed(() => deep.filter(x => x.val < 2))
358+
expect(result2.value).toStrictEqual([{ val: 1 }])
359+
expect(isReactive(result2.value[0])).toBe(true)
360+
361+
deep[1].val = 0
362+
expect(result2.value).toStrictEqual([{ val: 1 }, { val: 0 }])
363+
})
364+
365+
test('find and co.', () => {
366+
const shallow = shallowReactive([{ val: 1 }, { val: 2 }])
367+
let find = computed(() => shallow.find(x => x.val === 2))
368+
// @ts-expect-error tests are not limited to es2016
369+
let findLast = computed(() => shallow.findLast(x => x.val === 2))
370+
let findIndex = computed(() => shallow.findIndex(x => x.val === 2))
371+
let findLastIndex = computed(() =>
372+
// @ts-expect-error tests are not limited to es2016
373+
shallow.findLastIndex(x => x.val === 2),
374+
)
375+
376+
expect(find.value).toBe(shallow[1])
377+
expect(isReactive(find.value)).toBe(false)
378+
expect(findLast.value).toBe(shallow[1])
379+
expect(isReactive(findLast.value)).toBe(false)
380+
expect(findIndex.value).toBe(1)
381+
expect(findLastIndex.value).toBe(1)
382+
383+
shallow[1].val = 0
384+
385+
expect(find.value).toBe(shallow[1])
386+
expect(findLast.value).toBe(shallow[1])
387+
expect(findIndex.value).toBe(1)
388+
expect(findLastIndex.value).toBe(1)
389+
390+
shallow.pop()
391+
392+
expect(find.value).toBe(undefined)
393+
expect(findLast.value).toBe(undefined)
394+
expect(findIndex.value).toBe(-1)
395+
expect(findLastIndex.value).toBe(-1)
396+
397+
const deep = reactive([{ val: 1 }, { val: 2 }])
398+
find = computed(() => deep.find(x => x.val === 2))
399+
// @ts-expect-error tests are not limited to es2016
400+
findLast = computed(() => deep.findLast(x => x.val === 2))
401+
findIndex = computed(() => deep.findIndex(x => x.val === 2))
402+
// @ts-expect-error tests are not limited to es2016
403+
findLastIndex = computed(() => deep.findLastIndex(x => x.val === 2))
404+
405+
expect(find.value).toBe(deep[1])
406+
expect(isReactive(find.value)).toBe(true)
407+
expect(findLast.value).toBe(deep[1])
408+
expect(isReactive(findLast.value)).toBe(true)
409+
expect(findIndex.value).toBe(1)
410+
expect(findLastIndex.value).toBe(1)
411+
412+
deep[1].val = 0
413+
414+
expect(find.value).toBe(undefined)
415+
expect(findLast.value).toBe(undefined)
416+
expect(findIndex.value).toBe(-1)
417+
expect(findLastIndex.value).toBe(-1)
418+
})
419+
420+
test('forEach', () => {
421+
const shallow = shallowReactive([1, 2, 3, 4])
422+
let result = computed(() => {
423+
let sum = 0
424+
shallow.forEach(x => (sum += x ** 2))
425+
return sum
426+
})
427+
expect(result.value).toBe(30)
428+
429+
shallow[2] = 0
430+
expect(result.value).toBe(21)
431+
432+
const deep = reactive([{ val: 1 }, { val: 2 }])
433+
result = computed(() => {
434+
let sum = 0
435+
deep.forEach(x => (sum += x.val ** 2))
436+
return sum
437+
})
438+
expect(result.value).toBe(5)
439+
440+
deep[1].val = 3
441+
expect(result.value).toBe(10)
442+
})
443+
444+
test('join', () => {
445+
function toString(this: { val: number }) {
446+
return this.val
447+
}
448+
const shallow = shallowReactive([
449+
{ val: 1, toString },
450+
{ val: 2, toString },
451+
])
452+
let result = computed(() => shallow.join('+'))
453+
expect(result.value).toBe('1+2')
454+
455+
shallow[1].val = 23
456+
expect(result.value).toBe('1+2')
457+
458+
shallow.pop()
459+
expect(result.value).toBe('1')
460+
461+
const deep = reactive([
462+
{ val: 1, toString },
463+
{ val: 2, toString },
464+
])
465+
result = computed(() => deep.join())
466+
expect(result.value).toBe('1,2')
467+
468+
deep[1].val = 23
469+
expect(result.value).toBe('1,23')
470+
})
471+
472+
test('map', () => {
473+
const shallow = shallowReactive([1, 2, 3, 4])
474+
let result = computed(() => shallow.map(x => x ** 2))
475+
expect(result.value).toStrictEqual([1, 4, 9, 16])
476+
477+
shallow[2] = 0
478+
expect(result.value).toStrictEqual([1, 4, 0, 16])
479+
480+
const deep = reactive([{ val: 1 }, { val: 2 }])
481+
result = computed(() => deep.map(x => x.val ** 2))
482+
expect(result.value).toStrictEqual([1, 4])
483+
484+
deep[1].val = 3
485+
expect(result.value).toStrictEqual([1, 9])
486+
})
487+
488+
test('reduce left and right', () => {
489+
function toString(this: any) {
490+
return this.val + '-'
491+
}
492+
const shallow = shallowReactive([
493+
{ val: 1, toString },
494+
{ val: 2, toString },
495+
] as any[])
496+
497+
expect(shallow.reduce((acc, x) => acc + '' + x.val, undefined)).toBe(
498+
'undefined12',
499+
)
500+
501+
let left = computed(() => shallow.reduce((acc, x) => acc + '' + x.val))
502+
let right = computed(() =>
503+
shallow.reduceRight((acc, x) => acc + '' + x.val),
504+
)
505+
expect(left.value).toBe('1-2')
506+
expect(right.value).toBe('2-1')
507+
508+
shallow[1].val = 23
509+
expect(left.value).toBe('1-2')
510+
expect(right.value).toBe('2-1')
511+
512+
shallow.pop()
513+
expect(left.value).toBe(shallow[0])
514+
expect(right.value).toBe(shallow[0])
515+
516+
const deep = reactive([{ val: 1 }, { val: 2 }])
517+
left = computed(() => deep.reduce((acc, x) => acc + x.val, '0'))
518+
right = computed(() => deep.reduceRight((acc, x) => acc + x.val, '3'))
519+
expect(left.value).toBe('012')
520+
expect(right.value).toBe('321')
521+
522+
deep[1].val = 23
523+
expect(left.value).toBe('0123')
524+
expect(right.value).toBe('3231')
525+
})
526+
527+
test('some', () => {
528+
const shallow = shallowReactive([1, 2, 5])
529+
let result = computed(() => shallow.some(x => x > 4))
530+
expect(result.value).toBe(true)
531+
532+
shallow.pop()
533+
expect(result.value).toBe(false)
534+
535+
const deep = reactive([{ val: 1 }, { val: 5 }])
536+
result = computed(() => deep.some(x => x.val > 4))
537+
expect(result.value).toBe(true)
538+
539+
deep[1].val = 2
540+
expect(result.value).toBe(false)
541+
})
542+
543+
// Node 20+
544+
// @ts-expect-error tests are not limited to es2016
545+
test.skipIf(!Array.prototype.toReversed)('toReversed', () => {
546+
const array = reactive([1, { val: 2 }])
547+
const result = computed(() => (array as any).toReversed())
548+
expect(result.value).toStrictEqual([{ val: 2 }, 1])
549+
expect(isReactive(result.value[0])).toBe(true)
550+
551+
array.splice(1, 1, 2)
552+
expect(result.value).toStrictEqual([2, 1])
553+
})
554+
555+
// Node 20+
556+
// @ts-expect-error tests are not limited to es2016
557+
test.skipIf(!Array.prototype.toSorted)('toSorted', () => {
558+
// No comparer
559+
// @ts-expect-error
560+
expect(shallowReactive([2, 1, 3]).toSorted()).toStrictEqual([1, 2, 3])
561+
562+
const shallow = shallowReactive([{ val: 2 }, { val: 1 }, { val: 3 }])
563+
let result: ComputedRef<{ val: number }[]>
564+
// @ts-expect-error
565+
result = computed(() => shallow.toSorted((a, b) => a.val - b.val))
566+
expect(result.value.map(x => x.val)).toStrictEqual([1, 2, 3])
567+
expect(isReactive(result.value[0])).toBe(false)
568+
569+
shallow[0].val = 4
570+
expect(result.value.map(x => x.val)).toStrictEqual([1, 4, 3])
571+
572+
shallow.pop()
573+
expect(result.value.map(x => x.val)).toStrictEqual([1, 4])
574+
575+
const deep = reactive([{ val: 2 }, { val: 1 }, { val: 3 }])
576+
// @ts-expect-error
577+
result = computed(() => deep.toSorted((a, b) => a.val - b.val))
578+
expect(result.value.map(x => x.val)).toStrictEqual([1, 2, 3])
579+
expect(isReactive(result.value[0])).toBe(true)
580+
581+
deep[0].val = 4
582+
expect(result.value.map(x => x.val)).toStrictEqual([1, 3, 4])
583+
})
584+
585+
// Node 20+
586+
// @ts-expect-error tests are not limited to es2016
587+
test.skipIf(!Array.prototype.toSpliced)('toSpliced', () => {
588+
const array = reactive([1, 2, 3])
589+
// @ts-expect-error
590+
const result = computed(() => array.toSpliced(1, 1, -2))
591+
expect(result.value).toStrictEqual([1, -2, 3])
592+
593+
array[0] = 0
594+
expect(result.value).toStrictEqual([0, -2, 3])
595+
})
596+
597+
test('values', () => {
598+
const shallow = shallowReactive([{ val: 1 }, { val: 2 }])
599+
const result = computed(() => Array.from(shallow.values()))
600+
expect(result.value).toStrictEqual([{ val: 1 }, { val: 2 }])
601+
expect(isReactive(result.value[0])).toBe(false)
602+
603+
shallow.pop()
604+
expect(result.value).toStrictEqual([{ val: 1 }])
605+
606+
const deep = reactive([{ val: 1 }, { val: 2 }])
607+
const firstItem = Array.from(deep.values())[0]
608+
expect(isReactive(firstItem)).toBe(true)
609+
})
610+
})
255611
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import { TrackOpTypes } from './constants'
2+
import { endBatch, pauseTracking, resetTracking, startBatch } from './effect'
3+
import { isProxy, isShallow, toRaw, toReactive } from './reactive'
4+
import { ARRAY_ITERATE_KEY, track } from './dep'
5+
6+
/**
7+
* Track array iteration and return:
8+
* - if input is reactive: a cloned raw array with reactive values
9+
* - if input is non-reactive or shallowReactive: the original raw array
10+
*/
11+
export function reactiveReadArray<T>(array: T[]): T[] {
12+
const raw = toRaw(array)
13+
if (raw === array) return raw
14+
track(raw, TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY)
15+
return isShallow(array) ? raw : raw.map(toReactive)
16+
}
17+
18+
/**
19+
* Track array iteration and return raw array
20+
*/
21+
export function shallowReadArray<T>(arr: T[]): T[] {
22+
track((arr = toRaw(arr)), TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY)
23+
return arr
24+
}
25+
26+
export const arrayInstrumentations: Record<string | symbol, Function> = <any>{
27+
__proto__: null,
28+
29+
[Symbol.iterator]() {
30+
return iterator(this, Symbol.iterator, toReactive)
31+
},
32+
33+
concat(...args: unknown[][]) {
34+
return reactiveReadArray(this).concat(
35+
...args.map(x => reactiveReadArray(x)),
36+
)
37+
},
38+
39+
entries() {
40+
return iterator(this, 'entries', (value: [number, unknown]) => {
41+
value[1] = toReactive(value[1])
42+
return value
43+
})
44+
},
45+
46+
every(
47+
fn: (item: unknown, index: number, array: unknown[]) => unknown,
48+
thisArg?: unknown,
49+
) {
50+
return apply(this, 'every', fn, thisArg)
51+
},
52+
53+
filter(
54+
fn: (item: unknown, index: number, array: unknown[]) => unknown,
55+
thisArg?: unknown,
56+
) {
57+
const result = apply(this, 'filter', fn, thisArg)
58+
return isProxy(this) && !isShallow(this) ? result.map(toReactive) : result
59+
},
60+
61+
find(
62+
fn: (item: unknown, index: number, array: unknown[]) => boolean,
63+
thisArg?: unknown,
64+
) {
65+
const result = apply(this, 'find', fn, thisArg)
66+
return isProxy(this) && !isShallow(this) ? toReactive(result) : result
67+
},
68+
69+
findIndex(
70+
fn: (item: unknown, index: number, array: unknown[]) => boolean,
71+
thisArg?: unknown,
72+
) {
73+
return apply(this, 'findIndex', fn, thisArg)
74+
},
75+
76+
findLast(
77+
fn: (item: unknown, index: number, array: unknown[]) => boolean,
78+
thisArg?: unknown,
79+
) {
80+
const result = apply(this, 'findLast', fn, thisArg)
81+
return isProxy(this) && !isShallow(this) ? toReactive(result) : result
82+
},
83+
84+
findLastIndex(
85+
fn: (item: unknown, index: number, array: unknown[]) => boolean,
86+
thisArg?: unknown,
87+
) {
88+
return apply(this, 'findLastIndex', fn, thisArg)
89+
},
90+
91+
// flat, flatMap could benefit from ARRAY_ITERATE but are not straight-forward to implement
92+
93+
forEach(
94+
fn: (item: unknown, index: number, array: unknown[]) => unknown,
95+
thisArg?: unknown,
96+
) {
97+
return apply(this, 'forEach', fn, thisArg)
98+
},
99+
100+
includes(...args: unknown[]) {
101+
return searchProxy(this, 'includes', args)
102+
},
103+
104+
indexOf(...args: unknown[]) {
105+
return searchProxy(this, 'indexOf', args)
106+
},
107+
108+
join(separator?: string) {
109+
return reactiveReadArray(this).join(separator)
110+
},
111+
112+
// keys() iterator only reads `length`, no optimisation required
113+
114+
lastIndexOf(...args: unknown[]) {
115+
return searchProxy(this, 'lastIndexOf', args)
116+
},
117+
118+
map(
119+
fn: (item: unknown, index: number, array: unknown[]) => unknown,
120+
thisArg?: unknown,
121+
) {
122+
return apply(this, 'map', fn, thisArg)
123+
},
124+
125+
pop() {
126+
return noTracking(this, 'pop')
127+
},
128+
129+
push(...args: unknown[]) {
130+
return noTracking(this, 'push', args)
131+
},
132+
133+
reduce(
134+
fn: (
135+
acc: unknown,
136+
item: unknown,
137+
index: number,
138+
array: unknown[],
139+
) => unknown,
140+
...args: unknown[]
141+
) {
142+
return reduce(this, 'reduce', fn, args)
143+
},
144+
145+
reduceRight(
146+
fn: (
147+
acc: unknown,
148+
item: unknown,
149+
index: number,
150+
array: unknown[],
151+
) => unknown,
152+
...args: unknown[]
153+
) {
154+
return reduce(this, 'reduceRight', fn, args)
155+
},
156+
157+
shift() {
158+
return noTracking(this, 'shift')
159+
},
160+
161+
// slice could use ARRAY_ITERATE but also seems to beg for range tracking
162+
163+
some(
164+
fn: (item: unknown, index: number, array: unknown[]) => unknown,
165+
thisArg?: unknown,
166+
) {
167+
return apply(this, 'some', fn, thisArg)
168+
},
169+
170+
splice(...args: unknown[]) {
171+
return noTracking(this, 'splice', args)
172+
},
173+
174+
toReversed() {
175+
// @ts-expect-error user code may run in es2016+
176+
return reactiveReadArray(this).toReversed()
177+
},
178+
179+
toSorted(comparer?: (a: unknown, b: unknown) => number) {
180+
// @ts-expect-error user code may run in es2016+
181+
return reactiveReadArray(this).toSorted(comparer)
182+
},
183+
184+
toSpliced(...args: unknown[]) {
185+
// @ts-expect-error user code may run in es2016+
186+
return (reactiveReadArray(this).toSpliced as any)(...args)
187+
},
188+
189+
unshift(...args: unknown[]) {
190+
return noTracking(this, 'unshift', args)
191+
},
192+
193+
values() {
194+
return iterator(this, 'values', toReactive)
195+
},
196+
}
197+
198+
// instrument iterators to take ARRAY_ITERATE dependency
199+
function iterator(
200+
self: unknown[],
201+
method: keyof Array<any>,
202+
wrapValue: (value: any) => unknown,
203+
) {
204+
// note that taking ARRAY_ITERATE dependency here is not strictly equivalent
205+
// to calling iterate on the proxified array.
206+
// creating the iterator does not access any array property:
207+
// it is only when .next() is called that length and indexes are accessed.
208+
// pushed to the extreme, an iterator could be created in one effect scope,
209+
// partially iterated in another, then iterated more in yet another.
210+
// given that JS iterator can only be read once, this doesn't seem like
211+
// a plausible use-case, so this tracking simplification seems ok.
212+
const arr = shallowReadArray(self)
213+
const iter = (arr[method] as any)()
214+
if (arr !== self && !isShallow(self)) {
215+
;(iter as any)._next = iter.next
216+
iter.next = () => {
217+
const result = (iter as any)._next()
218+
if (result.value) {
219+
result.value = wrapValue(result.value)
220+
}
221+
return result
222+
}
223+
}
224+
return iter
225+
}
226+
227+
// in the codebase we enforce es2016, but user code may run in environments
228+
// higher than that
229+
type ArrayMethods = keyof Array<any> | 'findLast' | 'findLastIndex'
230+
231+
// instrument functions that read (potentially) all items
232+
// to take ARRAY_ITERATE dependency
233+
function apply(
234+
self: unknown[],
235+
method: ArrayMethods,
236+
fn: (item: unknown, index: number, array: unknown[]) => unknown,
237+
thisArg?: unknown,
238+
) {
239+
const arr = shallowReadArray(self)
240+
let wrappedFn = fn
241+
if (arr !== self) {
242+
if (!isShallow(self)) {
243+
wrappedFn = function (this: unknown, item, index) {
244+
return fn.call(this, toReactive(item), index, self)
245+
}
246+
} else if (fn.length > 2) {
247+
wrappedFn = function (this: unknown, item, index) {
248+
return fn.call(this, item, index, self)
249+
}
250+
}
251+
}
252+
// @ts-expect-error our code is limited to es2016 but user code is not
253+
return arr[method](wrappedFn, thisArg)
254+
}
255+
256+
// instrument reduce and reduceRight to take ARRAY_ITERATE dependency
257+
function reduce(
258+
self: unknown[],
259+
method: keyof Array<any>,
260+
fn: (acc: unknown, item: unknown, index: number, array: unknown[]) => unknown,
261+
args: unknown[],
262+
) {
263+
const arr = shallowReadArray(self)
264+
let wrappedFn = fn
265+
if (arr !== self) {
266+
if (!isShallow(self)) {
267+
wrappedFn = function (this: unknown, acc, item, index) {
268+
return fn.call(this, acc, toReactive(item), index, self)
269+
}
270+
} else if (fn.length > 3) {
271+
wrappedFn = function (this: unknown, acc, item, index) {
272+
return fn.call(this, acc, item, index, self)
273+
}
274+
}
275+
}
276+
return (arr[method] as any)(wrappedFn, ...args)
277+
}
278+
279+
// instrument identity-sensitive methods to account for reactive proxies
280+
function searchProxy(
281+
self: unknown[],
282+
method: keyof Array<any>,
283+
args: unknown[],
284+
) {
285+
const arr = toRaw(self) as any
286+
track(arr, TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY)
287+
// we run the method using the original args first (which may be reactive)
288+
const res = arr[method](...args)
289+
290+
// if that didn't work, run it again using raw values.
291+
if ((res === -1 || res === false) && isProxy(args[0])) {
292+
args[0] = toRaw(args[0])
293+
return arr[method](...args)
294+
}
295+
296+
return res
297+
}
298+
299+
// instrument length-altering mutation methods to avoid length being tracked
300+
// which leads to infinite loops in some cases (#2137)
301+
function noTracking(
302+
self: unknown[],
303+
method: keyof Array<any>,
304+
args: unknown[] = [],
305+
) {
306+
pauseTracking()
307+
startBatch()
308+
const res = (toRaw(self) as any)[method].apply(self, args)
309+
endBatch()
310+
resetTracking()
311+
return res
312+
}

‎packages/reactivity/src/baseHandlers.ts

+4-40
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
shallowReadonlyMap,
1111
toRaw,
1212
} from './reactive'
13+
import { arrayInstrumentations } from './arrayInstrumentations'
1314
import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from './constants'
1415
import { ITERATE_KEY, track, trigger } from './dep'
1516
import {
@@ -23,7 +24,6 @@ import {
2324
} from '@vue/shared'
2425
import { isRef } from './ref'
2526
import { warn } from './warning'
26-
import { endBatch, pauseTracking, resetTracking, startBatch } from './effect'
2727

2828
const isNonTrackableKeys = /*#__PURE__*/ makeMap(`__proto__,__v_isRef,__isVue`)
2929

@@ -38,43 +38,6 @@ const builtInSymbols = new Set(
3838
.filter(isSymbol),
3939
)
4040

41-
const arrayInstrumentations = /*#__PURE__*/ createArrayInstrumentations()
42-
43-
function createArrayInstrumentations() {
44-
const instrumentations: Record<string, Function> = {}
45-
// instrument identity-sensitive Array methods to account for possible reactive
46-
// values
47-
;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
48-
instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
49-
const arr = toRaw(this) as any
50-
for (let i = 0, l = this.length; i < l; i++) {
51-
track(arr, TrackOpTypes.GET, i + '')
52-
}
53-
// we run the method using the original args first (which may be reactive)
54-
const res = arr[key](...args)
55-
if (res === -1 || res === false) {
56-
// if that didn't work, run it again using raw values.
57-
return arr[key](...args.map(toRaw))
58-
} else {
59-
return res
60-
}
61-
}
62-
})
63-
// instrument length-altering mutation methods to avoid length being tracked
64-
// which leads to infinite loops in some cases (#2137)
65-
;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
66-
instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
67-
startBatch()
68-
pauseTracking()
69-
const res = (toRaw(this) as any)[key].apply(this, args)
70-
resetTracking()
71-
endBatch()
72-
return res
73-
}
74-
})
75-
return instrumentations
76-
}
77-
7841
function hasOwnProperty(this: object, key: string) {
7942
const obj = toRaw(this)
8043
track(obj, TrackOpTypes.HAS, key)
@@ -120,8 +83,9 @@ class BaseReactiveHandler implements ProxyHandler<Target> {
12083
const targetIsArray = isArray(target)
12184

12285
if (!isReadonly) {
123-
if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
124-
return Reflect.get(arrayInstrumentations, key, receiver)
86+
let fn: Function | undefined
87+
if (targetIsArray && (fn = arrayInstrumentations[key])) {
88+
return fn
12589
}
12690
if (key === 'hasOwnProperty') {
12791
return hasOwnProperty

‎packages/reactivity/src/dep.ts

+52-37
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,9 @@ function addSub(link: Link) {
162162
type KeyToDepMap = Map<any, Dep>
163163
const targetMap = new WeakMap<object, KeyToDepMap>()
164164

165-
export const ITERATE_KEY = Symbol(__DEV__ ? 'iterate' : '')
166-
export const MAP_KEY_ITERATE_KEY = Symbol(__DEV__ ? 'Map iterate' : '')
165+
export const ITERATE_KEY = Symbol(__DEV__ ? 'Object iterate' : '')
166+
export const MAP_KEY_ITERATE_KEY = Symbol(__DEV__ ? 'Map keys iterate' : '')
167+
export const ARRAY_ITERATE_KEY = Symbol(__DEV__ ? 'Array iterate' : '')
167168

168169
/**
169170
* Tracks access to a reactive property.
@@ -225,47 +226,61 @@ export function trigger(
225226
// collection being cleared
226227
// trigger all effects for target
227228
deps = [...depsMap.values()]
228-
} else if (key === 'length' && isArray(target)) {
229-
const newLength = Number(newValue)
230-
depsMap.forEach((dep, key) => {
231-
if (key === 'length' || (!isSymbol(key) && key >= newLength)) {
232-
deps.push(dep)
233-
}
234-
})
235229
} else {
236-
const push = (dep: Dep | undefined) => dep && deps.push(dep)
230+
const targetIsArray = isArray(target)
231+
const isArrayIndex = targetIsArray && isIntegerKey(key)
237232

238-
// schedule runs for SET | ADD | DELETE
239-
if (key !== void 0) {
240-
push(depsMap.get(key))
241-
}
233+
if (targetIsArray && key === 'length') {
234+
const newLength = Number(newValue)
235+
depsMap.forEach((dep, key) => {
236+
if (
237+
key === 'length' ||
238+
key === ARRAY_ITERATE_KEY ||
239+
(!isSymbol(key) && key >= newLength)
240+
) {
241+
deps.push(dep)
242+
}
243+
})
244+
} else {
245+
const push = (dep: Dep | undefined) => dep && deps.push(dep)
242246

243-
// also run for iteration key on ADD | DELETE | Map.SET
244-
switch (type) {
245-
case TriggerOpTypes.ADD:
246-
if (!isArray(target)) {
247-
push(depsMap.get(ITERATE_KEY))
248-
if (isMap(target)) {
249-
push(depsMap.get(MAP_KEY_ITERATE_KEY))
247+
// schedule runs for SET | ADD | DELETE
248+
if (key !== void 0) {
249+
push(depsMap.get(key))
250+
}
251+
252+
// schedule ARRAY_ITERATE for any numeric key change (length is handled above)
253+
if (isArrayIndex) {
254+
push(depsMap.get(ARRAY_ITERATE_KEY))
255+
}
256+
257+
// also run for iteration key on ADD | DELETE | Map.SET
258+
switch (type) {
259+
case TriggerOpTypes.ADD:
260+
if (!targetIsArray) {
261+
push(depsMap.get(ITERATE_KEY))
262+
if (isMap(target)) {
263+
push(depsMap.get(MAP_KEY_ITERATE_KEY))
264+
}
265+
} else if (isArrayIndex) {
266+
// new index added to array -> length changes
267+
push(depsMap.get('length'))
250268
}
251-
} else if (isIntegerKey(key)) {
252-
// new index added to array -> length changes
253-
push(depsMap.get('length'))
254-
}
255-
break
256-
case TriggerOpTypes.DELETE:
257-
if (!isArray(target)) {
258-
push(depsMap.get(ITERATE_KEY))
269+
break
270+
case TriggerOpTypes.DELETE:
271+
if (!targetIsArray) {
272+
push(depsMap.get(ITERATE_KEY))
273+
if (isMap(target)) {
274+
push(depsMap.get(MAP_KEY_ITERATE_KEY))
275+
}
276+
}
277+
break
278+
case TriggerOpTypes.SET:
259279
if (isMap(target)) {
260-
push(depsMap.get(MAP_KEY_ITERATE_KEY))
280+
push(depsMap.get(ITERATE_KEY))
261281
}
262-
}
263-
break
264-
case TriggerOpTypes.SET:
265-
if (isMap(target)) {
266-
push(depsMap.get(ITERATE_KEY))
267-
}
268-
break
282+
break
283+
}
269284
}
270285
}
271286

‎packages/reactivity/src/index.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export {
3131
shallowReadonly,
3232
markRaw,
3333
toRaw,
34+
toReactive,
35+
toReadonly,
3436
type Raw,
3537
type DeepReadonly,
3638
type ShallowReactive,
@@ -60,11 +62,18 @@ export {
6062
type DebuggerEvent,
6163
type DebuggerEventExtraInfo,
6264
} from './effect'
63-
export { trigger, track, ITERATE_KEY } from './dep'
65+
export {
66+
trigger,
67+
track,
68+
ITERATE_KEY,
69+
ARRAY_ITERATE_KEY,
70+
MAP_KEY_ITERATE_KEY,
71+
} from './dep'
6472
export {
6573
effectScope,
6674
EffectScope,
6775
getCurrentScope,
6876
onScopeDispose,
6977
} from './effectScope'
78+
export { reactiveReadArray, shallowReadArray } from './arrayInstrumentations'
7079
export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants'

‎packages/runtime-core/src/helpers/renderList.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { VNode, VNodeChild } from '../vnode'
2+
import { isReactive, shallowReadArray, toReactive } from '@vue/reactivity'
23
import { isArray, isObject, isString } from '@vue/shared'
34
import { warn } from '../warning'
45

@@ -58,11 +59,21 @@ export function renderList(
5859
): VNodeChild[] {
5960
let ret: VNodeChild[]
6061
const cached = (cache && cache[index!]) as VNode[] | undefined
62+
const sourceIsArray = isArray(source)
63+
const sourceIsReactiveArray = sourceIsArray && isReactive(source)
6164

62-
if (isArray(source) || isString(source)) {
65+
if (sourceIsArray || isString(source)) {
66+
if (sourceIsReactiveArray) {
67+
source = shallowReadArray(source)
68+
}
6369
ret = new Array(source.length)
6470
for (let i = 0, l = source.length; i < l; i++) {
65-
ret[i] = renderItem(source[i], i, undefined, cached && cached[i])
71+
ret[i] = renderItem(
72+
sourceIsReactiveArray ? toReactive(source[i]) : source[i],
73+
i,
74+
undefined,
75+
cached && cached[i],
76+
)
6677
}
6778
} else if (typeof source === 'number') {
6879
if (__DEV__ && !Number.isInteger(source)) {

0 commit comments

Comments
 (0)
Please sign in to comment.