Reading Vue Keep-Alive.
date: 2024-06-16
Motivation
I wanna implement some builtin components in vapor.
At first, I'll implement a KeepAlive
component.
However, I don't know how non-vapor KeepAlive
builtin component works.
So, let's read original source.
Reading Some Test Cases
At first, I'll read some test cases.
Before reading some details of concrete internal implementation, I should read interfaces expected working.
The source:
Previous knowledge:
Mainly, using tow components defined with Options API called one
and two
.
They are set on beforeEach
.
The tow components are mostly some, and configure mock function at lifecycle options.
Maybe, KeepAlive component's test case are mainly about lifecycle.
hmm... btw, vapor has some problem about lifecycle, bellow:
https://github.com/vuejs/core-vapor/issues/216
If I've implemented KeepAlive
component, some cases will be not worked.
hmmmmmmm, should I fix it before implementing KeepAlive
component? I hesitate a little...
Well, leaving aside which one to do first, for now let's read some test cases and KeepAlive
component's source!
Test Case 1
I don't wanna paste all test cases, I'll pick up some cases. For now, the first case.
test('should preserve state', async () => {
const viewRef = ref('one')
const instanceRef = ref<any>(null)
const App = {
render() {
return h(KeepAlive, null, {
default: () => h(views[viewRef.value], { ref: instanceRef }),
})
},
}
render(h(App), root)
expect(serializeInner(root)).toBe(`<div>one</div>`)
instanceRef.value.msg = 'changed'
await nextTick()
expect(serializeInner(root)).toBe(`<div>changed</div>`)
viewRef.value = 'two'
await nextTick()
expect(serializeInner(root)).toBe(`<div>two</div>`)
viewRef.value = 'one'
await nextTick()
expect(serializeInner(root)).toBe(`<div>changed</div>`)
})
I see.. state preservation.
The reason of appearing instanceRef.value.msg
is component defs on beforeEach
.
render(this: any) {
return h('div', this.msg)
},
Ok, it's quite easy.
I'll read all of others.
should call correct lifecycle hooks
test lifecycle via assertHookCalls
.
not difficult.
that being said, I may have to implement activated
/deactivated
hook too.
because it used in test cases.
should call correct lifecycle hooks when toggle the KeepAlive first
one.render = () => h(two)
const toggle = ref(true)
const App = {
render() {
return h(KeepAlive, () => (toggle.value ? h(one) : null))
},
}
hmm, it's ok.
should call lifecycle hooks on nested components when root component no hooks
related: https://github.com/vuejs/core/issues/1742
ha?
ha...?
return h(KeepAlive, () => (toggle.value ? h(one) : null))
ah, I see.
should call correct hooks for nested keep-alive
one.render = () => h(KeepAlive, () => (toggle2.value ? h(two) : null))
const toggle1 = ref(true)
const App = {
render() {
return h(KeepAlive, () => (toggle1.value ? h(one) : null))
},
}
OK.
props
Oh, so we also need to implement KeepAlive Props.
assertNameMatch
I'll read a little more.
async function assertNameMatch(props: KeepAliveProps) {
const outerRef = ref(true)
const viewRef = ref('one')
const App = {
render() {
return outerRef.value
? h(KeepAlive, props, () => h(views[viewRef.value]))
// ...
it's just pass the props to KeepAlive
. ok.
ok, ok, just a cases of include
, exclude
, max
props.
it's easy.
cache invalidation
I am not good at cache invalidation.
uu...
on include change
on exclude change
Ok. but, how relates to caching...?
on include change + view switch
// two should be pruned
assertHookCalls(two, [1, 1, 1, 1, 1])
Yes!
on exclude change + view switch
excludeRef.value = 'two'
// two should be pruned
assertHookCalls(two, [1, 1, 1, 1, 1])
Yes, Yes.
Ah, if same components paths are set to both include
and exclude
, how does it work...? (It seems a bit suspicious)
Is it written in the document?
Is this not written? 😵💫😵💫
Well, according to the test cases, it seems to be prior to exclude
.
should not prune current active instance
OK.
should not cache anonymous component when include is specified
should cache anonymous components if include is not specified
relates: https://github.com/vuejs/vue/issues/6938
haha, exactly.
I never realized this until I read the test case.
Realizing my own shortcomings.
h(
KeepAlive,
{
include: include ? 'one' : undefined,
},
() => h(views[viewRef.value]),
)
assert(1, include ? 2 : 1)
Ok.
should not destroy active instance when pruning cache
includeRef.value = ['foo', 'bar']
await nextTick()
includeRef.value = []
await nextTick()
expect(Foo.unmounted).not.toHaveBeenCalled()
OK.
should update re-activated component if props have changed
render(h(App), root)
expect(serializeInner(root)).toBe(`0`)
toggle.value = false
await nextTick()
expect(serializeInner(root)).toBe(`<!---->`)
n.value++
await nextTick()
toggle.value = true
await nextTick()
expect(serializeInner(root)).toBe(`1`)
OK.
should call correct vnode hooks
?
Vapor doesn't have vnode hooks.
Ignore.
should work with cloned root due to scopeId / fallthrough attrs
relates: vuejs/core #1511 | KeepAlive with keyed child and scoped style not working
const App = {
__scopeId: 'foo',
// ...
}
expect(serializeInner(root)).toBe(`<div foo>one</div>`)
instanceRef.value.msg = 'changed'
await nextTick()
expect(serializeInner(root)).toBe(`<div foo>changed</div>`)
viewRef.value = 'two'
await nextTick()
expect(serializeInner(root)).toBe(`<div foo>two</div>`)
viewRef.value = 'one'
await nextTick()
expect(serializeInner(root)).toBe(`<div foo>changed</div>`)
OK.
// #1513
test('should work with cloned root due to scopeId / fallthrough attrs', async () => {
//...
})
in source code, #1513 is mentioned, but it's incorrect. haha.
relates: vuejs/core #1513 | KeepAlive does not work with a child whose key = NaN
NaN === NaN // false
ahhhhhhhhhhhhhhhhhhhh
should work with async component
Vapor has not async component yet.
worked at https://github.com/vuejs/core-vapor/pull/239.
however, it's worth to read tests.
// async component has not been resolved
expect(serializeInner(root)).toBe('<!---->')
resolve!({
name: 'Foo',
data: () => ({ count: 0 }),
render() {
return h('p', this.count)
},
})
await timeout()
// resolved
expect(serializeInner(root)).toBe('<p>0</p>')
// change state + toggle out
instanceRef.value.count++
toggle.value = false
await nextTick()
expect(serializeInner(root)).toBe('<!---->')
// toggle in, state should be maintained
toggle.value = true
await nextTick()
expect(serializeInner(root)).toBe('<p>1</p>')
OK.
handle error in async onActivated
relates:
vuejs/core #4976 | errorHandler doesn't catch errors from onActivated when callback is async
const Child = {
setup() {
onActivated(async () => {
throw err
})
},
render() {},
}
app.config.errorHandler = handler
app.mount(nodeOps.createElement('div'))
await nextTick()
expect(handler).toHaveBeenCalledWith(err, {}, 'activated hook')
OK.
should avoid unmount later included components
relates:
vuejs/core #3648 | 使用keepAlive缓存路由,首次跳别的路由时候,被缓存的路由会执行unmounted方法
app.mount(root)
expect(serializeInner(root)).toBe(`A`)
expect(mountedA).toHaveBeenCalledTimes(1)
expect(unmountedA).toHaveBeenCalledTimes(0)
expect(activatedA).toHaveBeenCalledTimes(0)
expect(deactivatedA).toHaveBeenCalledTimes(0)
expect(mountedB).toHaveBeenCalledTimes(0)
expect(unmountedB).toHaveBeenCalledTimes(0)
include.push('A') // cache A
await nextTick()
current.value = B // toggle to B
await nextTick()
expect(serializeInner(root)).toBe(`B`)
expect(mountedA).toHaveBeenCalledTimes(1)
expect(unmountedA).toHaveBeenCalledTimes(0)
expect(activatedA).toHaveBeenCalledTimes(0)
expect(deactivatedA).toHaveBeenCalledTimes(1)
expect(mountedB).toHaveBeenCalledTimes(1)
expect(unmountedB).toHaveBeenCalledTimes(0)
OK.
There are so many edge cases!!
I have read all the tests for now!
Reading Actual Implementation
Well, first, where should I start reading from...
isKeepAlive
export const isKeepAlive = (vnode: VNode): boolean =>
(vnode.type as any).__isKeepAlive
Where is isKeepAlive
called...?
->
- KeepAlive.ts registerKeepAliveHook
- apiAsyncComponent.ts asyncComponent defs setup/load.then
- componentSlots.ts normalizeVNodeSlots
- BaseTransition.ts Transition/emptyPlaceholder, getInnerChild
OK.
Now, vapor has not BaseTransition yet.
So,I'll be careful about apiAsyncComponent and componentSlots.
Basically, KeepAlive
is just component definition.
Let's read the component options. (maybe, internal setup process is main)
Ah? Vapor may not have renderContext...
We need to think about what to do.
And we need to think where to put the cache too.
Shared Contexts have signature like below:
export interface KeepAliveContext extends ComponentRenderContext {
renderer: RendererInternals
activate: (
vnode: VNode,
container: RendererElement,
anchor: RendererNode | null,
namespace: ElementNamespace,
optimized: boolean,
) => void
deactivate: (vnode: VNode) => void
}
and, setup shared context methods behavior in setup option.
const KeepAliveImpl: ComponentOptions = {
name: `KeepAlive`,
// ...
setup() {
const instance = getCurrentInstance()!
const sharedContext = instance.ctx as KeepAliveContext
// ...
sharedContext.activate = (vnode, container, anchor, namespace, optimized) => {
// ...
}
sharedContext.deactivate = (vnode) => {
// ...
}
// ..
return () => {
// ...
}
},
}
As a internal methods, unmount, pruneCache, pruneCacheEntry, cacheSubtree is exist.
And the others, there some implementations about lifecycle.
- include, exclude watcher
- onMounted
- onUpdated
- onBeforeUnmount
Fow now, I'll read the details!
sharedContext.activate
move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
???
What is this all about, right away?
I don't really understand.
Next, it just seems to be doing patching and toggling isDeactivated
in queuePostRenderEffect, as well as executing lifecycle hooks.
I don't really understand why invoke move
, however, the other parts are not difficult.
sharedContext.deactivate
move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
I see.
Is it process for transition?
and the others are just lifecycle hooks (and toggle related flags).
internal methods
unmount
It's just sharedContext.renderer.unmount wrapper. (resetShapeFlag + unmount)
Maybe, vapor doesn't need to this method.
pruneCache
As the name suggests, it's a method to prune the cache.
signature:
function pruneCache(filter?: (name: string) => boolean)
usage:
pruneCache(name => matches(include, name))
And, pruneCache
calls pruneCacheEntry
.
pruneCacheEntry
signature:
function pruneCacheEntry(key: CacheKey)
Ff current vnode is not exist or vnode is not same type, unmount cached node.
Non-vapor use vnode as caches, so we need to think about how to cache in vapor...
cacheSubtree
caching vnode subtree.
cache.set(pendingCacheKey, getInnerChild(instance.subTree))
Lifecycle and watchers
And then, I'll read handler subscriptions.
onMounted:
onMounted(cacheSubtree)
onUpdated:
onUpdated(cacheSubtree)
onBeforeUnmount:
call deactivated hook and unmount cache.
onBeforeUnmount(() => {
cache.forEach(cached => {
// ...
if (cached.type === vnode.type && cached.key === vnode.key) {
const da = vnode.component!.da
da && queuePostRenderEffect(da, suspense)
} else {
unmount(cached).
}
})
})
watchers:
watch include/exclude props and prune cache.
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 },
)
Render part
OK! I read almost all of the source code!