feat(runtime-vapor): component slot (#143)

Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
This commit is contained in:
ubugeeei 2024-03-24 21:29:00 +09:00 committed by GitHub
parent bd888b9b1e
commit 78f74ce241
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 411 additions and 6 deletions

View File

@ -12,6 +12,7 @@ import {
import { genExpression } from './expression' import { genExpression } from './expression'
import { genPropKey } from './prop' import { genPropKey } from './prop'
// TODO: generate component slots
export function genCreateComponent( export function genCreateComponent(
oper: CreateComponentIRNode, oper: CreateComponentIRNode,
context: CodegenContext, context: CodegenContext,

View File

@ -6,7 +6,6 @@ import {
createComponent, createComponent,
createTextNode, createTextNode,
createVaporApp, createVaporApp,
getCurrentInstance,
hasInjectionContext, hasInjectionContext,
inject, inject,
nextTick, nextTick,

View File

@ -40,6 +40,8 @@ describe('attribute fallthrough', () => {
id: () => _ctx.id, id: () => _ctx.id,
}, },
], ],
null,
null,
true, true,
) )
}, },
@ -85,6 +87,8 @@ describe('attribute fallthrough', () => {
id: () => _ctx.id, id: () => _ctx.id,
}, },
], ],
null,
null,
true, true,
) )
}, },
@ -123,6 +127,8 @@ describe('attribute fallthrough', () => {
'custom-attr': () => 'custom-attr', 'custom-attr': () => 'custom-attr',
}, },
], ],
null,
null,
true, true,
) )
return n0 return n0
@ -144,6 +150,8 @@ describe('attribute fallthrough', () => {
id: () => _ctx.id, id: () => _ctx.id,
}, },
], ],
null,
null,
true, true,
) )
}, },

View File

@ -244,6 +244,8 @@ describe('component: props', () => {
foo: () => _ctx.foo, foo: () => _ctx.foo,
id: () => _ctx.id, id: () => _ctx.id,
}, },
null,
null,
true, true,
) )
}, },

View File

@ -0,0 +1,191 @@
// NOTE: This test is implemented based on the case of `runtime-core/__test__/componentSlots.spec.ts`.
import {
createComponent,
createVaporApp,
defineComponent,
getCurrentInstance,
nextTick,
ref,
template,
} from '../src'
import { makeRender } from './_utils'
const define = makeRender<any>()
function renderWithSlots(slots: any): any {
let instance: any
const Comp = defineComponent({
render() {
const t0 = template('<div></div>')
const n0 = t0()
instance = getCurrentInstance()
return n0
},
})
const { render } = define({
render() {
return createComponent(Comp, {}, slots)
},
})
render()
return instance
}
describe('component: slots', () => {
test('initSlots: instance.slots should be set correctly', () => {
const { slots } = renderWithSlots({ _: 1 })
expect(slots).toMatchObject({ _: 1 })
})
// NOTE: slot normalization is not supported
test.todo(
'initSlots: should normalize object slots (when value is null, string, array)',
() => {},
)
test.todo(
'initSlots: should normalize object slots (when value is function)',
() => {},
)
test('initSlots: instance.slots should be set correctly', () => {
let instance: any
const Comp = defineComponent({
render() {
const t0 = template('<div></div>')
const n0 = t0()
instance = getCurrentInstance()
return n0
},
})
const { render } = define({
render() {
return createComponent(Comp, {}, { header: () => template('header')() })
},
})
render()
expect(instance.slots.header()).toMatchObject(
document.createTextNode('header'),
)
})
// runtime-core's "initSlots: instance.slots should be set correctly (when vnode.shapeFlag is not SLOTS_CHILDREN)"
test('initSlots: instance.slots should be set correctly', () => {
const { slots } = renderWithSlots({
default: () => template('<span></span>')(),
})
// expect(
// '[Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance.',
// ).toHaveBeenWarned()
expect(slots.default()).toMatchObject(document.createElement('span'))
})
test('updateSlots: instance.slots should be updated correctly', async () => {
const flag1 = ref(true)
let instance: any
const Child = () => {
instance = getCurrentInstance()
return template('child')()
}
const { render } = define({
render() {
return createComponent(Child, {}, { _: 2 as any }, () => [
flag1.value
? { name: 'one', fn: () => template('<span></span>')() }
: { name: 'two', fn: () => template('<div></div>')() },
])
},
})
render()
expect(instance.slots).toHaveProperty('one')
expect(instance.slots).not.toHaveProperty('two')
flag1.value = false
await nextTick()
expect(instance.slots).not.toHaveProperty('one')
expect(instance.slots).toHaveProperty('two')
})
// NOTE: it is not supported
// test('updateSlots: instance.slots should be updated correctly (when slotType is null)', () => {})
// runtime-core's "updateSlots: instance.slots should be update correctly (when vnode.shapeFlag is not SLOTS_CHILDREN)"
test('updateSlots: instance.slots should be update correctly', async () => {
const flag1 = ref(true)
let instance: any
const Child = () => {
instance = getCurrentInstance()
return template('child')()
}
const { render } = define({
setup() {
return createComponent(Child, {}, {}, () => [
flag1.value
? [{ name: 'header', fn: () => template('header')() }]
: [{ name: 'footer', fn: () => template('footer')() }],
])
},
})
render()
expect(instance.slots).toHaveProperty('header')
flag1.value = false
await nextTick()
// expect(
// '[Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance.',
// ).toHaveBeenWarned()
expect(instance.slots).toHaveProperty('footer')
})
test.todo('should respect $stable flag', async () => {
// TODO: $stable flag?
})
test.todo('should not warn when mounting another app in setup', () => {
// TODO: warning
const Comp = defineComponent({
render() {
const i = getCurrentInstance()
return i!.slots.default!()
},
})
const mountComp = () => {
createVaporApp({
render() {
return createComponent(
Comp,
{},
{ default: () => template('msg')() },
)!
},
})
}
const App = {
setup() {
mountComp()
},
render() {
return null!
},
}
createVaporApp(App).mount(document.createElement('div'))
expect(
'Slot "default" invoked outside of the render function',
).not.toHaveBeenWarned()
})
})

View File

@ -5,17 +5,22 @@ import {
} from './component' } from './component'
import { setupComponent } from './apiRender' import { setupComponent } from './apiRender'
import type { RawProps } from './componentProps' import type { RawProps } from './componentProps'
import type { DynamicSlots, Slots } from './componentSlots'
import { withAttrs } from './componentAttrs' import { withAttrs } from './componentAttrs'
export function createComponent( export function createComponent(
comp: Component, comp: Component,
rawProps: RawProps | null = null, rawProps: RawProps | null = null,
slots: Slots | null = null,
dynamicSlots: DynamicSlots | null = null,
singleRoot: boolean = false, singleRoot: boolean = false,
) { ) {
const current = currentInstance! const current = currentInstance!
const instance = createComponentInstance( const instance = createComponentInstance(
comp, comp,
singleRoot ? withAttrs(rawProps) : rawProps, singleRoot ? withAttrs(rawProps) : rawProps,
slots,
dynamicSlots,
) )
setupComponent(instance, singleRoot) setupComponent(instance, singleRoot)

View File

@ -41,7 +41,13 @@ export function createVaporApp(
mount(rootContainer): any { mount(rootContainer): any {
if (!instance) { if (!instance) {
instance = createComponentInstance(rootComponent, rootProps, context) instance = createComponentInstance(
rootComponent,
rootProps,
null,
null,
context,
)
setupComponent(instance) setupComponent(instance)
render(instance, rootContainer) render(instance, rootContainer)
return instance return instance

View File

@ -17,6 +17,12 @@ import {
emit, emit,
normalizeEmitsOptions, normalizeEmitsOptions,
} from './componentEmits' } from './componentEmits'
import {
type DynamicSlots,
type InternalSlots,
type Slots,
initSlots,
} from './componentSlots'
import { VaporLifecycleHooks } from './apiLifecycle' import { VaporLifecycleHooks } from './apiLifecycle'
import { warn } from './warning' import { warn } from './warning'
import { type AppContext, createAppContext } from './apiCreateVaporApp' import { type AppContext, createAppContext } from './apiCreateVaporApp'
@ -32,7 +38,7 @@ export type SetupContext<E = EmitsOptions> = E extends any
attrs: Data attrs: Data
emit: EmitFn<E> emit: EmitFn<E>
expose: (exposed?: Record<string, any>) => void expose: (exposed?: Record<string, any>) => void
// TODO slots slots: Readonly<InternalSlots>
} }
: never : never
@ -46,6 +52,9 @@ export function createSetupContext(
get attrs() { get attrs() {
return getAttrsProxy(instance) return getAttrsProxy(instance)
}, },
get slots() {
return getSlotsProxy(instance)
},
get emit() { get emit() {
return (event: string, ...args: any[]) => instance.emit(event, ...args) return (event: string, ...args: any[]) => instance.emit(event, ...args)
}, },
@ -57,6 +66,7 @@ export function createSetupContext(
return getAttrsProxy(instance) return getAttrsProxy(instance)
}, },
emit: instance.emit, emit: instance.emit,
slots: instance.slots,
expose: NOOP, expose: NOOP,
} }
} }
@ -102,9 +112,11 @@ export interface ComponentInternalInstance {
emit: EmitFn emit: EmitFn
emitted: Record<string, boolean> | null emitted: Record<string, boolean> | null
attrs: Data attrs: Data
slots: InternalSlots
refs: Data refs: Data
attrsProxy: Data | null attrsProxy?: Data
slotsProxy?: Slots
// lifecycle // lifecycle
isMounted: boolean isMounted: boolean
@ -188,6 +200,8 @@ let uid = 0
export function createComponentInstance( export function createComponentInstance(
component: ObjectComponent | FunctionalComponent, component: ObjectComponent | FunctionalComponent,
rawProps: RawProps | null, rawProps: RawProps | null,
slots: Slots | null = null,
dynamicSlots: DynamicSlots | null = null,
// application root node only // application root node only
appContext: AppContext | null = null, appContext: AppContext | null = null,
): ComponentInternalInstance { ): ComponentInternalInstance {
@ -224,10 +238,9 @@ export function createComponentInstance(
emit: null!, emit: null!,
emitted: null, emitted: null,
attrs: EMPTY_OBJ, attrs: EMPTY_OBJ,
slots: EMPTY_OBJ,
refs: EMPTY_OBJ, refs: EMPTY_OBJ,
attrsProxy: null,
// lifecycle // lifecycle
isMounted: false, isMounted: false,
isUnmounted: false, isUnmounted: false,
@ -283,6 +296,7 @@ export function createComponentInstance(
// [VaporLifecycleHooks.SERVER_PREFETCH]: null, // [VaporLifecycleHooks.SERVER_PREFETCH]: null,
} }
initProps(instance, rawProps, !isFunction(component)) initProps(instance, rawProps, !isFunction(component))
initSlots(instance, slots, dynamicSlots)
instance.emit = emit.bind(null, instance) instance.emit = emit.bind(null, instance)
return instance return instance
@ -315,3 +329,17 @@ function getAttrsProxy(instance: ComponentInternalInstance): Data {
)) ))
) )
} }
/**
* Dev-only
*/
function getSlotsProxy(instance: ComponentInternalInstance): Slots {
return (
instance.slotsProxy ||
(instance.slotsProxy = new Proxy(instance.slots, {
get(target, key: string) {
return target[key]
},
}))
)
}

View File

@ -0,0 +1,80 @@
import { type IfAny, extend, isArray } from '@vue/shared'
import { baseWatch } from '@vue/reactivity'
import type { ComponentInternalInstance } from './component'
import type { Block } from './apiRender'
import { createVaporPreScheduler } from './scheduler'
// TODO: SSR
export type Slot<T extends any = any> = (
...args: IfAny<T, any[], [T] | (T extends undefined ? [] : never)>
) => Block
export type InternalSlots = {
[name: string]: Slot | undefined
}
export type Slots = Readonly<InternalSlots>
export interface DynamicSlot {
name: string
fn: Slot
key?: string
}
export type DynamicSlots = () => (DynamicSlot | DynamicSlot[])[]
export const initSlots = (
instance: ComponentInternalInstance,
rawSlots: InternalSlots | null = null,
dynamicSlots: DynamicSlots | null = null,
) => {
const slots: InternalSlots = extend({}, rawSlots)
if (dynamicSlots) {
const dynamicSlotKeys: Record<string, true> = {}
baseWatch(
() => {
const _dynamicSlots = dynamicSlots()
for (let i = 0; i < _dynamicSlots.length; i++) {
const slot = _dynamicSlots[i]
// array of dynamic slot generated by <template v-for="..." #[...]>
if (isArray(slot)) {
for (let j = 0; j < slot.length; j++) {
slots[slot[j].name] = slot[j].fn
dynamicSlotKeys[slot[j].name] = true
}
} else if (slot) {
// conditional single slot generated by <template v-if="..." #foo>
slots[slot.name] = slot.key
? (...args: any[]) => {
const res = slot.fn(...args)
// attach branch key so each conditional branch is considered a
// different fragment
if (res) (res as any).key = slot.key
return res
}
: slot.fn
dynamicSlotKeys[slot.name] = true
}
}
// delete stale slots
for (const key in dynamicSlotKeys) {
if (
!_dynamicSlots.some(slot =>
isArray(slot)
? slot.some(s => s.name === key)
: slot?.name === key,
)
) {
delete slots[key]
}
}
},
undefined,
{ scheduler: createVaporPreScheduler(instance) },
)
}
instance.slots = slots
}

85
playground/src/slots.js Normal file
View File

@ -0,0 +1,85 @@
// @ts-check
import {
children,
createComponent,
defineComponent,
insert,
on,
ref,
renderEffect,
setText,
template,
} from '@vue/vapor'
// <template #mySlot="{ message, changeMessage }">
// <div clas="slotted">
// <h1>{{ message }}</h1>
// <button @click="changeMessage">btn parent</button>
// </div>
// </template>
const t1 = template(
'<div class="slotted"><h1><!></h1><button>parent btn</button></div>',
)
const Parent = defineComponent({
vapor: true,
setup() {
return (() => {
/** @type {any} */
const n0 = createComponent(
Child,
{},
{
mySlot: ({ message, changeMessage }) => {
const n1 = t1()
const n2 = /** @type {any} */ (children(n1, 0))
const n3 = /** @type {any} */ (children(n1, 1))
renderEffect(() => setText(n2, message()))
on(n3, 'click', changeMessage)
return n1
},
// e.g. default slot
// default: () => {
// const n1 = t1()
// return n1
// }
},
)
return n0
})()
},
})
const t2 = template(
'<div class="child-container"><button>child btn</button></div>',
)
const Child = defineComponent({
vapor: true,
setup(_, { slots }) {
const message = ref('Hello World!')
function changeMessage() {
message.value += '!'
}
return (() => {
// <div>
// <slot name="mySlot" :message="msg" :changeMessage="changeMessage" />
// <button @click="changeMessage">button in child</button>
// </div>
const n0 = /** @type {any} */ (t2())
const n1 = /** @type {any} */ (children(n0, 0))
on(n1, 'click', () => changeMessage)
const s0 = /** @type {any} */ (
slots.mySlot?.({
message: () => message.value,
changeMessage: () => changeMessage,
})
)
insert(s0, n0, n1)
return n0
})()
},
})
export default Parent