feat(core): keep-alive
This commit is contained in:
parent
083296ead6
commit
c6cbca25fe
@ -111,7 +111,7 @@ describe('component: proxy', () => {
|
||||
expect(`Attempting to mutate public property "$data"`).toHaveBeenWarned()
|
||||
})
|
||||
|
||||
it('user', async () => {
|
||||
it('sink', async () => {
|
||||
const app = createApp()
|
||||
let instance: ComponentInternalInstance
|
||||
let instanceProxy: any
|
||||
@ -127,6 +127,6 @@ describe('component: proxy', () => {
|
||||
app.mount(Comp, nodeOps.createElement('div'))
|
||||
instanceProxy.foo = 1
|
||||
expect(instanceProxy.foo).toBe(1)
|
||||
expect(instance!.user.foo).toBe(1)
|
||||
expect(instance!.sink.foo).toBe(1)
|
||||
})
|
||||
})
|
||||
|
@ -132,7 +132,7 @@ describe('vnode', () => {
|
||||
mounted.el = {}
|
||||
const normalized = normalizeVNode(mounted)
|
||||
expect(normalized).not.toBe(mounted)
|
||||
expect(normalized).toEqual({ ...mounted, el: null })
|
||||
expect(normalized).toEqual(mounted)
|
||||
|
||||
// primitive types
|
||||
expect(normalizeVNode('foo')).toMatchObject({ type: Text, children: `foo` })
|
||||
@ -158,20 +158,6 @@ describe('vnode', () => {
|
||||
expect(cloned2).toEqual(node2)
|
||||
expect(cloneVNode(node2)).toEqual(node2)
|
||||
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', () => {
|
||||
|
@ -9,14 +9,17 @@ import { callWithAsyncErrorHandling, ErrorTypeStrings } from './errorHandling'
|
||||
import { warn } from './warning'
|
||||
import { capitalize } from '@vue/shared'
|
||||
import { pauseTracking, resumeTracking, DebuggerEvent } from '@vue/reactivity'
|
||||
import { registerKeepAliveHook } from './keepAlive'
|
||||
|
||||
function injectHook(
|
||||
export function injectHook(
|
||||
type: LifecycleHooks,
|
||||
hook: Function,
|
||||
target: ComponentInternalInstance | null
|
||||
target: ComponentInternalInstance | null = currentInstance,
|
||||
prepend: boolean = false
|
||||
) {
|
||||
if (target) {
|
||||
;(target[type] || (target[type] = [])).push((...args: unknown[]) => {
|
||||
const hooks = target[type] || (target[type] = [])
|
||||
const wrappedHook = (...args: unknown[]) => {
|
||||
if (target.isUnmounted) {
|
||||
return
|
||||
}
|
||||
@ -31,7 +34,12 @@ function injectHook(
|
||||
setCurrentInstance(null)
|
||||
resumeTracking()
|
||||
return res
|
||||
})
|
||||
}
|
||||
if (prepend) {
|
||||
hooks.unshift(wrappedHook)
|
||||
} else {
|
||||
hooks.push(wrappedHook)
|
||||
}
|
||||
} else if (__DEV__) {
|
||||
const apiName = `on${capitalize(
|
||||
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
|
||||
) => (hook: T, target: ComponentInternalInstance | null = currentInstance) =>
|
||||
injectHook(lifecycle, hook, target)
|
||||
@ -76,3 +84,17 @@ export type ErrorCapturedHook = (
|
||||
export const onErrorCaptured = createHook<ErrorCapturedHook>(
|
||||
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)
|
||||
}
|
||||
|
@ -26,6 +26,8 @@ import {
|
||||
onRenderTracked,
|
||||
onBeforeUnmount,
|
||||
onUnmounted,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onRenderTriggered,
|
||||
DebuggerHook,
|
||||
ErrorCapturedHook
|
||||
@ -226,8 +228,8 @@ export function applyOptions(
|
||||
mounted,
|
||||
beforeUpdate,
|
||||
updated,
|
||||
// TODO activated
|
||||
// TODO deactivated
|
||||
activated,
|
||||
deactivated,
|
||||
beforeUnmount,
|
||||
unmounted,
|
||||
renderTracked,
|
||||
@ -377,6 +379,12 @@ export function applyOptions(
|
||||
if (updated) {
|
||||
onUpdated(updated.bind(ctx))
|
||||
}
|
||||
if (activated) {
|
||||
onActivated(activated.bind(ctx))
|
||||
}
|
||||
if (deactivated) {
|
||||
onDeactivated(deactivated.bind(ctx))
|
||||
}
|
||||
if (errorCaptured) {
|
||||
onErrorCaptured(errorCaptured.bind(ctx))
|
||||
}
|
||||
|
@ -42,6 +42,7 @@ export interface FunctionalComponent<P = {}> {
|
||||
}
|
||||
|
||||
export type Component = ComponentOptions | FunctionalComponent
|
||||
export { ComponentOptions }
|
||||
|
||||
type LifecycleHook = Function[] | null
|
||||
|
||||
@ -89,13 +90,10 @@ export interface ComponentInternalInstance {
|
||||
// after initialized (e.g. inline handlers)
|
||||
renderCache: (Function | VNode)[] | null
|
||||
|
||||
// assets for fast resolution
|
||||
components: Record<string, Component>
|
||||
directives: Record<string, Directive>
|
||||
|
||||
asyncDep: Promise<any> | null
|
||||
asyncResult: unknown
|
||||
asyncResolved: boolean
|
||||
|
||||
// the rest are only for stateful components
|
||||
renderContext: Data
|
||||
data: Data
|
||||
@ -108,11 +106,17 @@ export interface ComponentInternalInstance {
|
||||
refs: Data
|
||||
emit: Emit
|
||||
|
||||
// user namespace
|
||||
user: { [key: string]: any }
|
||||
// suspense related
|
||||
asyncDep: Promise<any> | null
|
||||
asyncResult: unknown
|
||||
asyncResolved: boolean
|
||||
|
||||
// storage for any extra properties
|
||||
sink: { [key: string]: any }
|
||||
|
||||
// lifecycle
|
||||
isUnmounted: boolean
|
||||
isDeactivated: boolean
|
||||
[LifecycleHooks.BEFORE_CREATE]: LifecycleHook
|
||||
[LifecycleHooks.CREATED]: LifecycleHook
|
||||
[LifecycleHooks.BEFORE_MOUNT]: LifecycleHook
|
||||
@ -173,11 +177,13 @@ export function createComponentInstance(
|
||||
asyncResolved: false,
|
||||
|
||||
// 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
|
||||
// not using enums here because it results in computed properties
|
||||
isUnmounted: false,
|
||||
isDeactivated: false,
|
||||
bc: null,
|
||||
c: null,
|
||||
bm: null,
|
||||
|
@ -73,7 +73,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
|
||||
propsProxy,
|
||||
accessCache,
|
||||
type,
|
||||
user
|
||||
sink
|
||||
} = target
|
||||
// fast path for unscopables when using `with` block
|
||||
if (__RUNTIME_COMPILE__ && (key as any) === Symbol.unscopables) {
|
||||
@ -128,8 +128,8 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
|
||||
return instanceWatch.bind(target)
|
||||
}
|
||||
}
|
||||
if (hasOwn(user, key)) {
|
||||
return user[key]
|
||||
if (hasOwn(sink, key)) {
|
||||
return sink[key]
|
||||
} else if (__DEV__ && currentRenderingInstance != null) {
|
||||
warn(
|
||||
`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)
|
||||
return false
|
||||
} else {
|
||||
target.user[key] = value
|
||||
target.sink[key] = value
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
@ -51,6 +51,7 @@ import {
|
||||
queueEffectWithSuspense
|
||||
} from './suspense'
|
||||
import { ErrorCodes, callWithErrorHandling } from './errorHandling'
|
||||
import { KeepAliveSink } from './keepAlive'
|
||||
|
||||
export interface RendererOptions<HostNode = any, HostElement = any> {
|
||||
patchProp(
|
||||
@ -131,7 +132,7 @@ function isSameType(n1: VNode, n2: VNode): boolean {
|
||||
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++) {
|
||||
hooks[i](arg)
|
||||
}
|
||||
@ -755,6 +756,13 @@ export function createRenderer<
|
||||
optimized: boolean
|
||||
) {
|
||||
if (n1 == null) {
|
||||
if (n2.shapeFlag & ShapeFlags.STATEFUL_COMPONENT_KEPT_ALIVE) {
|
||||
;(parentComponent!.sink as KeepAliveSink).activate(
|
||||
n2,
|
||||
container,
|
||||
anchor
|
||||
)
|
||||
} else {
|
||||
mountComponent(
|
||||
n2,
|
||||
container,
|
||||
@ -763,6 +771,7 @@ export function createRenderer<
|
||||
parentSuspense,
|
||||
isSVG
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const instance = (n2.component = n1.component)!
|
||||
|
||||
@ -816,8 +825,17 @@ export function createRenderer<
|
||||
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
|
||||
const propsOptions = (initialVNode.type as Component).props
|
||||
const propsOptions = Comp.props
|
||||
resolveProps(instance, initialVNode.props, propsOptions)
|
||||
resolveSlots(instance, initialVNode.children)
|
||||
|
||||
@ -1381,7 +1399,11 @@ export function createRenderer<
|
||||
}
|
||||
|
||||
if (shapeFlag & ShapeFlags.COMPONENT) {
|
||||
if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT_SHOULD_KEEP_ALIVE) {
|
||||
;(parentComponent!.sink as KeepAliveSink).deactivate(vnode)
|
||||
} else {
|
||||
unmountComponent(vnode.component!, parentSuspense, doRemove)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -20,6 +20,8 @@ export {
|
||||
} from './vnode'
|
||||
// VNode type symbols
|
||||
export { Text, Comment, Fragment, Portal, Suspense } from './vnode'
|
||||
// Internal Components
|
||||
export { KeepAlive } from './keepAlive'
|
||||
// VNode flags
|
||||
export { PublicShapeFlags as ShapeFlags } from './shapeFlags'
|
||||
export { PublicPatchFlags as PatchFlags } from '@vue/shared'
|
||||
|
249
packages/runtime-core/src/keepAlive.ts
Normal file
249
packages/runtime-core/src/keepAlive.ts
Normal 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)
|
||||
}
|
@ -8,6 +8,8 @@ export const enum ShapeFlags {
|
||||
ARRAY_CHILDREN = 1 << 4,
|
||||
SLOTS_CHILDREN = 1 << 5,
|
||||
SUSPENSE = 1 << 6,
|
||||
STATEFUL_COMPONENT_SHOULD_KEEP_ALIVE = 1 << 7,
|
||||
STATEFUL_COMPONENT_KEPT_ALIVE = 1 << 8,
|
||||
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
|
||||
}
|
||||
|
||||
|
@ -264,13 +264,14 @@ export function cloneVNode<T, U>(
|
||||
appContext: vnode.appContext,
|
||||
dirs: vnode.dirs,
|
||||
|
||||
// these should be set to null since they should only be present on
|
||||
// mounted VNodes. If they are somehow not null, this means we have
|
||||
// encountered an already-mounted vnode being used again.
|
||||
component: null,
|
||||
suspense: null,
|
||||
el: null,
|
||||
anchor: null
|
||||
// These should technically only be non-null on mounted VNodes. However,
|
||||
// they *should* be copied for kept-alive vnodes. So we just always copy
|
||||
// them since them being non-null during a mount doesn't affect the logic as
|
||||
// they will simply be overwritten.
|
||||
component: vnode.component,
|
||||
suspense: vnode.suspense,
|
||||
el: vnode.el,
|
||||
anchor: vnode.anchor
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user