Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 7, 2021
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion packages/reactivity/__tests__/effect.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
51 changes: 51 additions & 0 deletions packages/reactivity/src/Dep.ts
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 2 additions & 1 deletion packages/reactivity/src/computed.ts
Original file line number Diff line number Diff line change
@@ -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
@@ -31,7 +32,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
90 changes: 81 additions & 9 deletions packages/reactivity/src/effect.ts
Original file line number Diff line number Diff line change
@@ -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,19 +64,57 @@ 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()
if (effectTrackDepth <= maxMarkerBits) {
this.finalizeDepMarkers()
}
effectTrackDepth--
resetTracking()
activeEffect = effectStack[effectStack.length - 1]
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
}
}

cleanup() {
const { deps } = this
if (deps.length) {
@@ -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)
}
}

18 changes: 7 additions & 11 deletions packages/reactivity/src/ref.ts
Original file line number Diff line number Diff line change
@@ -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,19 +23,19 @@ 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
}

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, {
@@ -101,7 +97,7 @@ export function shallowRef(value?: unknown) {
}

class RefImpl<T> {
public dep?: Set<ReactiveEffect> = undefined
public dep?: Dep = undefined

private _value: T

@@ -170,7 +166,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']
68 changes: 41 additions & 27 deletions packages/runtime-core/src/renderer.ts
Original file line number Diff line number Diff line change
@@ -1406,25 +1406,32 @@ 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

// beforeMount hook
if (bm) {
invokeArrayFns(bm)
}
// onVnodeBeforeMount
if ((vnodeHook = props && props.onVnodeBeforeMount)) {
invokeVNodeHook(vnodeHook, parent, initialVNode)
}
if (
__COMPAT__ &&
isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
) {
instance.emit('hook:beforeMount')
try {
// Disallow component effect recursion during pre-lifecycle hooks.
this.allowRecurse = false

// beforeMount hook
if (bm) {
invokeArrayFns(bm)
}
// onVnodeBeforeMount
if ((vnodeHook = props && props.onVnodeBeforeMount)) {
invokeVNodeHook(vnodeHook, parent, initialVNode)
}
if (
__COMPAT__ &&
isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
) {
instance.emit('hook:beforeMount')
}
} finally {
this.allowRecurse = true
}

if (el && hydrateNode) {
@@ -1551,19 +1558,26 @@ function baseCreateRenderer(
next = vnode
}

// beforeUpdate hook
if (bu) {
invokeArrayFns(bu)
}
// onVnodeBeforeUpdate
if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) {
invokeVNodeHook(vnodeHook, parent, next, vnode)
}
if (
__COMPAT__ &&
isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
) {
instance.emit('hook:beforeUpdate')
try {
// Disallow component effect recursion during pre-lifecycle hooks.
this.allowRecurse = false

// beforeUpdate hook
if (bu) {
invokeArrayFns(bu)
}
// onVnodeBeforeUpdate
if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) {
invokeVNodeHook(vnodeHook, parent, next, vnode)
}
if (
__COMPAT__ &&
isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
) {
instance.emit('hook:beforeUpdate')
}
} finally {
this.allowRecurse = true
}

// render