feat(transition): base transition component

This commit is contained in:
Evan You 2019-11-20 18:04:44 -05:00
parent f7009d58a3
commit 93561b080e
6 changed files with 310 additions and 31 deletions

View File

@ -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 (): {

View File

@ -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(
'<transition> can only be used on a single element. Use ' +
'<transition-group> 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 <transition> 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
}
}

View File

@ -147,7 +147,7 @@ export function withDirectives<T extends VNode>(
}
export function invokeDirectiveHook(
hook: Function | Function[],
hook: ((...args: any[]) => any) | ((...args: any[]) => any)[],
instance: ComponentInternalInstance | null,
vnode: VNode,
prevVNode: VNode | null = null

View File

@ -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'

View File

@ -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<HostNode = any, HostElement = any> {
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) {
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,

View File

@ -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<HostNode, HostElement> =
@ -79,11 +93,12 @@ export interface VNode<HostNode = any, HostElement = any> {
type: VNodeTypes
props: VNodeProps | null
key: string | number | null
ref: string | Function | null
ref: string | Ref | ((ref: object | null) => void) | null
children: NormalizedChildren<HostNode, HostElement>
component: ComponentInternalInstance | null
suspense: SuspenseBoundary<HostNode, HostElement> | 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<T, U>(
vnode: VNode<T, U>,
extraProps?: Data
extraProps?: Data & VNodeProps
): VNode<T, U> {
// This is intentionally NOT using spread or extend to avoid the runtime
// key enumeration cost.
@ -274,6 +294,7 @@ export function cloneVNode<T, U>(
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++) {