]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
perf(reactivity): optimize array tracking (#9511)
authorjods <jods4@users.noreply.github.com>
Mon, 26 Feb 2024 10:25:52 +0000 (11:25 +0100)
committerGitHub <noreply@github.com>
Mon, 26 Feb 2024 10:25:52 +0000 (18:25 +0800)
close #4318

packages/reactivity/__benchmarks__/reactiveArray.bench.ts
packages/reactivity/__tests__/reactiveArray.spec.ts
packages/reactivity/src/arrayInstrumentations.ts [new file with mode: 0644]
packages/reactivity/src/baseHandlers.ts
packages/reactivity/src/dep.ts
packages/reactivity/src/index.ts
packages/runtime-core/src/helpers/renderList.ts

index 6726cccfd8937e462c75b8273ae6740b1274503c..f5032cf7ae9f04c955101928387524dcce88264f 100644 (file)
@@ -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)
     })
   }
 }
index 1c6fcefd592112bf4e55b73c54f99eecf3622105..9caeaf116d272c356222a9a1863d6871f44584e1 100644 (file)
@@ -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 (file)
index 0000000..a16eabd
--- /dev/null
@@ -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<T>(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<T>(arr: T[]): T[] {
+  track((arr = toRaw(arr)), TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY)
+  return arr
+}
+
+export const arrayInstrumentations: Record<string | symbol, Function> = <any>{
+  __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<any>,
+  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<any> | '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<any>,
+  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<any>,
+  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<any>,
+  args: unknown[] = [],
+) {
+  pauseTracking()
+  startBatch()
+  const res = (toRaw(self) as any)[method].apply(self, args)
+  endBatch()
+  resetTracking()
+  return res
+}
index e5ce464cd670b940a037317f7844292a7b6d28ec..c8034dd072d8ab997dc3dbf7ab359d168ee5512f 100644 (file)
@@ -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<string, Function> = {}
-  // 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<Target> {
     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
index 5ba61d3a03fda4c2627f9f91bd0af92a0b6cf8fb..0dccf40aabab3379453f62bb15b121b87e3aae39 100644 (file)
@@ -162,8 +162,9 @@ function addSub(link: Link) {
 type KeyToDepMap = Map<any, Dep>
 const targetMap = new WeakMap<object, KeyToDepMap>()
 
-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
+      }
     }
   }
 
index 1b85e47b36f707cd5224cc6f865e254df4fbce34..609afc05f8a960d72cae28c93281e753c2d7cf5e 100644 (file)
@@ -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'
index 655435fdd7a539cef64bf1410241de6c0065db60..0abb68aef9c6b2f912505f73c0126e32724b7ef0 100644 (file)
@@ -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)) {