]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(core): keep-alive
authorEvan You <yyx990803@gmail.com>
Wed, 30 Oct 2019 02:28:38 +0000 (22:28 -0400)
committerEvan You <yyx990803@gmail.com>
Wed, 30 Oct 2019 02:28:38 +0000 (22:28 -0400)
packages/runtime-core/__tests__/componentProxy.spec.ts
packages/runtime-core/__tests__/vnode.spec.ts
packages/runtime-core/src/apiLifecycle.ts
packages/runtime-core/src/apiOptions.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/componentProxy.ts
packages/runtime-core/src/createRenderer.ts
packages/runtime-core/src/index.ts
packages/runtime-core/src/keepAlive.ts [new file with mode: 0644]
packages/runtime-core/src/shapeFlags.ts
packages/runtime-core/src/vnode.ts

index aa951c3a60957a7e35110e8d622ef2220192a7c5..4c46875c0bdf44c12f9ab4ab1ec664e5e430af59 100644 (file)
@@ -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)
   })
 })
index c2aaf21ede0d02b37608d9c6cca21c9040928405..cf203dfa17e2548922712e573a9d1c5172a56b78 100644 (file)
@@ -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', () => {
index 9e8709f2e14fca9985b6b69bb7ae1851ba2f4530..50f796f5d6097fba354d748c2a64988b6392e7f6 100644 (file)
@@ -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)
+}
index 9bf408b29a5ec408efd94bbad11bd6efc8dca883..4054e3d39927ffbc1adf43c666d3d8175be9c07e 100644 (file)
@@ -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))
   }
index 6125030b21c24a95e97b952ce714f56ce8737e02..2bfab7bb83029796e8a61f0154428aeabbccbb90 100644 (file)
@@ -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,
index e39022c15f9d63ef291f245d28ffd22aabe7ef4a..6cc1fcf87f8da13425f38f87279cb8a419e38dbe 100644 (file)
@@ -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
   }
index 14ebb90f0d41e1f4e46251e5908018e332d6c94a..4151b3d7e8c2d0b3c47d96712e43c84f63e6ac4f 100644 (file)
@@ -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,14 +756,22 @@ export function createRenderer<
     optimized: boolean
   ) {
     if (n1 == null) {
-      mountComponent(
-        n2,
-        container,
-        anchor,
-        parentComponent,
-        parentSuspense,
-        isSVG
-      )
+      if (n2.shapeFlag & ShapeFlags.STATEFUL_COMPONENT_KEPT_ALIVE) {
+        ;(parentComponent!.sink as KeepAliveSink).activate(
+          n2,
+          container,
+          anchor
+        )
+      } else {
+        mountComponent(
+          n2,
+          container,
+          anchor,
+          parentComponent,
+          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) {
-      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
     }
 
index 220cc45f43753d3b5deb35227284e96cc5c2e874..0361a1739ccb91e344f5c4d3b3ea785a751651cd 100644 (file)
@@ -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'
diff --git a/packages/runtime-core/src/keepAlive.ts b/packages/runtime-core/src/keepAlive.ts
new file mode 100644 (file)
index 0000000..93d8fa4
--- /dev/null
@@ -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)
+}
index baa2328b7c4aab3b333dbb66bb499cddf5d2f888..624e827e029e4fca2ff5cf90cbb40f113ea2fbd4 100644 (file)
@@ -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
 }
 
index 882ba059ad3f13bfe66368ac18d6f355602fd5ca..26391645b008bf4c5c0e0a46a526b0fff3b500b1 100644 (file)
@@ -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
   }
 }