]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
perf(reactivity): use bitwise dep markers to optimize re-tracking (#4017)
authorBas van Meurs <bvanmeurs1985@gmail.com>
Wed, 7 Jul 2021 18:13:23 +0000 (20:13 +0200)
committerEvan You <yyx990803@gmail.com>
Fri, 16 Jul 2021 18:30:49 +0000 (14:30 -0400)
packages/reactivity/__tests__/effect.spec.ts
packages/reactivity/src/Dep.ts [new file with mode: 0644]
packages/reactivity/src/computed.ts
packages/reactivity/src/effect.ts
packages/reactivity/src/ref.ts
packages/runtime-core/src/renderer.ts

index d458a17d8409a0945334c697c44b1c80a5e25612..6a05038554bee528116a88d8bc98d2e45672beef 100644 (file)
@@ -8,7 +8,8 @@ import {
   DebuggerEvent,
   markRaw,
   shallowReactive,
-  readonly
+  readonly,
+  ReactiveEffectRunner
 } from '../src/index'
 import { ITERATE_KEY } from '../src/effect'
 
@@ -490,6 +491,96 @@ describe('reactivity/effect', () => {
     expect(conditionalSpy).toHaveBeenCalledTimes(2)
   })
 
+  it('should handle deep effect recursion using cleanup fallback', () => {
+    const results = reactive([0])
+    const effects: { fx: ReactiveEffectRunner; index: number }[] = []
+    for (let i = 1; i < 40; i++) {
+      ;(index => {
+        const fx = effect(() => {
+          results[index] = results[index - 1] * 2
+        })
+        effects.push({ fx, index })
+      })(i)
+    }
+
+    expect(results[39]).toBe(0)
+    results[0] = 1
+    expect(results[39]).toBe(Math.pow(2, 39))
+  })
+
+  it('should register deps independently during effect recursion', () => {
+    const input = reactive({ a: 1, b: 2, c: 0 })
+    const output = reactive({ fx1: 0, fx2: 0 })
+
+    const fx1Spy = jest.fn(() => {
+      let result = 0
+      if (input.c < 2) result += input.a
+      if (input.c > 1) result += input.b
+      output.fx1 = result
+    })
+
+    const fx1 = effect(fx1Spy)
+
+    const fx2Spy = jest.fn(() => {
+      let result = 0
+      if (input.c > 1) result += input.a
+      if (input.c < 3) result += input.b
+      output.fx2 = result + output.fx1
+    })
+
+    const fx2 = effect(fx2Spy)
+
+    expect(fx1).not.toBeNull()
+    expect(fx2).not.toBeNull()
+
+    expect(output.fx1).toBe(1)
+    expect(output.fx2).toBe(2 + 1)
+    expect(fx1Spy).toHaveBeenCalledTimes(1)
+    expect(fx2Spy).toHaveBeenCalledTimes(1)
+
+    fx1Spy.mockClear()
+    fx2Spy.mockClear()
+    input.b = 3
+    expect(output.fx1).toBe(1)
+    expect(output.fx2).toBe(3 + 1)
+    expect(fx1Spy).toHaveBeenCalledTimes(0)
+    expect(fx2Spy).toHaveBeenCalledTimes(1)
+
+    fx1Spy.mockClear()
+    fx2Spy.mockClear()
+    input.c = 1
+    expect(output.fx1).toBe(1)
+    expect(output.fx2).toBe(3 + 1)
+    expect(fx1Spy).toHaveBeenCalledTimes(1)
+    expect(fx2Spy).toHaveBeenCalledTimes(1)
+
+    fx1Spy.mockClear()
+    fx2Spy.mockClear()
+    input.c = 2
+    expect(output.fx1).toBe(3)
+    expect(output.fx2).toBe(1 + 3 + 3)
+    expect(fx1Spy).toHaveBeenCalledTimes(1)
+
+    // Invoked twice due to change of fx1.
+    expect(fx2Spy).toHaveBeenCalledTimes(2)
+
+    fx1Spy.mockClear()
+    fx2Spy.mockClear()
+    input.c = 3
+    expect(output.fx1).toBe(3)
+    expect(output.fx2).toBe(1 + 3)
+    expect(fx1Spy).toHaveBeenCalledTimes(1)
+    expect(fx2Spy).toHaveBeenCalledTimes(1)
+
+    fx1Spy.mockClear()
+    fx2Spy.mockClear()
+    input.a = 10
+    expect(output.fx1).toBe(3)
+    expect(output.fx2).toBe(10 + 3)
+    expect(fx1Spy).toHaveBeenCalledTimes(0)
+    expect(fx2Spy).toHaveBeenCalledTimes(1)
+  })
+
   it('should not double wrap if the passed function is a effect', () => {
     const runner = effect(() => {})
     const otherRunner = effect(runner)
diff --git a/packages/reactivity/src/Dep.ts b/packages/reactivity/src/Dep.ts
new file mode 100644 (file)
index 0000000..1d73546
--- /dev/null
@@ -0,0 +1,51 @@
+import { ReactiveEffect, getTrackOpBit } from './effect'
+
+export type Dep = Set<ReactiveEffect> & TrackedMarkers
+
+/**
+ * wasTracked and newTracked maintain the status for several levels of effect
+ * tracking recursion. One bit per level is used to define wheter the dependency
+ * was/is tracked.
+ */
+type TrackedMarkers = { wasTracked: number; newTracked: number }
+
+export function createDep(effects?: ReactiveEffect[]): Dep {
+  const dep = new Set<ReactiveEffect>(effects) as Dep
+  dep.wasTracked = 0
+  dep.newTracked = 0
+  return dep
+}
+
+export function wasTracked(dep: Dep): boolean {
+  return hasBit(dep.wasTracked, getTrackOpBit())
+}
+
+export function newTracked(dep: Dep): boolean {
+  return hasBit(dep.newTracked, getTrackOpBit())
+}
+
+export function setWasTracked(dep: Dep) {
+  dep.wasTracked = setBit(dep.wasTracked, getTrackOpBit())
+}
+
+export function setNewTracked(dep: Dep) {
+  dep.newTracked = setBit(dep.newTracked, getTrackOpBit())
+}
+
+export function resetTracked(dep: Dep) {
+  const trackOpBit = getTrackOpBit()
+  dep.wasTracked = clearBit(dep.wasTracked, trackOpBit)
+  dep.newTracked = clearBit(dep.newTracked, trackOpBit)
+}
+
+function hasBit(value: number, bit: number): boolean {
+  return (value & bit) > 0
+}
+
+function setBit(value: number, bit: number): number {
+  return value | bit
+}
+
+function clearBit(value: number, bit: number): number {
+  return value & ~bit
+}
index 055a0dcf1b8b1b341c1198d3acd8780453b8c684..55316d9b51a286f00883f6b81434e5d31eee5cf2 100644 (file)
@@ -2,6 +2,7 @@ import { ReactiveEffect } from './effect'
 import { Ref, trackRefValue, triggerRefValue } from './ref'
 import { isFunction, NOOP } from '@vue/shared'
 import { ReactiveFlags, toRaw } from './reactive'
+import { Dep } from './Dep'
 
 export interface ComputedRef<T = any> extends WritableComputedRef<T> {
   readonly value: T
@@ -30,7 +31,7 @@ export const setComputedScheduler = (s: ComputedScheduler | undefined) => {
 }
 
 class ComputedRefImpl<T> {
-  public dep?: Set<ReactiveEffect> = undefined
+  public dep?: Dep = undefined
 
   private _value!: T
   private _dirty = true
index 37c80d8489601bedd8bd9e557e0b3fa3bae5ee0f..670b6b340c5736d96acdc52c2fb2092262f9e4f7 100644 (file)
@@ -1,12 +1,20 @@
 import { TrackOpTypes, TriggerOpTypes } from './operations'
 import { extend, isArray, isIntegerKey, isMap } from '@vue/shared'
 import { EffectScope, recordEffectScope } from './effectScope'
+import {
+  createDep,
+  Dep,
+  newTracked,
+  resetTracked,
+  setNewTracked,
+  setWasTracked,
+  wasTracked
+} from './Dep'
 
 // The main WeakMap that stores {target -> key -> dep} connections.
 // Conceptually, it's easier to think of a dependency as a Dep class
 // which maintains a Set of subscribers, but we simply store them as
 // raw Sets to reduce memory overhead.
-type Dep = Set<ReactiveEffect>
 type KeyToDepMap = Map<any, Dep>
 const targetMap = new WeakMap<any, KeyToDepMap>()
 
@@ -56,19 +64,57 @@ export class ReactiveEffect<T = any> {
       return this.fn()
     }
     if (!effectStack.includes(this)) {
-      this.cleanup()
       try {
-        enableTracking()
         effectStack.push((activeEffect = this))
+        enableTracking()
+
+        effectTrackDepth++
+
+        if (effectTrackDepth <= maxMarkerBits) {
+          this.initDepMarkers()
+        } else {
+          this.cleanup()
+        }
         return this.fn()
       } finally {
-        effectStack.pop()
+        if (effectTrackDepth <= maxMarkerBits) {
+          this.finalizeDepMarkers()
+        }
+        effectTrackDepth--
         resetTracking()
-        activeEffect = effectStack[effectStack.length - 1]
+        effectStack.pop()
+        const n = effectStack.length
+        activeEffect = n > 0 ? effectStack[n - 1] : undefined
       }
     }
   }
 
+  initDepMarkers() {
+    const { deps } = this
+    if (deps.length) {
+      for (let i = 0; i < deps.length; i++) {
+        setWasTracked(deps[i])
+      }
+    }
+  }
+
+  finalizeDepMarkers() {
+    const { deps } = this
+    if (deps.length) {
+      let ptr = 0
+      for (let i = 0; i < deps.length; i++) {
+        const dep = deps[i]
+        if (wasTracked(dep) && !newTracked(dep)) {
+          dep.delete(this)
+        } else {
+          deps[ptr++] = dep
+        }
+        resetTracked(dep)
+      }
+      deps.length = ptr
+    }
+  }
+
   cleanup() {
     const { deps } = this
     if (deps.length) {
@@ -90,6 +136,20 @@ export class ReactiveEffect<T = any> {
   }
 }
 
+// The number of effects currently being tracked recursively.
+let effectTrackDepth = 0
+
+/**
+ * The bitwise track markers support at most 30 levels op recursion.
+ * This value is chosen to enable modern JS engines to use a SMI on all platforms.
+ * When recursion depth is greater, fall back to using a full cleanup.
+ */
+const maxMarkerBits = 30
+
+export function getTrackOpBit(): number {
+  return 1 << effectTrackDepth
+}
+
 export interface ReactiveEffectOptions {
   lazy?: boolean
   scheduler?: EffectScheduler
@@ -158,7 +218,8 @@ export function track(target: object, type: TrackOpTypes, key: unknown) {
   }
   let dep = depsMap.get(key)
   if (!dep) {
-    depsMap.set(key, (dep = new Set()))
+    dep = createDep()
+    depsMap.set(key, dep)
   }
 
   const eventInfo = __DEV__
@@ -173,10 +234,21 @@ export function isTracking() {
 }
 
 export function trackEffects(
-  dep: Set<ReactiveEffect>,
+  dep: Dep,
   debuggerEventExtraInfo?: DebuggerEventExtraInfo
 ) {
-  if (!dep.has(activeEffect!)) {
+  let shouldTrack = false
+  if (effectTrackDepth <= maxMarkerBits) {
+    if (!newTracked(dep)) {
+      setNewTracked(dep)
+      shouldTrack = !wasTracked(dep)
+    }
+  } else {
+    // Full cleanup mode.
+    shouldTrack = !dep.has(activeEffect!)
+  }
+
+  if (shouldTrack) {
     dep.add(activeEffect!)
     activeEffect!.deps.push(dep)
     if (__DEV__ && activeEffect!.onTrack) {
@@ -267,7 +339,7 @@ export function trigger(
         effects.push(...dep)
       }
     }
-    triggerEffects(new Set(effects), eventInfo)
+    triggerEffects(createDep(effects), eventInfo)
   }
 }
 
index b88fb8be2bea9f5821aab297c64fc0562f99b492..318bf59c6aacb9fa21e4bea12cb2824f0cf834c2 100644 (file)
@@ -1,13 +1,9 @@
-import {
-  isTracking,
-  ReactiveEffect,
-  trackEffects,
-  triggerEffects
-} from './effect'
+import { isTracking, trackEffects, triggerEffects } from './effect'
 import { TrackOpTypes, TriggerOpTypes } from './operations'
 import { isArray, isObject, hasChanged } from '@vue/shared'
 import { reactive, isProxy, toRaw, isReactive } from './reactive'
 import { CollectionTypes } from './collectionHandlers'
+import { createDep, Dep } from './Dep'
 
 export declare const RefSymbol: unique symbol
 
@@ -27,11 +23,11 @@ export interface Ref<T = any> {
   /**
    * Deps are maintained locally rather than in depsMap for performance reasons.
    */
-  dep?: Set<ReactiveEffect>
+  dep?: Dep
 }
 
 type RefBase<T> = {
-  dep?: Set<ReactiveEffect>
+  dep?: Dep
   value: T
 }
 
@@ -39,7 +35,7 @@ export function trackRefValue(ref: RefBase<any>) {
   if (isTracking()) {
     ref = toRaw(ref)
     if (!ref.dep) {
-      ref.dep = new Set<ReactiveEffect>()
+      ref.dep = createDep()
     }
     if (__DEV__) {
       trackEffects(ref.dep, {
@@ -104,7 +100,7 @@ class RefImpl<T> {
   private _value: T
   private _rawValue: T
 
-  public dep?: Set<ReactiveEffect> = undefined
+  public dep?: Dep = undefined
   public readonly __v_isRef = true
 
   constructor(value: T, public readonly _shallow = false) {
@@ -172,7 +168,7 @@ export type CustomRefFactory<T> = (
 }
 
 class CustomRefImpl<T> {
-  public dep?: Set<ReactiveEffect> = undefined
+  public dep?: Dep = undefined
 
   private readonly _get: ReturnType<CustomRefFactory<T>>['get']
   private readonly _set: ReturnType<CustomRefFactory<T>>['set']
index 9415eabb2a2d63b14b56319c4b01ab2b4ba3c396..b879ed1b179839455de7657a5e2771b0396a3b13 100644 (file)
@@ -1395,25 +1395,32 @@ function baseCreateRenderer(
     isSVG,
     optimized
   ) => {
-    const componentUpdateFn = () => {
+    const componentUpdateFn = function(this: ReactiveEffect) {
       if (!instance.isMounted) {
         let vnodeHook: VNodeHook | null | undefined
         const { el, props } = initialVNode
         const { bm, m, parent } = instance
 
-        // beforeMount hook
-        if (bm) {
-          invokeArrayFns(bm)
-        }
-        // onVnodeBeforeMount
-        if ((vnodeHook = props && props.onVnodeBeforeMount)) {
-          invokeVNodeHook(vnodeHook, parent, initialVNode)
-        }
-        if (
-          __COMPAT__ &&
-          isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
-        ) {
-          instance.emit('hook:beforeMount')
+        try {
+          // Disallow component effect recursion during pre-lifecycle hooks.
+          this.allowRecurse = false
+
+          // beforeMount hook
+          if (bm) {
+            invokeArrayFns(bm)
+          }
+          // onVnodeBeforeMount
+          if ((vnodeHook = props && props.onVnodeBeforeMount)) {
+            invokeVNodeHook(vnodeHook, parent, initialVNode)
+          }
+          if (
+            __COMPAT__ &&
+            isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
+          ) {
+            instance.emit('hook:beforeMount')
+          }
+        } finally {
+          this.allowRecurse = true
         }
 
         if (el && hydrateNode) {
@@ -1540,19 +1547,26 @@ function baseCreateRenderer(
           next = vnode
         }
 
-        // beforeUpdate hook
-        if (bu) {
-          invokeArrayFns(bu)
-        }
-        // onVnodeBeforeUpdate
-        if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) {
-          invokeVNodeHook(vnodeHook, parent, next, vnode)
-        }
-        if (
-          __COMPAT__ &&
-          isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
-        ) {
-          instance.emit('hook:beforeUpdate')
+        try {
+          // Disallow component effect recursion during pre-lifecycle hooks.
+          this.allowRecurse = false
+
+          // beforeUpdate hook
+          if (bu) {
+            invokeArrayFns(bu)
+          }
+          // onVnodeBeforeUpdate
+          if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) {
+            invokeVNodeHook(vnodeHook, parent, next, vnode)
+          }
+          if (
+            __COMPAT__ &&
+            isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
+          ) {
+            instance.emit('hook:beforeUpdate')
+          }
+        } finally {
+          this.allowRecurse = true
         }
 
         // render