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,
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)

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 { 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

View 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,16 +64,54 @@ 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()
resetTracking()
activeEffect = effectStack[effectStack.length - 1]
if (effectTrackDepth <= maxMarkerBits) {
this.finalizeDepMarkers()
}
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 {
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)
}
}

View 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']

View File

@ -1395,12 +1395,16 @@ 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
try {
// Disallow component effect recursion during pre-lifecycle hooks.
this.allowRecurse = false
// beforeMount hook
if (bm) {
invokeArrayFns(bm)
@ -1415,6 +1419,9 @@ function baseCreateRenderer(
) {
instance.emit('hook:beforeMount')
}
} finally {
this.allowRecurse = true
}
if (el && hydrateNode) {
// vnode has adopted host node - perform hydration instead of mount.
@ -1540,6 +1547,10 @@ function baseCreateRenderer(
next = vnode
}
try {
// Disallow component effect recursion during pre-lifecycle hooks.
this.allowRecurse = false
// beforeUpdate hook
if (bu) {
invokeArrayFns(bu)
@ -1554,6 +1565,9 @@ function baseCreateRenderer(
) {
instance.emit('hook:beforeUpdate')
}
} finally {
this.allowRecurse = true
}
// render
if (__DEV__) {