wip: scheduler, more component
This commit is contained in:
parent
7a92ee04a0
commit
5c069eeae7
@ -1,9 +1,82 @@
|
|||||||
import { VNode, normalizeVNode } from './vnode'
|
import { VNode, normalizeVNode, VNodeChild } from './vnode'
|
||||||
|
import { ReactiveEffect } from '@vue/observer'
|
||||||
|
import { isFunction, EMPTY_OBJ } from '@vue/shared'
|
||||||
|
|
||||||
export class Component {}
|
interface Value<T> {
|
||||||
|
value: T
|
||||||
|
}
|
||||||
|
|
||||||
export function renderComponentRoot(instance: any): VNode {
|
type UnwrapBindings<T> = {
|
||||||
return normalizeVNode(instance.render(instance.vnode.props))
|
[key in keyof T]: T[key] extends Value<infer V> ? V : T[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Prop<T> = { (): T } | { new (...args: any[]): T & object }
|
||||||
|
|
||||||
|
type ExtractPropTypes<PropOptions> = {
|
||||||
|
readonly [key in keyof PropOptions]: PropOptions[key] extends Prop<infer V>
|
||||||
|
? V
|
||||||
|
: PropOptions[key] extends null | undefined ? any : PropOptions[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComponentPublicProperties<P, S> {
|
||||||
|
$props: P
|
||||||
|
$state: S
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentOptions<
|
||||||
|
RawProps = { [key: string]: Prop<any> },
|
||||||
|
RawBindings = { [key: string]: any } | void,
|
||||||
|
Props = ExtractPropTypes<RawProps>,
|
||||||
|
Bindings = UnwrapBindings<RawBindings>
|
||||||
|
> {
|
||||||
|
props?: RawProps
|
||||||
|
setup?: (props: Props) => RawBindings
|
||||||
|
render?: <B extends Bindings>(
|
||||||
|
this: ComponentPublicProperties<Props, B>,
|
||||||
|
ctx: {
|
||||||
|
state: B
|
||||||
|
props: Props
|
||||||
|
}
|
||||||
|
) => VNodeChild
|
||||||
|
}
|
||||||
|
|
||||||
|
// no-op, for type inference only
|
||||||
|
export function createComponent<
|
||||||
|
RawProps,
|
||||||
|
RawBindings,
|
||||||
|
Props = ExtractPropTypes<RawProps>,
|
||||||
|
Bindings = UnwrapBindings<RawBindings>
|
||||||
|
>(
|
||||||
|
options: ComponentOptions<RawProps, RawBindings, Props, Bindings>
|
||||||
|
): {
|
||||||
|
// for TSX
|
||||||
|
new (): { $props: Props }
|
||||||
|
} {
|
||||||
|
return options as any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentHandle {
|
||||||
|
type: Function | ComponentOptions
|
||||||
|
vnode: VNode | null
|
||||||
|
next: VNode | null
|
||||||
|
subTree: VNode | null
|
||||||
|
update: ReactiveEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderComponentRoot(handle: ComponentHandle): VNode {
|
||||||
|
const { type, vnode } = handle
|
||||||
|
// TODO actually resolve props
|
||||||
|
const renderArg = {
|
||||||
|
props: (vnode as VNode).props || EMPTY_OBJ
|
||||||
|
}
|
||||||
|
if (isFunction(type)) {
|
||||||
|
return normalizeVNode(type(renderArg))
|
||||||
|
} else {
|
||||||
|
if (__DEV__ && !type.render) {
|
||||||
|
// TODO warn missing render
|
||||||
|
}
|
||||||
|
return normalizeVNode((type.render as Function)(renderArg))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldUpdateComponent(
|
export function shouldUpdateComponent(
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
// TODO:
|
// TODO:
|
||||||
// - component
|
// - component
|
||||||
// - lifecycle
|
// - lifecycle / refs
|
||||||
|
// - keep alive
|
||||||
// - app context
|
// - app context
|
||||||
// - svg
|
// - svg
|
||||||
// - refs
|
|
||||||
// - hydration
|
// - hydration
|
||||||
// - warning context
|
// - warning context
|
||||||
// - parent chain
|
// - parent chain
|
||||||
@ -13,17 +13,20 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Fragment,
|
Fragment,
|
||||||
Empty,
|
Empty,
|
||||||
|
Portal,
|
||||||
normalizeVNode,
|
normalizeVNode,
|
||||||
VNode,
|
VNode,
|
||||||
VNodeChildren
|
VNodeChildren
|
||||||
} from './vnode.js'
|
} from './vnode'
|
||||||
|
import { isString, isArray, EMPTY_OBJ, EMPTY_ARR } from '@vue/shared'
|
||||||
import { TEXT, CLASS, STYLE, PROPS, KEYED, UNKEYED } from './patchFlags'
|
import { TEXT, CLASS, STYLE, PROPS, KEYED, UNKEYED } from './patchFlags'
|
||||||
import { effect } from '@vue/observer'
|
import { effect, stop } from '@vue/observer'
|
||||||
import { isString, isFunction, isArray } from '@vue/shared'
|
import {
|
||||||
import { renderComponentRoot, shouldUpdateComponent } from './component.js'
|
ComponentHandle,
|
||||||
|
renderComponentRoot,
|
||||||
const emptyArr: any[] = []
|
shouldUpdateComponent
|
||||||
const emptyObj: { [key: string]: any } = {}
|
} from './component'
|
||||||
|
import { queueJob } from './scheduler'
|
||||||
|
|
||||||
function isSameType(n1: VNode, n2: VNode): boolean {
|
function isSameType(n1: VNode, n2: VNode): boolean {
|
||||||
return n1.type === n2.type && n1.key === n2.key
|
return n1.type === n2.type && n1.key === n2.key
|
||||||
@ -81,16 +84,26 @@ export function createRenderer(options: RendererOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { type } = n2
|
const { type } = n2
|
||||||
if (type === Text) {
|
switch (type) {
|
||||||
processText(n1, n2, container, anchor)
|
case Text:
|
||||||
} else if (type === Empty) {
|
processText(n1, n2, container, anchor)
|
||||||
processEmptyNode(n1, n2, container, anchor)
|
break
|
||||||
} else if (type === Fragment) {
|
case Empty:
|
||||||
processFragment(n1, n2, container, anchor, optimized)
|
processEmptyNode(n1, n2, container, anchor)
|
||||||
} else if (isFunction(type)) {
|
break
|
||||||
processComponent(n1, n2, container, anchor)
|
case Fragment:
|
||||||
} else {
|
processFragment(n1, n2, container, anchor, optimized)
|
||||||
processElement(n1, n2, container, anchor, optimized)
|
break
|
||||||
|
case Portal:
|
||||||
|
// TODO
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
if (isString(type)) {
|
||||||
|
processElement(n1, n2, container, anchor, optimized)
|
||||||
|
} else {
|
||||||
|
processComponent(n1, n2, container, anchor)
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,8 +185,8 @@ export function createRenderer(options: RendererOptions) {
|
|||||||
function patchElement(n1: VNode, n2: VNode, optimized?: boolean) {
|
function patchElement(n1: VNode, n2: VNode, optimized?: boolean) {
|
||||||
const el = (n2.el = n1.el)
|
const el = (n2.el = n1.el)
|
||||||
const { patchFlag, dynamicChildren } = n2
|
const { patchFlag, dynamicChildren } = n2
|
||||||
const oldProps = (n1 && n1.props) || emptyObj
|
const oldProps = (n1 && n1.props) || EMPTY_OBJ
|
||||||
const newProps = n2.props || emptyObj
|
const newProps = n2.props || EMPTY_OBJ
|
||||||
|
|
||||||
if (patchFlag != null) {
|
if (patchFlag != null) {
|
||||||
// the presence of a patchFlag means this element's render code was
|
// the presence of a patchFlag means this element's render code was
|
||||||
@ -272,7 +285,7 @@ export function createRenderer(options: RendererOptions) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldProps !== emptyObj) {
|
if (oldProps !== EMPTY_OBJ) {
|
||||||
for (const key in oldProps) {
|
for (const key in oldProps) {
|
||||||
if (!(key in newProps)) {
|
if (!(key in newProps)) {
|
||||||
hostPatchProp(
|
hostPatchProp(
|
||||||
@ -320,10 +333,10 @@ export function createRenderer(options: RendererOptions) {
|
|||||||
if (n1 == null) {
|
if (n1 == null) {
|
||||||
mountComponent(n2, container, anchor)
|
mountComponent(n2, container, anchor)
|
||||||
} else {
|
} else {
|
||||||
const instance = (n2.component = n1.component)
|
const instance = (n2.component = n1.component) as ComponentHandle
|
||||||
if (shouldUpdateComponent(n1, n2)) {
|
if (shouldUpdateComponent(n1, n2)) {
|
||||||
instance.next = n2
|
instance.next = n2
|
||||||
instance.forceUpdate()
|
instance.update()
|
||||||
} else {
|
} else {
|
||||||
n2.el = n1.el
|
n2.el = n1.el
|
||||||
}
|
}
|
||||||
@ -335,15 +348,17 @@ export function createRenderer(options: RendererOptions) {
|
|||||||
container: HostNode,
|
container: HostNode,
|
||||||
anchor?: HostNode
|
anchor?: HostNode
|
||||||
) {
|
) {
|
||||||
const instance = (vnode.component = {
|
const instance: ComponentHandle = (vnode.component = {
|
||||||
|
type: vnode.type as Function,
|
||||||
vnode: null,
|
vnode: null,
|
||||||
next: null,
|
next: null,
|
||||||
subTree: null,
|
subTree: null,
|
||||||
forceUpdate: null,
|
update: null as any
|
||||||
render: vnode.type
|
})
|
||||||
} as any)
|
|
||||||
|
|
||||||
instance.forceUpdate = effect(
|
// TODO call setup, handle bindings and render context
|
||||||
|
|
||||||
|
instance.update = effect(
|
||||||
() => {
|
() => {
|
||||||
if (!instance.vnode) {
|
if (!instance.vnode) {
|
||||||
// initial mount
|
// initial mount
|
||||||
@ -357,9 +372,9 @@ export function createRenderer(options: RendererOptions) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scheduler: e => e() // TODO use proper scheduler
|
scheduler: queueJob
|
||||||
}
|
}
|
||||||
) as any
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateComponent(
|
function updateComponent(
|
||||||
@ -454,8 +469,8 @@ export function createRenderer(options: RendererOptions) {
|
|||||||
anchor?: HostNode,
|
anchor?: HostNode,
|
||||||
optimized?: boolean
|
optimized?: boolean
|
||||||
) {
|
) {
|
||||||
c1 = c1 || emptyArr
|
c1 = c1 || EMPTY_ARR
|
||||||
c2 = c2 || emptyArr
|
c2 = c2 || EMPTY_ARR
|
||||||
const oldLength = c1.length
|
const oldLength = c1.length
|
||||||
const newLength = c2.length
|
const newLength = c2.length
|
||||||
const commonLength = Math.min(oldLength, newLength)
|
const commonLength = Math.min(oldLength, newLength)
|
||||||
@ -618,7 +633,7 @@ export function createRenderer(options: RendererOptions) {
|
|||||||
// generate longest stable subsequence only when nodes have moved
|
// generate longest stable subsequence only when nodes have moved
|
||||||
const increasingNewIndexSequence = moved
|
const increasingNewIndexSequence = moved
|
||||||
? getSequence(newIndexToOldIndexMap)
|
? getSequence(newIndexToOldIndexMap)
|
||||||
: emptyArr
|
: EMPTY_ARR
|
||||||
j = increasingNewIndexSequence.length - 1
|
j = increasingNewIndexSequence.length - 1
|
||||||
// looping backwards so that we can use last patched node as anchor
|
// looping backwards so that we can use last patched node as anchor
|
||||||
for (i = toBePatched - 1; i >= 0; i--) {
|
for (i = toBePatched - 1; i >= 0; i--) {
|
||||||
@ -645,7 +660,7 @@ export function createRenderer(options: RendererOptions) {
|
|||||||
|
|
||||||
function move(vnode: VNode, container: HostNode, anchor: HostNode) {
|
function move(vnode: VNode, container: HostNode, anchor: HostNode) {
|
||||||
if (vnode.component != null) {
|
if (vnode.component != null) {
|
||||||
move(vnode.component.subTree, container, anchor)
|
move(vnode.component.subTree as VNode, container, anchor)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (vnode.type === Fragment) {
|
if (vnode.type === Fragment) {
|
||||||
@ -663,7 +678,8 @@ export function createRenderer(options: RendererOptions) {
|
|||||||
function unmount(vnode: VNode, doRemove?: boolean) {
|
function unmount(vnode: VNode, doRemove?: boolean) {
|
||||||
if (vnode.component != null) {
|
if (vnode.component != null) {
|
||||||
// TODO teardown component
|
// TODO teardown component
|
||||||
unmount(vnode.component.subTree, doRemove)
|
stop(vnode.component.update)
|
||||||
|
unmount(vnode.component.subTree as VNode, doRemove)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const shouldRemoveChildren = vnode.type === Fragment && doRemove
|
const shouldRemoveChildren = vnode.type === Fragment && doRemove
|
||||||
@ -691,7 +707,7 @@ export function createRenderer(options: RendererOptions) {
|
|||||||
function getNextHostNode(vnode: VNode): HostNode {
|
function getNextHostNode(vnode: VNode): HostNode {
|
||||||
return vnode.component === null
|
return vnode.component === null
|
||||||
? hostNextSibling(vnode.anchor || vnode.el)
|
? hostNextSibling(vnode.anchor || vnode.el)
|
||||||
: getNextHostNode(vnode.component.subTree)
|
: getNextHostNode(vnode.component.subTree as VNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return function render(vnode: VNode, dom: HostNode): VNode {
|
return function render(vnode: VNode, dom: HostNode): VNode {
|
||||||
|
74
packages/runtime-core/src/scheduler.ts
Normal file
74
packages/runtime-core/src/scheduler.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
const queue: Array<() => void> = []
|
||||||
|
const postFlushCbs: Array<() => void> = []
|
||||||
|
const p = Promise.resolve()
|
||||||
|
|
||||||
|
let isFlushing = false
|
||||||
|
|
||||||
|
export function nextTick(fn?: () => void): Promise<void> {
|
||||||
|
return fn ? p.then(fn) : p
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueJob(job: () => void, onError?: (err: Error) => void) {
|
||||||
|
if (queue.indexOf(job) === -1) {
|
||||||
|
queue.push(job)
|
||||||
|
if (!isFlushing) {
|
||||||
|
const p = nextTick(flushJobs)
|
||||||
|
if (onError) p.catch(onError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queuePostFlushCb(cb: () => void) {
|
||||||
|
if (postFlushCbs.indexOf(cb) === -1) {
|
||||||
|
postFlushCbs.push(cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flushPostFlushCbs() {
|
||||||
|
const cbs = postFlushCbs.slice()
|
||||||
|
let i = cbs.length
|
||||||
|
postFlushCbs.length = 0
|
||||||
|
// post flush cbs are flushed in reverse since they are queued top-down
|
||||||
|
// but should fire bottom-up
|
||||||
|
while (i--) {
|
||||||
|
cbs[i]()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECURSION_LIMIT = 100
|
||||||
|
type JobCountMap = Map<Function, number>
|
||||||
|
|
||||||
|
function flushJobs(seenJobs?: JobCountMap) {
|
||||||
|
isFlushing = true
|
||||||
|
let job
|
||||||
|
if (__DEV__) {
|
||||||
|
seenJobs = seenJobs || new Map()
|
||||||
|
}
|
||||||
|
while ((job = queue.shift())) {
|
||||||
|
if (__DEV__) {
|
||||||
|
const seen = seenJobs as JobCountMap
|
||||||
|
if (!seen.has(job)) {
|
||||||
|
seen.set(job, 1)
|
||||||
|
} else {
|
||||||
|
const count = seen.get(job) as number
|
||||||
|
if (count > RECURSION_LIMIT) {
|
||||||
|
throw new Error(
|
||||||
|
'Maximum recursive updates exceeded. ' +
|
||||||
|
"You may have code that is mutating state in your component's " +
|
||||||
|
'render function or updated hook.'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
seen.set(job, count + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
job()
|
||||||
|
}
|
||||||
|
flushPostFlushCbs()
|
||||||
|
isFlushing = false
|
||||||
|
// some postFlushCb queued jobs!
|
||||||
|
// keep flushing until it drains.
|
||||||
|
if (queue.length) {
|
||||||
|
flushJobs(seenJobs)
|
||||||
|
}
|
||||||
|
}
|
@ -1,29 +1,34 @@
|
|||||||
import { isArray, isFunction } from '@vue/shared'
|
import { isArray, isFunction } from '@vue/shared'
|
||||||
|
import { ComponentHandle } from './component'
|
||||||
|
import { HostNode } from './createRenderer'
|
||||||
|
|
||||||
export const Fragment = Symbol('Fragment')
|
export const Fragment = Symbol('Fragment')
|
||||||
export const Text = Symbol('Text')
|
export const Text = Symbol('Text')
|
||||||
export const Empty = Symbol('Empty')
|
export const Empty = Symbol('Empty')
|
||||||
|
export const Portal = Symbol('Portal')
|
||||||
|
|
||||||
type VNodeTypes =
|
type VNodeTypes =
|
||||||
| string
|
| string
|
||||||
| Function
|
| Function
|
||||||
|
| Object
|
||||||
| typeof Fragment
|
| typeof Fragment
|
||||||
| typeof Text
|
| typeof Text
|
||||||
| typeof Empty
|
| typeof Empty
|
||||||
|
|
||||||
export type VNodeChild = VNode | string | number | null
|
type VNodeChildAtom = VNode | string | number | null | void
|
||||||
export interface VNodeChildren extends Array<VNodeChildren | VNodeChild> {}
|
export interface VNodeChildren extends Array<VNodeChildren | VNodeChildAtom> {}
|
||||||
|
export type VNodeChild = VNodeChildAtom | VNodeChildren
|
||||||
|
|
||||||
export interface VNode {
|
export interface VNode {
|
||||||
type: VNodeTypes
|
type: VNodeTypes
|
||||||
props: { [key: string]: any } | null
|
props: { [key: string]: any } | null
|
||||||
key: string | number | null
|
key: string | number | null
|
||||||
children: string | VNodeChildren | null
|
children: string | VNodeChildren | null
|
||||||
component: any
|
component: ComponentHandle | null
|
||||||
|
|
||||||
// DOM
|
// DOM
|
||||||
el: any
|
el: HostNode | null
|
||||||
anchor: any // fragment anchor
|
anchor: HostNode | null // fragment anchor
|
||||||
|
|
||||||
// optimization only
|
// optimization only
|
||||||
patchFlag: number | null
|
patchFlag: number | null
|
||||||
@ -98,7 +103,7 @@ export function cloneVNode(vnode: VNode): VNode {
|
|||||||
return vnode
|
return vnode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeVNode(child: any): VNode {
|
export function normalizeVNode(child: VNodeChild): VNode {
|
||||||
if (child == null) {
|
if (child == null) {
|
||||||
// empty placeholder
|
// empty placeholder
|
||||||
return createVNode(Empty)
|
return createVNode(Empty)
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
__tests__/
|
|
||||||
__mocks__/
|
|
||||||
dist/packages
|
|
@ -1,5 +0,0 @@
|
|||||||
# @vue/scheduler
|
|
||||||
|
|
||||||
> This package is published only for typing and building custom renderers. It is NOT meant to be used in applications.
|
|
||||||
|
|
||||||
The default scheduler that uses Promise / Microtask to defer and batch updates.
|
|
@ -1,123 +0,0 @@
|
|||||||
import { queueJob, queuePostEffect, nextTick } from '../src/index'
|
|
||||||
|
|
||||||
describe('scheduler', () => {
|
|
||||||
it('queueJob', async () => {
|
|
||||||
const calls: any = []
|
|
||||||
const job1 = () => {
|
|
||||||
calls.push('job1')
|
|
||||||
}
|
|
||||||
const job2 = () => {
|
|
||||||
calls.push('job2')
|
|
||||||
}
|
|
||||||
queueJob(job1)
|
|
||||||
queueJob(job2)
|
|
||||||
expect(calls).toEqual([])
|
|
||||||
await nextTick()
|
|
||||||
expect(calls).toEqual(['job1', 'job2'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('queueJob while already flushing', async () => {
|
|
||||||
const calls: any = []
|
|
||||||
const job1 = () => {
|
|
||||||
calls.push('job1')
|
|
||||||
// job1 queues job2
|
|
||||||
queueJob(job2)
|
|
||||||
}
|
|
||||||
const job2 = () => {
|
|
||||||
calls.push('job2')
|
|
||||||
}
|
|
||||||
queueJob(job1)
|
|
||||||
expect(calls).toEqual([])
|
|
||||||
await nextTick()
|
|
||||||
expect(calls).toEqual(['job1', 'job2'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('queueJob w/ postCommitCb', async () => {
|
|
||||||
const calls: any = []
|
|
||||||
const job1 = () => {
|
|
||||||
calls.push('job1')
|
|
||||||
queuePostEffect(cb1)
|
|
||||||
}
|
|
||||||
const job2 = () => {
|
|
||||||
calls.push('job2')
|
|
||||||
queuePostEffect(cb2)
|
|
||||||
}
|
|
||||||
const cb1 = () => {
|
|
||||||
calls.push('cb1')
|
|
||||||
}
|
|
||||||
const cb2 = () => {
|
|
||||||
calls.push('cb2')
|
|
||||||
}
|
|
||||||
queueJob(job1)
|
|
||||||
queueJob(job2)
|
|
||||||
await nextTick()
|
|
||||||
// post commit cbs are called in reverse!
|
|
||||||
expect(calls).toEqual(['job1', 'job2', 'cb2', 'cb1'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('queueJob w/ postFlushCb while flushing', async () => {
|
|
||||||
const calls: any = []
|
|
||||||
const job1 = () => {
|
|
||||||
calls.push('job1')
|
|
||||||
queuePostEffect(cb1)
|
|
||||||
// job1 queues job2
|
|
||||||
queueJob(job2)
|
|
||||||
}
|
|
||||||
const job2 = () => {
|
|
||||||
calls.push('job2')
|
|
||||||
queuePostEffect(cb2)
|
|
||||||
}
|
|
||||||
const cb1 = () => {
|
|
||||||
calls.push('cb1')
|
|
||||||
}
|
|
||||||
const cb2 = () => {
|
|
||||||
calls.push('cb2')
|
|
||||||
}
|
|
||||||
queueJob(job1)
|
|
||||||
expect(calls).toEqual([])
|
|
||||||
await nextTick()
|
|
||||||
expect(calls).toEqual(['job1', 'job2', 'cb2', 'cb1'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should dedupe queued tasks', async () => {
|
|
||||||
const calls: any = []
|
|
||||||
const job1 = () => {
|
|
||||||
calls.push('job1')
|
|
||||||
}
|
|
||||||
const job2 = () => {
|
|
||||||
calls.push('job2')
|
|
||||||
}
|
|
||||||
queueJob(job1)
|
|
||||||
queueJob(job2)
|
|
||||||
queueJob(job1)
|
|
||||||
queueJob(job2)
|
|
||||||
expect(calls).toEqual([])
|
|
||||||
await nextTick()
|
|
||||||
expect(calls).toEqual(['job1', 'job2'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('queueJob inside postEffect', async () => {
|
|
||||||
const calls: any = []
|
|
||||||
const job1 = () => {
|
|
||||||
calls.push('job1')
|
|
||||||
queuePostEffect(cb1)
|
|
||||||
}
|
|
||||||
const cb1 = () => {
|
|
||||||
// queue another job in postFlushCb
|
|
||||||
calls.push('cb1')
|
|
||||||
queueJob(job2)
|
|
||||||
}
|
|
||||||
const job2 = () => {
|
|
||||||
calls.push('job2')
|
|
||||||
queuePostEffect(cb2)
|
|
||||||
}
|
|
||||||
const cb2 = () => {
|
|
||||||
calls.push('cb2')
|
|
||||||
}
|
|
||||||
|
|
||||||
queueJob(job1)
|
|
||||||
queueJob(job2)
|
|
||||||
await nextTick()
|
|
||||||
expect(calls).toEqual(['job1', 'job2', 'cb2', 'cb1', 'job2', 'cb2'])
|
|
||||||
})
|
|
||||||
})
|
|
@ -1,7 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
module.exports = require('./dist/scheduler.cjs.prod.js')
|
|
||||||
} else {
|
|
||||||
module.exports = require('./dist/scheduler.cjs.js')
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@vue/scheduler",
|
|
||||||
"version": "3.0.0-alpha.1",
|
|
||||||
"description": "@vue/scheduler",
|
|
||||||
"main": "index.js",
|
|
||||||
"module": "dist/scheduler.esm-bundler.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"sideEffects": false,
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/vuejs/vue.git"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"vue"
|
|
||||||
],
|
|
||||||
"author": "Evan You",
|
|
||||||
"license": "MIT",
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/vuejs/vue/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/vuejs/vue/tree/dev/packages/scheduler#readme"
|
|
||||||
}
|
|
@ -1,385 +0,0 @@
|
|||||||
// 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 8 arguments.
|
|
||||||
type Op = [Function, ...any[]]
|
|
||||||
|
|
||||||
// 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 `queuePostEffect` inside the job function.
|
|
||||||
postEffects: 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 ReactiveEffect). 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_STAGE,
|
|
||||||
PENDING_COMMIT
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
let globalHandler: ErrorHandler
|
|
||||||
|
|
||||||
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() {
|
|
||||||
flushStartTimestamp = getNow()
|
|
||||||
return p.then(flush).catch(handleError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
'message',
|
|
||||||
event => {
|
|
||||||
if (event.source !== window || event.data !== key) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flushStartTimestamp = getNow()
|
|
||||||
try {
|
|
||||||
flush()
|
|
||||||
} catch (e) {
|
|
||||||
handleError(e)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
function flushAfterMacroTask() {
|
|
||||||
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._update which is in turn
|
|
||||||
// an ReactiveEffect 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 queuePostEffect(fn: Function) {
|
|
||||||
if (currentJob) {
|
|
||||||
currentJob.postEffects.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> {
|
|
||||||
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 = stageQueue.shift()
|
|
||||||
if (job) {
|
|
||||||
stageJob(job)
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (!__COMPAT__) {
|
|
||||||
const now = getNow()
|
|
||||||
if (now - flushStartTimestamp > frameBudget && job.expiration > now) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stageQueue.length === 0) {
|
|
||||||
// all done, time to commit!
|
|
||||||
for (let i = 0; i < commitQueue.length; i++) {
|
|
||||||
commitJob(commitQueue[i])
|
|
||||||
}
|
|
||||||
commitQueue.length = 0
|
|
||||||
flushEffects()
|
|
||||||
// some post commit hook triggered more updates...
|
|
||||||
if (stageQueue.length > 0) {
|
|
||||||
if (!__COMPAT__ && getNow() - flushStartTimestamp > frameBudget) {
|
|
||||||
return flushAfterMacroTask()
|
|
||||||
} else {
|
|
||||||
// not out of budget yet, flush sync
|
|
||||||
return flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// now we are really done
|
|
||||||
hasPendingFlush = false
|
|
||||||
pendingRejectors.length = 0
|
|
||||||
for (let i = 0; i < nextTickQueue.length; i++) {
|
|
||||||
nextTickQueue[i]()
|
|
||||||
}
|
|
||||||
nextTickQueue.length = 0
|
|
||||||
} else {
|
|
||||||
// got more job to do
|
|
||||||
// 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.postEffects.length = 0
|
|
||||||
job.children.length = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueJobForStaging(job: Job) {
|
|
||||||
job.ops = job.ops || []
|
|
||||||
job.postEffects = job.postEffects || []
|
|
||||||
job.children = job.children || []
|
|
||||||
resetJob(job)
|
|
||||||
// inherit parent job's expiration deadline
|
|
||||||
job.expiration = currentJob
|
|
||||||
? currentJob.expiration
|
|
||||||
: getNow() + JobPriorities.NORMAL
|
|
||||||
stageQueue.push(job)
|
|
||||||
job.status = JobStatus.PENDING_STAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidateJob(job: Job) {
|
|
||||||
// recursively invalidate all child jobs
|
|
||||||
const { children } = job
|
|
||||||
for (let i = 0; i < children.length; i++) {
|
|
||||||
const child = children[i]
|
|
||||||
if (child.status === JobStatus.PENDING_COMMIT) {
|
|
||||||
invalidateJob(child)
|
|
||||||
} else if (child.status === JobStatus.PENDING_STAGE) {
|
|
||||||
stageQueue.splice(stageQueue.indexOf(child), 1)
|
|
||||||
child.status = JobStatus.IDLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (job.cleanup) {
|
|
||||||
job.cleanup()
|
|
||||||
job.cleanup = null
|
|
||||||
}
|
|
||||||
resetJob(job)
|
|
||||||
// remove from commit queue
|
|
||||||
commitQueue.splice(commitQueue.indexOf(job), 1)
|
|
||||||
job.status = JobStatus.IDLE
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
job.cleanup = job()
|
|
||||||
currentJob = null
|
|
||||||
commitQueue.push(job)
|
|
||||||
job.status = JobStatus.PENDING_COMMIT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitJob(job: Job) {
|
|
||||||
const { ops, postEffects } = job
|
|
||||||
for (let i = 0; i < ops.length; i++) {
|
|
||||||
applyOp(ops[i])
|
|
||||||
}
|
|
||||||
// queue post commit cbs
|
|
||||||
if (postEffects) {
|
|
||||||
postEffectsQueue.push(...postEffects)
|
|
||||||
}
|
|
||||||
resetJob(job)
|
|
||||||
job.status = JobStatus.IDLE
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyOp(op: Op) {
|
|
||||||
const fn = op[0]
|
|
||||||
// optimize for more common cases
|
|
||||||
// only patchData needs 8 arguments
|
|
||||||
if (op.length <= 3) {
|
|
||||||
fn(op[1], op[2], op[3])
|
|
||||||
} else {
|
|
||||||
fn(op[1], op[2], op[3], op[4], op[5], op[6], op[7], op[8])
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,5 @@
|
|||||||
export const EMPTY_OBJ: { readonly [key: string]: any } = Object.freeze({})
|
export const EMPTY_OBJ: { readonly [key: string]: any } = Object.freeze({})
|
||||||
|
export const EMPTY_ARR: [] = []
|
||||||
|
|
||||||
export const NOOP = () => {}
|
export const NOOP = () => {}
|
||||||
|
|
||||||
|
@ -24,7 +24,6 @@
|
|||||||
"@vue/runtime-dom": ["packages/runtime-dom/src"],
|
"@vue/runtime-dom": ["packages/runtime-dom/src"],
|
||||||
"@vue/runtime-test": ["packages/runtime-test/src"],
|
"@vue/runtime-test": ["packages/runtime-test/src"],
|
||||||
"@vue/observer": ["packages/observer/src"],
|
"@vue/observer": ["packages/observer/src"],
|
||||||
"@vue/scheduler": ["packages/scheduler/src"],
|
|
||||||
"@vue/compiler-core": ["packages/compiler-core/src"],
|
"@vue/compiler-core": ["packages/compiler-core/src"],
|
||||||
"@vue/server-renderer": ["packages/server-renderer/src"]
|
"@vue/server-renderer": ["packages/server-renderer/src"]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user