From 70196a40cc078f50fcc1110c38c06fbcc70b205e Mon Sep 17 00:00:00 2001 From: jods Date: Mon, 26 Feb 2024 11:25:52 +0100 Subject: [PATCH] perf(reactivity): optimize array tracking (#9511) close #4318 --- .../__benchmarks__/reactiveArray.bench.ts | 140 +++++-- .../__tests__/reactiveArray.spec.ts | 358 +++++++++++++++++- .../reactivity/src/arrayInstrumentations.ts | 312 +++++++++++++++ packages/reactivity/src/baseHandlers.ts | 44 +-- packages/reactivity/src/dep.ts | 89 +++-- packages/reactivity/src/index.ts | 11 +- .../runtime-core/src/helpers/renderList.ts | 15 +- 7 files changed, 849 insertions(+), 120 deletions(-) create mode 100644 packages/reactivity/src/arrayInstrumentations.ts diff --git a/packages/reactivity/__benchmarks__/reactiveArray.bench.ts b/packages/reactivity/__benchmarks__/reactiveArray.bench.ts index 6726cccfd89..f5032cf7ae9 100644 --- a/packages/reactivity/__benchmarks__/reactiveArray.bench.ts +++ b/packages/reactivity/__benchmarks__/reactiveArray.bench.ts @@ -1,22 +1,86 @@ import { bench } from 'vitest' -import { computed, reactive, readonly, shallowRef, triggerRef } from '../src' +import { effect, reactive, shallowReadArray } from '../src' for (let amount = 1e1; amount < 1e4; amount *= 10) { { - const rawArray: any[] = [] + const rawArray: number[] = [] for (let i = 0, n = amount; i < n; i++) { rawArray.push(i) } - const r = reactive(rawArray) - const c = computed(() => { - return r.reduce((v, a) => a + v, 0) + const arr = reactive(rawArray) + + bench(`track for loop, ${amount} elements`, () => { + let sum = 0 + effect(() => { + for (let i = 0; i < arr.length; i++) { + sum += arr[i] + } + }) }) + } - bench(`reduce *reactive* array, ${amount} elements`, () => { - for (let i = 0, n = r.length; i < n; i++) { - r[i]++ - } - c.value + { + const rawArray: number[] = [] + for (let i = 0, n = amount; i < n; i++) { + rawArray.push(i) + } + const arr = reactive(rawArray) + + bench(`track manual reactiveReadArray, ${amount} elements`, () => { + let sum = 0 + effect(() => { + const raw = shallowReadArray(arr) + for (let i = 0; i < raw.length; i++) { + sum += raw[i] + } + }) + }) + } + + { + const rawArray: number[] = [] + for (let i = 0, n = amount; i < n; i++) { + rawArray.push(i) + } + const arr = reactive(rawArray) + + bench(`track iteration, ${amount} elements`, () => { + let sum = 0 + effect(() => { + for (let x of arr) { + sum += x + } + }) + }) + } + + { + const rawArray: number[] = [] + for (let i = 0, n = amount; i < n; i++) { + rawArray.push(i) + } + const arr = reactive(rawArray) + + bench(`track forEach, ${amount} elements`, () => { + let sum = 0 + effect(() => { + arr.forEach(x => (sum += x)) + }) + }) + } + + { + const rawArray: number[] = [] + for (let i = 0, n = amount; i < n; i++) { + rawArray.push(i) + } + const arr = reactive(rawArray) + + bench(`track reduce, ${amount} elements`, () => { + let sum = 0 + effect(() => { + sum = arr.reduce((v, a) => a + v, 0) + }) }) } @@ -26,15 +90,12 @@ for (let amount = 1e1; amount < 1e4; amount *= 10) { rawArray.push(i) } const r = reactive(rawArray) - const c = computed(() => { - return r.reduce((v, a) => a + v, 0) - }) + effect(() => r.reduce((v, a) => a + v, 0)) bench( - `reduce *reactive* array, ${amount} elements, only change first value`, + `trigger index mutation (1st only), tracked with reduce, ${amount} elements`, () => { r[0]++ - c.value }, ) } @@ -44,30 +105,34 @@ for (let amount = 1e1; amount < 1e4; amount *= 10) { for (let i = 0, n = amount; i < n; i++) { rawArray.push(i) } - const r = reactive({ arr: readonly(rawArray) }) - const c = computed(() => { - return r.arr.reduce((v, a) => a + v, 0) - }) + const r = reactive(rawArray) + effect(() => r.reduce((v, a) => a + v, 0)) - bench(`reduce *readonly* array, ${amount} elements`, () => { - r.arr = r.arr.map(v => v + 1) - c.value - }) + bench( + `trigger index mutation (all), tracked with reduce, ${amount} elements`, + () => { + for (let i = 0, n = r.length; i < n; i++) { + r[i]++ + } + }, + ) } { - const rawArray: any[] = [] + const rawArray: number[] = [] for (let i = 0, n = amount; i < n; i++) { rawArray.push(i) } - const r = shallowRef(rawArray) - const c = computed(() => { - return r.value.reduce((v, a) => a + v, 0) + const arr = reactive(rawArray) + let sum = 0 + effect(() => { + for (let x of arr) { + sum += x + } }) - bench(`reduce *raw* array, copied, ${amount} elements`, () => { - r.value = r.value.map(v => v + 1) - c.value + bench(`push() trigger, tracked via iteration, ${amount} elements`, () => { + arr.push(1) }) } @@ -76,17 +141,14 @@ for (let amount = 1e1; amount < 1e4; amount *= 10) { for (let i = 0, n = amount; i < n; i++) { rawArray.push(i) } - const r = shallowRef(rawArray) - const c = computed(() => { - return r.value.reduce((v, a) => a + v, 0) + const arr = reactive(rawArray) + let sum = 0 + effect(() => { + arr.forEach(x => (sum += x)) }) - bench(`reduce *raw* array, manually triggered, ${amount} elements`, () => { - for (let i = 0, n = rawArray.length; i < n; i++) { - rawArray[i]++ - } - triggerRef(r) - c.value + bench(`push() trigger, tracked via forEach, ${amount} elements`, () => { + arr.push(1) }) } } diff --git a/packages/reactivity/__tests__/reactiveArray.spec.ts b/packages/reactivity/__tests__/reactiveArray.spec.ts index 1c6fcefd592..9caeaf116d2 100644 --- a/packages/reactivity/__tests__/reactiveArray.spec.ts +++ b/packages/reactivity/__tests__/reactiveArray.spec.ts @@ -1,4 +1,5 @@ -import { isReactive, reactive, toRaw } from '../src/reactive' +import { type ComputedRef, computed } from '../src/computed' +import { isReactive, reactive, shallowReactive, toRaw } from '../src/reactive' import { isRef, ref } from '../src/ref' import { effect } from '../src/effect' @@ -252,4 +253,359 @@ describe('reactivity/reactive/Array', () => { expect(observed.lastSearched).toBe(6) }) }) + + describe('Optimized array methods:', () => { + test('iterator', () => { + const shallow = shallowReactive([1, 2, 3, 4]) + let result = computed(() => { + let sum = 0 + for (let x of shallow) { + sum += x ** 2 + } + return sum + }) + expect(result.value).toBe(30) + + shallow[2] = 0 + expect(result.value).toBe(21) + + const deep = reactive([{ val: 1 }, { val: 2 }]) + result = computed(() => { + let sum = 0 + for (let x of deep) { + sum += x.val ** 2 + } + return sum + }) + expect(result.value).toBe(5) + + deep[1].val = 3 + expect(result.value).toBe(10) + }) + + test('concat', () => { + const a1 = shallowReactive([1, { val: 2 }]) + const a2 = reactive([{ val: 3 }]) + const a3 = [4, 5] + + let result = computed(() => a1.concat(a2, a3)) + expect(result.value).toStrictEqual([1, { val: 2 }, { val: 3 }, 4, 5]) + expect(isReactive(result.value[1])).toBe(false) + expect(isReactive(result.value[2])).toBe(true) + + a1.shift() + expect(result.value).toStrictEqual([{ val: 2 }, { val: 3 }, 4, 5]) + + a2.pop() + expect(result.value).toStrictEqual([{ val: 2 }, 4, 5]) + + a3.pop() + expect(result.value).toStrictEqual([{ val: 2 }, 4, 5]) + }) + + test('entries', () => { + const shallow = shallowReactive([0, 1]) + const result1 = computed(() => Array.from(shallow.entries())) + expect(result1.value).toStrictEqual([ + [0, 0], + [1, 1], + ]) + + shallow[1] = 10 + expect(result1.value).toStrictEqual([ + [0, 0], + [1, 10], + ]) + + const deep = reactive([{ val: 0 }, { val: 1 }]) + const result2 = computed(() => Array.from(deep.entries())) + expect(result2.value).toStrictEqual([ + [0, { val: 0 }], + [1, { val: 1 }], + ]) + expect(isReactive(result2.value[0][1])).toBe(true) + + deep.pop() + expect(Array.from(result2.value)).toStrictEqual([[0, { val: 0 }]]) + }) + + test('every', () => { + const shallow = shallowReactive([1, 2, 5]) + let result = computed(() => shallow.every(x => x < 5)) + expect(result.value).toBe(false) + + shallow.pop() + expect(result.value).toBe(true) + + const deep = reactive([{ val: 1 }, { val: 5 }]) + result = computed(() => deep.every(x => x.val < 5)) + expect(result.value).toBe(false) + + deep[1].val = 2 + expect(result.value).toBe(true) + }) + + test('filter', () => { + const shallow = shallowReactive([1, 2, 3, 4]) + const result1 = computed(() => shallow.filter(x => x < 3)) + expect(result1.value).toStrictEqual([1, 2]) + + shallow[2] = 0 + expect(result1.value).toStrictEqual([1, 2, 0]) + + const deep = reactive([{ val: 1 }, { val: 2 }]) + const result2 = computed(() => deep.filter(x => x.val < 2)) + expect(result2.value).toStrictEqual([{ val: 1 }]) + expect(isReactive(result2.value[0])).toBe(true) + + deep[1].val = 0 + expect(result2.value).toStrictEqual([{ val: 1 }, { val: 0 }]) + }) + + test('find and co.', () => { + const shallow = shallowReactive([{ val: 1 }, { val: 2 }]) + let find = computed(() => shallow.find(x => x.val === 2)) + // @ts-expect-error tests are not limited to es2016 + let findLast = computed(() => shallow.findLast(x => x.val === 2)) + let findIndex = computed(() => shallow.findIndex(x => x.val === 2)) + let findLastIndex = computed(() => + // @ts-expect-error tests are not limited to es2016 + shallow.findLastIndex(x => x.val === 2), + ) + + expect(find.value).toBe(shallow[1]) + expect(isReactive(find.value)).toBe(false) + expect(findLast.value).toBe(shallow[1]) + expect(isReactive(findLast.value)).toBe(false) + expect(findIndex.value).toBe(1) + expect(findLastIndex.value).toBe(1) + + shallow[1].val = 0 + + expect(find.value).toBe(shallow[1]) + expect(findLast.value).toBe(shallow[1]) + expect(findIndex.value).toBe(1) + expect(findLastIndex.value).toBe(1) + + shallow.pop() + + expect(find.value).toBe(undefined) + expect(findLast.value).toBe(undefined) + expect(findIndex.value).toBe(-1) + expect(findLastIndex.value).toBe(-1) + + const deep = reactive([{ val: 1 }, { val: 2 }]) + find = computed(() => deep.find(x => x.val === 2)) + // @ts-expect-error tests are not limited to es2016 + findLast = computed(() => deep.findLast(x => x.val === 2)) + findIndex = computed(() => deep.findIndex(x => x.val === 2)) + // @ts-expect-error tests are not limited to es2016 + findLastIndex = computed(() => deep.findLastIndex(x => x.val === 2)) + + expect(find.value).toBe(deep[1]) + expect(isReactive(find.value)).toBe(true) + expect(findLast.value).toBe(deep[1]) + expect(isReactive(findLast.value)).toBe(true) + expect(findIndex.value).toBe(1) + expect(findLastIndex.value).toBe(1) + + deep[1].val = 0 + + expect(find.value).toBe(undefined) + expect(findLast.value).toBe(undefined) + expect(findIndex.value).toBe(-1) + expect(findLastIndex.value).toBe(-1) + }) + + test('forEach', () => { + const shallow = shallowReactive([1, 2, 3, 4]) + let result = computed(() => { + let sum = 0 + shallow.forEach(x => (sum += x ** 2)) + return sum + }) + expect(result.value).toBe(30) + + shallow[2] = 0 + expect(result.value).toBe(21) + + const deep = reactive([{ val: 1 }, { val: 2 }]) + result = computed(() => { + let sum = 0 + deep.forEach(x => (sum += x.val ** 2)) + return sum + }) + expect(result.value).toBe(5) + + deep[1].val = 3 + expect(result.value).toBe(10) + }) + + test('join', () => { + function toString(this: { val: number }) { + return this.val + } + const shallow = shallowReactive([ + { val: 1, toString }, + { val: 2, toString }, + ]) + let result = computed(() => shallow.join('+')) + expect(result.value).toBe('1+2') + + shallow[1].val = 23 + expect(result.value).toBe('1+2') + + shallow.pop() + expect(result.value).toBe('1') + + const deep = reactive([ + { val: 1, toString }, + { val: 2, toString }, + ]) + result = computed(() => deep.join()) + expect(result.value).toBe('1,2') + + deep[1].val = 23 + expect(result.value).toBe('1,23') + }) + + test('map', () => { + const shallow = shallowReactive([1, 2, 3, 4]) + let result = computed(() => shallow.map(x => x ** 2)) + expect(result.value).toStrictEqual([1, 4, 9, 16]) + + shallow[2] = 0 + expect(result.value).toStrictEqual([1, 4, 0, 16]) + + const deep = reactive([{ val: 1 }, { val: 2 }]) + result = computed(() => deep.map(x => x.val ** 2)) + expect(result.value).toStrictEqual([1, 4]) + + deep[1].val = 3 + expect(result.value).toStrictEqual([1, 9]) + }) + + test('reduce left and right', () => { + function toString(this: any) { + return this.val + '-' + } + const shallow = shallowReactive([ + { val: 1, toString }, + { val: 2, toString }, + ] as any[]) + + expect(shallow.reduce((acc, x) => acc + '' + x.val, undefined)).toBe( + 'undefined12', + ) + + let left = computed(() => shallow.reduce((acc, x) => acc + '' + x.val)) + let right = computed(() => + shallow.reduceRight((acc, x) => acc + '' + x.val), + ) + expect(left.value).toBe('1-2') + expect(right.value).toBe('2-1') + + shallow[1].val = 23 + expect(left.value).toBe('1-2') + expect(right.value).toBe('2-1') + + shallow.pop() + expect(left.value).toBe(shallow[0]) + expect(right.value).toBe(shallow[0]) + + const deep = reactive([{ val: 1 }, { val: 2 }]) + left = computed(() => deep.reduce((acc, x) => acc + x.val, '0')) + right = computed(() => deep.reduceRight((acc, x) => acc + x.val, '3')) + expect(left.value).toBe('012') + expect(right.value).toBe('321') + + deep[1].val = 23 + expect(left.value).toBe('0123') + expect(right.value).toBe('3231') + }) + + test('some', () => { + const shallow = shallowReactive([1, 2, 5]) + let result = computed(() => shallow.some(x => x > 4)) + expect(result.value).toBe(true) + + shallow.pop() + expect(result.value).toBe(false) + + const deep = reactive([{ val: 1 }, { val: 5 }]) + result = computed(() => deep.some(x => x.val > 4)) + expect(result.value).toBe(true) + + deep[1].val = 2 + expect(result.value).toBe(false) + }) + + // Node 20+ + // @ts-expect-error tests are not limited to es2016 + test.skipIf(!Array.prototype.toReversed)('toReversed', () => { + const array = reactive([1, { val: 2 }]) + const result = computed(() => (array as any).toReversed()) + expect(result.value).toStrictEqual([{ val: 2 }, 1]) + expect(isReactive(result.value[0])).toBe(true) + + array.splice(1, 1, 2) + expect(result.value).toStrictEqual([2, 1]) + }) + + // Node 20+ + // @ts-expect-error tests are not limited to es2016 + test.skipIf(!Array.prototype.toSorted)('toSorted', () => { + // No comparer + // @ts-expect-error + expect(shallowReactive([2, 1, 3]).toSorted()).toStrictEqual([1, 2, 3]) + + const shallow = shallowReactive([{ val: 2 }, { val: 1 }, { val: 3 }]) + let result: ComputedRef<{ val: number }[]> + // @ts-expect-error + result = computed(() => shallow.toSorted((a, b) => a.val - b.val)) + expect(result.value.map(x => x.val)).toStrictEqual([1, 2, 3]) + expect(isReactive(result.value[0])).toBe(false) + + shallow[0].val = 4 + expect(result.value.map(x => x.val)).toStrictEqual([1, 4, 3]) + + shallow.pop() + expect(result.value.map(x => x.val)).toStrictEqual([1, 4]) + + const deep = reactive([{ val: 2 }, { val: 1 }, { val: 3 }]) + // @ts-expect-error + result = computed(() => deep.toSorted((a, b) => a.val - b.val)) + expect(result.value.map(x => x.val)).toStrictEqual([1, 2, 3]) + expect(isReactive(result.value[0])).toBe(true) + + deep[0].val = 4 + expect(result.value.map(x => x.val)).toStrictEqual([1, 3, 4]) + }) + + // Node 20+ + // @ts-expect-error tests are not limited to es2016 + test.skipIf(!Array.prototype.toSpliced)('toSpliced', () => { + const array = reactive([1, 2, 3]) + // @ts-expect-error + const result = computed(() => array.toSpliced(1, 1, -2)) + expect(result.value).toStrictEqual([1, -2, 3]) + + array[0] = 0 + expect(result.value).toStrictEqual([0, -2, 3]) + }) + + test('values', () => { + const shallow = shallowReactive([{ val: 1 }, { val: 2 }]) + const result = computed(() => Array.from(shallow.values())) + expect(result.value).toStrictEqual([{ val: 1 }, { val: 2 }]) + expect(isReactive(result.value[0])).toBe(false) + + shallow.pop() + expect(result.value).toStrictEqual([{ val: 1 }]) + + const deep = reactive([{ val: 1 }, { val: 2 }]) + const firstItem = Array.from(deep.values())[0] + expect(isReactive(firstItem)).toBe(true) + }) + }) }) diff --git a/packages/reactivity/src/arrayInstrumentations.ts b/packages/reactivity/src/arrayInstrumentations.ts new file mode 100644 index 00000000000..a16eabdf72f --- /dev/null +++ b/packages/reactivity/src/arrayInstrumentations.ts @@ -0,0 +1,312 @@ +import { TrackOpTypes } from './constants' +import { endBatch, pauseTracking, resetTracking, startBatch } from './effect' +import { isProxy, isShallow, toRaw, toReactive } from './reactive' +import { ARRAY_ITERATE_KEY, track } from './dep' + +/** + * Track array iteration and return: + * - if input is reactive: a cloned raw array with reactive values + * - if input is non-reactive or shallowReactive: the original raw array + */ +export function reactiveReadArray(array: T[]): T[] { + const raw = toRaw(array) + if (raw === array) return raw + track(raw, TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY) + return isShallow(array) ? raw : raw.map(toReactive) +} + +/** + * Track array iteration and return raw array + */ +export function shallowReadArray(arr: T[]): T[] { + track((arr = toRaw(arr)), TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY) + return arr +} + +export const arrayInstrumentations: Record = { + __proto__: null, + + [Symbol.iterator]() { + return iterator(this, Symbol.iterator, toReactive) + }, + + concat(...args: unknown[][]) { + return reactiveReadArray(this).concat( + ...args.map(x => reactiveReadArray(x)), + ) + }, + + entries() { + return iterator(this, 'entries', (value: [number, unknown]) => { + value[1] = toReactive(value[1]) + return value + }) + }, + + every( + fn: (item: unknown, index: number, array: unknown[]) => unknown, + thisArg?: unknown, + ) { + return apply(this, 'every', fn, thisArg) + }, + + filter( + fn: (item: unknown, index: number, array: unknown[]) => unknown, + thisArg?: unknown, + ) { + const result = apply(this, 'filter', fn, thisArg) + return isProxy(this) && !isShallow(this) ? result.map(toReactive) : result + }, + + find( + fn: (item: unknown, index: number, array: unknown[]) => boolean, + thisArg?: unknown, + ) { + const result = apply(this, 'find', fn, thisArg) + return isProxy(this) && !isShallow(this) ? toReactive(result) : result + }, + + findIndex( + fn: (item: unknown, index: number, array: unknown[]) => boolean, + thisArg?: unknown, + ) { + return apply(this, 'findIndex', fn, thisArg) + }, + + findLast( + fn: (item: unknown, index: number, array: unknown[]) => boolean, + thisArg?: unknown, + ) { + const result = apply(this, 'findLast', fn, thisArg) + return isProxy(this) && !isShallow(this) ? toReactive(result) : result + }, + + findLastIndex( + fn: (item: unknown, index: number, array: unknown[]) => boolean, + thisArg?: unknown, + ) { + return apply(this, 'findLastIndex', fn, thisArg) + }, + + // flat, flatMap could benefit from ARRAY_ITERATE but are not straight-forward to implement + + forEach( + fn: (item: unknown, index: number, array: unknown[]) => unknown, + thisArg?: unknown, + ) { + return apply(this, 'forEach', fn, thisArg) + }, + + includes(...args: unknown[]) { + return searchProxy(this, 'includes', args) + }, + + indexOf(...args: unknown[]) { + return searchProxy(this, 'indexOf', args) + }, + + join(separator?: string) { + return reactiveReadArray(this).join(separator) + }, + + // keys() iterator only reads `length`, no optimisation required + + lastIndexOf(...args: unknown[]) { + return searchProxy(this, 'lastIndexOf', args) + }, + + map( + fn: (item: unknown, index: number, array: unknown[]) => unknown, + thisArg?: unknown, + ) { + return apply(this, 'map', fn, thisArg) + }, + + pop() { + return noTracking(this, 'pop') + }, + + push(...args: unknown[]) { + return noTracking(this, 'push', args) + }, + + reduce( + fn: ( + acc: unknown, + item: unknown, + index: number, + array: unknown[], + ) => unknown, + ...args: unknown[] + ) { + return reduce(this, 'reduce', fn, args) + }, + + reduceRight( + fn: ( + acc: unknown, + item: unknown, + index: number, + array: unknown[], + ) => unknown, + ...args: unknown[] + ) { + return reduce(this, 'reduceRight', fn, args) + }, + + shift() { + return noTracking(this, 'shift') + }, + + // slice could use ARRAY_ITERATE but also seems to beg for range tracking + + some( + fn: (item: unknown, index: number, array: unknown[]) => unknown, + thisArg?: unknown, + ) { + return apply(this, 'some', fn, thisArg) + }, + + splice(...args: unknown[]) { + return noTracking(this, 'splice', args) + }, + + toReversed() { + // @ts-expect-error user code may run in es2016+ + return reactiveReadArray(this).toReversed() + }, + + toSorted(comparer?: (a: unknown, b: unknown) => number) { + // @ts-expect-error user code may run in es2016+ + return reactiveReadArray(this).toSorted(comparer) + }, + + toSpliced(...args: unknown[]) { + // @ts-expect-error user code may run in es2016+ + return (reactiveReadArray(this).toSpliced as any)(...args) + }, + + unshift(...args: unknown[]) { + return noTracking(this, 'unshift', args) + }, + + values() { + return iterator(this, 'values', toReactive) + }, +} + +// instrument iterators to take ARRAY_ITERATE dependency +function iterator( + self: unknown[], + method: keyof Array, + wrapValue: (value: any) => unknown, +) { + // note that taking ARRAY_ITERATE dependency here is not strictly equivalent + // to calling iterate on the proxified array. + // creating the iterator does not access any array property: + // it is only when .next() is called that length and indexes are accessed. + // pushed to the extreme, an iterator could be created in one effect scope, + // partially iterated in another, then iterated more in yet another. + // given that JS iterator can only be read once, this doesn't seem like + // a plausible use-case, so this tracking simplification seems ok. + const arr = shallowReadArray(self) + const iter = (arr[method] as any)() + if (arr !== self && !isShallow(self)) { + ;(iter as any)._next = iter.next + iter.next = () => { + const result = (iter as any)._next() + if (result.value) { + result.value = wrapValue(result.value) + } + return result + } + } + return iter +} + +// in the codebase we enforce es2016, but user code may run in environments +// higher than that +type ArrayMethods = keyof Array | 'findLast' | 'findLastIndex' + +// instrument functions that read (potentially) all items +// to take ARRAY_ITERATE dependency +function apply( + self: unknown[], + method: ArrayMethods, + fn: (item: unknown, index: number, array: unknown[]) => unknown, + thisArg?: unknown, +) { + const arr = shallowReadArray(self) + let wrappedFn = fn + if (arr !== self) { + if (!isShallow(self)) { + wrappedFn = function (this: unknown, item, index) { + return fn.call(this, toReactive(item), index, self) + } + } else if (fn.length > 2) { + wrappedFn = function (this: unknown, item, index) { + return fn.call(this, item, index, self) + } + } + } + // @ts-expect-error our code is limited to es2016 but user code is not + return arr[method](wrappedFn, thisArg) +} + +// instrument reduce and reduceRight to take ARRAY_ITERATE dependency +function reduce( + self: unknown[], + method: keyof Array, + fn: (acc: unknown, item: unknown, index: number, array: unknown[]) => unknown, + args: unknown[], +) { + const arr = shallowReadArray(self) + let wrappedFn = fn + if (arr !== self) { + if (!isShallow(self)) { + wrappedFn = function (this: unknown, acc, item, index) { + return fn.call(this, acc, toReactive(item), index, self) + } + } else if (fn.length > 3) { + wrappedFn = function (this: unknown, acc, item, index) { + return fn.call(this, acc, item, index, self) + } + } + } + return (arr[method] as any)(wrappedFn, ...args) +} + +// instrument identity-sensitive methods to account for reactive proxies +function searchProxy( + self: unknown[], + method: keyof Array, + args: unknown[], +) { + const arr = toRaw(self) as any + track(arr, TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY) + // we run the method using the original args first (which may be reactive) + const res = arr[method](...args) + + // if that didn't work, run it again using raw values. + if ((res === -1 || res === false) && isProxy(args[0])) { + args[0] = toRaw(args[0]) + return arr[method](...args) + } + + return res +} + +// instrument length-altering mutation methods to avoid length being tracked +// which leads to infinite loops in some cases (#2137) +function noTracking( + self: unknown[], + method: keyof Array, + args: unknown[] = [], +) { + pauseTracking() + startBatch() + const res = (toRaw(self) as any)[method].apply(self, args) + endBatch() + resetTracking() + return res +} diff --git a/packages/reactivity/src/baseHandlers.ts b/packages/reactivity/src/baseHandlers.ts index e5ce464cd67..c8034dd072d 100644 --- a/packages/reactivity/src/baseHandlers.ts +++ b/packages/reactivity/src/baseHandlers.ts @@ -10,6 +10,7 @@ import { shallowReadonlyMap, toRaw, } from './reactive' +import { arrayInstrumentations } from './arrayInstrumentations' import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from './constants' import { ITERATE_KEY, track, trigger } from './dep' import { @@ -23,7 +24,6 @@ import { } from '@vue/shared' import { isRef } from './ref' import { warn } from './warning' -import { endBatch, pauseTracking, resetTracking, startBatch } from './effect' const isNonTrackableKeys = /*#__PURE__*/ makeMap(`__proto__,__v_isRef,__isVue`) @@ -38,43 +38,6 @@ const builtInSymbols = new Set( .filter(isSymbol), ) -const arrayInstrumentations = /*#__PURE__*/ createArrayInstrumentations() - -function createArrayInstrumentations() { - const instrumentations: Record = {} - // instrument identity-sensitive Array methods to account for possible reactive - // values - ;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => { - instrumentations[key] = function (this: unknown[], ...args: unknown[]) { - const arr = toRaw(this) as any - for (let i = 0, l = this.length; i < l; i++) { - track(arr, TrackOpTypes.GET, i + '') - } - // we run the method using the original args first (which may be reactive) - const res = arr[key](...args) - if (res === -1 || res === false) { - // if that didn't work, run it again using raw values. - return arr[key](...args.map(toRaw)) - } else { - return res - } - } - }) - // instrument length-altering mutation methods to avoid length being tracked - // which leads to infinite loops in some cases (#2137) - ;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => { - instrumentations[key] = function (this: unknown[], ...args: unknown[]) { - startBatch() - pauseTracking() - const res = (toRaw(this) as any)[key].apply(this, args) - resetTracking() - endBatch() - return res - } - }) - return instrumentations -} - function hasOwnProperty(this: object, key: string) { const obj = toRaw(this) track(obj, TrackOpTypes.HAS, key) @@ -120,8 +83,9 @@ class BaseReactiveHandler implements ProxyHandler { const targetIsArray = isArray(target) if (!isReadonly) { - if (targetIsArray && hasOwn(arrayInstrumentations, key)) { - return Reflect.get(arrayInstrumentations, key, receiver) + let fn: Function | undefined + if (targetIsArray && (fn = arrayInstrumentations[key])) { + return fn } if (key === 'hasOwnProperty') { return hasOwnProperty diff --git a/packages/reactivity/src/dep.ts b/packages/reactivity/src/dep.ts index 5ba61d3a03f..0dccf40aaba 100644 --- a/packages/reactivity/src/dep.ts +++ b/packages/reactivity/src/dep.ts @@ -162,8 +162,9 @@ function addSub(link: Link) { type KeyToDepMap = Map const targetMap = new WeakMap() -export const ITERATE_KEY = Symbol(__DEV__ ? 'iterate' : '') -export const MAP_KEY_ITERATE_KEY = Symbol(__DEV__ ? 'Map iterate' : '') +export const ITERATE_KEY = Symbol(__DEV__ ? 'Object iterate' : '') +export const MAP_KEY_ITERATE_KEY = Symbol(__DEV__ ? 'Map keys iterate' : '') +export const ARRAY_ITERATE_KEY = Symbol(__DEV__ ? 'Array iterate' : '') /** * Tracks access to a reactive property. @@ -225,47 +226,61 @@ export function trigger( // collection being cleared // trigger all effects for target deps = [...depsMap.values()] - } else if (key === 'length' && isArray(target)) { - const newLength = Number(newValue) - depsMap.forEach((dep, key) => { - if (key === 'length' || (!isSymbol(key) && key >= newLength)) { - deps.push(dep) - } - }) } else { - const push = (dep: Dep | undefined) => dep && deps.push(dep) + const targetIsArray = isArray(target) + const isArrayIndex = targetIsArray && isIntegerKey(key) - // schedule runs for SET | ADD | DELETE - if (key !== void 0) { - push(depsMap.get(key)) - } + if (targetIsArray && key === 'length') { + const newLength = Number(newValue) + depsMap.forEach((dep, key) => { + if ( + key === 'length' || + key === ARRAY_ITERATE_KEY || + (!isSymbol(key) && key >= newLength) + ) { + deps.push(dep) + } + }) + } else { + const push = (dep: Dep | undefined) => dep && deps.push(dep) - // also run for iteration key on ADD | DELETE | Map.SET - switch (type) { - case TriggerOpTypes.ADD: - if (!isArray(target)) { - push(depsMap.get(ITERATE_KEY)) - if (isMap(target)) { - push(depsMap.get(MAP_KEY_ITERATE_KEY)) + // schedule runs for SET | ADD | DELETE + if (key !== void 0) { + push(depsMap.get(key)) + } + + // schedule ARRAY_ITERATE for any numeric key change (length is handled above) + if (isArrayIndex) { + push(depsMap.get(ARRAY_ITERATE_KEY)) + } + + // also run for iteration key on ADD | DELETE | Map.SET + switch (type) { + case TriggerOpTypes.ADD: + if (!targetIsArray) { + push(depsMap.get(ITERATE_KEY)) + if (isMap(target)) { + push(depsMap.get(MAP_KEY_ITERATE_KEY)) + } + } else if (isArrayIndex) { + // new index added to array -> length changes + push(depsMap.get('length')) } - } else if (isIntegerKey(key)) { - // new index added to array -> length changes - push(depsMap.get('length')) - } - break - case TriggerOpTypes.DELETE: - if (!isArray(target)) { - push(depsMap.get(ITERATE_KEY)) + break + case TriggerOpTypes.DELETE: + if (!targetIsArray) { + push(depsMap.get(ITERATE_KEY)) + if (isMap(target)) { + push(depsMap.get(MAP_KEY_ITERATE_KEY)) + } + } + break + case TriggerOpTypes.SET: if (isMap(target)) { - push(depsMap.get(MAP_KEY_ITERATE_KEY)) + push(depsMap.get(ITERATE_KEY)) } - } - break - case TriggerOpTypes.SET: - if (isMap(target)) { - push(depsMap.get(ITERATE_KEY)) - } - break + break + } } } diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index 1b85e47b36f..609afc05f8a 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -31,6 +31,8 @@ export { shallowReadonly, markRaw, toRaw, + toReactive, + toReadonly, type Raw, type DeepReadonly, type ShallowReactive, @@ -60,11 +62,18 @@ export { type DebuggerEvent, type DebuggerEventExtraInfo, } from './effect' -export { trigger, track, ITERATE_KEY } from './dep' +export { + trigger, + track, + ITERATE_KEY, + ARRAY_ITERATE_KEY, + MAP_KEY_ITERATE_KEY, +} from './dep' export { effectScope, EffectScope, getCurrentScope, onScopeDispose, } from './effectScope' +export { reactiveReadArray, shallowReadArray } from './arrayInstrumentations' export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants' diff --git a/packages/runtime-core/src/helpers/renderList.ts b/packages/runtime-core/src/helpers/renderList.ts index 655435fdd7a..0abb68aef9c 100644 --- a/packages/runtime-core/src/helpers/renderList.ts +++ b/packages/runtime-core/src/helpers/renderList.ts @@ -1,4 +1,5 @@ import type { VNode, VNodeChild } from '../vnode' +import { isReactive, shallowReadArray, toReactive } from '@vue/reactivity' import { isArray, isObject, isString } from '@vue/shared' import { warn } from '../warning' @@ -58,11 +59,21 @@ export function renderList( ): VNodeChild[] { let ret: VNodeChild[] const cached = (cache && cache[index!]) as VNode[] | undefined + const sourceIsArray = isArray(source) + const sourceIsReactiveArray = sourceIsArray && isReactive(source) - if (isArray(source) || isString(source)) { + if (sourceIsArray || isString(source)) { + if (sourceIsReactiveArray) { + source = shallowReadArray(source) + } ret = new Array(source.length) for (let i = 0, l = source.length; i < l; i++) { - ret[i] = renderItem(source[i], i, undefined, cached && cached[i]) + ret[i] = renderItem( + sourceIsReactiveArray ? toReactive(source[i]) : source[i], + i, + undefined, + cached && cached[i], + ) } } else if (typeof source === 'number') { if (__DEV__ && !Number.isInteger(source)) {