refactor(scheduler): use bitwise flags for scheduler jobs + move scheduler into reactivity

related: https://github.com/vuejs/core/pull/10407
This commit is contained in:
Rizumu Ayaka 2024-03-17 22:33:36 +08:00
parent 174118ae40
commit db4040d13a
No known key found for this signature in database
8 changed files with 141 additions and 124 deletions

View File

@ -1,8 +1,9 @@
import type { Scheduler, SchedulerJob } from '../src/baseWatch'
import {
BaseWatchErrorCodes,
EffectScope,
type Ref,
type SchedulerJob,
type WatchScheduler,
baseWatch,
onEffectCleanup,
ref,
@ -15,9 +16,13 @@ let isFlushPending = false
const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
const nextTick = (fn?: () => any) =>
fn ? resolvedPromise.then(fn) : resolvedPromise
const scheduler: Scheduler = job => {
queue.push(job)
flushJobs()
const scheduler: WatchScheduler = (job, effect, immediateFirstRun, hasCb) => {
if (immediateFirstRun) {
!hasCb && effect.run()
} else {
queue.push(() => job(immediateFirstRun))
flushJobs()
}
}
const flushJobs = () => {
if (isFlushPending) return
@ -214,7 +219,11 @@ describe('baseWatch', () => {
},
)
expect(effectCalls).toEqual([])
expect(effectCalls).toEqual([
'before effect running',
'effect',
'effect ran',
])
expect(watchCalls).toEqual([])
await nextTick()
expect(effectCalls).toEqual([

View File

@ -22,7 +22,7 @@ import {
} from './effect'
import { isReactive, isShallow } from './reactive'
import { type Ref, isRef } from './ref'
import { getCurrentScope } from './effectScope'
import { type SchedulerJob, SchedulerJobFlags } from './scheduler'
// These errors were transferred from `packages/runtime-core/src/errorHandling.ts`
// along with baseWatch to maintain code compatibility. Hence,
@ -33,32 +33,6 @@ export enum BaseWatchErrorCodes {
WATCH_CLEANUP,
}
// TODO move to a scheduler package
export interface SchedulerJob extends Function {
id?: number
// TODO refactor these boolean flags to a single bitwise flag
pre?: boolean
active?: boolean
computed?: boolean
queued?: boolean
/**
* Indicates whether the effect 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
}
type WatchEffect = (onCleanup: OnCleanup) => void
type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T)
type WatchCallback<V = any, OV = any> = (
@ -254,8 +228,11 @@ export function baseWatch(
let oldValue: any = isMultiSource
? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE)
: INITIAL_WATCHER_VALUE
const job: SchedulerJob = () => {
if (!effect.active || !effect.dirty) {
const job: SchedulerJob = (immediateFirstRun?: boolean) => {
if (
!(effect.flags & EffectFlags.ACTIVE) ||
(!effect.dirty && !immediateFirstRun)
) {
return
}
if (cb) {
@ -310,11 +287,10 @@ export function baseWatch(
// important: mark the job as a watcher callback so that scheduler knows
// it is allowed to self-trigger (#1727)
job.allowRecurse = !!cb
if (cb) job.flags! |= SchedulerJobFlags.ALLOW_RECURSE
let effectScheduler: EffectScheduler = () => scheduler(job, effect, false)
effect = new ReactiveEffect(getter, NOOP, effectScheduler, scope)
effect = new ReactiveEffect(getter)
effect.scheduler = () => scheduler(job, effect, false, !!cb)
cleanup = effect.onStop = () => {
const cleanups = cleanupMap.get(effect)
@ -337,13 +313,14 @@ export function baseWatch(
// initial run
if (cb) {
scheduler(job, effect, true, !!cb)
if (immediate) {
job()
job(true)
} else {
oldValue = effect.run()
}
} else {
scheduler(job, effect, true)
scheduler(job, effect, true, !!cb)
}
return effect

View File

@ -1,6 +1,6 @@
import { SchedulerJobFlags } from '@vue/reactivity'
import {
type SchedulerJob,
SchedulerJobFlags,
flushPostFlushCbs,
flushPreFlushCbs,
invalidateJob,

View File

@ -14,12 +14,11 @@ import {
} from '../vnode'
import { warn } from '../warning'
import { isKeepAlive } from './KeepAlive'
import { toRaw } from '@vue/reactivity'
import { SchedulerJobFlags, toRaw } from '@vue/reactivity'
import { ErrorCodes, callWithAsyncErrorHandling } from '../errorHandling'
import { PatchFlags, ShapeFlags, isArray } from '@vue/shared'
import { onBeforeUnmount, onMounted } from '../apiLifecycle'
import type { RendererElement } from '../renderer'
import { SchedulerJobFlags } from '../scheduler'
type Hook<T = () => void> = T | T[]

View File

@ -40,7 +40,6 @@ import {
import {
type SchedulerFactory,
type SchedulerJob,
SchedulerJobFlags,
flushPostFlushCbs,
flushPreFlushCbs,
invalidateJob,
@ -50,6 +49,7 @@ import {
import {
EffectFlags,
ReactiveEffect,
SchedulerJobFlags,
pauseTracking,
resetTracking,
} from '@vue/reactivity'
@ -289,14 +289,14 @@ export const queuePostRenderEffect = __FEATURE_SUSPENSE__
: queuePostFlushCb
export const createPostRenderScheduler: SchedulerFactory =
instance => (job, effect, isInit) => {
if (isInit) {
instance => (job, effect, immediateFirstRun, hasCb) => {
if (!immediateFirstRun) {
queuePostRenderEffect(job, instance && instance.suspense)
} else if (!hasCb) {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense,
)
} else {
queuePostRenderEffect(job, instance && instance.suspense)
}
}

View File

@ -1,37 +1,14 @@
import { ErrorCodes, callWithErrorHandling, handleError } from './errorHandling'
import { type Awaited, NOOP, isArray } from '@vue/shared'
import { type ComponentInternalInstance, getComponentName } from './component'
import type { Scheduler } from '@vue/reactivity'
import {
type SchedulerJob as BaseSchedulerJob,
EffectFlags,
SchedulerJobFlags,
type WatchScheduler,
} from '@vue/reactivity'
export enum SchedulerJobFlags {
QUEUED = 1 << 0,
PRE = 1 << 1,
/**
* Indicates whether the effect 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).
*/
ALLOW_RECURSE = 1 << 2,
DISPOSED = 1 << 3,
}
export interface SchedulerJob extends Function {
id?: number
/**
* flags can technically be undefined, but it can still be used in bitwise
* operations just like 0.
*/
flags?: SchedulerJobFlags
export interface SchedulerJob extends BaseSchedulerJob {
/**
* Attached by renderer.ts when setting up a component's render effect
* Used to obtain component information when reporting max recursive updates.
@ -301,24 +278,25 @@ function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob) {
export type SchedulerFactory = (
instance: ComponentInternalInstance | null,
) => Scheduler
) => WatchScheduler
export const createSyncScheduler: SchedulerFactory =
instance => (job, effect, isInit) => {
if (isInit) {
effect.run()
instance => (job, effect, immediateFirstRun, hasCb) => {
if (immediateFirstRun) {
effect.flags |= EffectFlags.NO_BATCH
if (!hasCb) effect.run()
} else {
job()
}
}
export const createPreScheduler: SchedulerFactory =
instance => (job, effect, isInit) => {
if (isInit) {
effect.run()
} else {
job.pre = true
instance => (job, effect, immediateFirstRun, hasCb) => {
if (!immediateFirstRun) {
job.flags! |= SchedulerJobFlags.PRE
if (instance) job.id = instance.uid
queueJob(job)
} else if (!hasCb) {
effect.run()
}
}

View File

@ -38,11 +38,24 @@ describe('renderWatch', () => {
renderEffect(() => {
dummy = source.value
})
expect(dummy).toBe(0)
await nextTick()
expect(dummy).toBe(0)
source.value++
expect(dummy).toBe(0)
await nextTick()
expect(dummy).toBe(1)
source.value++
expect(dummy).toBe(1)
await nextTick()
expect(dummy).toBe(2)
source.value++
expect(dummy).toBe(2)
await nextTick()
expect(dummy).toBe(3)
})
test('watch', async () => {
@ -53,9 +66,16 @@ describe('renderWatch', () => {
})
await nextTick()
expect(dummy).toBe(undefined)
source.value++
expect(dummy).toBe(undefined)
await nextTick()
expect(dummy).toBe(1)
source.value++
expect(dummy).toBe(1)
await nextTick()
expect(dummy).toBe(2)
})
test('should run with the scheduling order', async () => {
@ -136,6 +156,28 @@ describe('renderWatch', () => {
'post 1',
'updated 1',
])
calls.length = 0
// Update
changeRender()
change()
expect(calls).toEqual(['sync cleanup 1', 'sync 2'])
calls.length = 0
await nextTick()
expect(calls).toEqual([
'pre cleanup 1',
'pre 2',
'beforeUpdate 2',
'renderEffect cleanup 1',
'renderEffect 2',
'renderWatch cleanup 1',
'renderWatch 2',
'post cleanup 1',
'post 2',
'updated 2',
])
})
test('errors should include the execution location with beforeUpdate hook', async () => {

View File

@ -1,4 +1,9 @@
import type { Scheduler, SchedulerJob } from '@vue/reactivity'
import {
EffectFlags,
type SchedulerJob,
SchedulerJobFlags,
type WatchScheduler,
} from '@vue/reactivity'
import type { ComponentInternalInstance } from './component'
import { isArray } from '@vue/shared'
@ -28,19 +33,21 @@ const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null
function queueJob(job: SchedulerJob) {
if (!job.queued) {
if (!(job.flags! & SchedulerJobFlags.QUEUED)) {
if (job.id == null) {
queue.push(job)
} else {
} else if (
// fast path when the job id is larger than the tail
if (!job.pre && job.id >= (queue[queue.length - 1]?.id || 0)) {
queue.push(job)
} else {
queue.splice(findInsertionIndex(job.id), 0, job)
}
!(job.flags! & SchedulerJobFlags.PRE) &&
job.id >= (queue[queue.length - 1]?.id || 0)
) {
queue.push(job)
} else {
queue.splice(findInsertionIndex(job.id), 0, job)
}
if (!job.allowRecurse) {
job.queued = true
if (!(job.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
job.flags! |= SchedulerJobFlags.QUEUED
}
queueFlush()
}
@ -48,10 +55,10 @@ function queueJob(job: SchedulerJob) {
export function queuePostRenderEffect(cb: SchedulerJobs) {
if (!isArray(cb)) {
if (!cb.queued) {
if (!(cb.flags! & SchedulerJobFlags.QUEUED)) {
pendingPostFlushCbs.push(cb)
if (!cb.allowRecurse) {
cb.queued = true
if (!(cb.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
cb.flags! |= SchedulerJobFlags.QUEUED
}
}
} else {
@ -92,7 +99,7 @@ export function flushPostFlushCbs() {
postFlushIndex++
) {
activePostFlushCbs[postFlushIndex]()
activePostFlushCbs[postFlushIndex].queued = false
activePostFlushCbs[postFlushIndex].flags! &= ~SchedulerJobFlags.QUEUED
}
activePostFlushCbs = null
postFlushIndex = 0
@ -114,8 +121,8 @@ function flushJobs() {
try {
for (let i = 0; i < queue!.length; i++) {
queue![i]()
queue![i].queued = false
queue[i]()
queue[i].flags! &= ~SchedulerJobFlags.QUEUED
}
} finally {
flushIndex = 0
@ -154,7 +161,10 @@ function findInsertionIndex(id: number) {
const middle = (start + end) >>> 1
const middleJob = queue[middle]
const middleJobId = getId(middleJob)
if (middleJobId < id || (middleJobId === id && middleJob.pre)) {
if (
middleJobId < id ||
(middleJobId === id && middleJob.flags! & SchedulerJobFlags.PRE)
) {
start = middle + 1
} else {
end = middle
@ -170,52 +180,54 @@ const getId = (job: SchedulerJob): number =>
const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
const diff = getId(a) - getId(b)
if (diff === 0) {
if (a.pre && !b.pre) return -1
if (b.pre && !a.pre) return 1
const isAPre = a.flags! & SchedulerJobFlags.PRE
const isBPre = b.flags! & SchedulerJobFlags.PRE
if (isAPre && !isBPre) return -1
if (isBPre && !isAPre) return 1
}
return diff
}
export type SchedulerFactory = (
instance: ComponentInternalInstance | null,
) => Scheduler
) => WatchScheduler
export const createVaporSyncScheduler: SchedulerFactory =
() => (job, effect, isInit) => {
if (isInit) {
effect.run()
instance => (job, effect, immediateFirstRun, hasCb) => {
if (immediateFirstRun) {
effect.flags |= EffectFlags.NO_BATCH
if (!hasCb) effect.run()
} else {
job()
}
}
export const createVaporPreScheduler: SchedulerFactory =
instance => (job, effect, isInit) => {
if (isInit) {
effect.run()
} else {
job.pre = true
instance => (job, effect, immediateFirstRun, hasCb) => {
if (!immediateFirstRun) {
job.flags! |= SchedulerJobFlags.PRE
if (instance) job.id = instance.uid
queueJob(job)
} else if (!hasCb) {
effect.run()
}
}
export const createVaporRenderingScheduler: SchedulerFactory =
instance => (job, effect, isInit) => {
if (isInit) {
effect.run()
} else {
job.pre = false
instance => (job, effect, immediateFirstRun, hasCb) => {
if (!immediateFirstRun) {
if (instance) job.id = instance.uid
queueJob(job)
} else if (!hasCb) {
effect.run()
}
}
export const createVaporPostScheduler: SchedulerFactory =
() => (job, effect, isInit) => {
if (isInit) {
queuePostRenderEffect(effect.run.bind(effect))
} else {
instance => (job, effect, immediateFirstRun, hasCb) => {
if (!immediateFirstRun) {
queuePostRenderEffect(job)
} else if (!hasCb) {
queuePostRenderEffect(effect.run.bind(effect))
}
}