Skip to content

Commit a889895

Browse files
authored
perf(reactivity): use bitwise dep markers to optimize re-tracking (#4017)
1 parent 05e5c98 commit a889895

File tree

6 files changed

+274
-49
lines changed

6 files changed

+274
-49
lines changed

packages/reactivity/__tests__/effect.spec.ts

+92-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
DebuggerEvent,
99
markRaw,
1010
shallowReactive,
11-
readonly
11+
readonly,
12+
ReactiveEffectRunner
1213
} from '../src/index'
1314
import { ITERATE_KEY } from '../src/effect'
1415

@@ -490,6 +491,96 @@ describe('reactivity/effect', () => {
490491
expect(conditionalSpy).toHaveBeenCalledTimes(2)
491492
})
492493

494+
it('should handle deep effect recursion using cleanup fallback', () => {
495+
const results = reactive([0])
496+
const effects: { fx: ReactiveEffectRunner; index: number }[] = []
497+
for (let i = 1; i < 40; i++) {
498+
;(index => {
499+
const fx = effect(() => {
500+
results[index] = results[index - 1] * 2
501+
})
502+
effects.push({ fx, index })
503+
})(i)
504+
}
505+
506+
expect(results[39]).toBe(0)
507+
results[0] = 1
508+
expect(results[39]).toBe(Math.pow(2, 39))
509+
})
510+
511+
it('should register deps independently during effect recursion', () => {
512+
const input = reactive({ a: 1, b: 2, c: 0 })
513+
const output = reactive({ fx1: 0, fx2: 0 })
514+
515+
const fx1Spy = jest.fn(() => {
516+
let result = 0
517+
if (input.c < 2) result += input.a
518+
if (input.c > 1) result += input.b
519+
output.fx1 = result
520+
})
521+
522+
const fx1 = effect(fx1Spy)
523+
524+
const fx2Spy = jest.fn(() => {
525+
let result = 0
526+
if (input.c > 1) result += input.a
527+
if (input.c < 3) result += input.b
528+
output.fx2 = result + output.fx1
529+
})
530+
531+
const fx2 = effect(fx2Spy)
532+
533+
expect(fx1).not.toBeNull()
534+
expect(fx2).not.toBeNull()
535+
536+
expect(output.fx1).toBe(1)
537+
expect(output.fx2).toBe(2 + 1)
538+
expect(fx1Spy).toHaveBeenCalledTimes(1)
539+
expect(fx2Spy).toHaveBeenCalledTimes(1)
540+
541+
fx1Spy.mockClear()
542+
fx2Spy.mockClear()
543+
input.b = 3
544+
expect(output.fx1).toBe(1)
545+
expect(output.fx2).toBe(3 + 1)
546+
expect(fx1Spy).toHaveBeenCalledTimes(0)
547+
expect(fx2Spy).toHaveBeenCalledTimes(1)
548+
549+
fx1Spy.mockClear()
550+
fx2Spy.mockClear()
551+
input.c = 1
552+
expect(output.fx1).toBe(1)
553+
expect(output.fx2).toBe(3 + 1)
554+
expect(fx1Spy).toHaveBeenCalledTimes(1)
555+
expect(fx2Spy).toHaveBeenCalledTimes(1)
556+
557+
fx1Spy.mockClear()
558+
fx2Spy.mockClear()
559+
input.c = 2
560+
expect(output.fx1).toBe(3)
561+
expect(output.fx2).toBe(1 + 3 + 3)
562+
expect(fx1Spy).toHaveBeenCalledTimes(1)
563+
564+
// Invoked twice due to change of fx1.
565+
expect(fx2Spy).toHaveBeenCalledTimes(2)
566+
567+
fx1Spy.mockClear()
568+
fx2Spy.mockClear()
569+
input.c = 3
570+
expect(output.fx1).toBe(3)
571+
expect(output.fx2).toBe(1 + 3)
572+
expect(fx1Spy).toHaveBeenCalledTimes(1)
573+
expect(fx2Spy).toHaveBeenCalledTimes(1)
574+
575+
fx1Spy.mockClear()
576+
fx2Spy.mockClear()
577+
input.a = 10
578+
expect(output.fx1).toBe(3)
579+
expect(output.fx2).toBe(10 + 3)
580+
expect(fx1Spy).toHaveBeenCalledTimes(0)
581+
expect(fx2Spy).toHaveBeenCalledTimes(1)
582+
})
583+
493584
it('should not double wrap if the passed function is a effect', () => {
494585
const runner = effect(() => {})
495586
const otherRunner = effect(runner)

packages/reactivity/src/Dep.ts

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { ReactiveEffect, getTrackOpBit } from './effect'
2+
3+
export type Dep = Set<ReactiveEffect> & TrackedMarkers
4+
5+
/**
6+
* wasTracked and newTracked maintain the status for several levels of effect
7+
* tracking recursion. One bit per level is used to define wheter the dependency
8+
* was/is tracked.
9+
*/
10+
type TrackedMarkers = { wasTracked: number; newTracked: number }
11+
12+
export function createDep(effects?: ReactiveEffect[]): Dep {
13+
const dep = new Set<ReactiveEffect>(effects) as Dep
14+
dep.wasTracked = 0
15+
dep.newTracked = 0
16+
return dep
17+
}
18+
19+
export function wasTracked(dep: Dep): boolean {
20+
return hasBit(dep.wasTracked, getTrackOpBit())
21+
}
22+
23+
export function newTracked(dep: Dep): boolean {
24+
return hasBit(dep.newTracked, getTrackOpBit())
25+
}
26+
27+
export function setWasTracked(dep: Dep) {
28+
dep.wasTracked = setBit(dep.wasTracked, getTrackOpBit())
29+
}
30+
31+
export function setNewTracked(dep: Dep) {
32+
dep.newTracked = setBit(dep.newTracked, getTrackOpBit())
33+
}
34+
35+
export function resetTracked(dep: Dep) {
36+
const trackOpBit = getTrackOpBit()
37+
dep.wasTracked = clearBit(dep.wasTracked, trackOpBit)
38+
dep.newTracked = clearBit(dep.newTracked, trackOpBit)
39+
}
40+
41+
function hasBit(value: number, bit: number): boolean {
42+
return (value & bit) > 0
43+
}
44+
45+
function setBit(value: number, bit: number): number {
46+
return value | bit
47+
}
48+
49+
function clearBit(value: number, bit: number): number {
50+
return value & ~bit
51+
}

packages/reactivity/src/computed.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ReactiveEffect } from './effect'
22
import { Ref, trackRefValue, triggerRefValue } from './ref'
33
import { isFunction, NOOP } from '@vue/shared'
44
import { ReactiveFlags, toRaw } from './reactive'
5+
import { Dep } from './Dep'
56

67
export interface ComputedRef<T = any> extends WritableComputedRef<T> {
78
readonly value: T
@@ -30,7 +31,7 @@ export const setComputedScheduler = (s: ComputedScheduler | undefined) => {
3031
}
3132

3233
class ComputedRefImpl<T> {
33-
public dep?: Set<ReactiveEffect> = undefined
34+
public dep?: Dep = undefined
3435

3536
private _value!: T
3637
private _dirty = true

packages/reactivity/src/effect.ts

+81-9
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import { TrackOpTypes, TriggerOpTypes } from './operations'
22
import { extend, isArray, isIntegerKey, isMap } from '@vue/shared'
33
import { EffectScope, recordEffectScope } from './effectScope'
4+
import {
5+
createDep,
6+
Dep,
7+
newTracked,
8+
resetTracked,
9+
setNewTracked,
10+
setWasTracked,
11+
wasTracked
12+
} from './Dep'
413

514
// The main WeakMap that stores {target -> key -> dep} connections.
615
// Conceptually, it's easier to think of a dependency as a Dep class
716
// which maintains a Set of subscribers, but we simply store them as
817
// raw Sets to reduce memory overhead.
9-
type Dep = Set<ReactiveEffect>
1018
type KeyToDepMap = Map<any, Dep>
1119
const targetMap = new WeakMap<any, KeyToDepMap>()
1220

@@ -56,19 +64,57 @@ export class ReactiveEffect<T = any> {
5664
return this.fn()
5765
}
5866
if (!effectStack.includes(this)) {
59-
this.cleanup()
6067
try {
61-
enableTracking()
6268
effectStack.push((activeEffect = this))
69+
enableTracking()
70+
71+
effectTrackDepth++
72+
73+
if (effectTrackDepth <= maxMarkerBits) {
74+
this.initDepMarkers()
75+
} else {
76+
this.cleanup()
77+
}
6378
return this.fn()
6479
} finally {
65-
effectStack.pop()
80+
if (effectTrackDepth <= maxMarkerBits) {
81+
this.finalizeDepMarkers()
82+
}
83+
effectTrackDepth--
6684
resetTracking()
67-
activeEffect = effectStack[effectStack.length - 1]
85+
effectStack.pop()
86+
const n = effectStack.length
87+
activeEffect = n > 0 ? effectStack[n - 1] : undefined
6888
}
6989
}
7090
}
7191

92+
initDepMarkers() {
93+
const { deps } = this
94+
if (deps.length) {
95+
for (let i = 0; i < deps.length; i++) {
96+
setWasTracked(deps[i])
97+
}
98+
}
99+
}
100+
101+
finalizeDepMarkers() {
102+
const { deps } = this
103+
if (deps.length) {
104+
let ptr = 0
105+
for (let i = 0; i < deps.length; i++) {
106+
const dep = deps[i]
107+
if (wasTracked(dep) && !newTracked(dep)) {
108+
dep.delete(this)
109+
} else {
110+
deps[ptr++] = dep
111+
}
112+
resetTracked(dep)
113+
}
114+
deps.length = ptr
115+
}
116+
}
117+
72118
cleanup() {
73119
const { deps } = this
74120
if (deps.length) {
@@ -90,6 +136,20 @@ export class ReactiveEffect<T = any> {
90136
}
91137
}
92138

139+
// The number of effects currently being tracked recursively.
140+
let effectTrackDepth = 0
141+
142+
/**
143+
* The bitwise track markers support at most 30 levels op recursion.
144+
* This value is chosen to enable modern JS engines to use a SMI on all platforms.
145+
* When recursion depth is greater, fall back to using a full cleanup.
146+
*/
147+
const maxMarkerBits = 30
148+
149+
export function getTrackOpBit(): number {
150+
return 1 << effectTrackDepth
151+
}
152+
93153
export interface ReactiveEffectOptions {
94154
lazy?: boolean
95155
scheduler?: EffectScheduler
@@ -158,7 +218,8 @@ export function track(target: object, type: TrackOpTypes, key: unknown) {
158218
}
159219
let dep = depsMap.get(key)
160220
if (!dep) {
161-
depsMap.set(key, (dep = new Set()))
221+
dep = createDep()
222+
depsMap.set(key, dep)
162223
}
163224

164225
const eventInfo = __DEV__
@@ -173,10 +234,21 @@ export function isTracking() {
173234
}
174235

175236
export function trackEffects(
176-
dep: Set<ReactiveEffect>,
237+
dep: Dep,
177238
debuggerEventExtraInfo?: DebuggerEventExtraInfo
178239
) {
179-
if (!dep.has(activeEffect!)) {
240+
let shouldTrack = false
241+
if (effectTrackDepth <= maxMarkerBits) {
242+
if (!newTracked(dep)) {
243+
setNewTracked(dep)
244+
shouldTrack = !wasTracked(dep)
245+
}
246+
} else {
247+
// Full cleanup mode.
248+
shouldTrack = !dep.has(activeEffect!)
249+
}
250+
251+
if (shouldTrack) {
180252
dep.add(activeEffect!)
181253
activeEffect!.deps.push(dep)
182254
if (__DEV__ && activeEffect!.onTrack) {
@@ -267,7 +339,7 @@ export function trigger(
267339
effects.push(...dep)
268340
}
269341
}
270-
triggerEffects(new Set(effects), eventInfo)
342+
triggerEffects(createDep(effects), eventInfo)
271343
}
272344
}
273345

packages/reactivity/src/ref.ts

+7-11
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
1-
import {
2-
isTracking,
3-
ReactiveEffect,
4-
trackEffects,
5-
triggerEffects
6-
} from './effect'
1+
import { isTracking, trackEffects, triggerEffects } from './effect'
72
import { TrackOpTypes, TriggerOpTypes } from './operations'
83
import { isArray, isObject, hasChanged } from '@vue/shared'
94
import { reactive, isProxy, toRaw, isReactive } from './reactive'
105
import { CollectionTypes } from './collectionHandlers'
6+
import { createDep, Dep } from './Dep'
117

128
export declare const RefSymbol: unique symbol
139

@@ -27,19 +23,19 @@ export interface Ref<T = any> {
2723
/**
2824
* Deps are maintained locally rather than in depsMap for performance reasons.
2925
*/
30-
dep?: Set<ReactiveEffect>
26+
dep?: Dep
3127
}
3228

3329
type RefBase<T> = {
34-
dep?: Set<ReactiveEffect>
30+
dep?: Dep
3531
value: T
3632
}
3733

3834
export function trackRefValue(ref: RefBase<any>) {
3935
if (isTracking()) {
4036
ref = toRaw(ref)
4137
if (!ref.dep) {
42-
ref.dep = new Set<ReactiveEffect>()
38+
ref.dep = createDep()
4339
}
4440
if (__DEV__) {
4541
trackEffects(ref.dep, {
@@ -101,7 +97,7 @@ export function shallowRef(value?: unknown) {
10197
}
10298

10399
class RefImpl<T> {
104-
public dep?: Set<ReactiveEffect> = undefined
100+
public dep?: Dep = undefined
105101

106102
private _value: T
107103

@@ -170,7 +166,7 @@ export type CustomRefFactory<T> = (
170166
}
171167

172168
class CustomRefImpl<T> {
173-
public dep?: Set<ReactiveEffect> = undefined
169+
public dep?: Dep = undefined
174170

175171
private readonly _get: ReturnType<CustomRefFactory<T>>['get']
176172
private readonly _set: ReturnType<CustomRefFactory<T>>['set']

0 commit comments

Comments
 (0)