vue3-core/packages/reactivity/src/effect.ts

586 lines
13 KiB
TypeScript

import { extend, hasChanged } from '@vue/shared'
import type { ComputedRefImpl } from './computed'
import type { TrackOpTypes, TriggerOpTypes } from './constants'
import { type Link, globalVersion } from './dep'
import { activeEffectScope } from './effectScope'
import { warn } from './warning'
export type EffectScheduler = (...args: any[]) => any
export type DebuggerEvent = {
effect: Subscriber
} & DebuggerEventExtraInfo
export type DebuggerEventExtraInfo = {
target: object
type: TrackOpTypes | TriggerOpTypes
key: any
newValue?: any
oldValue?: any
oldTarget?: Map<any, any> | Set<any>
}
export interface DebuggerOptions {
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
}
export interface ReactiveEffectOptions extends DebuggerOptions {
scheduler?: EffectScheduler
allowRecurse?: boolean
onStop?: () => void
}
export interface ReactiveEffectRunner<T = any> {
(): T
effect: ReactiveEffect
}
export let activeSub: Subscriber | undefined
export enum EffectFlags {
/**
* ReactiveEffect only
*/
ACTIVE = 1 << 0,
RUNNING = 1 << 1,
TRACKING = 1 << 2,
NOTIFIED = 1 << 3,
DIRTY = 1 << 4,
ALLOW_RECURSE = 1 << 5,
PAUSED = 1 << 6,
EVALUATED = 1 << 7,
}
/**
* Subscriber is a type that tracks (or subscribes to) a list of deps.
*/
export interface Subscriber extends DebuggerOptions {
/**
* Head of the doubly linked list representing the deps
* @internal
*/
deps?: Link
/**
* Tail of the same list
* @internal
*/
depsTail?: Link
/**
* @internal
*/
flags: EffectFlags
/**
* @internal
*/
next?: Subscriber
/**
* returning `true` indicates it's a computed that needs to call notify
* on its dep too
* @internal
*/
notify(): true | void
}
const pausedQueueEffects = new WeakSet<ReactiveEffect>()
export class ReactiveEffect<T = any>
implements Subscriber, ReactiveEffectOptions
{
/**
* @internal
*/
deps?: Link = undefined
/**
* @internal
*/
depsTail?: Link = undefined
/**
* @internal
*/
flags: EffectFlags = EffectFlags.ACTIVE | EffectFlags.TRACKING
/**
* @internal
*/
next?: Subscriber = undefined
/**
* @internal
*/
cleanup?: () => void = undefined
scheduler?: EffectScheduler = undefined
onStop?: () => void
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
constructor(public fn: () => T) {
if (activeEffectScope && activeEffectScope.active) {
activeEffectScope.effects.push(this)
}
}
pause(): void {
this.flags |= EffectFlags.PAUSED
}
resume(): void {
if (this.flags & EffectFlags.PAUSED) {
this.flags &= ~EffectFlags.PAUSED
if (pausedQueueEffects.has(this)) {
pausedQueueEffects.delete(this)
this.trigger()
}
}
}
/**
* @internal
*/
notify(): void {
if (
this.flags & EffectFlags.RUNNING &&
!(this.flags & EffectFlags.ALLOW_RECURSE)
) {
return
}
if (!(this.flags & EffectFlags.NOTIFIED)) {
batch(this)
}
}
run(): T {
// TODO cleanupEffect
if (!(this.flags & EffectFlags.ACTIVE)) {
// stopped during cleanup
return this.fn()
}
this.flags |= EffectFlags.RUNNING
cleanupEffect(this)
prepareDeps(this)
const prevEffect = activeSub
const prevShouldTrack = shouldTrack
activeSub = this
shouldTrack = true
try {
return this.fn()
} finally {
if (__DEV__ && activeSub !== this) {
warn(
'Active effect was not restored correctly - ' +
'this is likely a Vue internal bug.',
)
}
cleanupDeps(this)
activeSub = prevEffect
shouldTrack = prevShouldTrack
this.flags &= ~EffectFlags.RUNNING
}
}
stop(): void {
if (this.flags & EffectFlags.ACTIVE) {
for (let link = this.deps; link; link = link.nextDep) {
removeSub(link)
}
this.deps = this.depsTail = undefined
cleanupEffect(this)
this.onStop && this.onStop()
this.flags &= ~EffectFlags.ACTIVE
}
}
trigger(): void {
if (this.flags & EffectFlags.PAUSED) {
pausedQueueEffects.add(this)
} else if (this.scheduler) {
this.scheduler()
} else {
this.runIfDirty()
}
}
/**
* @internal
*/
runIfDirty(): void {
if (isDirty(this)) {
this.run()
}
}
get dirty(): boolean {
return isDirty(this)
}
}
/**
* For debugging
*/
// function printDeps(sub: Subscriber) {
// let d = sub.deps
// let ds = []
// while (d) {
// ds.push(d)
// d = d.nextDep
// }
// return ds.map(d => ({
// id: d.id,
// prev: d.prevDep?.id,
// next: d.nextDep?.id,
// }))
// }
let batchDepth = 0
let batchedSub: Subscriber | undefined
let batchedComputed: Subscriber | undefined
export function batch(sub: Subscriber, isComputed = false): void {
sub.flags |= EffectFlags.NOTIFIED
if (isComputed) {
sub.next = batchedComputed
batchedComputed = sub
return
}
sub.next = batchedSub
batchedSub = sub
}
/**
* @internal
*/
export function startBatch(): void {
batchDepth++
}
/**
* Run batched effects when all batches have ended
* @internal
*/
export function endBatch(): void {
if (--batchDepth > 0) {
return
}
if (batchedComputed) {
let e: Subscriber | undefined = batchedComputed
batchedComputed = undefined
while (e) {
const next: Subscriber | undefined = e.next
e.next = undefined
e.flags &= ~EffectFlags.NOTIFIED
e = next
}
}
let error: unknown
while (batchedSub) {
let e: Subscriber | undefined = batchedSub
batchedSub = undefined
while (e) {
const next: Subscriber | undefined = e.next
e.next = undefined
e.flags &= ~EffectFlags.NOTIFIED
if (e.flags & EffectFlags.ACTIVE) {
try {
// ACTIVE flag is effect-only
;(e as ReactiveEffect).trigger()
} catch (err) {
if (!error) error = err
}
}
e = next
}
}
if (error) throw error
}
function prepareDeps(sub: Subscriber) {
// Prepare deps for tracking, starting from the head
for (let link = sub.deps; link; link = link.nextDep) {
// set all previous deps' (if any) version to -1 so that we can track
// which ones are unused after the run
link.version = -1
// store previous active sub if link was being used in another context
link.prevActiveLink = link.dep.activeLink
link.dep.activeLink = link
}
}
function cleanupDeps(sub: Subscriber) {
// Cleanup unsued deps
let head
let tail = sub.depsTail
let link = tail
while (link) {
const prev = link.prevDep
if (link.version === -1) {
if (link === tail) tail = prev
// unused - remove it from the dep's subscribing effect list
removeSub(link)
// also remove it from this effect's dep list
removeDep(link)
} else {
// The new head is the last node seen which wasn't removed
// from the doubly-linked list
head = link
}
// restore previous active link if any
link.dep.activeLink = link.prevActiveLink
link.prevActiveLink = undefined
link = prev
}
// set the new head & tail
sub.deps = head
sub.depsTail = tail
}
function isDirty(sub: Subscriber): boolean {
for (let link = sub.deps; link; link = link.nextDep) {
if (
link.dep.version !== link.version ||
(link.dep.computed &&
(refreshComputed(link.dep.computed) ||
link.dep.version !== link.version))
) {
return true
}
}
// @ts-expect-error only for backwards compatibility where libs manually set
// this flag - e.g. Pinia's testing module
if (sub._dirty) {
return true
}
return false
}
/**
* Returning false indicates the refresh failed
* @internal
*/
export function refreshComputed(computed: ComputedRefImpl): undefined {
if (
computed.flags & EffectFlags.TRACKING &&
!(computed.flags & EffectFlags.DIRTY)
) {
return
}
computed.flags &= ~EffectFlags.DIRTY
// Global version fast path when no reactive changes has happened since
// last refresh.
if (computed.globalVersion === globalVersion) {
return
}
computed.globalVersion = globalVersion
// In SSR there will be no render effect, so the computed has no subscriber
// and therefore tracks no deps, thus we cannot rely on the dirty check.
// Instead, computed always re-evaluate and relies on the globalVersion
// fast path above for caching.
// #12337 if computed has no deps (does not rely on any reactive data) and evaluated,
// there is no need to re-evaluate.
if (
!computed.isSSR &&
computed.flags & EffectFlags.EVALUATED &&
((!computed.deps && !(computed as any)._dirty) || !isDirty(computed))
) {
return
}
computed.flags |= EffectFlags.RUNNING
const dep = computed.dep
const prevSub = activeSub
const prevShouldTrack = shouldTrack
activeSub = computed
shouldTrack = true
try {
prepareDeps(computed)
const value = computed.fn(computed._value)
if (dep.version === 0 || hasChanged(value, computed._value)) {
computed.flags |= EffectFlags.EVALUATED
computed._value = value
dep.version++
}
} catch (err) {
dep.version++
throw err
} finally {
activeSub = prevSub
shouldTrack = prevShouldTrack
cleanupDeps(computed)
computed.flags &= ~EffectFlags.RUNNING
}
}
function removeSub(link: Link, soft = false) {
const { dep, prevSub, nextSub } = link
if (prevSub) {
prevSub.nextSub = nextSub
link.prevSub = undefined
}
if (nextSub) {
nextSub.prevSub = prevSub
link.nextSub = undefined
}
if (__DEV__ && dep.subsHead === link) {
// was previous head, point new head to next
dep.subsHead = nextSub
}
if (dep.subs === link) {
// was previous tail, point new tail to prev
dep.subs = prevSub
if (!prevSub && dep.computed) {
// if computed, unsubscribe it from all its deps so this computed and its
// value can be GCed
dep.computed.flags &= ~EffectFlags.TRACKING
for (let l = dep.computed.deps; l; l = l.nextDep) {
// here we are only "soft" unsubscribing because the computed still keeps
// referencing the deps and the dep should not decrease its sub count
removeSub(l, true)
}
}
}
if (!soft && !--dep.sc && dep.map) {
// #11979
// property dep no longer has effect subscribers, delete it
// this mostly is for the case where an object is kept in memory but only a
// subset of its properties is tracked at one time
dep.map.delete(dep.key)
}
}
function removeDep(link: Link) {
const { prevDep, nextDep } = link
if (prevDep) {
prevDep.nextDep = nextDep
link.prevDep = undefined
}
if (nextDep) {
nextDep.prevDep = prevDep
link.nextDep = undefined
}
}
export interface ReactiveEffectRunner<T = any> {
(): T
effect: ReactiveEffect
}
export function effect<T = any>(
fn: () => T,
options?: ReactiveEffectOptions,
): ReactiveEffectRunner<T> {
if ((fn as ReactiveEffectRunner).effect instanceof ReactiveEffect) {
fn = (fn as ReactiveEffectRunner).effect.fn
}
const e = new ReactiveEffect(fn)
if (options) {
extend(e, options)
}
try {
e.run()
} catch (err) {
e.stop()
throw err
}
const runner = e.run.bind(e) as ReactiveEffectRunner
runner.effect = e
return runner
}
/**
* Stops the effect associated with the given runner.
*
* @param runner - Association with the effect to stop tracking.
*/
export function stop(runner: ReactiveEffectRunner): void {
runner.effect.stop()
}
/**
* @internal
*/
export let shouldTrack = true
const trackStack: boolean[] = []
/**
* Temporarily pauses tracking.
*/
export function pauseTracking(): void {
trackStack.push(shouldTrack)
shouldTrack = false
}
/**
* Re-enables effect tracking (if it was paused).
*/
export function enableTracking(): void {
trackStack.push(shouldTrack)
shouldTrack = true
}
/**
* Resets the previous global effect tracking state.
*/
export function resetTracking(): void {
const last = trackStack.pop()
shouldTrack = last === undefined ? true : last
}
export function untrack<T>(fn: () => T): T {
try {
pauseTracking()
return fn()
} finally {
resetTracking()
}
}
/**
* Registers a cleanup function for the current active effect.
* The cleanup function is called right before the next effect run, or when the
* effect is stopped.
*
* Throws a warning if there is no current active effect. The warning can be
* suppressed by passing `true` to the second argument.
*
* @param fn - the cleanup function to be registered
* @param failSilently - if `true`, will not throw warning when called without
* an active effect.
*/
export function onEffectCleanup(fn: () => void, failSilently = false): void {
if (activeSub instanceof ReactiveEffect) {
activeSub.cleanup = fn
} else if (__DEV__ && !failSilently) {
warn(
`onEffectCleanup() was called when there was no active effect` +
` to associate with.`,
)
}
}
function cleanupEffect(e: ReactiveEffect) {
const { cleanup } = e
e.cleanup = undefined
if (cleanup) {
// run cleanup without active effect
const prevSub = activeSub
activeSub = undefined
try {
cleanup()
} finally {
activeSub = prevSub
}
}
}