Skip to content

Commit 2046d36

Browse files
committed
perf(reactivity): improve reactive effect memory usage (#4001)
Based on #2345 , but with smaller API change - Use class implementation for `ReactiveEffect` - Switch internal creation of effects to use the class constructor - Avoid options object allocation - Avoid creating bound effect runner function (used in schedulers) when not necessary. - Consumes ~17% less memory compared to last commit - Introduces a very minor breaking change: the `scheduler` option passed to `effect` no longer receives the runner function.
1 parent c456b15 commit 2046d36

File tree

12 files changed

+221
-208
lines changed

12 files changed

+221
-208
lines changed

Diff for: packages/reactivity/__tests__/computed.spec.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
computed,
33
reactive,
44
effect,
5-
stop,
65
ref,
76
WritableComputedRef,
87
isReadonly
@@ -125,7 +124,7 @@ describe('reactivity/computed', () => {
125124
expect(dummy).toBe(undefined)
126125
value.foo = 1
127126
expect(dummy).toBe(1)
128-
stop(cValue.effect)
127+
cValue.effect.stop()
129128
value.foo = 2
130129
expect(dummy).toBe(1)
131130
})
@@ -196,7 +195,7 @@ describe('reactivity/computed', () => {
196195

197196
it('should expose value when stopped', () => {
198197
const x = computed(() => 1)
199-
stop(x.effect)
198+
x.effect.stop()
200199
expect(x.value).toBe(1)
201200
})
202201
})

Diff for: packages/reactivity/__tests__/effect.spec.ts

+12-11
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ describe('reactivity/effect', () => {
494494
const runner = effect(() => {})
495495
const otherRunner = effect(runner)
496496
expect(runner).not.toBe(otherRunner)
497-
expect(runner.raw).toBe(otherRunner.raw)
497+
expect(runner.effect.fn).toBe(otherRunner.effect.fn)
498498
})
499499

500500
it('should not run multiple times for a single mutation', () => {
@@ -590,12 +590,13 @@ describe('reactivity/effect', () => {
590590
})
591591

592592
it('scheduler', () => {
593-
let runner: any, dummy
594-
const scheduler = jest.fn(_runner => {
595-
runner = _runner
593+
let dummy
594+
let run: any
595+
const scheduler = jest.fn(() => {
596+
run = runner
596597
})
597598
const obj = reactive({ foo: 1 })
598-
effect(
599+
const runner = effect(
599600
() => {
600601
dummy = obj.foo
601602
},
@@ -609,7 +610,7 @@ describe('reactivity/effect', () => {
609610
// should not run yet
610611
expect(dummy).toBe(1)
611612
// manually run
612-
runner()
613+
run()
613614
// should have run
614615
expect(dummy).toBe(2)
615616
})
@@ -633,19 +634,19 @@ describe('reactivity/effect', () => {
633634
expect(onTrack).toHaveBeenCalledTimes(3)
634635
expect(events).toEqual([
635636
{
636-
effect: runner,
637+
effect: runner.effect,
637638
target: toRaw(obj),
638639
type: TrackOpTypes.GET,
639640
key: 'foo'
640641
},
641642
{
642-
effect: runner,
643+
effect: runner.effect,
643644
target: toRaw(obj),
644645
type: TrackOpTypes.HAS,
645646
key: 'bar'
646647
},
647648
{
648-
effect: runner,
649+
effect: runner.effect,
649650
target: toRaw(obj),
650651
type: TrackOpTypes.ITERATE,
651652
key: ITERATE_KEY
@@ -671,7 +672,7 @@ describe('reactivity/effect', () => {
671672
expect(dummy).toBe(2)
672673
expect(onTrigger).toHaveBeenCalledTimes(1)
673674
expect(events[0]).toEqual({
674-
effect: runner,
675+
effect: runner.effect,
675676
target: toRaw(obj),
676677
type: TriggerOpTypes.SET,
677678
key: 'foo',
@@ -684,7 +685,7 @@ describe('reactivity/effect', () => {
684685
expect(dummy).toBeUndefined()
685686
expect(onTrigger).toHaveBeenCalledTimes(2)
686687
expect(events[1]).toEqual({
687-
effect: runner,
688+
effect: runner.effect,
688689
target: toRaw(obj),
689690
type: TriggerOpTypes.DELETE,
690691
key: 'foo',

Diff for: packages/reactivity/__tests__/readonly.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ describe('reactivity/readonly', () => {
382382
const eff = effect(() => {
383383
roArr.includes(2)
384384
})
385-
expect(eff.deps.length).toBe(0)
385+
expect(eff.effect.deps.length).toBe(0)
386386
})
387387

388388
test('readonly should track and trigger if wrapping reactive original (collection)', () => {

Diff for: packages/reactivity/src/computed.ts

+7-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { effect, ReactiveEffect } from './effect'
1+
import { ReactiveEffect } from './effect'
22
import { Ref, trackRefValue, triggerRefValue } from './ref'
33
import { isFunction, NOOP } from '@vue/shared'
44
import { ReactiveFlags, toRaw } from './reactive'
@@ -35,27 +35,23 @@ class ComputedRefImpl<T> {
3535
private readonly _setter: ComputedSetter<T>,
3636
isReadonly: boolean
3737
) {
38-
this.effect = effect(getter, {
39-
lazy: true,
40-
scheduler: () => {
41-
if (!this._dirty) {
42-
this._dirty = true
43-
triggerRefValue(this)
44-
}
38+
this.effect = new ReactiveEffect(getter, () => {
39+
if (!this._dirty) {
40+
this._dirty = true
41+
triggerRefValue(this)
4542
}
4643
})
47-
4844
this[ReactiveFlags.IS_READONLY] = isReadonly
4945
}
5046

5147
get value() {
5248
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
5349
const self = toRaw(this)
5450
if (self._dirty) {
55-
self._value = this.effect()
51+
self._value = self.effect.run()!
5652
self._dirty = false
5753
}
58-
trackRefValue(this)
54+
trackRefValue(self)
5955
return self._value
6056
}
6157

Diff for: packages/reactivity/src/effect.ts

+87-98
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { TrackOpTypes, TriggerOpTypes } from './operations'
2-
import { EMPTY_OBJ, extend, isArray, isIntegerKey, isMap } from '@vue/shared'
2+
import { extend, isArray, isIntegerKey, isMap } from '@vue/shared'
33

44
// The main WeakMap that stores {target -> key -> dep} connections.
55
// Conceptually, it's easier to think of a dependency as a Dep class
@@ -9,40 +9,7 @@ type Dep = Set<ReactiveEffect>
99
type KeyToDepMap = Map<any, Dep>
1010
const targetMap = new WeakMap<any, KeyToDepMap>()
1111

12-
export interface ReactiveEffect<T = any> {
13-
(): T
14-
_isEffect: true
15-
id: number
16-
active: boolean
17-
raw: () => T
18-
deps: Array<Dep>
19-
options: ReactiveEffectOptions
20-
allowRecurse: boolean
21-
}
22-
23-
export interface ReactiveEffectOptions {
24-
lazy?: boolean
25-
scheduler?: (job: ReactiveEffect) => void
26-
onTrack?: (event: DebuggerEvent) => void
27-
onTrigger?: (event: DebuggerEvent) => void
28-
onStop?: () => void
29-
/**
30-
* Indicates whether the job is allowed to recursively trigger itself when
31-
* managed by the scheduler.
32-
*
33-
* By default, a job cannot trigger itself because some built-in method calls,
34-
* e.g. Array.prototype.push actually performs reads as well (#1740) which
35-
* can lead to confusing infinite loops.
36-
* The allowed cases are component update functions and watch callbacks.
37-
* Component update functions may update child component props, which in turn
38-
* trigger flush: "pre" watch callbacks that mutates state that the parent
39-
* relies on (#1801). Watch callbacks doesn't track its dependencies so if it
40-
* triggers itself again, it's likely intentional and it is the user's
41-
* responsibility to perform recursive state mutation that eventually
42-
* stabilizes (#1727).
43-
*/
44-
allowRecurse?: boolean
45-
}
12+
export type EffectScheduler = () => void
4613

4714
export type DebuggerEvent = {
4815
effect: ReactiveEffect
@@ -62,78 +29,100 @@ let activeEffect: ReactiveEffect | undefined
6229

6330
export const ITERATE_KEY = Symbol(__DEV__ ? 'iterate' : '')
6431
export const MAP_KEY_ITERATE_KEY = Symbol(__DEV__ ? 'Map key iterate' : '')
32+
export class ReactiveEffect<T = any> {
33+
active = true
34+
deps: Dep[] = []
6535

66-
export function isEffect(fn: any): fn is ReactiveEffect {
67-
return fn && fn._isEffect === true
68-
}
69-
70-
export function effect<T = any>(
71-
fn: () => T,
72-
options: ReactiveEffectOptions = EMPTY_OBJ
73-
): ReactiveEffect<T> {
74-
if (isEffect(fn)) {
75-
fn = fn.raw
76-
}
77-
const effect = createReactiveEffect(fn, options)
78-
if (!options.lazy) {
79-
effect()
80-
}
81-
return effect
82-
}
83-
84-
export function stop(effect: ReactiveEffect) {
85-
if (effect.active) {
86-
cleanup(effect)
87-
if (effect.options.onStop) {
88-
effect.options.onStop()
89-
}
90-
effect.active = false
91-
}
92-
}
36+
// can be attached after creation
37+
onStop?: () => void
38+
// dev only
39+
onTrack?: (event: DebuggerEvent) => void
40+
// dev only
41+
onTrigger?: (event: DebuggerEvent) => void
9342

94-
let uid = 0
43+
constructor(
44+
public fn: () => T,
45+
public scheduler: EffectScheduler | null = null,
46+
// allow recursive self-invocation
47+
public allowRecurse = false
48+
) {}
9549

96-
function createReactiveEffect<T = any>(
97-
fn: () => T,
98-
options: ReactiveEffectOptions
99-
): ReactiveEffect<T> {
100-
const effect = function reactiveEffect(): unknown {
101-
if (!effect.active) {
102-
return fn()
50+
run() {
51+
if (!this.active) {
52+
return this.fn()
10353
}
104-
if (!effectStack.includes(effect)) {
105-
cleanup(effect)
54+
if (!effectStack.includes(this)) {
55+
this.cleanup()
10656
try {
10757
enableTracking()
108-
effectStack.push(effect)
109-
activeEffect = effect
110-
return fn()
58+
effectStack.push((activeEffect = this))
59+
return this.fn()
11160
} finally {
11261
effectStack.pop()
11362
resetTracking()
11463
const n = effectStack.length
11564
activeEffect = n > 0 ? effectStack[n - 1] : undefined
11665
}
11766
}
118-
} as ReactiveEffect
119-
effect.id = uid++
120-
effect.allowRecurse = !!options.allowRecurse
121-
effect._isEffect = true
122-
effect.active = true
123-
effect.raw = fn
124-
effect.deps = []
125-
effect.options = options
126-
return effect
127-
}
67+
}
12868

129-
function cleanup(effect: ReactiveEffect) {
130-
const { deps } = effect
131-
if (deps.length) {
132-
for (let i = 0; i < deps.length; i++) {
133-
deps[i].delete(effect)
69+
cleanup() {
70+
const { deps } = this
71+
if (deps.length) {
72+
for (let i = 0; i < deps.length; i++) {
73+
deps[i].delete(this)
74+
}
75+
deps.length = 0
13476
}
135-
deps.length = 0
13677
}
78+
79+
stop() {
80+
if (this.active) {
81+
this.cleanup()
82+
if (this.onStop) {
83+
this.onStop()
84+
}
85+
this.active = false
86+
}
87+
}
88+
}
89+
90+
export interface ReactiveEffectOptions {
91+
lazy?: boolean
92+
scheduler?: EffectScheduler
93+
allowRecurse?: boolean
94+
onStop?: () => void
95+
onTrack?: (event: DebuggerEvent) => void
96+
onTrigger?: (event: DebuggerEvent) => void
97+
}
98+
99+
export interface ReactiveEffectRunner<T = any> {
100+
(): T
101+
effect: ReactiveEffect
102+
}
103+
104+
export function effect<T = any>(
105+
fn: () => T,
106+
options?: ReactiveEffectOptions
107+
): ReactiveEffectRunner {
108+
if ((fn as ReactiveEffectRunner).effect) {
109+
fn = (fn as ReactiveEffectRunner).effect.fn
110+
}
111+
112+
const _effect = new ReactiveEffect(fn)
113+
if (options) {
114+
extend(_effect, options)
115+
}
116+
if (!options || !options.lazy) {
117+
_effect.run()
118+
}
119+
const runner = _effect.run.bind(_effect) as ReactiveEffectRunner
120+
runner.effect = _effect
121+
return runner
122+
}
123+
124+
export function stop(runner: ReactiveEffectRunner) {
125+
runner.effect.stop()
137126
}
138127

139128
let shouldTrack = true
@@ -185,8 +174,8 @@ export function trackEffects(
185174
if (!dep.has(activeEffect!)) {
186175
dep.add(activeEffect!)
187176
activeEffect!.deps.push(dep)
188-
if (__DEV__ && activeEffect!.options.onTrack) {
189-
activeEffect!.options.onTrack(
177+
if (__DEV__ && activeEffect!.onTrack) {
178+
activeEffect!.onTrack(
190179
Object.assign(
191180
{
192181
effect: activeEffect!
@@ -284,13 +273,13 @@ export function triggerEffects(
284273
// spread into array for stabilization
285274
for (const effect of [...dep]) {
286275
if (effect !== activeEffect || effect.allowRecurse) {
287-
if (__DEV__ && effect.options.onTrigger) {
288-
effect.options.onTrigger(extend({ effect }, debuggerEventExtraInfo))
276+
if (__DEV__ && effect.onTrigger) {
277+
effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
289278
}
290-
if (effect.options.scheduler) {
291-
effect.options.scheduler(effect)
279+
if (effect.scheduler) {
280+
effect.scheduler()
292281
} else {
293-
effect()
282+
effect.run()
294283
}
295284
}
296285
}

Diff for: packages/reactivity/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ export {
4646
resetTracking,
4747
ITERATE_KEY,
4848
ReactiveEffect,
49+
ReactiveEffectRunner,
4950
ReactiveEffectOptions,
51+
EffectScheduler,
5052
DebuggerEvent
5153
} from './effect'
5254
export { TrackOpTypes, TriggerOpTypes } from './operations'

0 commit comments

Comments
 (0)