From b0a6ae2cf0156e040c3f5402eb8719853bf61039 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 8 Apr 2025 14:30:48 +0800 Subject: [PATCH] wip: vapor keepalive --- .../runtime-core/src/components/KeepAlive.ts | 17 +- packages/runtime-core/src/index.ts | 14 +- .../__tests__/components/KeepAlive.spec.ts | 122 +++++++++++ packages/runtime-vapor/src/apiTemplateRef.ts | 8 +- packages/runtime-vapor/src/component.ts | 28 ++- .../runtime-vapor/src/components/KeepAlive.ts | 196 ++++++++++++++++++ packages/runtime-vapor/src/index.ts | 1 + 7 files changed, 372 insertions(+), 14 deletions(-) create mode 100644 packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts create mode 100644 packages/runtime-vapor/src/components/KeepAlive.ts diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index 949de0bdd5..42f8518bb9 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -1,10 +1,10 @@ import { - type ComponentInternalInstance, type ComponentOptions, type ConcreteComponent, type GenericComponentInstance, type SetupContext, getComponentName, + getCurrentGenericInstance, getCurrentInstance, } from '../component' import { @@ -398,7 +398,7 @@ export const KeepAlive = (__COMPAT__ } } -function matches(pattern: MatchPattern, name: string): boolean { +export function matches(pattern: MatchPattern, name: string): boolean { if (isArray(pattern)) { return pattern.some((p: string | RegExp) => matches(p, name)) } else if (isString(pattern)) { @@ -413,14 +413,14 @@ function matches(pattern: MatchPattern, name: string): boolean { export function onActivated( hook: Function, - target?: ComponentInternalInstance | null, + target?: GenericComponentInstance | null, ): void { registerKeepAliveHook(hook, LifecycleHooks.ACTIVATED, target) } export function onDeactivated( hook: Function, - target?: ComponentInternalInstance | null, + target?: GenericComponentInstance | null, ): void { registerKeepAliveHook(hook, LifecycleHooks.DEACTIVATED, target) } @@ -428,7 +428,7 @@ export function onDeactivated( function registerKeepAliveHook( hook: Function & { __wdc?: Function }, type: LifecycleHooks, - target: ComponentInternalInstance | null = getCurrentInstance(), + target: GenericComponentInstance | null = getCurrentGenericInstance(), ) { // cache the deactivate branch check wrapper for injected hooks so the same // hook can be properly deduped by the scheduler. "__wdc" stands for "with @@ -454,8 +454,9 @@ function registerKeepAliveHook( // arrays. if (target) { let current = target.parent - while (current && current.parent && current.parent.vnode) { - if (isKeepAlive(current.parent.vnode)) { + while (current && current.parent) { + let parent = current.parent + if (isKeepAlive(parent.vapor ? (parent as any) : current.parent.vnode)) { injectToKeepAliveRoot(wrappedHook, type, target, current) } current = current.parent @@ -466,7 +467,7 @@ function registerKeepAliveHook( function injectToKeepAliveRoot( hook: Function & { __weh?: Function }, type: LifecycleHooks, - target: ComponentInternalInstance, + target: GenericComponentInstance, keepAliveRoot: GenericComponentInstance, ) { // injectHook wraps the original for error handling, so make sure to remove diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index c7150e38e8..0b0e58af1a 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -505,7 +505,7 @@ export { type VaporInteropInterface } from './apiCreateApp' /** * @internal */ -export { type RendererInternals, MoveType } from './renderer' +export { type RendererInternals, MoveType, invalidateMount } from './renderer' /** * @internal */ @@ -557,3 +557,15 @@ export { startMeasure, endMeasure } from './profiling' * @internal */ export { initFeatureFlags } from './featureFlags' +/** + * @internal + */ +export { getComponentName } from './component' +/** + * @internal + */ +export { matches, isKeepAlive } from './components/KeepAlive' +/** + * @internal + */ +export { devtoolsComponentAdded } from './devtools' diff --git a/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts b/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts new file mode 100644 index 0000000000..890459ad6a --- /dev/null +++ b/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts @@ -0,0 +1,122 @@ +import { type VaporComponent, createComponent } from '../../src/component' +import { makeRender } from '../_utils' +import { VaporKeepAlive } from '../../src/components/KeepAlive' +import { defineVaporComponent } from '../../src/apiDefineComponent' +import { child } from '../../src/dom/node' +import { setText } from '../../src/dom/prop' +import { template } from '../../src/dom/template' +import { renderEffect } from '../../src/renderEffect' +import { createTemplateRefSetter } from '../../src/apiTemplateRef' +import { createDynamicComponent } from '../../src/apiCreateDynamicComponent' +import { + nextTick, + onActivated, + onBeforeMount, + onDeactivated, + onMounted, + onUnmounted, + ref, +} from 'vue' + +const define = makeRender() + +describe('VaporKeepAlive', () => { + let one: VaporComponent + let two: VaporComponent + let oneTest: VaporComponent + let views: Record + let root: HTMLDivElement + + beforeEach(() => { + root = document.createElement('div') + one = defineVaporComponent({ + name: 'one', + setup(_, { expose }) { + onBeforeMount(vi.fn()) + onMounted(vi.fn()) + onActivated(vi.fn()) + onDeactivated(vi.fn()) + onUnmounted(vi.fn()) + + const msg = ref('one') + expose({ setMsg: (m: string) => (msg.value = m) }) + + const n0 = template(`
`)() as any + const x0 = child(n0) as any + renderEffect(() => setText(x0, msg.value)) + return n0 + }, + }) + oneTest = defineVaporComponent({ + name: 'oneTest', + setup() { + onBeforeMount(vi.fn()) + onMounted(vi.fn()) + onActivated(vi.fn()) + onDeactivated(vi.fn()) + onUnmounted(vi.fn()) + + const msg = ref('oneTest') + const n0 = template(`
`)() as any + const x0 = child(n0) as any + renderEffect(() => setText(x0, msg.value)) + return n0 + }, + }) + two = defineVaporComponent({ + name: 'two', + setup() { + onBeforeMount(vi.fn()) + onMounted(vi.fn()) + onActivated(vi.fn()) + onDeactivated(vi.fn()) + onUnmounted(vi.fn()) + + const msg = ref('two') + const n0 = template(`
`)() as any + const x0 = child(n0) as any + renderEffect(() => setText(x0, msg.value)) + return n0 + }, + }) + views = { + one, + oneTest, + two, + } + }) + + test('should preserve state', async () => { + const viewRef = ref('one') + const instanceRef = ref(null) + + const { mount } = define({ + setup() { + const setTemplateRef = createTemplateRefSetter() + const n4 = createComponent(VaporKeepAlive as any, null, { + default: () => { + const n0 = createDynamicComponent(() => views[viewRef.value]) as any + setTemplateRef(n0, instanceRef) + return n0 + }, + }) + return n4 + }, + }).create() + + mount(root) + expect(root.innerHTML).toBe(`
one
`) + + instanceRef.value.setMsg('changed') + await nextTick() + expect(root.innerHTML).toBe(`
changed
`) + + viewRef.value = 'two' + await nextTick() + expect(root.innerHTML).toBe(`
two
`) + + viewRef.value = 'one' + await nextTick() + expect(root.innerHTML).toBe(`
changed
`) + }) +}) diff --git a/packages/runtime-vapor/src/apiTemplateRef.ts b/packages/runtime-vapor/src/apiTemplateRef.ts index c5a6c5fb2b..06a2c63af1 100644 --- a/packages/runtime-vapor/src/apiTemplateRef.ts +++ b/packages/runtime-vapor/src/apiTemplateRef.ts @@ -20,6 +20,7 @@ import { isString, remove, } from '@vue/shared' +import { DynamicFragment } from './block' export type NodeRef = string | Ref | ((ref: Element) => void) export type RefEl = Element | VaporComponentInstance @@ -49,8 +50,11 @@ export function setRef( if (!instance || instance.isUnmounted) return const setupState: any = __DEV__ ? instance.setupState || {} : null - const refValue = isVaporComponent(el) ? getExposed(el) || el : el - + const refValue = isVaporComponent(el) + ? getExposed(el) || el + : el instanceof DynamicFragment + ? getExposed(el.nodes as VaporComponentInstance) || el.nodes + : el const refs = instance.refs === EMPTY_OBJ ? (instance.refs = {}) : instance.refs diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 548babebf8..118ffe5406 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -15,6 +15,7 @@ import { currentInstance, endMeasure, expose, + isKeepAlive, nextUid, popWarningContext, pushWarningContext, @@ -60,6 +61,7 @@ import { import { hmrReload, hmrRerender } from './hmr' import { isHydrating, locateHydrationNode } from './dom/hydration' import { insertionAnchor, insertionParent } from './insertionState' +import type { KeepAliveInstance } from './components/KeepAlive' export { currentInstance } from '@vue/runtime-dom' @@ -270,7 +272,7 @@ export function createComponent( onScopeDispose(() => unmountComponent(instance), true) if (!isHydrating && _insertionParent) { - insert(instance.block, _insertionParent, _insertionAnchor) + mountComponent(instance, _insertionParent, _insertionAnchor) } return instance @@ -493,14 +495,24 @@ export function createComponentWithFallback( export function mountComponent( instance: VaporComponentInstance, - parent: ParentNode, + parentNode: ParentNode, anchor?: Node | null | 0, ): void { + let parent + if ( + (parent = instance.parent) && + isKeepAlive(parent as any) && + (parent as KeepAliveInstance).isKeptAlive(instance) + ) { + ;(parent as KeepAliveInstance).activate(instance, parentNode, anchor as any) + return + } + if (__DEV__) { startMeasure(instance, `mount`) } if (instance.bm) invokeArrayFns(instance.bm) - insert(instance.block, parent, anchor) + insert(instance.block, parentNode, anchor) if (instance.m) queuePostFlushCb(() => invokeArrayFns(instance.m!)) instance.isMounted = true if (__DEV__) { @@ -512,6 +524,16 @@ export function unmountComponent( instance: VaporComponentInstance, parentNode?: ParentNode, ): void { + let parent + if ( + (parent = instance.parent) && + isKeepAlive(parent as any) && + (parent as KeepAliveInstance).shouldKeepAlive(instance) + ) { + ;(parent as KeepAliveInstance).deactivate(instance) + return + } + if (instance.isMounted && !instance.isUnmounted) { if (__DEV__ && instance.type.__hmrId) { unregisterHMR(instance) diff --git a/packages/runtime-vapor/src/components/KeepAlive.ts b/packages/runtime-vapor/src/components/KeepAlive.ts new file mode 100644 index 0000000000..fe44630398 --- /dev/null +++ b/packages/runtime-vapor/src/components/KeepAlive.ts @@ -0,0 +1,196 @@ +import { + type KeepAliveProps, + currentInstance, + devtoolsComponentAdded, + getComponentName, + invalidateMount, + matches, + onBeforeUnmount, + onMounted, + onUpdated, + queuePostFlushCb, + warn, + watch, +} from '@vue/runtime-dom' +import { type Block, insert, isFragment, isValidBlock, remove } from '../block' +import { + type VaporComponent, + type VaporComponentInstance, + isVaporComponent, +} from '../component' +import { defineVaporComponent } from '../apiDefineComponent' +import { invokeArrayFns, isArray } from '@vue/shared' + +export interface KeepAliveInstance extends VaporComponentInstance { + activate: ( + instance: VaporComponentInstance, + parentNode: ParentNode, + anchor: Node, + ) => void + deactivate: (instance: VaporComponentInstance) => void + shouldKeepAlive: (instance: VaporComponentInstance) => boolean + isKeptAlive: (instance: VaporComponentInstance) => boolean +} + +type CacheKey = PropertyKey | VaporComponent +type Cache = Map +type Keys = Set + +const VaporKeepAliveImpl = defineVaporComponent({ + name: 'VaporKeepAlive', + // @ts-expect-error + __isKeepAlive: true, + props: { + include: [String, RegExp, Array], + exclude: [String, RegExp, Array], + max: [String, Number], + }, + setup(props: KeepAliveProps, { slots }) { + if (!slots.default) { + return undefined + } + + const keepAliveInstance = currentInstance! as KeepAliveInstance + const cache: Cache = new Map() + const keys: Keys = new Set() + const storageContainer = document.createElement('div') + + if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { + ;(keepAliveInstance as any).__v_cache = cache + } + + const { include, exclude, max } = props + + function cacheBlock() { + // TODO suspense + const current = keepAliveInstance.block! + if (!isValidBlock(current)) return + + const block = getInnerBlock(current)! + if (!block) return + + const key = block.type + if (cache.has(key)) { + // 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(keys.values().next().value!) + } + } + cache.set(key, block) + } + + onMounted(cacheBlock) + onUpdated(cacheBlock) + onBeforeUnmount(() => cache.forEach(cached => remove(cached))) + + const children = slots.default() + if (isArray(children) && children.length > 1) { + if (__DEV__) { + warn(`KeepAlive should contain exactly one component child.`) + } + return children + } + + keepAliveInstance.activate = ( + instance: VaporComponentInstance, + parentNode: ParentNode, + anchor: Node, + ) => { + invalidateMount(instance.m) + invalidateMount(instance.a) + + const cachedBlock = cache.get(instance.type)! + insert((instance.block = cachedBlock.block), parentNode, anchor) + queuePostFlushCb(() => { + instance.isDeactivated = false + if (instance.a) invokeArrayFns(instance.a) + }) + + if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { + devtoolsComponentAdded(instance) + } + } + + keepAliveInstance.deactivate = (instance: VaporComponentInstance) => { + insert(instance.block, storageContainer) + queuePostFlushCb(() => { + if (instance.da) invokeArrayFns(instance.da) + instance.isDeactivated = true + }) + + if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { + devtoolsComponentAdded(instance) + } + } + + keepAliveInstance.shouldKeepAlive = (instance: VaporComponentInstance) => { + const name = getComponentName(instance.type) + if ( + (include && (!name || !matches(include, name))) || + (exclude && name && matches(exclude, name)) + ) { + return false + } + return true + } + + keepAliveInstance.isKeptAlive = (instance: VaporComponentInstance) => { + return cache.has(instance.type) + } + + function pruneCache(filter: (name: string) => boolean) { + cache.forEach((instance, key) => { + const name = getComponentName(instance.type) + if (name && !filter(name)) { + pruneCacheEntry(key) + } + }) + } + + function pruneCacheEntry(key: CacheKey) { + const cached = cache.get(key) + if (cached) { + remove(cached) + } + cache.delete(key) + keys.delete(key) + } + + // prune cache on include/exclude prop change + watch( + () => [props.include, props.exclude], + ([include, exclude]) => { + include && pruneCache(name => matches(include, name)) + exclude && pruneCache(name => !matches(exclude, name)) + }, + // prune post-render after `current` has been updated + { flush: 'post', deep: true }, + ) + + return children + }, +}) + +export const VaporKeepAlive = VaporKeepAliveImpl as any as { + __isKeepAlive: true + new (): { + $props: KeepAliveProps + $slots: { + default(): Block + } + } +} + +function getInnerBlock(block: Block): VaporComponentInstance | undefined { + if (isVaporComponent(block)) { + return block + } + if (isFragment(block)) { + return getInnerBlock(block.nodes) + } +} diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 682532fa4d..6cc06a72e8 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -3,6 +3,7 @@ export { createVaporApp, createVaporSSRApp } from './apiCreateApp' export { defineVaporComponent } from './apiDefineComponent' export { vaporInteropPlugin } from './vdomInterop' export type { VaporDirective } from './directives/custom' +export { VaporKeepAlive } from './components/KeepAlive' // compiler-use only export { insert, prepend, remove, isFragment, VaporFragment } from './block' -- 2.47.2