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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,37 +1,14 @@
import { ErrorCodes, callWithErrorHandling, handleError } from './errorHandling' import { ErrorCodes, callWithErrorHandling, handleError } from './errorHandling'
import { type Awaited, NOOP, isArray } from '@vue/shared' import { type Awaited, NOOP, isArray } from '@vue/shared'
import { type ComponentInternalInstance, getComponentName } from './component' 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 { export interface SchedulerJob extends BaseSchedulerJob {
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
/** /**
* Attached by renderer.ts when setting up a component's render effect * Attached by renderer.ts when setting up a component's render effect
* Used to obtain component information when reporting max recursive updates. * Used to obtain component information when reporting max recursive updates.
@ -301,24 +278,25 @@ function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob) {
export type SchedulerFactory = ( export type SchedulerFactory = (
instance: ComponentInternalInstance | null, instance: ComponentInternalInstance | null,
) => Scheduler ) => WatchScheduler
export const createSyncScheduler: SchedulerFactory = export const createSyncScheduler: SchedulerFactory =
instance => (job, effect, isInit) => { instance => (job, effect, immediateFirstRun, hasCb) => {
if (isInit) { if (immediateFirstRun) {
effect.run() effect.flags |= EffectFlags.NO_BATCH
if (!hasCb) effect.run()
} else { } else {
job() job()
} }
} }
export const createPreScheduler: SchedulerFactory = export const createPreScheduler: SchedulerFactory =
instance => (job, effect, isInit) => { instance => (job, effect, immediateFirstRun, hasCb) => {
if (isInit) { if (!immediateFirstRun) {
effect.run() job.flags! |= SchedulerJobFlags.PRE
} else {
job.pre = true
if (instance) job.id = instance.uid if (instance) job.id = instance.uid
queueJob(job) queueJob(job)
} else if (!hasCb) {
effect.run()
} }
} }

View File

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