mirror of https://github.com/vuejs/core.git
feat(runtime-vapor): component slot (#143)
Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
This commit is contained in:
parent
bd888b9b1e
commit
78f74ce241
|
@ -12,6 +12,7 @@ import {
|
|||
import { genExpression } from './expression'
|
||||
import { genPropKey } from './prop'
|
||||
|
||||
// TODO: generate component slots
|
||||
export function genCreateComponent(
|
||||
oper: CreateComponentIRNode,
|
||||
context: CodegenContext,
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
createComponent,
|
||||
createTextNode,
|
||||
createVaporApp,
|
||||
getCurrentInstance,
|
||||
hasInjectionContext,
|
||||
inject,
|
||||
nextTick,
|
||||
|
|
|
@ -40,6 +40,8 @@ describe('attribute fallthrough', () => {
|
|||
id: () => _ctx.id,
|
||||
},
|
||||
],
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
},
|
||||
|
@ -85,6 +87,8 @@ describe('attribute fallthrough', () => {
|
|||
id: () => _ctx.id,
|
||||
},
|
||||
],
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
},
|
||||
|
@ -123,6 +127,8 @@ describe('attribute fallthrough', () => {
|
|||
'custom-attr': () => 'custom-attr',
|
||||
},
|
||||
],
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
return n0
|
||||
|
@ -144,6 +150,8 @@ describe('attribute fallthrough', () => {
|
|||
id: () => _ctx.id,
|
||||
},
|
||||
],
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
},
|
||||
|
|
|
@ -244,6 +244,8 @@ describe('component: props', () => {
|
|||
foo: () => _ctx.foo,
|
||||
id: () => _ctx.id,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -5,17 +5,22 @@ import {
|
|||
} from './component'
|
||||
import { setupComponent } from './apiRender'
|
||||
import type { RawProps } from './componentProps'
|
||||
import type { DynamicSlots, Slots } from './componentSlots'
|
||||
import { withAttrs } from './componentAttrs'
|
||||
|
||||
export function createComponent(
|
||||
comp: Component,
|
||||
rawProps: RawProps | null = null,
|
||||
slots: Slots | null = null,
|
||||
dynamicSlots: DynamicSlots | null = null,
|
||||
singleRoot: boolean = false,
|
||||
) {
|
||||
const current = currentInstance!
|
||||
const instance = createComponentInstance(
|
||||
comp,
|
||||
singleRoot ? withAttrs(rawProps) : rawProps,
|
||||
slots,
|
||||
dynamicSlots,
|
||||
)
|
||||
setupComponent(instance, singleRoot)
|
||||
|
||||
|
|
|
@ -41,7 +41,13 @@ export function createVaporApp(
|
|||
|
||||
mount(rootContainer): any {
|
||||
if (!instance) {
|
||||
instance = createComponentInstance(rootComponent, rootProps, context)
|
||||
instance = createComponentInstance(
|
||||
rootComponent,
|
||||
rootProps,
|
||||
null,
|
||||
null,
|
||||
context,
|
||||
)
|
||||
setupComponent(instance)
|
||||
render(instance, rootContainer)
|
||||
return instance
|
||||
|
|
|
@ -17,6 +17,12 @@ import {
|
|||
emit,
|
||||
normalizeEmitsOptions,
|
||||
} from './componentEmits'
|
||||
import {
|
||||
type DynamicSlots,
|
||||
type InternalSlots,
|
||||
type Slots,
|
||||
initSlots,
|
||||
} from './componentSlots'
|
||||
import { VaporLifecycleHooks } from './apiLifecycle'
|
||||
import { warn } from './warning'
|
||||
import { type AppContext, createAppContext } from './apiCreateVaporApp'
|
||||
|
@ -32,7 +38,7 @@ export type SetupContext<E = EmitsOptions> = E extends any
|
|||
attrs: Data
|
||||
emit: EmitFn<E>
|
||||
expose: (exposed?: Record<string, any>) => void
|
||||
// TODO slots
|
||||
slots: Readonly<InternalSlots>
|
||||
}
|
||||
: never
|
||||
|
||||
|
@ -46,6 +52,9 @@ export function createSetupContext(
|
|||
get attrs() {
|
||||
return getAttrsProxy(instance)
|
||||
},
|
||||
get slots() {
|
||||
return getSlotsProxy(instance)
|
||||
},
|
||||
get emit() {
|
||||
return (event: string, ...args: any[]) => instance.emit(event, ...args)
|
||||
},
|
||||
|
@ -57,6 +66,7 @@ export function createSetupContext(
|
|||
return getAttrsProxy(instance)
|
||||
},
|
||||
emit: instance.emit,
|
||||
slots: instance.slots,
|
||||
expose: NOOP,
|
||||
}
|
||||
}
|
||||
|
@ -102,9 +112,11 @@ export interface ComponentInternalInstance {
|
|||
emit: EmitFn
|
||||
emitted: Record<string, boolean> | null
|
||||
attrs: Data
|
||||
slots: InternalSlots
|
||||
refs: Data
|
||||
|
||||
attrsProxy: Data | null
|
||||
attrsProxy?: Data
|
||||
slotsProxy?: Slots
|
||||
|
||||
// lifecycle
|
||||
isMounted: boolean
|
||||
|
@ -188,6 +200,8 @@ let uid = 0
|
|||
export function createComponentInstance(
|
||||
component: ObjectComponent | FunctionalComponent,
|
||||
rawProps: RawProps | null,
|
||||
slots: Slots | null = null,
|
||||
dynamicSlots: DynamicSlots | null = null,
|
||||
// application root node only
|
||||
appContext: AppContext | null = null,
|
||||
): ComponentInternalInstance {
|
||||
|
@ -224,10 +238,9 @@ export function createComponentInstance(
|
|||
emit: null!,
|
||||
emitted: null,
|
||||
attrs: EMPTY_OBJ,
|
||||
slots: EMPTY_OBJ,
|
||||
refs: EMPTY_OBJ,
|
||||
|
||||
attrsProxy: null,
|
||||
|
||||
// lifecycle
|
||||
isMounted: false,
|
||||
isUnmounted: false,
|
||||
|
@ -283,6 +296,7 @@ export function createComponentInstance(
|
|||
// [VaporLifecycleHooks.SERVER_PREFETCH]: null,
|
||||
}
|
||||
initProps(instance, rawProps, !isFunction(component))
|
||||
initSlots(instance, slots, dynamicSlots)
|
||||
instance.emit = emit.bind(null, 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]
|
||||
},
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
Loading…
Reference in New Issue