perf(reactivity): use bitwise dep markers to optimize re-tracking (#4017)

This commit is contained in:
Bas van Meurs 2021-07-07 20:13:23 +02:00 committed by Evan You
parent cc09772d55
commit 6cf2377cd4
6 changed files with 274 additions and 49 deletions

View File

@ -8,7 +8,8 @@ import {
DebuggerEvent, DebuggerEvent,
markRaw, markRaw,
shallowReactive, shallowReactive,
readonly readonly,
ReactiveEffectRunner
} from '../src/index' } from '../src/index'
import { ITERATE_KEY } from '../src/effect' import { ITERATE_KEY } from '../src/effect'
@ -490,6 +491,96 @@ describe('reactivity/effect', () => {
expect(conditionalSpy).toHaveBeenCalledTimes(2) 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', () => { it('should not double wrap if the passed function is a effect', () => {
const runner = effect(() => {}) const runner = effect(() => {})
const otherRunner = effect(runner) const otherRunner = effect(runner)

View File

@ -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
}

View File

@ -2,6 +2,7 @@ import { ReactiveEffect } from './effect'
import { Ref, trackRefValue, triggerRefValue } from './ref' import { Ref, trackRefValue, triggerRefValue } from './ref'
import { isFunction, NOOP } from '@vue/shared' import { isFunction, NOOP } from '@vue/shared'
import { ReactiveFlags, toRaw } from './reactive' import { ReactiveFlags, toRaw } from './reactive'
import { Dep } from './Dep'
export interface ComputedRef<T = any> extends WritableComputedRef<T> { export interface ComputedRef<T = any> extends WritableComputedRef<T> {
readonly value: T readonly value: T
@ -30,7 +31,7 @@ export const setComputedScheduler = (s: ComputedScheduler | undefined) => {
} }
class ComputedRefImpl<T> { class ComputedRefImpl<T> {
public dep?: Set<ReactiveEffect> = undefined public dep?: Dep = undefined
private _value!: T private _value!: T
private _dirty = true private _dirty = true

View File

@ -1,12 +1,20 @@
import { TrackOpTypes, TriggerOpTypes } from './operations' import { TrackOpTypes, TriggerOpTypes } from './operations'
import { extend, isArray, isIntegerKey, isMap } from '@vue/shared' import { extend, isArray, isIntegerKey, isMap } from '@vue/shared'
import { EffectScope, recordEffectScope } from './effectScope' import { EffectScope, recordEffectScope } from './effectScope'
import {
createDep,
Dep,
newTracked,
resetTracked,
setNewTracked,
setWasTracked,
wasTracked
} from './Dep'
// The main WeakMap that stores {target -> key -> dep} connections. // The main WeakMap that stores {target -> key -> dep} connections.
// Conceptually, it's easier to think of a dependency as a Dep class // 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 // which maintains a Set of subscribers, but we simply store them as
// raw Sets to reduce memory overhead. // raw Sets to reduce memory overhead.
type Dep = Set<ReactiveEffect>
type KeyToDepMap = Map<any, Dep> type KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>() const targetMap = new WeakMap<any, KeyToDepMap>()
@ -56,16 +64,54 @@ export class ReactiveEffect<T = any> {
return this.fn() return this.fn()
} }
if (!effectStack.includes(this)) { if (!effectStack.includes(this)) {
this.cleanup()
try { try {
enableTracking()
effectStack.push((activeEffect = this)) effectStack.push((activeEffect = this))
enableTracking()
effectTrackDepth++
if (effectTrackDepth <= maxMarkerBits) {
this.initDepMarkers()
} else {
this.cleanup()
}
return this.fn() return this.fn()
} finally { } finally {
effectStack.pop() if (effectTrackDepth <= maxMarkerBits) {
resetTracking() this.finalizeDepMarkers()
activeEffect = effectStack[effectStack.length - 1]
} }
effectTrackDepth--
resetTracking()
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
} }
} }
@ -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 { export interface ReactiveEffectOptions {
lazy?: boolean lazy?: boolean
scheduler?: EffectScheduler scheduler?: EffectScheduler
@ -158,7 +218,8 @@ export function track(target: object, type: TrackOpTypes, key: unknown) {
} }
let dep = depsMap.get(key) let dep = depsMap.get(key)
if (!dep) { if (!dep) {
depsMap.set(key, (dep = new Set())) dep = createDep()
depsMap.set(key, dep)
} }
const eventInfo = __DEV__ const eventInfo = __DEV__
@ -173,10 +234,21 @@ export function isTracking() {
} }
export function trackEffects( export function trackEffects(
dep: Set<ReactiveEffect>, dep: Dep,
debuggerEventExtraInfo?: DebuggerEventExtraInfo 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!) dep.add(activeEffect!)
activeEffect!.deps.push(dep) activeEffect!.deps.push(dep)
if (__DEV__ && activeEffect!.onTrack) { if (__DEV__ && activeEffect!.onTrack) {
@ -267,7 +339,7 @@ export function trigger(
effects.push(...dep) effects.push(...dep)
} }
} }
triggerEffects(new Set(effects), eventInfo) triggerEffects(createDep(effects), eventInfo)
} }
} }

View File

@ -1,13 +1,9 @@
import { import { isTracking, trackEffects, triggerEffects } from './effect'
isTracking,
ReactiveEffect,
trackEffects,
triggerEffects
} from './effect'
import { TrackOpTypes, TriggerOpTypes } from './operations' import { TrackOpTypes, TriggerOpTypes } from './operations'
import { isArray, isObject, hasChanged } from '@vue/shared' import { isArray, isObject, hasChanged } from '@vue/shared'
import { reactive, isProxy, toRaw, isReactive } from './reactive' import { reactive, isProxy, toRaw, isReactive } from './reactive'
import { CollectionTypes } from './collectionHandlers' import { CollectionTypes } from './collectionHandlers'
import { createDep, Dep } from './Dep'
export declare const RefSymbol: unique symbol 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. * Deps are maintained locally rather than in depsMap for performance reasons.
*/ */
dep?: Set<ReactiveEffect> dep?: Dep
} }
type RefBase<T> = { type RefBase<T> = {
dep?: Set<ReactiveEffect> dep?: Dep
value: T value: T
} }
@ -39,7 +35,7 @@ export function trackRefValue(ref: RefBase<any>) {
if (isTracking()) { if (isTracking()) {
ref = toRaw(ref) ref = toRaw(ref)
if (!ref.dep) { if (!ref.dep) {
ref.dep = new Set<ReactiveEffect>() ref.dep = createDep()
} }
if (__DEV__) { if (__DEV__) {
trackEffects(ref.dep, { trackEffects(ref.dep, {
@ -104,7 +100,7 @@ class RefImpl<T> {
private _value: T private _value: T
private _rawValue: T private _rawValue: T
public dep?: Set<ReactiveEffect> = undefined public dep?: Dep = undefined
public readonly __v_isRef = true public readonly __v_isRef = true
constructor(value: T, public readonly _shallow = false) { constructor(value: T, public readonly _shallow = false) {
@ -172,7 +168,7 @@ export type CustomRefFactory<T> = (
} }
class CustomRefImpl<T> { class CustomRefImpl<T> {
public dep?: Set<ReactiveEffect> = undefined public dep?: Dep = undefined
private readonly _get: ReturnType<CustomRefFactory<T>>['get'] private readonly _get: ReturnType<CustomRefFactory<T>>['get']
private readonly _set: ReturnType<CustomRefFactory<T>>['set'] private readonly _set: ReturnType<CustomRefFactory<T>>['set']

View File

@ -1395,12 +1395,16 @@ function baseCreateRenderer(
isSVG, isSVG,
optimized optimized
) => { ) => {
const componentUpdateFn = () => { const componentUpdateFn = function(this: ReactiveEffect) {
if (!instance.isMounted) { if (!instance.isMounted) {
let vnodeHook: VNodeHook | null | undefined let vnodeHook: VNodeHook | null | undefined
const { el, props } = initialVNode const { el, props } = initialVNode
const { bm, m, parent } = instance const { bm, m, parent } = instance
try {
// Disallow component effect recursion during pre-lifecycle hooks.
this.allowRecurse = false
// beforeMount hook // beforeMount hook
if (bm) { if (bm) {
invokeArrayFns(bm) invokeArrayFns(bm)
@ -1415,6 +1419,9 @@ function baseCreateRenderer(
) { ) {
instance.emit('hook:beforeMount') instance.emit('hook:beforeMount')
} }
} finally {
this.allowRecurse = true
}
if (el && hydrateNode) { if (el && hydrateNode) {
// vnode has adopted host node - perform hydration instead of mount. // vnode has adopted host node - perform hydration instead of mount.
@ -1540,6 +1547,10 @@ function baseCreateRenderer(
next = vnode next = vnode
} }
try {
// Disallow component effect recursion during pre-lifecycle hooks.
this.allowRecurse = false
// beforeUpdate hook // beforeUpdate hook
if (bu) { if (bu) {
invokeArrayFns(bu) invokeArrayFns(bu)
@ -1554,6 +1565,9 @@ function baseCreateRenderer(
) { ) {
instance.emit('hook:beforeUpdate') instance.emit('hook:beforeUpdate')
} }
} finally {
this.allowRecurse = true
}
// render // render
if (__DEV__) { if (__DEV__) {