feat(core): keep-alive

This commit is contained in:
Evan You 2019-10-29 22:28:38 -04:00
parent 083296ead6
commit c6cbca25fe
11 changed files with 351 additions and 53 deletions

View File

@ -111,7 +111,7 @@ describe('component: proxy', () => {
expect(`Attempting to mutate public property "$data"`).toHaveBeenWarned() expect(`Attempting to mutate public property "$data"`).toHaveBeenWarned()
}) })
it('user', async () => { it('sink', async () => {
const app = createApp() const app = createApp()
let instance: ComponentInternalInstance let instance: ComponentInternalInstance
let instanceProxy: any let instanceProxy: any
@ -127,6 +127,6 @@ describe('component: proxy', () => {
app.mount(Comp, nodeOps.createElement('div')) app.mount(Comp, nodeOps.createElement('div'))
instanceProxy.foo = 1 instanceProxy.foo = 1
expect(instanceProxy.foo).toBe(1) expect(instanceProxy.foo).toBe(1)
expect(instance!.user.foo).toBe(1) expect(instance!.sink.foo).toBe(1)
}) })
}) })

View File

@ -132,7 +132,7 @@ describe('vnode', () => {
mounted.el = {} mounted.el = {}
const normalized = normalizeVNode(mounted) const normalized = normalizeVNode(mounted)
expect(normalized).not.toBe(mounted) expect(normalized).not.toBe(mounted)
expect(normalized).toEqual({ ...mounted, el: null }) expect(normalized).toEqual(mounted)
// primitive types // primitive types
expect(normalizeVNode('foo')).toMatchObject({ type: Text, children: `foo` }) expect(normalizeVNode('foo')).toMatchObject({ type: Text, children: `foo` })
@ -158,20 +158,6 @@ describe('vnode', () => {
expect(cloned2).toEqual(node2) expect(cloned2).toEqual(node2)
expect(cloneVNode(node2)).toEqual(node2) expect(cloneVNode(node2)).toEqual(node2)
expect(cloneVNode(node2)).toEqual(cloned2) expect(cloneVNode(node2)).toEqual(cloned2)
// should reset mounted state
const node3 = createVNode('div', { foo: 1 }, [node1])
node3.el = {}
node3.anchor = {}
node3.component = {} as any
node3.suspense = {} as any
expect(cloneVNode(node3)).toEqual({
...node3,
el: null,
anchor: null,
component: null,
suspense: null
})
}) })
describe('mergeProps', () => { describe('mergeProps', () => {

View File

@ -9,14 +9,17 @@ import { callWithAsyncErrorHandling, ErrorTypeStrings } from './errorHandling'
import { warn } from './warning' import { warn } from './warning'
import { capitalize } from '@vue/shared' import { capitalize } from '@vue/shared'
import { pauseTracking, resumeTracking, DebuggerEvent } from '@vue/reactivity' import { pauseTracking, resumeTracking, DebuggerEvent } from '@vue/reactivity'
import { registerKeepAliveHook } from './keepAlive'
function injectHook( export function injectHook(
type: LifecycleHooks, type: LifecycleHooks,
hook: Function, hook: Function,
target: ComponentInternalInstance | null target: ComponentInternalInstance | null = currentInstance,
prepend: boolean = false
) { ) {
if (target) { if (target) {
;(target[type] || (target[type] = [])).push((...args: unknown[]) => { const hooks = target[type] || (target[type] = [])
const wrappedHook = (...args: unknown[]) => {
if (target.isUnmounted) { if (target.isUnmounted) {
return return
} }
@ -31,7 +34,12 @@ function injectHook(
setCurrentInstance(null) setCurrentInstance(null)
resumeTracking() resumeTracking()
return res return res
}) }
if (prepend) {
hooks.unshift(wrappedHook)
} else {
hooks.push(wrappedHook)
}
} else if (__DEV__) { } else if (__DEV__) {
const apiName = `on${capitalize( const apiName = `on${capitalize(
ErrorTypeStrings[type].replace(/ hook$/, '') ErrorTypeStrings[type].replace(/ hook$/, '')
@ -48,7 +56,7 @@ function injectHook(
} }
} }
const createHook = <T extends Function = () => any>( export const createHook = <T extends Function = () => any>(
lifecycle: LifecycleHooks lifecycle: LifecycleHooks
) => (hook: T, target: ComponentInternalInstance | null = currentInstance) => ) => (hook: T, target: ComponentInternalInstance | null = currentInstance) =>
injectHook(lifecycle, hook, target) injectHook(lifecycle, hook, target)
@ -76,3 +84,17 @@ export type ErrorCapturedHook = (
export const onErrorCaptured = createHook<ErrorCapturedHook>( export const onErrorCaptured = createHook<ErrorCapturedHook>(
LifecycleHooks.ERROR_CAPTURED LifecycleHooks.ERROR_CAPTURED
) )
export function onActivated(
hook: Function,
target?: ComponentInternalInstance | null
) {
registerKeepAliveHook(hook, LifecycleHooks.ACTIVATED, target)
}
export function onDeactivated(
hook: Function,
target?: ComponentInternalInstance | null
) {
registerKeepAliveHook(hook, LifecycleHooks.DEACTIVATED, target)
}

View File

@ -26,6 +26,8 @@ import {
onRenderTracked, onRenderTracked,
onBeforeUnmount, onBeforeUnmount,
onUnmounted, onUnmounted,
onActivated,
onDeactivated,
onRenderTriggered, onRenderTriggered,
DebuggerHook, DebuggerHook,
ErrorCapturedHook ErrorCapturedHook
@ -226,8 +228,8 @@ export function applyOptions(
mounted, mounted,
beforeUpdate, beforeUpdate,
updated, updated,
// TODO activated activated,
// TODO deactivated deactivated,
beforeUnmount, beforeUnmount,
unmounted, unmounted,
renderTracked, renderTracked,
@ -377,6 +379,12 @@ export function applyOptions(
if (updated) { if (updated) {
onUpdated(updated.bind(ctx)) onUpdated(updated.bind(ctx))
} }
if (activated) {
onActivated(activated.bind(ctx))
}
if (deactivated) {
onDeactivated(deactivated.bind(ctx))
}
if (errorCaptured) { if (errorCaptured) {
onErrorCaptured(errorCaptured.bind(ctx)) onErrorCaptured(errorCaptured.bind(ctx))
} }

View File

@ -42,6 +42,7 @@ export interface FunctionalComponent<P = {}> {
} }
export type Component = ComponentOptions | FunctionalComponent export type Component = ComponentOptions | FunctionalComponent
export { ComponentOptions }
type LifecycleHook = Function[] | null type LifecycleHook = Function[] | null
@ -89,13 +90,10 @@ export interface ComponentInternalInstance {
// after initialized (e.g. inline handlers) // after initialized (e.g. inline handlers)
renderCache: (Function | VNode)[] | null renderCache: (Function | VNode)[] | null
// assets for fast resolution
components: Record<string, Component> components: Record<string, Component>
directives: Record<string, Directive> directives: Record<string, Directive>
asyncDep: Promise<any> | null
asyncResult: unknown
asyncResolved: boolean
// the rest are only for stateful components // the rest are only for stateful components
renderContext: Data renderContext: Data
data: Data data: Data
@ -108,11 +106,17 @@ export interface ComponentInternalInstance {
refs: Data refs: Data
emit: Emit emit: Emit
// user namespace // suspense related
user: { [key: string]: any } asyncDep: Promise<any> | null
asyncResult: unknown
asyncResolved: boolean
// storage for any extra properties
sink: { [key: string]: any }
// lifecycle // lifecycle
isUnmounted: boolean isUnmounted: boolean
isDeactivated: boolean
[LifecycleHooks.BEFORE_CREATE]: LifecycleHook [LifecycleHooks.BEFORE_CREATE]: LifecycleHook
[LifecycleHooks.CREATED]: LifecycleHook [LifecycleHooks.CREATED]: LifecycleHook
[LifecycleHooks.BEFORE_MOUNT]: LifecycleHook [LifecycleHooks.BEFORE_MOUNT]: LifecycleHook
@ -173,11 +177,13 @@ export function createComponentInstance(
asyncResolved: false, asyncResolved: false,
// user namespace for storing whatever the user assigns to `this` // user namespace for storing whatever the user assigns to `this`
user: {}, // can also be used as a wildcard storage for ad-hoc injections internally
sink: {},
// lifecycle hooks // lifecycle hooks
// not using enums here because it results in computed properties // not using enums here because it results in computed properties
isUnmounted: false, isUnmounted: false,
isDeactivated: false,
bc: null, bc: null,
c: null, c: null,
bm: null, bm: null,

View File

@ -73,7 +73,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
propsProxy, propsProxy,
accessCache, accessCache,
type, type,
user sink
} = target } = target
// fast path for unscopables when using `with` block // fast path for unscopables when using `with` block
if (__RUNTIME_COMPILE__ && (key as any) === Symbol.unscopables) { if (__RUNTIME_COMPILE__ && (key as any) === Symbol.unscopables) {
@ -128,8 +128,8 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
return instanceWatch.bind(target) return instanceWatch.bind(target)
} }
} }
if (hasOwn(user, key)) { if (hasOwn(sink, key)) {
return user[key] return sink[key]
} else if (__DEV__ && currentRenderingInstance != null) { } else if (__DEV__ && currentRenderingInstance != null) {
warn( warn(
`Property ${JSON.stringify(key)} was accessed during render ` + `Property ${JSON.stringify(key)} was accessed during render ` +
@ -157,7 +157,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
warn(`Attempting to mutate prop "${key}". Props are readonly.`, target) warn(`Attempting to mutate prop "${key}". Props are readonly.`, target)
return false return false
} else { } else {
target.user[key] = value target.sink[key] = value
} }
return true return true
} }

View File

@ -51,6 +51,7 @@ import {
queueEffectWithSuspense queueEffectWithSuspense
} from './suspense' } from './suspense'
import { ErrorCodes, callWithErrorHandling } from './errorHandling' import { ErrorCodes, callWithErrorHandling } from './errorHandling'
import { KeepAliveSink } from './keepAlive'
export interface RendererOptions<HostNode = any, HostElement = any> { export interface RendererOptions<HostNode = any, HostElement = any> {
patchProp( patchProp(
@ -131,7 +132,7 @@ 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
} }
function invokeHooks(hooks: Function[], arg?: DebuggerEvent) { export function invokeHooks(hooks: Function[], arg?: DebuggerEvent) {
for (let i = 0; i < hooks.length; i++) { for (let i = 0; i < hooks.length; i++) {
hooks[i](arg) hooks[i](arg)
} }
@ -755,14 +756,22 @@ export function createRenderer<
optimized: boolean optimized: boolean
) { ) {
if (n1 == null) { if (n1 == null) {
mountComponent( if (n2.shapeFlag & ShapeFlags.STATEFUL_COMPONENT_KEPT_ALIVE) {
n2, ;(parentComponent!.sink as KeepAliveSink).activate(
container, n2,
anchor, container,
parentComponent, anchor
parentSuspense, )
isSVG } else {
) mountComponent(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG
)
}
} else { } else {
const instance = (n2.component = n1.component)! const instance = (n2.component = n1.component)!
@ -816,8 +825,17 @@ export function createRenderer<
pushWarningContext(initialVNode) pushWarningContext(initialVNode)
} }
const Comp = initialVNode.type as Component
// inject renderer internals for keepAlive
if ((Comp as any).__isKeepAlive) {
const sink = instance.sink as KeepAliveSink
sink.renderer = internals
sink.parentSuspense = parentSuspense
}
// resolve props and slots for setup context // resolve props and slots for setup context
const propsOptions = (initialVNode.type as Component).props const propsOptions = Comp.props
resolveProps(instance, initialVNode.props, propsOptions) resolveProps(instance, initialVNode.props, propsOptions)
resolveSlots(instance, initialVNode.children) resolveSlots(instance, initialVNode.children)
@ -1381,7 +1399,11 @@ export function createRenderer<
} }
if (shapeFlag & ShapeFlags.COMPONENT) { if (shapeFlag & ShapeFlags.COMPONENT) {
unmountComponent(vnode.component!, parentSuspense, doRemove) if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT_SHOULD_KEEP_ALIVE) {
;(parentComponent!.sink as KeepAliveSink).deactivate(vnode)
} else {
unmountComponent(vnode.component!, parentSuspense, doRemove)
}
return return
} }

View File

@ -20,6 +20,8 @@ export {
} from './vnode' } from './vnode'
// VNode type symbols // VNode type symbols
export { Text, Comment, Fragment, Portal, Suspense } from './vnode' export { Text, Comment, Fragment, Portal, Suspense } from './vnode'
// Internal Components
export { KeepAlive } from './keepAlive'
// VNode flags // VNode flags
export { PublicShapeFlags as ShapeFlags } from './shapeFlags' export { PublicShapeFlags as ShapeFlags } from './shapeFlags'
export { PublicPatchFlags as PatchFlags } from '@vue/shared' export { PublicPatchFlags as PatchFlags } from '@vue/shared'

View File

@ -0,0 +1,249 @@
import {
Component,
getCurrentInstance,
FunctionalComponent,
SetupContext,
ComponentInternalInstance,
LifecycleHooks,
currentInstance
} from './component'
import { VNode, cloneVNode, isVNode } from './vnode'
import { warn } from './warning'
import { onBeforeUnmount, injectHook } from './apiLifecycle'
import { isString, isArray } from '@vue/shared'
import { watch } from './apiWatch'
import { ShapeFlags } from './shapeFlags'
import { SuspenseBoundary } from './suspense'
import {
RendererInternals,
queuePostRenderEffect,
invokeHooks
} from './createRenderer'
type MatchPattern = string | RegExp | string[] | RegExp[]
interface KeepAliveProps {
include?: MatchPattern
exclude?: MatchPattern
max?: number | string
}
type CacheKey = string | number | Component
type Cache = Map<CacheKey, VNode>
type Keys = Set<CacheKey>
export interface KeepAliveSink {
renderer: RendererInternals
parentSuspense: SuspenseBoundary | null
activate: (vnode: VNode, container: object, anchor: object | null) => void
deactivate: (vnode: VNode) => void
}
export const KeepAlive = {
name: `KeepAlive`,
__isKeepAlive: true,
setup(props: KeepAliveProps, { slots }: SetupContext) {
const cache: Cache = new Map()
const keys: Keys = new Set()
let current: VNode | null = null
const instance = getCurrentInstance()!
const sink = instance.sink as KeepAliveSink
const {
renderer: {
move,
unmount: _unmount,
options: { createElement }
},
parentSuspense
} = sink
const storageContainer = createElement('div')
sink.activate = (vnode, container, anchor) => {
move(vnode, container, anchor)
queuePostRenderEffect(() => {
vnode.component!.isDeactivated = false
invokeHooks(vnode.component!.a!)
}, parentSuspense)
}
sink.deactivate = (vnode: VNode) => {
move(vnode, storageContainer, null)
queuePostRenderEffect(() => {
invokeHooks(vnode.component!.da!)
vnode.component!.isDeactivated = true
}, parentSuspense)
}
function unmount(vnode: VNode) {
// reset the shapeFlag so it can be properly unmounted
vnode.shapeFlag = ShapeFlags.STATEFUL_COMPONENT
_unmount(vnode, instance, parentSuspense)
}
function pruneCache(filter?: (name: string) => boolean) {
cache.forEach((vnode, key) => {
const name = getName(vnode.type)
if (name && (!filter || !filter(name))) {
pruneCacheEntry(key)
}
})
}
function pruneCacheEntry(key: CacheKey) {
const cached = cache.get(key) as VNode
if (!current || cached.type !== current.type) {
unmount(cached)
}
cache.delete(key)
keys.delete(key)
}
watch(
() => [props.include, props.exclude],
([include, exclude]) => {
include && pruneCache(name => matches(include, name))
exclude && pruneCache(name => matches(exclude, name))
},
{ lazy: true }
)
onBeforeUnmount(() => {
cache.forEach(unmount)
})
return () => {
if (!slots.default) {
return
}
const children = slots.default()
let vnode = children[0]
if (children.length > 1) {
if (__DEV__) {
warn(`KeepAlive should contain exactly one component child.`)
}
current = null
return children
} else if (
!isVNode(vnode) ||
!(vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT)
) {
current = null
return vnode
}
const comp = vnode.type as Component
const name = getName(comp)
const { include, exclude, max } = props
if (
(include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))
) {
return vnode
}
const key = vnode.key == null ? comp : vnode.key
const cached = cache.get(key)
// clone vnode if it's reused because we are going to mutate it
if (vnode.el) {
vnode = cloneVNode(vnode)
}
cache.set(key, vnode)
if (cached) {
// copy over mounted state
vnode.el = cached.el
vnode.anchor = cached.anchor
vnode.component = cached.component
// avoid vnode being mounted as fresh
vnode.shapeFlag |= ShapeFlags.STATEFUL_COMPONENT_KEPT_ALIVE
// make this key the freshest
keys.delete(key)
keys.add(key)
} else {
keys.add(key)
// prune oldest entry
if (max && keys.size > parseInt(max as string, 10)) {
pruneCacheEntry(Array.from(keys)[0])
}
}
// avoid vnode being unmounted
vnode.shapeFlag |= ShapeFlags.STATEFUL_COMPONENT_SHOULD_KEEP_ALIVE
current = vnode
return vnode
}
}
}
if (__DEV__) {
;(KeepAlive as any).props = {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
max: [String, Number]
}
}
function getName(comp: Component): string | void {
return (comp as FunctionalComponent).displayName || comp.name
}
function matches(pattern: MatchPattern, name: string): boolean {
if (isArray(pattern)) {
return (pattern as any).some((p: string | RegExp) => matches(p, name))
} else if (isString(pattern)) {
return pattern.split(',').indexOf(name) > -1
} else if (pattern.test) {
return pattern.test(name)
}
/* istanbul ignore next */
return false
}
export function registerKeepAliveHook(
hook: Function,
type: LifecycleHooks,
target: ComponentInternalInstance | null = currentInstance
) {
// When registering an activated/deactivated hook, instead of registering it
// on the target instance, we walk up the parent chain and register it on
// every ancestor instance that is a keep-alive root. This avoids the need
// to walk the entire component tree when invoking these hooks, and more
// importantly, avoids the need to track child components in arrays.
if (target) {
let current = target
while (current.parent) {
if (current.parent.type === KeepAlive) {
register(hook, type, target, current)
}
current = current.parent
}
}
}
function register(
hook: Function,
type: LifecycleHooks,
target: ComponentInternalInstance,
keepAliveRoot: ComponentInternalInstance
) {
const wrappedHook = () => {
// only fire the hook if the target instance is NOT in a deactivated branch.
let current: ComponentInternalInstance | null = target
while (current) {
if (current.isDeactivated) {
return
}
current = current.parent
}
hook()
}
injectHook(type, wrappedHook, keepAliveRoot, true)
onBeforeUnmount(() => {
const hooks = keepAliveRoot[type]!
hooks.splice(hooks.indexOf(wrappedHook), 1)
}, target)
}

View File

@ -8,6 +8,8 @@ export const enum ShapeFlags {
ARRAY_CHILDREN = 1 << 4, ARRAY_CHILDREN = 1 << 4,
SLOTS_CHILDREN = 1 << 5, SLOTS_CHILDREN = 1 << 5,
SUSPENSE = 1 << 6, SUSPENSE = 1 << 6,
STATEFUL_COMPONENT_SHOULD_KEEP_ALIVE = 1 << 7,
STATEFUL_COMPONENT_KEPT_ALIVE = 1 << 8,
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
} }

View File

@ -264,13 +264,14 @@ export function cloneVNode<T, U>(
appContext: vnode.appContext, appContext: vnode.appContext,
dirs: vnode.dirs, dirs: vnode.dirs,
// these should be set to null since they should only be present on // These should technically only be non-null on mounted VNodes. However,
// mounted VNodes. If they are somehow not null, this means we have // they *should* be copied for kept-alive vnodes. So we just always copy
// encountered an already-mounted vnode being used again. // them since them being non-null during a mount doesn't affect the logic as
component: null, // they will simply be overwritten.
suspense: null, component: vnode.component,
el: null, suspense: vnode.suspense,
anchor: null el: vnode.el,
anchor: vnode.anchor
} }
} }