refactor: sync value access for chained computed w/ scheduler

This commit is contained in:
Evan You 2021-07-08 00:31:26 -04:00
parent 02339b67d8
commit 1fe2239270
4 changed files with 132 additions and 41 deletions

View File

@ -201,10 +201,29 @@ describe('reactivity/computed', () => {
}) })
describe('with scheduler', () => { describe('with scheduler', () => {
const p = Promise.resolve() // a simple scheduler similar to the main Vue scheduler
const defer = (fn?: any) => (fn ? p.then(fn) : p) const tick = Promise.resolve()
const queue: any[] = []
let queued = false
const schedule = (fn: any) => {
queue.push(fn)
if (!queued) {
queued = true
tick.then(flush)
}
}
const flush = () => {
for (let i = 0; i < queue.length; i++) {
queue[i]()
}
queue.length = 0
queued = false
}
beforeEach(() => { beforeEach(() => {
setComputedScheduler(defer) setComputedScheduler(schedule)
}) })
afterEach(() => { afterEach(() => {
@ -224,7 +243,7 @@ describe('reactivity/computed', () => {
src.value = 3 src.value = 3
// not called yet // not called yet
expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledTimes(1)
await defer() await tick
// should only trigger once // should only trigger once
expect(spy).toHaveBeenCalledTimes(2) expect(spy).toHaveBeenCalledTimes(2)
expect(spy).toHaveBeenCalledWith(c.value) expect(spy).toHaveBeenCalledWith(c.value)
@ -241,16 +260,67 @@ describe('reactivity/computed', () => {
src.value = 1 src.value = 1
src.value = 2 src.value = 2
await defer() await tick
// should not trigger // should not trigger
expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledTimes(1)
src.value = 3 src.value = 3
src.value = 4 src.value = 4
src.value = 5 src.value = 5
await defer() await tick
// should trigger because latest value changes // should trigger because latest value changes
expect(spy).toHaveBeenCalledTimes(2) expect(spy).toHaveBeenCalledTimes(2)
}) })
test('chained computed value invalidation', async () => {
const effectSpy = jest.fn()
const c1Spy = jest.fn()
const c2Spy = jest.fn()
const src = ref(0)
const c1 = computed(() => {
c1Spy()
return src.value % 2
})
const c2 = computed(() => {
c2Spy()
return c1.value + 1
})
effect(() => {
effectSpy(c2.value)
})
expect(effectSpy).toHaveBeenCalledTimes(1)
expect(effectSpy).toHaveBeenCalledWith(1)
expect(c2.value).toBe(1)
expect(c1Spy).toHaveBeenCalledTimes(1)
expect(c2Spy).toHaveBeenCalledTimes(1)
src.value = 1
// value should be available sync
expect(c2.value).toBe(2)
expect(c2Spy).toHaveBeenCalledTimes(2)
await tick
expect(effectSpy).toHaveBeenCalledTimes(2)
expect(effectSpy).toHaveBeenCalledWith(2)
expect(c1Spy).toHaveBeenCalledTimes(2)
expect(c2Spy).toHaveBeenCalledTimes(2)
src.value = 2
await tick
expect(effectSpy).toHaveBeenCalledTimes(3)
expect(c1Spy).toHaveBeenCalledTimes(3)
expect(c2Spy).toHaveBeenCalledTimes(3)
src.value = 4
await tick
expect(effectSpy).toHaveBeenCalledTimes(3)
expect(c1Spy).toHaveBeenCalledTimes(4)
// in-between chained computed always re-compute, but it does avoid
// triggering the final subscribing effect.
expect(c2Spy).toHaveBeenCalledTimes(4)
})
}) })
}) })

View File

@ -1,8 +1,9 @@
import { ReactiveEffect } from './effect' import { ReactiveEffect, triggerEffects } from './effect'
import { Ref, trackRefValue, triggerRefValue } from './ref' import { Ref, trackRefValue, triggerRefValue } from './ref'
import { isFunction, NOOP } from '@vue/shared' import { isFunction, NOOP } from '@vue/shared'
import { ReactiveFlags, toRaw } from './reactive' import { ReactiveFlags, toRaw } from './reactive'
import { Dep } from './dep' import { Dep } from './dep'
import { TriggerOpTypes } from '@vue/runtime-core'
export interface ComputedRef<T = any> extends WritableComputedRef<T> { export interface ComputedRef<T = any> extends WritableComputedRef<T> {
readonly value: T readonly value: T
@ -35,6 +36,7 @@ class ComputedRefImpl<T> {
private _value!: T private _value!: T
private _dirty = true private _dirty = true
private _changed = false
public readonly effect: ReactiveEffect<T> public readonly effect: ReactiveEffect<T>
@ -46,46 +48,58 @@ class ComputedRefImpl<T> {
private readonly _setter: ComputedSetter<T>, private readonly _setter: ComputedSetter<T>,
isReadonly: boolean isReadonly: boolean
) { ) {
let deferFn: () => void
let scheduled = false
this.effect = new ReactiveEffect(getter, () => { this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) { if (!this._dirty) {
this._dirty = true this._dirty = true
if (scheduler) { if (scheduler) {
if (!scheduled) { if (this.dep) {
scheduled = true const effects: ReactiveEffect[] = []
scheduler( scheduler(() => {
deferFn || if ((this._get(), this._changed)) {
(deferFn = () => { if (__DEV__) {
scheduled = false triggerEffects(effects, {
if (this._dirty) { target: this,
this._dirty = false type: TriggerOpTypes.SET,
const newValue = this.effect.run()! key: 'value',
if (this._value !== newValue) { newValue: this._value
this._value = newValue })
triggerRefValue(this)
}
} else { } else {
triggerRefValue(this) triggerEffects(effects)
}
} }
}) })
) // chained upstream computeds are notified synchronously to ensure
// value invalidation in case of sync access; normal effects are
// deferred to be triggered in scheduler.
for (const e of this.dep) {
if (e.computed) {
e.scheduler!()
} else {
effects.push(e)
}
}
} }
} else { } else {
triggerRefValue(this) triggerRefValue(this)
} }
} }
}) })
this.effect.computed = true
this[ReactiveFlags.IS_READONLY] = isReadonly this[ReactiveFlags.IS_READONLY] = isReadonly
} }
private _get() {
if (this._dirty) {
const oldValue = this._value
this._changed = oldValue !== (this._value = this.effect.run()!)
this._dirty = false
}
}
get value() { get value() {
// the computed ref may get wrapped by other proxies e.g. readonly() #3376 // the computed ref may get wrapped by other proxies e.g. readonly() #3376
const self = toRaw(this) const self = toRaw(this)
if (self._dirty) { self._get()
self._value = self.effect.run()!
self._dirty = false
}
trackRefValue(self) trackRefValue(self)
return self._value return self._value
} }

View File

@ -55,6 +55,8 @@ export class ReactiveEffect<T = any> {
deps: Dep[] = [] deps: Dep[] = []
// can be attached after creation // can be attached after creation
computed?: boolean
allowRecurse?: boolean
onStop?: () => void onStop?: () => void
// dev only // dev only
onTrack?: (event: DebuggerEvent) => void onTrack?: (event: DebuggerEvent) => void
@ -64,9 +66,7 @@ export class ReactiveEffect<T = any> {
constructor( constructor(
public fn: () => T, public fn: () => T,
public scheduler: EffectScheduler | null = null, public scheduler: EffectScheduler | null = null,
scope?: EffectScope | null, scope?: EffectScope | null
// allow recursive self-invocation
public allowRecurse = false
) { ) {
recordEffectScope(this, scope) recordEffectScope(this, scope)
} }
@ -303,7 +303,11 @@ export function trigger(
if (deps.length === 1) { if (deps.length === 1) {
if (deps[0]) { if (deps[0]) {
if (__DEV__) {
triggerEffects(deps[0], eventInfo) triggerEffects(deps[0], eventInfo)
} else {
triggerEffects(deps[0])
}
} }
} else { } else {
const effects: ReactiveEffect[] = [] const effects: ReactiveEffect[] = []
@ -312,16 +316,20 @@ export function trigger(
effects.push(...dep) effects.push(...dep)
} }
} }
if (__DEV__) {
triggerEffects(createDep(effects), eventInfo) triggerEffects(createDep(effects), eventInfo)
} else {
triggerEffects(createDep(effects))
}
} }
} }
export function triggerEffects( export function triggerEffects(
dep: Dep, dep: Dep | ReactiveEffect[],
debuggerEventExtraInfo?: DebuggerEventExtraInfo debuggerEventExtraInfo?: DebuggerEventExtraInfo
) { ) {
// spread into array for stabilization // spread into array for stabilization
for (const effect of [...dep]) { for (const effect of isArray(dep) ? dep : [...dep]) {
if (effect !== activeEffect || effect.allowRecurse) { if (effect !== activeEffect || effect.allowRecurse) {
if (__DEV__ && effect.onTrigger) { if (__DEV__ && effect.onTrigger) {
effect.onTrigger(extend({ effect }, debuggerEventExtraInfo)) effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))

View File

@ -1630,15 +1630,14 @@ function baseCreateRenderer(
const effect = new ReactiveEffect( const effect = new ReactiveEffect(
componentUpdateFn, componentUpdateFn,
() => queueJob(instance.update), () => queueJob(instance.update),
instance.scope, // track it in component's effect scope instance.scope // track it in component's effect scope
true /* allowRecurse */
) )
const update = (instance.update = effect.run.bind(effect) as SchedulerJob) const update = (instance.update = effect.run.bind(effect) as SchedulerJob)
update.id = instance.uid update.id = instance.uid
// allowRecurse // allowRecurse
// #1801, #2043 component render effects should allow recursive updates // #1801, #2043 component render effects should allow recursive updates
update.allowRecurse = true effect.allowRecurse = update.allowRecurse = true
if (__DEV__) { if (__DEV__) {
effect.onTrack = instance.rtc effect.onTrack = instance.rtc