From 93561b080eea3acb028739e1354b9004c5feaf0d Mon Sep 17 00:00:00 2001 From: Evan You Date: Wed, 20 Nov 2019 18:04:44 -0500 Subject: [PATCH] feat(transition): base transition component --- .../runtime-core/src/components/KeepAlive.ts | 17 +- .../runtime-core/src/components/Transition.ts | 199 ++++++++++++++++++ packages/runtime-core/src/directives.ts | 2 +- packages/runtime-core/src/index.ts | 1 + packages/runtime-core/src/renderer.ts | 93 ++++++-- packages/runtime-core/src/vnode.ts | 29 ++- 6 files changed, 310 insertions(+), 31 deletions(-) create mode 100644 packages/runtime-core/src/components/Transition.ts diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index 0f37c9996..50bb946cc 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -39,6 +39,9 @@ export interface KeepAliveSink { deactivate: (vnode: VNode) => void } +export const isKeepAlive = (vnode: VNode): boolean => + (vnode.type as any).__isKeepAlive + const KeepAliveImpl = { name: `KeepAlive`, @@ -47,6 +50,12 @@ const KeepAliveImpl = { // would prevent it from being tree-shaken. __isKeepAlive: true, + props: { + include: [String, RegExp, Array], + exclude: [String, RegExp, Array], + max: [String, Number] + }, + setup(props: KeepAliveProps, { slots }: SetupContext) { const cache: Cache = new Map() const keys: Keys = new Set() @@ -200,14 +209,6 @@ const KeepAliveImpl = { } } -if (__DEV__) { - ;(KeepAliveImpl as any).props = { - include: [String, RegExp, Array], - exclude: [String, RegExp, Array], - max: [String, Number] - } -} - // export the public type for h/tsx inference export const KeepAlive = (KeepAliveImpl as any) as { new (): { diff --git a/packages/runtime-core/src/components/Transition.ts b/packages/runtime-core/src/components/Transition.ts new file mode 100644 index 000000000..8b96af7ca --- /dev/null +++ b/packages/runtime-core/src/components/Transition.ts @@ -0,0 +1,199 @@ +import { createComponent } from '../apiCreateComponent' +import { getCurrentInstance } from '../component' +import { + cloneVNode, + Comment, + isSameVNodeType, + VNodeProps, + VNode, + mergeProps +} from '../vnode' +import { warn } from '../warning' +import { isKeepAlive } from './KeepAlive' +import { toRaw } from '@vue/reactivity' +import { onMounted } from '../apiLifecycle' + +// Using camel case here makes it easier to use in render functions & JSX. +// In templates these will be written as @before-enter="xxx" +// The compiler has special handling to convert them into the proper cases. +export interface TransitionProps { + mode?: 'in-out' | 'out-in' | 'default' + appear?: boolean + // enter + onBeforeEnter?: (el: any) => void + onEnter?: (el: any, done: () => void) => void + onAfterEnter?: (el: any) => void + onEnterCancelled?: (el: any) => void + // leave + onBeforeLeave?: (el: any) => void + onLeave?: (el: any, done: () => void) => void + onAfterLeave?: (el: any) => void + onLeaveCancelled?: (el: any) => void +} + +export const Transition = createComponent({ + name: `Transition`, + setup(props: TransitionProps, { slots }) { + const instance = getCurrentInstance()! + let isLeaving = false + let isMounted = false + + onMounted(() => { + isMounted = true + }) + + return () => { + const children = slots.default && slots.default() + if (!children || !children.length) { + return + } + + // warn multiple elements + if (__DEV__ && children.length > 1) { + warn( + ' can only be used on a single element. Use ' + + ' for lists.' + ) + } + + // there's no need to track reactivity for these props so use the raw + // props for a bit better perf + const rawProps = toRaw(props) + const { mode } = rawProps + // check mode + if (__DEV__ && mode && !['in-out', 'out-in', 'default'].includes(mode)) { + warn(`invalid mode: ${mode}`) + } + + // at this point children has a guaranteed length of 1. + const rawChild = children[0] + if (isLeaving) { + return placeholder(rawChild) + } + + rawChild.transition = rawProps + // clone old subTree because we need to modify it + const oldChild = instance.subTree + ? (instance.subTree = cloneVNode(instance.subTree)) + : null + + // handle mode + let performDelayedLeave: (() => void) | undefined + if ( + oldChild && + !isSameVNodeType(rawChild, oldChild) && + oldChild.type !== Comment + ) { + // update old tree's hooks in case of dynamic transition + oldChild.transition = rawProps + // switching between different views + if (mode === 'out-in') { + isLeaving = true + // return placeholder node and queue update when leave finishes + oldChild.props = mergeProps(oldChild.props!, { + onVnodeRemoved() { + isLeaving = false + instance.update() + } + }) + return placeholder(rawChild) + } else if (mode === 'in-out') { + let delayedLeave: () => void + performDelayedLeave = () => delayedLeave() + oldChild.props = mergeProps(oldChild.props!, { + onVnodeDelayLeave(performLeave) { + delayedLeave = performLeave + } + }) + } + } + + return cloneVNode( + rawChild, + resolveTransitionInjections(rawProps, isMounted, performDelayedLeave) + ) + } + } +}) + +if (__DEV__) { + ;(Transition as any).props = { + mode: String, + appear: Boolean, + // enter + onBeforeEnter: Function, + onEnter: Function, + onAfterEnter: Function, + onEnterCancelled: Function, + // leave + onBeforeLeave: Function, + onLeave: Function, + onAfterLeave: Function, + onLeaveCancelled: Function + } +} + +function resolveTransitionInjections( + { + appear, + onBeforeEnter, + onEnter, + onAfterEnter, + onEnterCancelled, + onBeforeLeave, + onLeave, + onAfterLeave, + onLeaveCancelled + }: TransitionProps, + isMounted: boolean, + performDelayedLeave?: () => void +): VNodeProps { + // TODO handle appear + // TODO handle cancel hooks + return { + onVnodeBeforeMount(vnode) { + if (!isMounted && !appear) { + return + } + onBeforeEnter && onBeforeEnter(vnode.el) + }, + onVnodeMounted({ el }) { + if (!isMounted && !appear) { + return + } + const done = () => { + onAfterEnter && onAfterEnter(el) + performDelayedLeave && performDelayedLeave() + } + if (onEnter) { + onEnter(el, done) + } else { + done() + } + }, + onVnodeBeforeRemove({ el }, remove) { + onBeforeLeave && onBeforeLeave(el) + if (onLeave) { + onLeave(el, () => { + remove() + onAfterLeave && onAfterLeave(el) + }) + } else { + remove() + onAfterLeave && onAfterLeave(el) + } + } + } +} + +// the placeholder really only handles one special case: KeepAlive +// in the case of a KeepAlive in a leave phase we need to return a KeepAlive +// placeholder with empty content to avoid the KeepAlive instance from being +// unmounted. +function placeholder(vnode: VNode): VNode | undefined { + if (isKeepAlive(vnode)) { + vnode = cloneVNode(vnode) + vnode.children = null + return vnode + } +} diff --git a/packages/runtime-core/src/directives.ts b/packages/runtime-core/src/directives.ts index 9f7735870..4f80bb1f2 100644 --- a/packages/runtime-core/src/directives.ts +++ b/packages/runtime-core/src/directives.ts @@ -147,7 +147,7 @@ export function withDirectives( } export function invokeDirectiveHook( - hook: Function | Function[], + hook: ((...args: any[]) => any) | ((...args: any[]) => any)[], instance: ComponentInternalInstance | null, vnode: VNode, prevVNode: VNode | null = null diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 7729342f2..4c241a57a 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -28,6 +28,7 @@ export { Text, Comment, Fragment, Portal } from './vnode' // Internal Components export { Suspense, SuspenseProps } from './components/Suspense' export { KeepAlive, KeepAliveProps } from './components/KeepAlive' +export { Transition, TransitionProps } from './components/Transition' // VNode flags export { PublicShapeFlags as ShapeFlags } from './shapeFlags' import { PublicPatchFlags } from '@vue/shared' diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 2cae06e92..5ca2ca063 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -6,7 +6,8 @@ import { normalizeVNode, VNode, VNodeChildren, - createVNode + createVNode, + isSameVNodeType } from './vnode' import { ComponentInternalInstance, @@ -26,7 +27,8 @@ import { EMPTY_ARR, isReservedProp, isFunction, - PatchFlags + PatchFlags, + isArray } from '@vue/shared' import { queueJob, queuePostFlushCb, flushPostFlushCbs } from './scheduler' import { @@ -50,8 +52,12 @@ import { queueEffectWithSuspense, SuspenseImpl } from './components/Suspense' -import { ErrorCodes, callWithErrorHandling } from './errorHandling' -import { KeepAliveSink } from './components/KeepAlive' +import { + ErrorCodes, + callWithErrorHandling, + callWithAsyncErrorHandling +} from './errorHandling' +import { KeepAliveSink, isKeepAlive } from './components/KeepAlive' export interface RendererOptions { patchProp( @@ -128,10 +134,6 @@ function createDevEffectOptions( } } -function isSameType(n1: VNode, n2: VNode): boolean { - return n1.type === n2.type && n1.key === n2.key -} - export function invokeHooks(hooks: Function[], arg?: DebuggerEvent) { for (let i = 0; i < hooks.length; i++) { hooks[i](arg) @@ -203,7 +205,7 @@ export function createRenderer< optimized: boolean = false ) { // patching & not same type, unmount old tree - if (n1 != null && !isSameType(n1, n2)) { + if (n1 != null && !isSameVNodeType(n1, n2)) { anchor = getNextHostNode(n1) unmount(n1, parentComponent, parentSuspense, true) n1 = null @@ -386,7 +388,7 @@ export function createRenderer< hostInsert(el, container, anchor) if (props != null && props.onVnodeMounted != null) { queuePostRenderEffect(() => { - invokeDirectiveHook(props.onVnodeMounted, parentComponent, vnode) + invokeDirectiveHook(props.onVnodeMounted!, parentComponent, vnode) }, parentSuspense) } } @@ -844,7 +846,7 @@ export function createRenderer< const Comp = initialVNode.type as Component // inject renderer internals for keepAlive - if ((Comp as any).__isKeepAlive) { + if (isKeepAlive(initialVNode)) { const sink = instance.sink as KeepAliveSink sink.renderer = internals sink.parentSuspense = parentSuspense @@ -937,8 +939,9 @@ export function createRenderer< if (next !== null) { updateComponentPreRender(instance, next) } + const nextTree = renderComponentRoot(instance) const prevTree = instance.subTree - const nextTree = (instance.subTree = renderComponentRoot(instance)) + instance.subTree = nextTree // beforeUpdate hook if (instance.bu !== null) { invokeHooks(instance.bu) @@ -1167,7 +1170,7 @@ export function createRenderer< const n2 = optimized ? (c2[i] as HostVNode) : (c2[i] = normalizeVNode(c2[i])) - if (isSameType(n1, n2)) { + if (isSameVNodeType(n1, n2)) { patch( n1, n2, @@ -1192,7 +1195,7 @@ export function createRenderer< const n2 = optimized ? (c2[i] as HostVNode) : (c2[e2] = normalizeVNode(c2[e2])) - if (isSameType(n1, n2)) { + if (isSameVNodeType(n1, n2)) { patch( n1, n2, @@ -1308,7 +1311,7 @@ export function createRenderer< for (j = s2; j <= e2; j++) { if ( newIndexToOldIndexMap[j - s2] === 0 && - isSameType(prevChild, c2[j] as HostVNode) + isSameVNodeType(prevChild, c2[j] as HostVNode) ) { newIndex = j break @@ -1459,17 +1462,71 @@ export function createRenderer< } if (doRemove) { - hostRemove(vnode.el!) - if (anchor != null) hostRemove(anchor) + const beforeRemoveHooks = props && props.onVnodeBeforeRemove + const remove = () => { + hostRemove(vnode.el!) + if (anchor != null) hostRemove(anchor) + const removedHook = props && props.onVnodeRemoved + removedHook && removedHook() + } + if (vnode.shapeFlag & ShapeFlags.ELEMENT && beforeRemoveHooks != null) { + const delayLeave = props && props.onVnodeDelayLeave + const performLeave = () => { + invokeBeforeRemoveHooks( + beforeRemoveHooks, + parentComponent, + vnode, + remove + ) + } + if (delayLeave) { + delayLeave(performLeave) + } else { + performLeave() + } + } else { + remove() + } } if (props != null && props.onVnodeUnmounted != null) { queuePostRenderEffect(() => { - invokeDirectiveHook(props.onVnodeUnmounted, parentComponent, vnode) + invokeDirectiveHook(props.onVnodeUnmounted!, parentComponent, vnode) }, parentSuspense) } } + function invokeBeforeRemoveHooks( + hooks: ((...args: any[]) => any) | ((...args: any[]) => any)[], + instance: ComponentInternalInstance | null, + vnode: HostVNode, + done: () => void + ) { + if (!isArray(hooks)) { + hooks = [hooks] + } + let delayedRemoveCount = hooks.length + const doneRemove = () => { + delayedRemoveCount-- + if (allHooksCalled && !delayedRemoveCount) { + done() + } + } + let allHooksCalled = false + for (let i = 0; i < hooks.length; i++) { + callWithAsyncErrorHandling( + hooks[i], + instance, + ErrorCodes.DIRECTIVE_HOOK, + [vnode, doneRemove] + ) + } + allHooksCalled = true + if (!delayedRemoveCount) { + done() + } + } + function unmountComponent( instance: ComponentInternalInstance, parentSuspense: HostSuspenseBoundary | null, diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 71227f3d9..3134057fe 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -19,6 +19,7 @@ import { AppContext } from './apiApp' import { SuspenseBoundary } from './components/Suspense' import { DirectiveBinding } from './directives' import { SuspenseImpl } from './components/Suspense' +import { TransitionProps } from './components/Transition' export const Fragment = (Symbol(__DEV__ ? 'Fragment' : undefined) as any) as { __isFragment: true @@ -48,6 +49,19 @@ export interface VNodeProps { [key: string]: any key?: string | number ref?: string | Ref | ((ref: object | null) => void) + + // vnode hooks + onVnodeBeforeMount?: (vnode: VNode) => void + onVnodeMounted?: (vnode: VNode) => void + onVnodeBeforeUpdate?: (vnode: VNode, oldVNode: VNode) => void + onVnodeUpdated?: (vnode: VNode, oldVNode: VNode) => void + onVnodeBeforeUnmount?: (vnode: VNode) => void + onVnodeUnmounted?: (vnode: VNode) => void + + // transition hooks, internal. + onVnodeDelayLeave?: (performLeave: () => void) => void + onVnodeBeforeRemove?: (vnode: VNode, remove: () => void) => void + onVnodeRemoved?: () => void } type VNodeChildAtom = @@ -79,11 +93,12 @@ export interface VNode { type: VNodeTypes props: VNodeProps | null key: string | number | null - ref: string | Function | null + ref: string | Ref | ((ref: object | null) => void) | null children: NormalizedChildren component: ComponentInternalInstance | null suspense: SuspenseBoundary | null dirs: DirectiveBinding[] | null + transition: TransitionProps | null // DOM el: HostNode | null @@ -173,9 +188,13 @@ export function isVNode(value: any): value is VNode { return value ? value._isVNode === true : false } +export function isSameVNodeType(n1: VNode, n2: VNode): boolean { + return n1.type === n2.type && n1.key === n2.key +} + export function createVNode( type: VNodeTypes, - props: { [key: string]: any } | null = null, + props: (Data & VNodeProps) | null = null, children: unknown = null, patchFlag: number = 0, dynamicProps: string[] | null = null @@ -221,6 +240,7 @@ export function createVNode( component: null, suspense: null, dirs: null, + transition: null, el: null, anchor: null, target: null, @@ -252,7 +272,7 @@ export function createVNode( export function cloneVNode( vnode: VNode, - extraProps?: Data + extraProps?: Data & VNodeProps ): VNode { // This is intentionally NOT using spread or extend to avoid the runtime // key enumeration cost. @@ -274,6 +294,7 @@ export function cloneVNode( dynamicChildren: vnode.dynamicChildren, appContext: vnode.appContext, dirs: vnode.dirs, + transition: vnode.transition, // These should technically only be non-null on mounted VNodes. However, // they *should* be copied for kept-alive vnodes. So we just always copy @@ -376,7 +397,7 @@ export function normalizeClass(value: unknown): string { const handlersRE = /^on|^vnode/ -export function mergeProps(...args: Data[]) { +export function mergeProps(...args: (Data & VNodeProps)[]) { const ret: Data = {} extend(ret, args[0]) for (let i = 1; i < args.length; i++) {