feat: directive lifecycle hooks in `v-for`, `v-if` and component (#123)

Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
This commit is contained in:
Rizumu Ayaka 2024-05-27 02:47:51 +08:00 committed by GitHub
parent 969f53f2e7
commit b5ecb72864
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 683 additions and 128 deletions

View File

@ -34,13 +34,13 @@ export class EffectScope {
*/ */
private index: number | undefined private index: number | undefined
constructor(public detached = false) { constructor(
this.parent = activeEffectScope public detached = false,
if (!detached && activeEffectScope) { parent = activeEffectScope,
this.index = ) {
(activeEffectScope.scopes || (activeEffectScope.scopes = [])).push( this.parent = parent
this, if (!detached && parent) {
) - 1 this.index = (parent.scopes || (parent.scopes = [])).push(this) - 1
} }
} }

View File

@ -77,7 +77,7 @@ describe('directive: v-show', () => {
}).render() }).render()
expect(host.innerHTML).toBe('<button>toggle</button><div>child</div>') expect(host.innerHTML).toBe('<button>toggle</button><div>child</div>')
expect(instance.dirs.get(n0)![0].dir).toBe(vShow) expect(instance.scope.dirs!.get(n0)![0].dir).toBe(vShow)
const btn = host.querySelector('button') const btn = host.querySelector('button')
btn?.click() btn?.click()

View File

@ -1,5 +1,16 @@
import { createFor, nextTick, ref, renderEffect } from '../src' import { NOOP } from '@vue/shared'
import {
type Directive,
children,
createFor,
nextTick,
ref,
renderEffect,
template,
withDirectives,
} from '../src'
import { makeRender } from './_utils' import { makeRender } from './_utils'
import { unmountComponent } from '../src/apiRender'
const define = makeRender() const define = makeRender()
@ -184,4 +195,92 @@ describe('createFor', () => {
await nextTick() await nextTick()
expect(host.innerHTML).toBe('<!--for-->') expect(host.innerHTML).toBe('<!--for-->')
}) })
test('should work with directive hooks', async () => {
const calls: string[] = []
const list = ref([0])
const update = ref(0)
const add = () => list.value.push(list.value.length)
const spySrcFn = vi.fn(() => list.value)
const vDirective: Directive = {
created: (el, { value }) => calls.push(`${value} created`),
beforeMount: (el, { value }) => calls.push(`${value} beforeMount`),
mounted: (el, { value }) => calls.push(`${value} mounted`),
beforeUpdate: (el, { value }) => calls.push(`${value} beforeUpdate`),
updated: (el, { value }) => calls.push(`${value} updated`),
beforeUnmount: (el, { value }) => calls.push(`${value} beforeUnmount`),
unmounted: (el, { value }) => calls.push(`${value} unmounted`),
}
const t0 = template('<p></p>')
const { instance } = define(() => {
const n1 = createFor(spySrcFn, block => {
const n2 = t0()
const n3 = children(n2, 0)
withDirectives(n3, [[vDirective, () => block.s[0]]])
return [n2, NOOP]
})
renderEffect(() => update.value)
return [n1]
}).render()
await nextTick()
// `${item index} ${hook name}`
expect(calls).toEqual(['0 created', '0 beforeMount', '0 mounted'])
calls.length = 0
expect(spySrcFn).toHaveBeenCalledTimes(1)
add()
await nextTick()
expect(calls).toEqual([
'0 beforeUpdate',
'1 created',
'1 beforeMount',
'0 updated',
'1 mounted',
])
calls.length = 0
expect(spySrcFn).toHaveBeenCalledTimes(2)
list.value.reverse()
await nextTick()
expect(calls).toEqual([
'1 beforeUpdate',
'0 beforeUpdate',
'1 updated',
'0 updated',
])
expect(spySrcFn).toHaveBeenCalledTimes(3)
list.value.reverse()
await nextTick()
calls.length = 0
expect(spySrcFn).toHaveBeenCalledTimes(4)
update.value++
await nextTick()
expect(calls).toEqual([
'0 beforeUpdate',
'1 beforeUpdate',
'0 updated',
'1 updated',
])
calls.length = 0
expect(spySrcFn).toHaveBeenCalledTimes(4)
list.value.pop()
await nextTick()
expect(calls).toEqual([
'0 beforeUpdate',
'1 beforeUnmount',
'0 updated',
'1 unmounted',
])
calls.length = 0
expect(spySrcFn).toHaveBeenCalledTimes(5)
unmountComponent(instance)
expect(calls).toEqual(['0 beforeUnmount', '0 unmounted'])
expect(spySrcFn).toHaveBeenCalledTimes(5)
})
}) })

View File

@ -1,4 +1,5 @@
import { import {
children,
createIf, createIf,
insert, insert,
nextTick, nextTick,
@ -6,9 +7,11 @@ import {
renderEffect, renderEffect,
setText, setText,
template, template,
withDirectives,
} from '../src' } from '../src'
import type { Mock } from 'vitest' import type { Mock } from 'vitest'
import { makeRender } from './_utils' import { makeRender } from './_utils'
import { unmountComponent } from '../src/apiRender'
const define = makeRender() const define = makeRender()
@ -24,6 +27,8 @@ describe('createIf', () => {
let spyElseFn: Mock<any, any> let spyElseFn: Mock<any, any>
const count = ref(0) const count = ref(0)
const spyConditionFn = vi.fn(() => count.value)
// templates can be reused through caching. // templates can be reused through caching.
const t0 = template('<div></div>') const t0 = template('<div></div>')
const t1 = template('<p></p>') const t1 = template('<p></p>')
@ -34,7 +39,7 @@ describe('createIf', () => {
insert( insert(
createIf( createIf(
() => count.value, spyConditionFn,
// v-if // v-if
(spyIfFn ||= vi.fn(() => { (spyIfFn ||= vi.fn(() => {
const n2 = t1() const n2 = t1()
@ -55,24 +60,28 @@ describe('createIf', () => {
}).render() }).render()
expect(host.innerHTML).toBe('<div><p>zero</p><!--if--></div>') expect(host.innerHTML).toBe('<div><p>zero</p><!--if--></div>')
expect(spyConditionFn).toHaveBeenCalledTimes(1)
expect(spyIfFn!).toHaveBeenCalledTimes(0) expect(spyIfFn!).toHaveBeenCalledTimes(0)
expect(spyElseFn!).toHaveBeenCalledTimes(1) expect(spyElseFn!).toHaveBeenCalledTimes(1)
count.value++ count.value++
await nextTick() await nextTick()
expect(host.innerHTML).toBe('<div><p>1</p><!--if--></div>') expect(host.innerHTML).toBe('<div><p>1</p><!--if--></div>')
expect(spyConditionFn).toHaveBeenCalledTimes(2)
expect(spyIfFn!).toHaveBeenCalledTimes(1) expect(spyIfFn!).toHaveBeenCalledTimes(1)
expect(spyElseFn!).toHaveBeenCalledTimes(1) expect(spyElseFn!).toHaveBeenCalledTimes(1)
count.value++ count.value++
await nextTick() await nextTick()
expect(host.innerHTML).toBe('<div><p>2</p><!--if--></div>') expect(host.innerHTML).toBe('<div><p>2</p><!--if--></div>')
expect(spyConditionFn).toHaveBeenCalledTimes(3)
expect(spyIfFn!).toHaveBeenCalledTimes(1) expect(spyIfFn!).toHaveBeenCalledTimes(1)
expect(spyElseFn!).toHaveBeenCalledTimes(1) expect(spyElseFn!).toHaveBeenCalledTimes(1)
count.value = 0 count.value = 0
await nextTick() await nextTick()
expect(host.innerHTML).toBe('<div><p>zero</p><!--if--></div>') expect(host.innerHTML).toBe('<div><p>zero</p><!--if--></div>')
expect(spyConditionFn).toHaveBeenCalledTimes(4)
expect(spyIfFn!).toHaveBeenCalledTimes(1) expect(spyIfFn!).toHaveBeenCalledTimes(1)
expect(spyElseFn!).toHaveBeenCalledTimes(2) expect(spyElseFn!).toHaveBeenCalledTimes(2)
}) })
@ -124,4 +133,113 @@ describe('createIf', () => {
await nextTick() await nextTick()
expect(host.innerHTML).toBe('<!--if-->') expect(host.innerHTML).toBe('<!--if-->')
}) })
test('should work with directive hooks', async () => {
const calls: string[] = []
const show1 = ref(true)
const show2 = ref(true)
const update = ref(0)
const spyConditionFn1 = vi.fn(() => show1.value)
const spyConditionFn2 = vi.fn(() => show2.value)
const vDirective: any = {
created: (el: any, { value }: any) => calls.push(`${value} created`),
beforeMount: (el: any, { value }: any) =>
calls.push(`${value} beforeMount`),
mounted: (el: any, { value }: any) => calls.push(`${value} mounted`),
beforeUpdate: (el: any, { value }: any) =>
calls.push(`${value} beforeUpdate`),
updated: (el: any, { value }: any) => calls.push(`${value} updated`),
beforeUnmount: (el: any, { value }: any) =>
calls.push(`${value} beforeUnmount`),
unmounted: (el: any, { value }: any) => calls.push(`${value} unmounted`),
}
const t0 = template('<p></p>')
const { instance } = define(() => {
const n1 = createIf(
spyConditionFn1,
() => {
const n2 = t0()
withDirectives(children(n2, 0), [
[vDirective, () => (update.value, '1')],
])
return n2
},
() =>
createIf(
spyConditionFn2,
() => {
const n2 = t0()
withDirectives(children(n2, 0), [[vDirective, () => '2']])
return n2
},
() => {
const n2 = t0()
withDirectives(children(n2, 0), [[vDirective, () => '3']])
return n2
},
),
)
return [n1]
}).render()
await nextTick()
expect(calls).toEqual(['1 created', '1 beforeMount', '1 mounted'])
calls.length = 0
expect(spyConditionFn1).toHaveBeenCalledTimes(1)
expect(spyConditionFn2).toHaveBeenCalledTimes(0)
show1.value = false
await nextTick()
expect(calls).toEqual([
'1 beforeUnmount',
'2 created',
'2 beforeMount',
'1 unmounted',
'2 mounted',
])
calls.length = 0
expect(spyConditionFn1).toHaveBeenCalledTimes(2)
expect(spyConditionFn2).toHaveBeenCalledTimes(1)
show2.value = false
await nextTick()
expect(calls).toEqual([
'2 beforeUnmount',
'3 created',
'3 beforeMount',
'2 unmounted',
'3 mounted',
])
calls.length = 0
expect(spyConditionFn1).toHaveBeenCalledTimes(2)
expect(spyConditionFn2).toHaveBeenCalledTimes(2)
show1.value = true
await nextTick()
expect(calls).toEqual([
'3 beforeUnmount',
'1 created',
'1 beforeMount',
'3 unmounted',
'1 mounted',
])
calls.length = 0
expect(spyConditionFn1).toHaveBeenCalledTimes(3)
expect(spyConditionFn2).toHaveBeenCalledTimes(2)
update.value++
await nextTick()
expect(calls).toEqual(['1 beforeUpdate', '1 updated'])
calls.length = 0
expect(spyConditionFn1).toHaveBeenCalledTimes(3)
expect(spyConditionFn2).toHaveBeenCalledTimes(2)
unmountComponent(instance)
expect(calls).toEqual(['1 beforeUnmount', '1 unmounted'])
expect(spyConditionFn1).toHaveBeenCalledTimes(3)
expect(spyConditionFn2).toHaveBeenCalledTimes(2)
})
}) })

View File

@ -1,14 +1,26 @@
import { type EffectScope, effectScope, isReactive } from '@vue/reactivity' import { getCurrentScope, isReactive, traverse } from '@vue/reactivity'
import { isArray, isObject, isString } from '@vue/shared' import { isArray, isObject, isString } from '@vue/shared'
import { createComment, createTextNode, insert, remove } from './dom/element' import {
import { renderEffect } from './renderEffect' createComment,
createTextNode,
insert,
remove as removeBlock,
} from './dom/element'
import { type Block, type Fragment, fragmentKey } from './apiRender' import { type Block, type Fragment, fragmentKey } from './apiRender'
import { warn } from './warning' import { warn } from './warning'
import { currentInstance } from './component'
import { componentKey } from './component' import { componentKey } from './component'
import { BlockEffectScope, isRenderEffectScope } from './blockEffectScope'
import {
createChildFragmentDirectives,
invokeWithMount,
invokeWithUnmount,
invokeWithUpdate,
} from './directivesChildFragment'
import type { DynamicSlot } from './componentSlots' import type { DynamicSlot } from './componentSlots'
interface ForBlock extends Fragment { interface ForBlock extends Fragment {
scope: EffectScope scope: BlockEffectScope
/** state, use short key since it's used a lot in generated code */ /** state, use short key since it's used a lot in generated code */
s: [item: any, key: any, index?: number] s: [item: any, key: any, index?: number]
update: () => void update: () => void
@ -16,9 +28,11 @@ interface ForBlock extends Fragment {
memo: any[] | undefined memo: any[] | undefined
} }
type Source = any[] | Record<any, any> | number | Set<any> | Map<any, any>
/*! #__NO_SIDE_EFFECTS__ */ /*! #__NO_SIDE_EFFECTS__ */
export const createFor = ( export const createFor = (
src: () => any[] | Record<any, any> | number | Set<any> | Map<any, any>, src: () => Source,
renderItem: (block: ForBlock) => [Block, () => void], renderItem: (block: ForBlock) => [Block, () => void],
getKey?: (item: any, key: any, index?: number) => any, getKey?: (item: any, key: any, index?: number) => any,
getMemo?: (item: any, key: any, index?: number) => any[], getMemo?: (item: any, key: any, index?: number) => any[],
@ -29,18 +43,34 @@ export const createFor = (
let oldBlocks: ForBlock[] = [] let oldBlocks: ForBlock[] = []
let newBlocks: ForBlock[] let newBlocks: ForBlock[]
let parent: ParentNode | undefined | null let parent: ParentNode | undefined | null
const update = getMemo ? updateWithMemo : updateWithoutMemo
const parentScope = getCurrentScope()!
const parentAnchor = __DEV__ ? createComment('for') : createTextNode() const parentAnchor = __DEV__ ? createComment('for') : createTextNode()
const ref: Fragment = { const ref: Fragment = {
nodes: oldBlocks, nodes: oldBlocks,
[fragmentKey]: true, [fragmentKey]: true,
} }
const update = getMemo ? updateWithMemo : updateWithoutMemo
once ? renderList() : renderEffect(renderList) const instance = currentInstance!
if (__DEV__ && (!instance || !isRenderEffectScope(parentScope))) {
warn('createFor() can only be used inside setup()')
}
createChildFragmentDirectives(
parentAnchor,
() => oldBlocks.map(b => b.scope),
// source getter
() => traverse(src(), 1),
// init cb
getValue => doFor(getValue()),
// effect cb
getValue => doFor(getValue()),
once,
)
return ref return ref
function renderList() { function doFor(source: any) {
const source = src()
const newLength = getLength(source) const newLength = getLength(source)
const oldLength = oldBlocks.length const oldLength = oldBlocks.length
newBlocks = new Array(newLength) newBlocks = new Array(newLength)
@ -225,7 +255,8 @@ export const createFor = (
idx: number, idx: number,
anchor: Node = parentAnchor, anchor: Node = parentAnchor,
): ForBlock { ): ForBlock {
const scope = effectScope() const scope = new BlockEffectScope(instance, parentScope)
const [item, key, index] = getItem(source, idx) const [item, key, index] = getItem(source, idx)
const block: ForBlock = (newBlocks[idx] = { const block: ForBlock = (newBlocks[idx] = {
nodes: null!, // set later nodes: null!, // set later
@ -239,8 +270,12 @@ export const createFor = (
const res = scope.run(() => renderItem(block))! const res = scope.run(() => renderItem(block))!
block.nodes = res[0] block.nodes = res[0]
block.update = res[1] block.update = res[1]
if (getMemo) block.update()
if (parent) insert(block.nodes, parent, anchor) invokeWithMount(scope, () => {
if (getMemo) block.update()
if (parent) insert(block.nodes, parent, anchor)
})
return block return block
} }
@ -275,10 +310,13 @@ export const createFor = (
} }
} }
} }
if (needsUpdate) {
block.s = [newItem, newKey, newIndex] block.s = [newItem, newKey, newIndex]
block.update() invokeWithUpdate(block.scope, () => {
} if (needsUpdate) {
block.update()
}
})
} }
function updateWithoutMemo( function updateWithoutMemo(
@ -287,20 +325,24 @@ export const createFor = (
newKey = block.s[1], newKey = block.s[1],
newIndex = block.s[2], newIndex = block.s[2],
) { ) {
if ( let needsUpdate =
newItem !== block.s[0] || newItem !== block.s[0] ||
newKey !== block.s[1] || newKey !== block.s[1] ||
newIndex !== block.s[2] || newIndex !== block.s[2] ||
!isReactive(newItem) !isReactive(newItem)
) {
block.s = [newItem, newKey, newIndex] block.s = [newItem, newKey, newIndex]
block.update() invokeWithUpdate(block.scope, () => {
} if (needsUpdate) {
block.update()
}
})
} }
function unmount({ nodes, scope }: ForBlock) { function unmount({ nodes, scope }: ForBlock) {
remove(nodes, parent!) invokeWithUnmount(scope, () => {
scope.stop() removeBlock(nodes, parent!)
})
} }
} }

View File

@ -1,7 +1,15 @@
import { renderEffect } from './renderEffect'
import { type Block, type Fragment, fragmentKey } from './apiRender' import { type Block, type Fragment, fragmentKey } from './apiRender'
import { type EffectScope, effectScope } from '@vue/reactivity' import { getCurrentScope } from '@vue/reactivity'
import { createComment, createTextNode, insert, remove } from './dom/element' import { createComment, createTextNode, insert, remove } from './dom/element'
import { currentInstance } from './component'
import { warn } from './warning'
import { BlockEffectScope, isRenderEffectScope } from './blockEffectScope'
import {
createChildFragmentDirectives,
invokeWithMount,
invokeWithUnmount,
invokeWithUpdate,
} from './directivesChildFragment'
type BlockFn = () => Block type BlockFn = () => Block
@ -18,7 +26,8 @@ export const createIf = (
let branch: BlockFn | undefined let branch: BlockFn | undefined
let parent: ParentNode | undefined | null let parent: ParentNode | undefined | null
let block: Block | undefined let block: Block | undefined
let scope: EffectScope | undefined let scope: BlockEffectScope | undefined
const parentScope = getCurrentScope()!
const anchor = __DEV__ ? createComment('if') : createTextNode() const anchor = __DEV__ ? createComment('if') : createTextNode()
const fragment: Fragment = { const fragment: Fragment = {
nodes: [], nodes: [],
@ -26,35 +35,37 @@ export const createIf = (
[fragmentKey]: true, [fragmentKey]: true,
} }
const instance = currentInstance!
if (__DEV__ && (!instance || !isRenderEffectScope(parentScope))) {
warn('createIf() can only be used inside setup()')
}
// TODO: SSR // TODO: SSR
// if (isHydrating) { // if (isHydrating) {
// parent = hydrationNode!.parentNode // parent = hydrationNode!.parentNode
// setCurrentHydrationNode(hydrationNode!) // setCurrentHydrationNode(hydrationNode!)
// } // }
if (once) { createChildFragmentDirectives(
doIf() anchor,
} else { () => (scope ? [scope] : []),
renderEffect(() => doIf()) // source getter
} condition,
// init cb
function doIf() { getValue => {
if ((newValue = !!condition()) !== oldValue) { newValue = !!getValue()
parent ||= anchor.parentNode doIf()
if (block) { },
scope!.stop() // effect cb
remove(block, parent!) getValue => {
if ((newValue = !!getValue()) !== oldValue) {
doIf()
} else if (scope) {
invokeWithUpdate(scope)
} }
if ((branch = (oldValue = newValue) ? b1 : b2)) { },
scope = effectScope() once,
fragment.nodes = block = scope.run(branch)! )
parent && insert(block, parent, anchor)
} else {
scope = block = undefined
fragment.nodes = []
}
}
}
// TODO: SSR // TODO: SSR
// if (isHydrating) { // if (isHydrating) {
@ -62,4 +73,19 @@ export const createIf = (
// } // }
return fragment return fragment
function doIf() {
parent ||= anchor.parentNode
if (block) {
invokeWithUnmount(scope!, () => remove(block!, parent!))
}
if ((branch = (oldValue = newValue) ? b1 : b2)) {
scope = new BlockEffectScope(instance, parentScope)
fragment.nodes = block = scope.run(branch)!
invokeWithMount(scope, () => parent && insert(block!, parent, anchor))
} else {
scope = block = undefined
fragment.nodes = []
}
}
} }

View File

@ -0,0 +1,36 @@
import { EffectScope } from '@vue/reactivity'
import type { ComponentInternalInstance } from './component'
import type { DirectiveBindingsMap } from './directives'
export class BlockEffectScope extends EffectScope {
/**
* instance
* @internal
*/
it: ComponentInternalInstance
/**
* isMounted
* @internal
*/
im: boolean
/**
* directives
* @internal
*/
dirs?: DirectiveBindingsMap
constructor(
instance: ComponentInternalInstance,
parentScope: EffectScope | null,
) {
super(false, parentScope || undefined)
this.im = false
this.it = instance
}
}
export function isRenderEffectScope(
scope: EffectScope | undefined,
): scope is BlockEffectScope {
return scope instanceof BlockEffectScope
}

View File

@ -1,7 +1,6 @@
import { EffectScope, isRef } from '@vue/reactivity' import { isRef } from '@vue/reactivity'
import { EMPTY_OBJ, hasOwn, isArray, isFunction } from '@vue/shared' import { EMPTY_OBJ, hasOwn, isArray, isFunction } from '@vue/shared'
import type { Block } from './apiRender' import type { Block } from './apiRender'
import type { DirectiveBinding } from './directives'
import { import {
type ComponentPropsOptions, type ComponentPropsOptions,
type NormalizedPropsOptions, type NormalizedPropsOptions,
@ -27,6 +26,7 @@ import { VaporLifecycleHooks } from './apiLifecycle'
import { warn } from './warning' import { warn } from './warning'
import { type AppContext, createAppContext } from './apiCreateVaporApp' import { type AppContext, createAppContext } from './apiCreateVaporApp'
import type { Data } from '@vue/runtime-shared' import type { Data } from '@vue/runtime-shared'
import { BlockEffectScope } from './blockEffectScope'
export type Component = FunctionalComponent | ObjectComponent export type Component = FunctionalComponent | ObjectComponent
@ -154,10 +154,9 @@ export interface ComponentInternalInstance {
parent: ComponentInternalInstance | null parent: ComponentInternalInstance | null
provides: Data provides: Data
scope: EffectScope scope: BlockEffectScope
component: Component component: Component
comps: Set<ComponentInternalInstance> comps: Set<ComponentInternalInstance>
dirs: Map<Node, DirectiveBinding[]>
rawProps: NormalizedRawProps rawProps: NormalizedRawProps
propsOptions: NormalizedPropsOptions propsOptions: NormalizedPropsOptions
@ -280,11 +279,10 @@ export function createComponentInstance(
parent, parent,
scope: new EffectScope(true /* detached */)!, scope: null!,
provides: parent ? parent.provides : Object.create(_appContext.provides), provides: parent ? parent.provides : Object.create(_appContext.provides),
component, component,
comps: new Set(), comps: new Set(),
dirs: new Map(),
// resolved props and emits options // resolved props and emits options
rawProps: null!, // set later rawProps: null!, // set later
@ -355,6 +353,7 @@ export function createComponentInstance(
*/ */
// [VaporLifecycleHooks.SERVER_PREFETCH]: null, // [VaporLifecycleHooks.SERVER_PREFETCH]: null,
} }
instance.scope = new BlockEffectScope(instance, parent && parent.scope)
initProps(instance, rawProps, !isFunction(component), once) initProps(instance, rawProps, !isFunction(component), once)
initSlots(instance, slots, dynamicSlots) initSlots(instance, slots, dynamicSlots)
instance.emit = emit.bind(null, instance) instance.emit = emit.bind(null, instance)

View File

@ -25,7 +25,7 @@ export function invokeLifecycle(
post ? queuePostFlushCb(fn) : fn() post ? queuePostFlushCb(fn) : fn()
} }
invokeDirectiveHook(instance, directive) invokeDirectiveHook(instance, directive, instance.scope)
} }
function invokeSub() { function invokeSub() {

View File

@ -1,32 +1,48 @@
import { isFunction } from '@vue/shared' import { invokeArrayFns, isFunction } from '@vue/shared'
import { import {
type ComponentInternalInstance, type ComponentInternalInstance,
currentInstance, currentInstance,
isVaporComponent, isVaporComponent,
setCurrentInstance,
} from './component' } from './component'
import { pauseTracking, resetTracking, traverse } from '@vue/reactivity' import {
import { VaporErrorCodes, callWithAsyncErrorHandling } from './errorHandling' EffectFlags,
import { renderEffect } from './renderEffect' ReactiveEffect,
type SchedulerJob,
getCurrentScope,
pauseTracking,
resetTracking,
traverse,
} from '@vue/reactivity'
import {
VaporErrorCodes,
callWithAsyncErrorHandling,
callWithErrorHandling,
} from './errorHandling'
import { queueJob, queuePostFlushCb } from './scheduler'
import { warn } from './warning' import { warn } from './warning'
import { type BlockEffectScope, isRenderEffectScope } from './blockEffectScope'
import { normalizeBlock } from './dom/element' import { normalizeBlock } from './dom/element'
export type DirectiveModifiers<M extends string = string> = Record<M, boolean> export type DirectiveModifiers<M extends string = string> = Record<M, boolean>
export interface DirectiveBinding<V = any, M extends string = string> { export interface DirectiveBinding<T = any, V = any, M extends string = string> {
instance: ComponentInternalInstance instance: ComponentInternalInstance
source?: () => V source?: () => V
value: V value: V
oldValue: V | null oldValue: V | null
arg?: string arg?: string
modifiers?: DirectiveModifiers<M> modifiers?: DirectiveModifiers<M>
dir: ObjectDirective<any, V> dir: ObjectDirective<T, V, M>
} }
export type DirectiveBindingsMap = Map<Node, DirectiveBinding[]>
export type DirectiveHook< export type DirectiveHook<
T = any | null, T = any | null,
V = any, V = any,
M extends string = string, M extends string = string,
> = (node: T, binding: DirectiveBinding<V, M>) => void > = (node: T, binding: DirectiveBinding<T, V, M>) => void
// create node -> `created` -> node operation -> `beforeMount` -> node mounted -> `mounted` // create node -> `created` -> node operation -> `beforeMount` -> node mounted -> `mounted`
// effect update -> `beforeUpdate` -> node updated -> `updated` // effect update -> `beforeUpdate` -> node updated -> `updated`
@ -43,7 +59,7 @@ export type ObjectDirective<T = any, V = any, M extends string = string> = {
[K in DirectiveHookName]?: DirectiveHook<T, V, M> | undefined [K in DirectiveHookName]?: DirectiveHook<T, V, M> | undefined
} & { } & {
/** Watch value deeply */ /** Watch value deeply */
deep?: boolean deep?: boolean | number
} }
export type FunctionDirective< export type FunctionDirective<
@ -86,9 +102,18 @@ export function withDirectives<T extends ComponentInternalInstance | Node>(
node = nodeOrComponent node = nodeOrComponent
} }
const instance = currentInstance let bindings: DirectiveBinding[]
if (!instance.dirs.has(node)) instance.dirs.set(node, []) const instance = currentInstance!
const bindings = instance.dirs.get(node)! const parentScope = getCurrentScope() as BlockEffectScope
if (__DEV__ && !isRenderEffectScope(parentScope)) {
warn(`Directives should be used inside of RenderEffectScope.`)
}
const directivesMap = (parentScope.dirs ||= new Map())
if (!(bindings = directivesMap.get(node))) {
directivesMap.set(node, (bindings = []))
}
for (const directive of directives) { for (const directive of directives) {
let [dir, source, arg, modifiers] = directive let [dir, source, arg, modifiers] = directive
@ -103,25 +128,38 @@ export function withDirectives<T extends ComponentInternalInstance | Node>(
const binding: DirectiveBinding = { const binding: DirectiveBinding = {
dir, dir,
instance, instance,
source,
value: null, // set later value: null, // set later
oldValue: undefined, oldValue: undefined,
arg, arg,
modifiers, modifiers,
} }
bindings.push(binding)
callDirectiveHook(node, binding, instance, 'created')
// register source
if (source) { if (source) {
if (dir.deep) { if (dir.deep) {
const deep = dir.deep === true ? undefined : dir.deep const deep = dir.deep === true ? undefined : dir.deep
const baseSource = source const baseSource = source
source = () => traverse(baseSource(), deep) source = () => traverse(baseSource(), deep)
} }
renderEffect(source)
const effect = new ReactiveEffect(() =>
callWithErrorHandling(
source!,
instance,
VaporErrorCodes.RENDER_FUNCTION,
),
)
const triggerRenderingUpdate = createRenderingUpdateTrigger(
instance,
effect,
)
effect.scheduler = () => queueJob(triggerRenderingUpdate)
binding.source = effect.run.bind(effect)
} }
bindings.push(binding)
callDirectiveHook(node, binding, instance, 'created')
} }
return nodeOrComponent return nodeOrComponent
@ -145,13 +183,14 @@ function getComponentNode(component: ComponentInternalInstance) {
export function invokeDirectiveHook( export function invokeDirectiveHook(
instance: ComponentInternalInstance | null, instance: ComponentInternalInstance | null,
name: DirectiveHookName, name: DirectiveHookName,
nodes?: IterableIterator<Node>, scope: BlockEffectScope,
) { ) {
if (!instance) return const { dirs } = scope
nodes = nodes || instance.dirs.keys() if (name === 'mounted') scope.im = true
for (const node of nodes) { if (!dirs) return
const directives = instance.dirs.get(node) || [] const iterator = dirs.entries()
for (const binding of directives) { for (const [node, bindings] of iterator) {
for (const binding of bindings) {
callDirectiveHook(node, binding, instance, name) callDirectiveHook(node, binding, instance, name)
} }
} }
@ -179,3 +218,43 @@ function callDirectiveHook(
]) ])
resetTracking() resetTracking()
} }
export function createRenderingUpdateTrigger(
instance: ComponentInternalInstance,
effect: ReactiveEffect,
): SchedulerJob {
job.id = instance.uid
return job
function job() {
if (!(effect.flags & EffectFlags.ACTIVE) || !effect.dirty) {
return
}
if (instance.isMounted && !instance.isUpdating) {
instance.isUpdating = true
const reset = setCurrentInstance(instance)
const { bu, u, scope } = instance
const { dirs } = scope
// beforeUpdate hook
if (bu) {
invokeArrayFns(bu)
}
invokeDirectiveHook(instance, 'beforeUpdate', scope)
queuePostFlushCb(() => {
instance.isUpdating = false
const reset = setCurrentInstance(instance)
if (dirs) {
invokeDirectiveHook(instance, 'updated', scope)
}
// updated hook
if (u) {
queuePostFlushCb(u)
}
reset()
})
reset()
}
}
}

View File

@ -0,0 +1,152 @@
import { ReactiveEffect, getCurrentScope } from '@vue/reactivity'
import {
type Directive,
type DirectiveHookName,
createRenderingUpdateTrigger,
invokeDirectiveHook,
} from './directives'
import { warn } from './warning'
import { type BlockEffectScope, isRenderEffectScope } from './blockEffectScope'
import { currentInstance } from './component'
import { VaporErrorCodes, callWithErrorHandling } from './errorHandling'
import { queueJob, queuePostFlushCb } from './scheduler'
/**
* used in createIf and createFor
* manage directives of child fragments in components.
*/
export function createChildFragmentDirectives(
anchor: Node,
getScopes: () => BlockEffectScope[],
source: () => any,
initCallback: (getValue: () => any) => void,
effectCallback: (getValue: () => any) => void,
once?: boolean,
) {
let isTriggered = false
const instance = currentInstance!
const parentScope = getCurrentScope() as BlockEffectScope
if (__DEV__) {
if (!isRenderEffectScope(parentScope)) {
warn('child directives can only be added to a render effect scope')
}
if (!instance) {
warn('child directives can only be added in a component')
}
}
const callSourceWithErrorHandling = () =>
callWithErrorHandling(source, instance, VaporErrorCodes.RENDER_FUNCTION)
if (once) {
initCallback(callSourceWithErrorHandling)
return
}
const directiveBindingsMap = (parentScope.dirs ||= new Map())
const dir: Directive = {
beforeUpdate: onDirectiveBeforeUpdate,
beforeMount: () => invokeChildrenDirectives('beforeMount'),
mounted: () => invokeChildrenDirectives('mounted'),
beforeUnmount: () => invokeChildrenDirectives('beforeUnmount'),
unmounted: () => invokeChildrenDirectives('unmounted'),
}
directiveBindingsMap.set(anchor, [
{
dir,
instance,
value: null,
oldValue: undefined,
},
])
const effect = new ReactiveEffect(callSourceWithErrorHandling)
const triggerRenderingUpdate = createRenderingUpdateTrigger(instance, effect)
effect.scheduler = () => {
isTriggered = true
queueJob(triggerRenderingUpdate)
}
const getValue = () => effect.run()
initCallback(getValue)
function onDirectiveBeforeUpdate() {
if (isTriggered) {
isTriggered = false
effectCallback(getValue)
} else {
const scopes = getScopes()
for (const scope of scopes) {
invokeWithUpdate(scope)
}
return
}
}
function invokeChildrenDirectives(name: DirectiveHookName) {
const scopes = getScopes()
for (const scope of scopes) {
invokeDirectiveHook(instance, name, scope)
}
}
}
export function invokeWithMount(scope: BlockEffectScope, handler?: () => any) {
if (isRenderEffectScope(scope.parent) && !scope.parent.im) {
return handler && handler()
}
return invokeWithDirsHooks(scope, 'mount', handler)
}
export function invokeWithUnmount(
scope: BlockEffectScope,
handler?: () => void,
) {
try {
return invokeWithDirsHooks(scope, 'unmount', handler)
} finally {
scope.stop()
}
}
export function invokeWithUpdate(
scope: BlockEffectScope,
handler?: () => void,
) {
return invokeWithDirsHooks(scope, 'update', handler)
}
const lifecycleMap = {
mount: ['beforeMount', 'mounted'],
update: ['beforeUpdate', 'updated'],
unmount: ['beforeUnmount', 'unmounted'],
} as const
function invokeWithDirsHooks(
scope: BlockEffectScope,
name: keyof typeof lifecycleMap,
handler?: () => any,
) {
const { dirs, it: instance } = scope
const [before, after] = lifecycleMap[name]
if (!dirs) {
const res = handler && handler()
if (name === 'mount') {
queuePostFlushCb(() => (scope.im = true))
}
return res
}
invokeDirectiveHook(instance, before, scope)
try {
if (handler) {
return handler()
}
} finally {
queuePostFlushCb(() => {
invokeDirectiveHook(instance, after, scope)
})
}
}

View File

@ -18,41 +18,6 @@ import { invokeDirectiveHook } from './directives'
export function renderEffect(cb: () => void) { export function renderEffect(cb: () => void) {
const instance = getCurrentInstance() const instance = getCurrentInstance()
const scope = getCurrentScope() const scope = getCurrentScope()
let effect: ReactiveEffect
const job: SchedulerJob = () => {
if (!(effect.flags & EffectFlags.ACTIVE) || !effect.dirty) {
return
}
if (instance && instance.isMounted && !instance.isUpdating) {
instance.isUpdating = true
const { bu, u, dirs } = instance
// beforeUpdate hook
if (bu) {
invokeArrayFns(bu)
}
if (dirs) {
invokeDirectiveHook(instance, 'beforeUpdate')
}
effect.run()
queuePostFlushCb(() => {
instance.isUpdating = false
if (dirs) {
invokeDirectiveHook(instance, 'updated')
}
// updated hook
if (u) {
queuePostFlushCb(u)
}
})
} else {
effect.run()
}
}
if (scope) { if (scope) {
const baseCb = cb const baseCb = cb
@ -66,16 +31,14 @@ export function renderEffect(cb: () => void) {
baseCb() baseCb()
reset() reset()
} }
job.id = instance.uid
} }
effect = new ReactiveEffect(() => const effect = new ReactiveEffect(() =>
callWithAsyncErrorHandling(cb, instance, VaporErrorCodes.RENDER_FUNCTION), callWithAsyncErrorHandling(cb, instance, VaporErrorCodes.RENDER_FUNCTION),
) )
effect.scheduler = () => { effect.scheduler = () => queueJob(job)
if (instance) job.id = instance.uid
queueJob(job)
}
if (__DEV__ && instance) { if (__DEV__ && instance) {
effect.onTrack = instance.rtc effect.onTrack = instance.rtc
? e => invokeArrayFns(instance.rtc!, e) ? e => invokeArrayFns(instance.rtc!, e)
@ -85,6 +48,47 @@ export function renderEffect(cb: () => void) {
: void 0 : void 0
} }
effect.run() effect.run()
function job() {
if (!(effect.flags & EffectFlags.ACTIVE) || !effect.dirty) {
return
}
const reset = instance && setCurrentInstance(instance)
if (instance && instance.isMounted && !instance.isUpdating) {
instance.isUpdating = true
const { bu, u, scope } = instance
const { dirs } = scope
// beforeUpdate hook
if (bu) {
invokeArrayFns(bu)
}
if (dirs) {
invokeDirectiveHook(instance, 'beforeUpdate', scope)
}
effect.run()
queuePostFlushCb(() => {
instance.isUpdating = false
const reset = setCurrentInstance(instance)
if (dirs) {
invokeDirectiveHook(instance, 'updated', scope)
}
// updated hook
if (u) {
queuePostFlushCb(u)
}
reset()
})
} else {
effect.run()
}
reset && reset()
}
} }
export function firstEffect( export function firstEffect(