]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
refactor: document new scheduler
authorEvan You <yyx990803@gmail.com>
Mon, 12 Nov 2018 17:42:35 +0000 (12:42 -0500)
committerEvan You <yyx990803@gmail.com>
Mon, 12 Nov 2018 17:42:35 +0000 (12:42 -0500)
packages/runtime-core/src/createRenderer.ts
packages/scheduler/__tests__/scheduler.spec.ts
packages/scheduler/src/index.ts

index 215b1a62c20b5ddc6d9c8add3b21b641f70d0b32..9b9021ce9b66bc74e2bcd762ca910da179686627 100644 (file)
@@ -9,8 +9,8 @@ import {
   queueJob,
   handleSchedulerError,
   nextTick,
-  queuePostCommitCb,
-  flushPostCommitCbs,
+  queueEffect,
+  flushEffects,
   queueNodeOp
 } from '@vue/scheduler'
 import { VNodeFlags, ChildrenFlags } from './flags'
@@ -188,12 +188,12 @@ export function createRenderer(options: RendererOptions) {
       insertOrAppend(container, el, endNode)
     }
     if (ref) {
-      queuePostCommitCb(() => {
+      queueEffect(() => {
         ref(el)
       })
     }
     if (data != null && data.vnodeMounted) {
-      queuePostCommitCb(() => {
+      queueEffect(() => {
         data.vnodeMounted(vnode)
       })
     }
@@ -268,7 +268,7 @@ export function createRenderer(options: RendererOptions) {
             const subTree = (handle.prevTree = vnode.children = renderFunctionalRoot(
               vnode
             ))
-            queuePostCommitCb(() => {
+            queueEffect(() => {
               vnode.el = subTree.el as RenderNode
             })
             mount(subTree, container, vnode as MountedVNode, isSVG, endNode)
@@ -308,7 +308,7 @@ export function createRenderer(options: RendererOptions) {
     const nextTree = (handle.prevTree = current.children = renderFunctionalRoot(
       current
     ))
-    queuePostCommitCb(() => {
+    queueEffect(() => {
       current.el = nextTree.el
     })
     patch(
@@ -344,7 +344,7 @@ export function createRenderer(options: RendererOptions) {
     const { children, childFlags } = vnode
     switch (childFlags) {
       case ChildrenFlags.SINGLE_VNODE:
-        queuePostCommitCb(() => {
+        queueEffect(() => {
           vnode.el = (children as MountedVNode).el
         })
         mount(children as VNode, container, contextVNode, isSVG, endNode)
@@ -355,7 +355,7 @@ export function createRenderer(options: RendererOptions) {
         vnode.el = placeholder.el
         break
       default:
-        queuePostCommitCb(() => {
+        queueEffect(() => {
           vnode.el = (children as MountedVNode[])[0].el
         })
         mountArrayChildren(
@@ -392,7 +392,7 @@ export function createRenderer(options: RendererOptions) {
       )
     }
     if (ref) {
-      queuePostCommitCb(() => {
+      queueEffect(() => {
         ref(target)
       })
     }
@@ -607,7 +607,7 @@ export function createRenderer(options: RendererOptions) {
     // then retrieve its next sibling to use as the end node for patchChildren.
     const endNode = platformNextSibling(getVNodeLastEl(prevVNode))
     const { childFlags, children } = nextVNode
-    queuePostCommitCb(() => {
+    queueEffect(() => {
       switch (childFlags) {
         case ChildrenFlags.SINGLE_VNODE:
           nextVNode.el = (children as MountedVNode).el
@@ -1280,7 +1280,7 @@ export function createRenderer(options: RendererOptions) {
 
         instance.$vnode = renderInstanceRoot(instance) as MountedVNode
 
-        queuePostCommitCb(() => {
+        queueEffect(() => {
           vnode.el = instance.$vnode.el
           if (__COMPAT__) {
             // expose __vue__ for devtools
@@ -1337,7 +1337,7 @@ export function createRenderer(options: RendererOptions) {
 
     const nextVNode = renderInstanceRoot(instance) as MountedVNode
 
-    queuePostCommitCb(() => {
+    queueEffect(() => {
       instance.$vnode = nextVNode
       const el = nextVNode.el as RenderNode
       if (__COMPAT__) {
@@ -1426,7 +1426,7 @@ export function createRenderer(options: RendererOptions) {
     if (__DEV__) {
       popWarningContext()
     }
-    queuePostCommitCb(() => {
+    queueEffect(() => {
       callActivatedHook(instance, true)
     })
   }
@@ -1510,7 +1510,7 @@ export function createRenderer(options: RendererOptions) {
       }
     }
     if (__COMPAT__) {
-      flushPostCommitCbs()
+      flushEffects()
       return vnode && vnode.flags & VNodeFlags.COMPONENT_STATEFUL
         ? (vnode.children as ComponentInstance).$proxy
         : null
index 159e0e614878d0bcc02c0925dd295814da7ad19c..e18881e609c7475e46e38d9566287a5e7eddd5aa 100644 (file)
@@ -1,4 +1,4 @@
-import { queueJob, queuePostCommitCb, nextTick } from '../src/index'
+import { queueJob, queueEffect, nextTick } from '../src/index'
 
 describe('scheduler', () => {
   it('queueJob', async () => {
@@ -36,11 +36,11 @@ describe('scheduler', () => {
     const calls: any = []
     const job1 = () => {
       calls.push('job1')
-      queuePostCommitCb(cb1)
+      queueEffect(cb1)
     }
     const job2 = () => {
       calls.push('job2')
-      queuePostCommitCb(cb2)
+      queueEffect(cb2)
     }
     const cb1 = () => {
       calls.push('cb1')
@@ -59,13 +59,13 @@ describe('scheduler', () => {
     const calls: any = []
     const job1 = () => {
       calls.push('job1')
-      queuePostCommitCb(cb1)
+      queueEffect(cb1)
       // job1 queues job2
       queueJob(job2)
     }
     const job2 = () => {
       calls.push('job2')
-      queuePostCommitCb(cb2)
+      queueEffect(cb2)
     }
     const cb1 = () => {
       calls.push('cb1')
@@ -100,7 +100,7 @@ describe('scheduler', () => {
     const calls: any = []
     const job1 = () => {
       calls.push('job1')
-      queuePostCommitCb(cb1)
+      queueEffect(cb1)
     }
     const cb1 = () => {
       // queue another job in postFlushCb
@@ -109,7 +109,7 @@ describe('scheduler', () => {
     }
     const job2 = () => {
       calls.push('job2')
-      queuePostCommitCb(cb2)
+      queueEffect(cb2)
     }
     const cb2 = () => {
       calls.push('cb2')
index aaadb567ea3fde7582afb37991c9542eafe3e4fe..73ae5a883248e039e0dbcb1cea32c8d5e110c9ec 100644 (file)
 // 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[]]
 
-const enum Priorities {
-  NORMAL = 500
+// A "job" stands for a unit of work that needs to be performed.
+// 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 {
   IDLE = 0,
-  PENDING_PATCH,
+  PENDING_STAGE,
   PENDING_COMMIT
 }
 
-interface Job extends Function {
-  status: JobStatus
-  ops: Op[]
-  post: Function[]
-  children: Job[]
-  cleanup: Function | null
-  expiration: number
+// Priorities for different types of jobs. This number is added to the
+// current time when a new job is queued to calculate the expiration time
+// for that job.
+//
+// Currently we have only one type which expires 500ms after it is initially
+// queued. There could be higher/lower priorities in the future.
+const enum JobPriorities {
+  NORMAL = 500
 }
 
-type ErrorHandler = (err: Error) => any
-
+// 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
 
-let start: number = 0
-const getNow = () => performance.now()
+// 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 patchQueue: Job[] = []
+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[] = []
-const postCommitQueue: Function[] = []
+
+// 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
 
 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()
 
 function flushAfterMicroTask() {
-  start = getNow()
+  flushStartTimestamp = getNow()
   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`
 
 window.addEventListener(
@@ -54,7 +153,7 @@ window.addEventListener(
     if (event.source !== window || event.data !== key) {
       return
     }
-    start = getNow()
+    flushStartTimestamp = getNow()
     try {
       flush()
     } catch (e) {
@@ -68,51 +167,28 @@ function flushAfterMacroTask() {
   window.postMessage(key, `*`)
 }
 
-export function nextTick<T>(fn?: () => T): Promise<T> {
-  return new Promise((resolve, reject) => {
-    p.then(() => {
-      if (hasPendingFlush) {
-        nextTickQueue.push(() => {
-          resolve(fn ? fn() : undefined)
-        })
-        pendingRejectors.push(err => {
-          if (fn) fn()
-          reject(err)
-        })
-      } else {
-        resolve(fn ? fn() : undefined)
-      }
-    }).catch(reject)
-  })
-}
-
-function handleError(err: Error) {
-  if (globalHandler) globalHandler(err)
-  pendingRejectors.forEach(handler => {
-    handler(err)
-  })
-}
-
-export function handleSchedulerError(handler: ErrorHandler) {
-  globalHandler = handler
-}
-
-let hasPendingFlush = false
+// 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)
   }
-  // 1. let's see if this invalidates any work that
-  // has already been done.
+  // Let's see if this invalidates any work that
+  // has already been staged.
   if (job.status === JobStatus.PENDING_COMMIT) {
-    // pending commit job invalidated
+    // staged job invalidated
     invalidateJob(job)
+    // re-insert it into the stage queue
     requeueInvalidatedJob(job)
-  } else if (job.status !== JobStatus.PENDING_PATCH) {
+  } else if (job.status !== JobStatus.PENDING_STAGE) {
     // a new job
-    insertNewJob(job)
+    queueJobForStaging(job)
   }
   if (!hasPendingFlush) {
     hasPendingFlush = true
@@ -120,37 +196,23 @@ export function queueJob(rawJob: Function) {
   }
 }
 
-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) {
+export function queueEffect(fn: Function) {
   if (currentJob) {
-    currentJob.post.push(fn)
+    currentJob.effects.push(fn)
   } else {
-    postCommitQueue.push(fn)
+    postEffectsQueue.push(fn)
   }
 }
 
-export function flushPostCommitCbs() {
+export function flushEffects() {
   // post commit hooks (updated, mounted)
   // this queue is flushed in reverse becuase these hooks should be invoked
   // child first
-  let i = postCommitQueue.length
+  let i = postEffectsQueue.length
   while (i--) {
-    postCommitQueue[i]()
+    postEffectsQueue[i]()
   }
-  postCommitQueue.length = 0
+  postEffectsQueue.length = 0
 }
 
 export function queueNodeOp(op: Op) {
@@ -161,33 +223,56 @@ export function queueNodeOp(op: 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> {
+  return new Promise((resolve, reject) => {
+    p.then(() => {
+      if (hasPendingFlush) {
+        nextTickQueue.push(() => {
+          resolve(fn ? fn() : undefined)
+        })
+        pendingRejectors.push(err => {
+          if (fn) fn()
+          reject(err)
+        })
+      } else {
+        resolve(fn ? fn() : undefined)
+      }
+    }).catch(reject)
+  })
+}
+
+// Internals -------------------------------------------------------------------
+
 function flush(): void {
   let job
   while (true) {
-    job = patchQueue.shift()
+    job = stageQueue.shift()
     if (job) {
-      patchJob(job)
+      stageJob(job)
     } else {
       break
     }
     if (!__COMPAT__) {
       const now = getNow()
-      if (now - start > frameBudget && job.expiration > now) {
+      if (now - flushStartTimestamp > frameBudget && job.expiration > now) {
         break
       }
     }
   }
 
-  if (patchQueue.length === 0) {
+  if (stageQueue.length === 0) {
     // all done, time to commit!
     for (let i = 0; i < commitQueue.length; i++) {
       commitJob(commitQueue[i])
     }
     commitQueue.length = 0
-    flushPostCommitCbs()
+    flushEffects()
     // some post commit hook triggered more updates...
-    if (patchQueue.length > 0) {
-      if (!__COMPAT__ && getNow() - start > frameBudget) {
+    if (stageQueue.length > 0) {
+      if (!__COMPAT__ && getNow() - flushStartTimestamp > frameBudget) {
         return flushAfterMacroTask()
       } else {
         // not out of budget yet, flush sync
@@ -203,29 +288,29 @@ function flush(): void {
     nextTickQueue.length = 0
   } else {
     // got more job to do
-    // shouldn't reach here in compat mode, because the patchQueue is
-    // guarunteed to be drained
+    // shouldn't reach here in compat mode, because the stageQueue is
+    // guarunteed to have been depleted
     flushAfterMacroTask()
   }
 }
 
 function resetJob(job: Job) {
   job.ops.length = 0
-  job.post.length = 0
+  job.effects.length = 0
   job.children.length = 0
 }
 
-function insertNewJob(job: Job) {
+function queueJobForStaging(job: Job) {
   job.ops = job.ops || []
-  job.post = job.post || []
+  job.effects = job.effects || []
   job.children = job.children || []
   resetJob(job)
   // inherit parent job's expiration deadline
   job.expiration = currentJob
     ? currentJob.expiration
-    : getNow() + Priorities.NORMAL
-  patchQueue.push(job)
-  job.status = JobStatus.PENDING_PATCH
+    : getNow() + JobPriorities.NORMAL
+  stageQueue.push(job)
+  job.status = JobStatus.PENDING_STAGE
 }
 
 function invalidateJob(job: Job) {
@@ -235,8 +320,8 @@ function invalidateJob(job: Job) {
     const child = children[i]
     if (child.status === JobStatus.PENDING_COMMIT) {
       invalidateJob(child)
-    } else if (child.status === JobStatus.PENDING_PATCH) {
-      patchQueue.splice(patchQueue.indexOf(child), 1)
+    } else if (child.status === JobStatus.PENDING_STAGE) {
+      stageQueue.splice(stageQueue.indexOf(child), 1)
       child.status = JobStatus.IDLE
     }
   }
@@ -250,7 +335,21 @@ function invalidateJob(job: Job) {
   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
   if (job.ops.length === 0) {
     currentJob = job
@@ -262,13 +361,13 @@ function patchJob(job: Job) {
 }
 
 function commitJob(job: Job) {
-  const { ops, post } = job
+  const { ops, effects } = job
   for (let i = 0; i < ops.length; i++) {
     applyOp(ops[i])
   }
   // queue post commit cbs
-  if (post) {
-    postCommitQueue.push(...post)
+  if (effects) {
+    postEffectsQueue.push(...effects)
   }
   resetJob(job)
   job.status = JobStatus.IDLE