refactor: document new scheduler

This commit is contained in:
Evan You 2018-11-12 12:42:35 -05:00
parent cb7ad12ed4
commit 2f3ddf20b5
3 changed files with 241 additions and 142 deletions

View File

@ -9,8 +9,8 @@ import {
queueJob, queueJob,
handleSchedulerError, handleSchedulerError,
nextTick, nextTick,
queuePostCommitCb, queueEffect,
flushPostCommitCbs, flushEffects,
queueNodeOp queueNodeOp
} from '@vue/scheduler' } from '@vue/scheduler'
import { VNodeFlags, ChildrenFlags } from './flags' import { VNodeFlags, ChildrenFlags } from './flags'
@ -188,12 +188,12 @@ export function createRenderer(options: RendererOptions) {
insertOrAppend(container, el, endNode) insertOrAppend(container, el, endNode)
} }
if (ref) { if (ref) {
queuePostCommitCb(() => { queueEffect(() => {
ref(el) ref(el)
}) })
} }
if (data != null && data.vnodeMounted) { if (data != null && data.vnodeMounted) {
queuePostCommitCb(() => { queueEffect(() => {
data.vnodeMounted(vnode) data.vnodeMounted(vnode)
}) })
} }
@ -268,7 +268,7 @@ export function createRenderer(options: RendererOptions) {
const subTree = (handle.prevTree = vnode.children = renderFunctionalRoot( const subTree = (handle.prevTree = vnode.children = renderFunctionalRoot(
vnode vnode
)) ))
queuePostCommitCb(() => { queueEffect(() => {
vnode.el = subTree.el as RenderNode vnode.el = subTree.el as RenderNode
}) })
mount(subTree, container, vnode as MountedVNode, isSVG, endNode) mount(subTree, container, vnode as MountedVNode, isSVG, endNode)
@ -308,7 +308,7 @@ export function createRenderer(options: RendererOptions) {
const nextTree = (handle.prevTree = current.children = renderFunctionalRoot( const nextTree = (handle.prevTree = current.children = renderFunctionalRoot(
current current
)) ))
queuePostCommitCb(() => { queueEffect(() => {
current.el = nextTree.el current.el = nextTree.el
}) })
patch( patch(
@ -344,7 +344,7 @@ export function createRenderer(options: RendererOptions) {
const { children, childFlags } = vnode const { children, childFlags } = vnode
switch (childFlags) { switch (childFlags) {
case ChildrenFlags.SINGLE_VNODE: case ChildrenFlags.SINGLE_VNODE:
queuePostCommitCb(() => { queueEffect(() => {
vnode.el = (children as MountedVNode).el vnode.el = (children as MountedVNode).el
}) })
mount(children as VNode, container, contextVNode, isSVG, endNode) mount(children as VNode, container, contextVNode, isSVG, endNode)
@ -355,7 +355,7 @@ export function createRenderer(options: RendererOptions) {
vnode.el = placeholder.el vnode.el = placeholder.el
break break
default: default:
queuePostCommitCb(() => { queueEffect(() => {
vnode.el = (children as MountedVNode[])[0].el vnode.el = (children as MountedVNode[])[0].el
}) })
mountArrayChildren( mountArrayChildren(
@ -392,7 +392,7 @@ export function createRenderer(options: RendererOptions) {
) )
} }
if (ref) { if (ref) {
queuePostCommitCb(() => { queueEffect(() => {
ref(target) ref(target)
}) })
} }
@ -607,7 +607,7 @@ export function createRenderer(options: RendererOptions) {
// then retrieve its next sibling to use as the end node for patchChildren. // then retrieve its next sibling to use as the end node for patchChildren.
const endNode = platformNextSibling(getVNodeLastEl(prevVNode)) const endNode = platformNextSibling(getVNodeLastEl(prevVNode))
const { childFlags, children } = nextVNode const { childFlags, children } = nextVNode
queuePostCommitCb(() => { queueEffect(() => {
switch (childFlags) { switch (childFlags) {
case ChildrenFlags.SINGLE_VNODE: case ChildrenFlags.SINGLE_VNODE:
nextVNode.el = (children as MountedVNode).el nextVNode.el = (children as MountedVNode).el
@ -1280,7 +1280,7 @@ export function createRenderer(options: RendererOptions) {
instance.$vnode = renderInstanceRoot(instance) as MountedVNode instance.$vnode = renderInstanceRoot(instance) as MountedVNode
queuePostCommitCb(() => { queueEffect(() => {
vnode.el = instance.$vnode.el vnode.el = instance.$vnode.el
if (__COMPAT__) { if (__COMPAT__) {
// expose __vue__ for devtools // expose __vue__ for devtools
@ -1337,7 +1337,7 @@ export function createRenderer(options: RendererOptions) {
const nextVNode = renderInstanceRoot(instance) as MountedVNode const nextVNode = renderInstanceRoot(instance) as MountedVNode
queuePostCommitCb(() => { queueEffect(() => {
instance.$vnode = nextVNode instance.$vnode = nextVNode
const el = nextVNode.el as RenderNode const el = nextVNode.el as RenderNode
if (__COMPAT__) { if (__COMPAT__) {
@ -1426,7 +1426,7 @@ export function createRenderer(options: RendererOptions) {
if (__DEV__) { if (__DEV__) {
popWarningContext() popWarningContext()
} }
queuePostCommitCb(() => { queueEffect(() => {
callActivatedHook(instance, true) callActivatedHook(instance, true)
}) })
} }
@ -1510,7 +1510,7 @@ export function createRenderer(options: RendererOptions) {
} }
} }
if (__COMPAT__) { if (__COMPAT__) {
flushPostCommitCbs() flushEffects()
return vnode && vnode.flags & VNodeFlags.COMPONENT_STATEFUL return vnode && vnode.flags & VNodeFlags.COMPONENT_STATEFUL
? (vnode.children as ComponentInstance).$proxy ? (vnode.children as ComponentInstance).$proxy
: null : null

View File

@ -1,4 +1,4 @@
import { queueJob, queuePostCommitCb, nextTick } from '../src/index' import { queueJob, queueEffect, nextTick } from '../src/index'
describe('scheduler', () => { describe('scheduler', () => {
it('queueJob', async () => { it('queueJob', async () => {
@ -36,11 +36,11 @@ describe('scheduler', () => {
const calls: any = [] const calls: any = []
const job1 = () => { const job1 = () => {
calls.push('job1') calls.push('job1')
queuePostCommitCb(cb1) queueEffect(cb1)
} }
const job2 = () => { const job2 = () => {
calls.push('job2') calls.push('job2')
queuePostCommitCb(cb2) queueEffect(cb2)
} }
const cb1 = () => { const cb1 = () => {
calls.push('cb1') calls.push('cb1')
@ -59,13 +59,13 @@ describe('scheduler', () => {
const calls: any = [] const calls: any = []
const job1 = () => { const job1 = () => {
calls.push('job1') calls.push('job1')
queuePostCommitCb(cb1) queueEffect(cb1)
// job1 queues job2 // job1 queues job2
queueJob(job2) queueJob(job2)
} }
const job2 = () => { const job2 = () => {
calls.push('job2') calls.push('job2')
queuePostCommitCb(cb2) queueEffect(cb2)
} }
const cb1 = () => { const cb1 = () => {
calls.push('cb1') calls.push('cb1')
@ -100,7 +100,7 @@ describe('scheduler', () => {
const calls: any = [] const calls: any = []
const job1 = () => { const job1 = () => {
calls.push('job1') calls.push('job1')
queuePostCommitCb(cb1) queueEffect(cb1)
} }
const cb1 = () => { const cb1 = () => {
// queue another job in postFlushCb // queue another job in postFlushCb
@ -109,7 +109,7 @@ describe('scheduler', () => {
} }
const job2 = () => { const job2 = () => {
calls.push('job2') calls.push('job2')
queuePostCommitCb(cb2) queueEffect(cb2)
} }
const cb2 = () => { const cb2 = () => {
calls.push('cb2') calls.push('cb2')

View File

@ -1,51 +1,150 @@
// TODO infinite updates detection // TODO infinite updates detection
// A data structure that stores a deferred DOM operation.
// the first element is the function to call, and the rest of the array
// stores up to 3 arguments.
type Op = [Function, ...any[]] type Op = [Function, ...any[]]
const enum Priorities { // A "job" stands for a unit of work that needs to be performed.
NORMAL = 500 // Typically, one job corresponds to the mounting or updating of one component
// instance (including functional ones).
interface Job<T extends Function = () => void> {
// A job is itself a function that performs work. It can contain work such as
// calling render functions, running the diff algorithm (patch), mounting new
// vnodes, and tearing down old vnodes. However, these work needs to be
// performed in several different phases, most importantly to separate
// workloads that do not produce side-effects ("stage") vs. those that do
// ("commit").
// During the stage call it should not perform any direct sife-effects.
// Instead, it buffers them. All side effects from multiple jobs queued in the
// same tick are flushed together during the "commit" phase. This allows us to
// perform side-effect-free work over multiple frames (yielding to the browser
// in-between to keep the app responsive), and only flush all the side effects
// together when all work is done (AKA time-slicing).
(): T
// A job's status changes over the different update phaes. See comments for
// phases below.
status: JobStatus
// Any operations performed by the job that directly mutates the DOM are
// buffered inside the job's ops queue, and only flushed in the commit phase.
// These ops are queued by calling `queueNodeOp` inside the job function.
ops: Op[]
// Any post DOM mutation side-effects (updated / mounted hooks, refs) are
// buffered inside the job's effects queue.
// Effects are queued by calling `queueEffect` inside the job function.
effects: Function[]
// A job may queue other jobs (e.g. a parent component update triggers the
// update of a child component). Jobs queued by another job is kept in the
// parent's children array, so that in case the parent job is invalidated,
// all its children can be invalidated as well (recursively).
children: Job[]
// Sometimes it's inevitable for a stage fn to produce some side effects
// (e.g. a component instance sets up an Autorun). In those cases the stage fn
// can return a cleanup function which will be called when the job is
// invalidated.
cleanup: T | null
// The expiration time is a timestamp past which the job needs to
// be force-committed regardless of frame budget.
// Why do we need an expiration time? Because a job may get invalidated before
// it is fully commited. If it keeps getting invalidated, we may "starve" the
// system and never apply any commits as jobs keep getting invalidated. The
// expiration time sets a limit on how long before a job can keep getting
// invalidated before it must be comitted.
expiration: number
} }
const enum JobStatus { const enum JobStatus {
IDLE = 0, IDLE = 0,
PENDING_PATCH, PENDING_STAGE,
PENDING_COMMIT PENDING_COMMIT
} }
interface Job extends Function { // Priorities for different types of jobs. This number is added to the
status: JobStatus // current time when a new job is queued to calculate the expiration time
ops: Op[] // for that job.
post: Function[] //
children: Job[] // Currently we have only one type which expires 500ms after it is initially
cleanup: Function | null // queued. There could be higher/lower priorities in the future.
expiration: number const enum JobPriorities {
NORMAL = 500
} }
// There can be only one job being patched at one time. This allows us to
// automatically "capture" and buffer the node ops and post effects queued
// during a job.
let currentJob: Job | null = null
// Indicates we have a flush pending.
let hasPendingFlush = false
// A timestamp that indicates when a flush was started.
let flushStartTimestamp: number = 0
// The frame budget is the maximum amount of time passed while performing
// "stage" work before we need to yield back to the browser.
// Aiming for 60fps. Maybe we need to dynamically adjust this?
const frameBudget = __JSDOM__ ? Infinity : 1000 / 60
const getNow = () => performance.now()
// An entire update consists of 4 phases:
// 1. Stage phase. Render functions are called, diffs are performed, new
// component instances are created. However, no side-effects should be
// performed (i.e. no lifecycle hooks, no direct DOM operations).
const stageQueue: Job[] = []
// 2. Commit phase. This is only reached when the stageQueue has been depleted.
// Node ops are applied - in the browser, this means DOM is actually mutated
// during this phase. If a job is committed, it's post effects are then
// queued for the next phase.
const commitQueue: Job[] = []
// 3. Post-commit effects phase. Effect callbacks are only queued after a
// successful commit. These include callbacks that need to be invoked
// after DOM mutation - i.e. refs, mounted & updated hooks. This queue is
// flushed in reverse because child component effects are queued after but
// should be invoked before the parent's.
const postEffectsQueue: Function[] = []
// 4. NextTick phase. This is the user's catch-all mechanism for deferring
// work after a complete update cycle.
const nextTickQueue: Function[] = []
const pendingRejectors: ErrorHandler[] = []
// Error handling --------------------------------------------------------------
type ErrorHandler = (err: Error) => any type ErrorHandler = (err: Error) => any
let currentJob: Job | null = null
let start: number = 0
const getNow = () => performance.now()
const frameBudget = __JSDOM__ ? Infinity : 1000 / 60
const patchQueue: Job[] = []
const commitQueue: Job[] = []
const postCommitQueue: Function[] = []
const nextTickQueue: Function[] = []
let globalHandler: ErrorHandler let globalHandler: ErrorHandler
const pendingRejectors: ErrorHandler[] = []
// Microtask for batching state mutations export function handleSchedulerError(handler: ErrorHandler) {
globalHandler = handler
}
function handleError(err: Error) {
if (globalHandler) globalHandler(err)
pendingRejectors.forEach(handler => {
handler(err)
})
}
// Microtask defer -------------------------------------------------------------
// For batching state mutations before we start an update. This does
// NOT yield to the browser.
const p = Promise.resolve() const p = Promise.resolve()
function flushAfterMicroTask() { function flushAfterMicroTask() {
start = getNow() flushStartTimestamp = getNow()
return p.then(flush).catch(handleError) return p.then(flush).catch(handleError)
} }
// Macrotask for time slicing // Macrotask defer -------------------------------------------------------------
// For time slicing. This uses the window postMessage event to "yield"
// to the browser so that other user events can trigger in between. This keeps
// the app responsive even when performing large amount of JavaScript work.
const key = `$vueTick` const key = `$vueTick`
window.addEventListener( window.addEventListener(
@ -54,7 +153,7 @@ window.addEventListener(
if (event.source !== window || event.data !== key) { if (event.source !== window || event.data !== key) {
return return
} }
start = getNow() flushStartTimestamp = getNow()
try { try {
flush() flush()
} catch (e) { } catch (e) {
@ -68,6 +167,65 @@ function flushAfterMacroTask() {
window.postMessage(key, `*`) window.postMessage(key, `*`)
} }
// API -------------------------------------------------------------------------
// This is the main API of the scheduler. The raw job can actually be any
// function, but since they are invalidated by identity, it is important that
// a component's update job is a consistent function across its lifecycle -
// in the renderer, it's actually instance._updateHandle which is in turn
// an Autorun function.
export function queueJob(rawJob: Function) {
const job = rawJob as Job
if (currentJob) {
currentJob.children.push(job)
}
// Let's see if this invalidates any work that
// has already been staged.
if (job.status === JobStatus.PENDING_COMMIT) {
// staged job invalidated
invalidateJob(job)
// re-insert it into the stage queue
requeueInvalidatedJob(job)
} else if (job.status !== JobStatus.PENDING_STAGE) {
// a new job
queueJobForStaging(job)
}
if (!hasPendingFlush) {
hasPendingFlush = true
flushAfterMicroTask()
}
}
export function queueEffect(fn: Function) {
if (currentJob) {
currentJob.effects.push(fn)
} else {
postEffectsQueue.push(fn)
}
}
export function flushEffects() {
// post commit hooks (updated, mounted)
// this queue is flushed in reverse becuase these hooks should be invoked
// child first
let i = postEffectsQueue.length
while (i--) {
postEffectsQueue[i]()
}
postEffectsQueue.length = 0
}
export function queueNodeOp(op: Op) {
if (currentJob) {
currentJob.ops.push(op)
} else {
applyOp(op)
}
}
// The original nextTick now needs to be reworked so that the callback only
// triggers after the next commit, when all node ops and post effects have been
// completed.
export function nextTick<T>(fn?: () => T): Promise<T> { export function nextTick<T>(fn?: () => T): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
p.then(() => { p.then(() => {
@ -86,108 +244,35 @@ export function nextTick<T>(fn?: () => T): Promise<T> {
}) })
} }
function handleError(err: Error) { // Internals -------------------------------------------------------------------
if (globalHandler) globalHandler(err)
pendingRejectors.forEach(handler => {
handler(err)
})
}
export function handleSchedulerError(handler: ErrorHandler) {
globalHandler = handler
}
let hasPendingFlush = false
export function queueJob(rawJob: Function) {
const job = rawJob as Job
if (currentJob) {
currentJob.children.push(job)
}
// 1. let's see if this invalidates any work that
// has already been done.
if (job.status === JobStatus.PENDING_COMMIT) {
// pending commit job invalidated
invalidateJob(job)
requeueInvalidatedJob(job)
} else if (job.status !== JobStatus.PENDING_PATCH) {
// a new job
insertNewJob(job)
}
if (!hasPendingFlush) {
hasPendingFlush = true
flushAfterMicroTask()
}
}
function requeueInvalidatedJob(job: Job) {
// With varying priorities we should insert job at correct position
// based on expiration time.
for (let i = 0; i < patchQueue.length; i++) {
if (job.expiration < patchQueue[i].expiration) {
patchQueue.splice(i, 0, job)
job.status = JobStatus.PENDING_PATCH
return
}
}
patchQueue.push(job)
job.status = JobStatus.PENDING_PATCH
}
export function queuePostCommitCb(fn: Function) {
if (currentJob) {
currentJob.post.push(fn)
} else {
postCommitQueue.push(fn)
}
}
export function flushPostCommitCbs() {
// post commit hooks (updated, mounted)
// this queue is flushed in reverse becuase these hooks should be invoked
// child first
let i = postCommitQueue.length
while (i--) {
postCommitQueue[i]()
}
postCommitQueue.length = 0
}
export function queueNodeOp(op: Op) {
if (currentJob) {
currentJob.ops.push(op)
} else {
applyOp(op)
}
}
function flush(): void { function flush(): void {
let job let job
while (true) { while (true) {
job = patchQueue.shift() job = stageQueue.shift()
if (job) { if (job) {
patchJob(job) stageJob(job)
} else { } else {
break break
} }
if (!__COMPAT__) { if (!__COMPAT__) {
const now = getNow() const now = getNow()
if (now - start > frameBudget && job.expiration > now) { if (now - flushStartTimestamp > frameBudget && job.expiration > now) {
break break
} }
} }
} }
if (patchQueue.length === 0) { if (stageQueue.length === 0) {
// all done, time to commit! // all done, time to commit!
for (let i = 0; i < commitQueue.length; i++) { for (let i = 0; i < commitQueue.length; i++) {
commitJob(commitQueue[i]) commitJob(commitQueue[i])
} }
commitQueue.length = 0 commitQueue.length = 0
flushPostCommitCbs() flushEffects()
// some post commit hook triggered more updates... // some post commit hook triggered more updates...
if (patchQueue.length > 0) { if (stageQueue.length > 0) {
if (!__COMPAT__ && getNow() - start > frameBudget) { if (!__COMPAT__ && getNow() - flushStartTimestamp > frameBudget) {
return flushAfterMacroTask() return flushAfterMacroTask()
} else { } else {
// not out of budget yet, flush sync // not out of budget yet, flush sync
@ -203,29 +288,29 @@ function flush(): void {
nextTickQueue.length = 0 nextTickQueue.length = 0
} else { } else {
// got more job to do // got more job to do
// shouldn't reach here in compat mode, because the patchQueue is // shouldn't reach here in compat mode, because the stageQueue is
// guarunteed to be drained // guarunteed to have been depleted
flushAfterMacroTask() flushAfterMacroTask()
} }
} }
function resetJob(job: Job) { function resetJob(job: Job) {
job.ops.length = 0 job.ops.length = 0
job.post.length = 0 job.effects.length = 0
job.children.length = 0 job.children.length = 0
} }
function insertNewJob(job: Job) { function queueJobForStaging(job: Job) {
job.ops = job.ops || [] job.ops = job.ops || []
job.post = job.post || [] job.effects = job.effects || []
job.children = job.children || [] job.children = job.children || []
resetJob(job) resetJob(job)
// inherit parent job's expiration deadline // inherit parent job's expiration deadline
job.expiration = currentJob job.expiration = currentJob
? currentJob.expiration ? currentJob.expiration
: getNow() + Priorities.NORMAL : getNow() + JobPriorities.NORMAL
patchQueue.push(job) stageQueue.push(job)
job.status = JobStatus.PENDING_PATCH job.status = JobStatus.PENDING_STAGE
} }
function invalidateJob(job: Job) { function invalidateJob(job: Job) {
@ -235,8 +320,8 @@ function invalidateJob(job: Job) {
const child = children[i] const child = children[i]
if (child.status === JobStatus.PENDING_COMMIT) { if (child.status === JobStatus.PENDING_COMMIT) {
invalidateJob(child) invalidateJob(child)
} else if (child.status === JobStatus.PENDING_PATCH) { } else if (child.status === JobStatus.PENDING_STAGE) {
patchQueue.splice(patchQueue.indexOf(child), 1) stageQueue.splice(stageQueue.indexOf(child), 1)
child.status = JobStatus.IDLE child.status = JobStatus.IDLE
} }
} }
@ -250,7 +335,21 @@ function invalidateJob(job: Job) {
job.status = JobStatus.IDLE job.status = JobStatus.IDLE
} }
function patchJob(job: Job) { function requeueInvalidatedJob(job: Job) {
// With varying priorities we should insert job at correct position
// based on expiration time.
for (let i = 0; i < stageQueue.length; i++) {
if (job.expiration < stageQueue[i].expiration) {
stageQueue.splice(i, 0, job)
job.status = JobStatus.PENDING_STAGE
return
}
}
stageQueue.push(job)
job.status = JobStatus.PENDING_STAGE
}
function stageJob(job: Job) {
// job with existing ops means it's already been patched in a low priority queue // job with existing ops means it's already been patched in a low priority queue
if (job.ops.length === 0) { if (job.ops.length === 0) {
currentJob = job currentJob = job
@ -262,13 +361,13 @@ function patchJob(job: Job) {
} }
function commitJob(job: Job) { function commitJob(job: Job) {
const { ops, post } = job const { ops, effects } = job
for (let i = 0; i < ops.length; i++) { for (let i = 0; i < ops.length; i++) {
applyOp(ops[i]) applyOp(ops[i])
} }
// queue post commit cbs // queue post commit cbs
if (post) { if (effects) {
postCommitQueue.push(...post) postEffectsQueue.push(...effects)
} }
resetJob(job) resetJob(job)
job.status = JobStatus.IDLE job.status = JobStatus.IDLE