Skip to content

Commit

Permalink
perf(reactivity): improve reactive effect memory usage (#4001)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
yyx990803 committed Jul 1, 2021
1 parent 6a0380c commit 687ea83
Show file tree
Hide file tree
Showing 12 changed files with 221 additions and 208 deletions.
5 changes: 2 additions & 3 deletions packages/reactivity/__tests__/computed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
computed,
reactive,
effect,
stop,
ref,
WritableComputedRef,
isReadonly
Expand Down Expand Up @@ -125,7 +124,7 @@ describe('reactivity/computed', () => {
expect(dummy).toBe(undefined)
value.foo = 1
expect(dummy).toBe(1)
stop(cValue.effect)
cValue.effect.stop()
value.foo = 2
expect(dummy).toBe(1)
})
Expand Down Expand Up @@ -196,7 +195,7 @@ describe('reactivity/computed', () => {

it('should expose value when stopped', () => {
const x = computed(() => 1)
stop(x.effect)
x.effect.stop()
expect(x.value).toBe(1)
})
})
23 changes: 12 additions & 11 deletions packages/reactivity/__tests__/effect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ describe('reactivity/effect', () => {
const runner = effect(() => {})
const otherRunner = effect(runner)
expect(runner).not.toBe(otherRunner)
expect(runner.raw).toBe(otherRunner.raw)
expect(runner.effect.fn).toBe(otherRunner.effect.fn)
})

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

it('scheduler', () => {
let runner: any, dummy
const scheduler = jest.fn(_runner => {
runner = _runner
let dummy
let run: any
const scheduler = jest.fn(() => {
run = runner
})
const obj = reactive({ foo: 1 })
effect(
const runner = effect(
() => {
dummy = obj.foo
},
Expand All @@ -609,7 +610,7 @@ describe('reactivity/effect', () => {
// should not run yet
expect(dummy).toBe(1)
// manually run
runner()
run()
// should have run
expect(dummy).toBe(2)
})
Expand All @@ -633,19 +634,19 @@ describe('reactivity/effect', () => {
expect(onTrack).toHaveBeenCalledTimes(3)
expect(events).toEqual([
{
effect: runner,
effect: runner.effect,
target: toRaw(obj),
type: TrackOpTypes.GET,
key: 'foo'
},
{
effect: runner,
effect: runner.effect,
target: toRaw(obj),
type: TrackOpTypes.HAS,
key: 'bar'
},
{
effect: runner,
effect: runner.effect,
target: toRaw(obj),
type: TrackOpTypes.ITERATE,
key: ITERATE_KEY
Expand All @@ -671,7 +672,7 @@ describe('reactivity/effect', () => {
expect(dummy).toBe(2)
expect(onTrigger).toHaveBeenCalledTimes(1)
expect(events[0]).toEqual({
effect: runner,
effect: runner.effect,
target: toRaw(obj),
type: TriggerOpTypes.SET,
key: 'foo',
Expand All @@ -684,7 +685,7 @@ describe('reactivity/effect', () => {
expect(dummy).toBeUndefined()
expect(onTrigger).toHaveBeenCalledTimes(2)
expect(events[1]).toEqual({
effect: runner,
effect: runner.effect,
target: toRaw(obj),
type: TriggerOpTypes.DELETE,
key: 'foo',
Expand Down
2 changes: 1 addition & 1 deletion packages/reactivity/__tests__/readonly.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ describe('reactivity/readonly', () => {
const eff = effect(() => {
roArr.includes(2)
})
expect(eff.deps.length).toBe(0)
expect(eff.effect.deps.length).toBe(0)
})

test('readonly should track and trigger if wrapping reactive original (collection)', () => {
Expand Down
18 changes: 7 additions & 11 deletions packages/reactivity/src/computed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { effect, ReactiveEffect } from './effect'
import { ReactiveEffect } from './effect'
import { Ref, trackRefValue, triggerRefValue } from './ref'
import { isFunction, NOOP } from '@vue/shared'
import { ReactiveFlags, toRaw } from './reactive'
Expand Down Expand Up @@ -35,27 +35,23 @@ class ComputedRefImpl<T> {
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean
) {
this.effect = effect(getter, {
lazy: true,
scheduler: () => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this)
}
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this)
}
})

this[ReactiveFlags.IS_READONLY] = isReadonly
}

get value() {
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
const self = toRaw(this)
if (self._dirty) {
self._value = this.effect()
self._value = self.effect.run()!
self._dirty = false
}
trackRefValue(this)
trackRefValue(self)
return self._value
}

Expand Down
185 changes: 87 additions & 98 deletions packages/reactivity/src/effect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TrackOpTypes, TriggerOpTypes } from './operations'
import { EMPTY_OBJ, extend, isArray, isIntegerKey, isMap } from '@vue/shared'
import { extend, isArray, isIntegerKey, isMap } from '@vue/shared'

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

export interface ReactiveEffect<T = any> {
(): T
_isEffect: true
id: number
active: boolean
raw: () => T
deps: Array<Dep>
options: ReactiveEffectOptions
allowRecurse: boolean
}

export interface ReactiveEffectOptions {
lazy?: boolean
scheduler?: (job: ReactiveEffect) => void
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
onStop?: () => void
/**
* Indicates whether the job is allowed to recursively trigger itself when
* managed by the scheduler.
*
* By default, a job cannot trigger itself because some built-in method calls,
* e.g. Array.prototype.push actually performs reads as well (#1740) which
* can lead to confusing infinite loops.
* The allowed cases are component update functions and watch callbacks.
* Component update functions may update child component props, which in turn
* trigger flush: "pre" watch callbacks that mutates state that the parent
* relies on (#1801). Watch callbacks doesn't track its dependencies so if it
* triggers itself again, it's likely intentional and it is the user's
* responsibility to perform recursive state mutation that eventually
* stabilizes (#1727).
*/
allowRecurse?: boolean
}
export type EffectScheduler = () => void

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

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

export function isEffect(fn: any): fn is ReactiveEffect {
return fn && fn._isEffect === true
}

export function effect<T = any>(
fn: () => T,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
if (isEffect(fn)) {
fn = fn.raw
}
const effect = createReactiveEffect(fn, options)
if (!options.lazy) {
effect()
}
return effect
}

export function stop(effect: ReactiveEffect) {
if (effect.active) {
cleanup(effect)
if (effect.options.onStop) {
effect.options.onStop()
}
effect.active = false
}
}
// can be attached after creation
onStop?: () => void
// dev only
onTrack?: (event: DebuggerEvent) => void
// dev only
onTrigger?: (event: DebuggerEvent) => void

let uid = 0
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null,
// allow recursive self-invocation
public allowRecurse = false
) {}

function createReactiveEffect<T = any>(
fn: () => T,
options: ReactiveEffectOptions
): ReactiveEffect<T> {
const effect = function reactiveEffect(): unknown {
if (!effect.active) {
return fn()
run() {
if (!this.active) {
return this.fn()
}
if (!effectStack.includes(effect)) {
cleanup(effect)
if (!effectStack.includes(this)) {
this.cleanup()
try {
enableTracking()
effectStack.push(effect)
activeEffect = effect
return fn()
effectStack.push((activeEffect = this))
return this.fn()
} finally {
effectStack.pop()
resetTracking()
const n = effectStack.length
activeEffect = n > 0 ? effectStack[n - 1] : undefined
}
}
} as ReactiveEffect
effect.id = uid++
effect.allowRecurse = !!options.allowRecurse
effect._isEffect = true
effect.active = true
effect.raw = fn
effect.deps = []
effect.options = options
return effect
}
}

function cleanup(effect: ReactiveEffect) {
const { deps } = effect
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect)
cleanup() {
const { deps } = this
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].delete(this)
}
deps.length = 0
}
deps.length = 0
}

stop() {
if (this.active) {
this.cleanup()
if (this.onStop) {
this.onStop()
}
this.active = false
}
}
}

export interface ReactiveEffectOptions {
lazy?: boolean
scheduler?: EffectScheduler
allowRecurse?: boolean
onStop?: () => void
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
}

export interface ReactiveEffectRunner<T = any> {
(): T
effect: ReactiveEffect
}

export function effect<T = any>(
fn: () => T,
options?: ReactiveEffectOptions
): ReactiveEffectRunner {
if ((fn as ReactiveEffectRunner).effect) {
fn = (fn as ReactiveEffectRunner).effect.fn
}

const _effect = new ReactiveEffect(fn)
if (options) {
extend(_effect, options)
}
if (!options || !options.lazy) {
_effect.run()
}
const runner = _effect.run.bind(_effect) as ReactiveEffectRunner
runner.effect = _effect
return runner
}

export function stop(runner: ReactiveEffectRunner) {
runner.effect.stop()
}

let shouldTrack = true
Expand Down Expand Up @@ -185,8 +174,8 @@ export function trackEffects(
if (!dep.has(activeEffect!)) {
dep.add(activeEffect!)
activeEffect!.deps.push(dep)
if (__DEV__ && activeEffect!.options.onTrack) {
activeEffect!.options.onTrack(
if (__DEV__ && activeEffect!.onTrack) {
activeEffect!.onTrack(
Object.assign(
{
effect: activeEffect!
Expand Down Expand Up @@ -284,13 +273,13 @@ export function triggerEffects(
// spread into array for stabilization
for (const effect of [...dep]) {
if (effect !== activeEffect || effect.allowRecurse) {
if (__DEV__ && effect.options.onTrigger) {
effect.options.onTrigger(extend({ effect }, debuggerEventExtraInfo))
if (__DEV__ && effect.onTrigger) {
effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
}
if (effect.options.scheduler) {
effect.options.scheduler(effect)
if (effect.scheduler) {
effect.scheduler()
} else {
effect()
effect.run()
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/reactivity/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ export {
resetTracking,
ITERATE_KEY,
ReactiveEffect,
ReactiveEffectRunner,
ReactiveEffectOptions,
EffectScheduler,
DebuggerEvent
} from './effect'
export { TrackOpTypes, TriggerOpTypes } from './operations'
Loading

0 comments on commit 687ea83

Please sign in to comment.